diff --git a/.gitignore b/.gitignore
index e62553ec..a878eda3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,4 @@ Thumbs.db
host/client_app/.playwright-cli/*
.superpowers/
.qa/
+.verify/
diff --git a/framework/core/simple_module_core/__init__.py b/framework/core/simple_module_core/__init__.py
index d43e1bfa..65ba4730 100644
--- a/framework/core/simple_module_core/__init__.py
+++ b/framework/core/simple_module_core/__init__.py
@@ -1,5 +1,6 @@
"""SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
+from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
from simple_module_core.design_packs import DesignPack, DesignPackRegistry
from simple_module_core.diagnostics import (
DiagnosticLevel,
@@ -44,6 +45,8 @@
__all__ = [
"DEFAULT_AUTH_PROVIDER",
"FRAMEWORK_API_VERSION",
+ "AuditLink",
+ "AuditLinkRegistry",
"CircularDependencyError",
"DesignPack",
"DesignPackRegistry",
diff --git a/framework/core/simple_module_core/audit_links.py b/framework/core/simple_module_core/audit_links.py
new file mode 100644
index 00000000..9c61b1f9
--- /dev/null
+++ b/framework/core/simple_module_core/audit_links.py
@@ -0,0 +1,84 @@
+"""Audit-link registry — modules teach the audit log how to reach their records.
+
+An audit entry stores the *model class name* and primary key of the row that
+changed (``StoredFile``, ``a91f3c2b…`` — ``snapshot_changes`` records
+``type(obj).__name__``, never ``__tablename__``). That is enough to prove what
+happened and useless for doing anything about it: the reader has an id and no
+way to open the record it names.
+
+A module declares where its rows live via
+:meth:`~simple_module_core.module.ModuleBase.register_audit_links`; the host
+collects them into one registry at boot and stores it on
+``app.state.sm.audit_links``.
+
+**The registry maps model class names to URL templates, nothing more.** It does
+not verify the row exists or that the reader may open it — following a link to a
+deleted record lands on that screen's own 404, and permissions are enforced by
+the target route as usual.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+_ID_PLACEHOLDER = "{id}"
+
+
+@dataclass(frozen=True)
+class AuditLink:
+ """Where the records of one audited model can be viewed.
+
+ Args:
+ entity_type: The **model class name**, matching
+ ``AuditEntry.entity_type`` (e.g. ``"User"``, not ``"users_user"``).
+ ``snapshot_changes`` records ``type(obj).__name__``, so keying this
+ off ``__tablename__`` silently never matches — and because an
+ unmatched lookup falls back to showing ``entity_type`` as the
+ label, a table-name key looks like it worked.
+ url_template: Path containing ``{id}``, substituted with the entity id
+ (e.g. ``"/admin/users/{id}/edit"``).
+ label: Human-readable name for the entity kind, shown instead of the
+ raw class name (e.g. ``"User account"``).
+ """
+
+ entity_type: str
+ url_template: str
+ label: str = ""
+
+ def __post_init__(self) -> None:
+ if _ID_PLACEHOLDER not in self.url_template:
+ raise ValueError(
+ f"AuditLink for {self.entity_type!r} has url_template "
+ f"{self.url_template!r}, which contains no {_ID_PLACEHOLDER} — "
+ f"every row would link to the same page"
+ )
+
+ def url_for(self, entity_id: str) -> str:
+ return self.url_template.replace(_ID_PLACEHOLDER, entity_id)
+
+
+class AuditLinkRegistry:
+ """Aggregates every module's :class:`AuditLink` declarations.
+
+ Populated once during boot (``register_audit_links`` hook) and read
+ thereafter by the audit log view when it renders each row.
+ """
+
+ def __init__(self) -> None:
+ self._links: dict[str, AuditLink] = {}
+
+ def register(self, link: AuditLink) -> None:
+ existing = self._links.get(link.entity_type)
+ if existing is not None and existing != link:
+ raise ValueError(
+ f"Two modules claim audit links for {link.entity_type!r}: "
+ f"{existing.url_template!r} and {link.url_template!r}"
+ )
+ self._links[link.entity_type] = link
+
+ def get(self, entity_type: str) -> AuditLink | None:
+ return self._links.get(entity_type)
+
+ @property
+ def all_links(self) -> dict[str, AuditLink]:
+ return dict(self._links)
diff --git a/framework/core/simple_module_core/health.py b/framework/core/simple_module_core/health.py
index 4c6f3f39..6999f690 100644
--- a/framework/core/simple_module_core/health.py
+++ b/framework/core/simple_module_core/health.py
@@ -30,6 +30,22 @@ class HealthCheck:
name: str
check: HealthCheckFn
+ module: str = ""
+ """Module that contributed the check. Stamped by the registry during
+ ``register_health_checks``; module authors never set it by hand."""
+ probe: bool = True
+ """Whether automatic pollers may run this check.
+
+ Set ``False`` for checks that reach a third party — an SMTP login, an S3
+ request. Readiness is asked on a timer (a Kubernetes probe every 10s), and
+ a check that authenticates against a mail provider on that schedule earns
+ a rate-limit and binds probe latency to someone else's uptime. Such a
+ dependency is also not a readiness signal: the app serves pages fine while
+ its mailer is down.
+
+ ``False`` checks still run when explicitly requested — that is what the
+ "Test connection" action on the module-settings screen invokes.
+ """
class HealthRegistry:
@@ -37,10 +53,32 @@ class HealthRegistry:
def __init__(self) -> None:
self._checks: list[HealthCheck] = []
+ self._current_owner: str = ""
+
+ def set_owner(self, module_name: str) -> None:
+ """Attribute subsequently-added checks to ``module_name``.
+
+ The host calls this around each ``register_health_checks`` hook so a
+ check knows which module it belongs to without changing the ``add``
+ signature module authors already use. Attribution is what lets the
+ dashboard show health per module rather than one global number.
+ """
+ self._current_owner = module_name
def add(self, check: HealthCheck) -> None:
+ if not check.module:
+ check.module = self._current_owner
self._checks.append(check)
@property
def all_checks(self) -> list[HealthCheck]:
+ """Every registered check, including on-demand ones.
+
+ Callers that poll on a timer want :attr:`probe_checks` instead.
+ """
return list(self._checks)
+
+ @property
+ def probe_checks(self) -> list[HealthCheck]:
+ """Checks safe to run automatically, on a timer."""
+ return [c for c in self._checks if c.probe]
diff --git a/framework/core/simple_module_core/menu.py b/framework/core/simple_module_core/menu.py
index 648e9261..e4c0869b 100644
--- a/framework/core/simple_module_core/menu.py
+++ b/framework/core/simple_module_core/menu.py
@@ -30,6 +30,15 @@ class MenuItem:
requires_auth: bool = True
roles: list[str] = field(default_factory=list)
"""Empty list = visible to all authenticated users."""
+ permissions: list[str] = field(default_factory=list)
+ """Permission keys required to see this entry. Empty = no permission check.
+
+ Declare the same permission the target route enforces. Roles alone cannot
+ express this: a custom role holding ``settings.view`` should see Settings,
+ and hard-coding ``roles=["admin"]`` would hide it from them while still
+ showing it to any admin-adjacent role that cannot actually open it. An
+ entry that 403s on click is worse than no entry at all.
+ """
method: MenuItemMethod = "get"
"""HTTP method used when the item is activated. ``"post"`` renders as an
Inertia form submission so the target endpoint can be POST-only (e.g. logout)."""
@@ -69,12 +78,18 @@ def get_for_user(
*,
is_authenticated: bool,
roles: list[str] | None = None,
+ permissions: list[str] | None = None,
) -> dict[str, list[dict]]:
- """Return menu items grouped by section, filtered by auth/roles.
+ """Return menu items grouped by section, filtered by auth/roles/permissions.
+
+ ``permissions`` is the caller's already-expanded permission list (no
+ wildcards). Items declaring permissions the caller lacks are dropped,
+ so the sidebar never offers a screen that will 403 on click.
Returns a dict ready to be serialized into Inertia shared props.
"""
roles = roles or []
+ granted = set(permissions or [])
result: dict[str, list[dict]] = {s.value: [] for s in MenuSection}
for item in self.all_items:
@@ -82,6 +97,10 @@ def get_for_user(
continue
if item.roles and not any(r in item.roles for r in roles):
continue
+ # All declared permissions must be held: an entry naming several is
+ # asking for all of them, matching how route guards compose.
+ if item.permissions and not granted.issuperset(item.permissions):
+ continue
result[item.section.value].append(
{
"label": item.label,
diff --git a/framework/core/simple_module_core/module.py b/framework/core/simple_module_core/module.py
index 703c04e6..04d8d233 100644
--- a/framework/core/simple_module_core/module.py
+++ b/framework/core/simple_module_core/module.py
@@ -10,6 +10,7 @@
if TYPE_CHECKING:
from fastapi import APIRouter, FastAPI
+ from simple_module_core.audit_links import AuditLinkRegistry
from simple_module_core.design_packs import DesignPackRegistry
from simple_module_core.events import EventBus
from simple_module_core.feature_flags import FeatureFlagRegistry
@@ -163,6 +164,28 @@ def register_design_packs(self, registry):
order.
"""
+ def register_audit_links(self, registry: AuditLinkRegistry) -> None:
+ """Declare where this module's audited records can be viewed.
+
+ An audit entry stores a table name and a primary key, which tells the
+ reader what changed but gives them no way to open it. Override this
+ hook to make your module's rows reachable from the audit log::
+
+ def register_audit_links(self, registry):
+ registry.register(
+ AuditLink(
+ entity_type="users_user",
+ url_template="/admin/users/{id}/edit",
+ label="User",
+ )
+ )
+
+ ``entity_type`` is the ``__tablename__`` the rows are audited under.
+ Registering only supplies the URL — the target route still enforces
+ its own permissions, so linking never widens access. Called once at
+ boot, in dependency order.
+ """
+
def register_middleware(self, app: FastAPI) -> None:
"""Add middleware to the application.
diff --git a/framework/core/simple_module_core/services.py b/framework/core/simple_module_core/services.py
index cb86c11c..4df8860c 100644
--- a/framework/core/simple_module_core/services.py
+++ b/framework/core/simple_module_core/services.py
@@ -20,6 +20,7 @@
from simple_module_db.session import DatabaseState
from simple_module_hosting.settings import Settings
+ from simple_module_core.audit_links import AuditLinkRegistry
from simple_module_core.design_packs import DesignPackRegistry
from simple_module_core.events import EventBus
from simple_module_core.feature_flags import FeatureFlagRegistry
@@ -44,6 +45,7 @@ class Services:
health_registry: HealthRegistry
public_routes: PublicRouteRegistry
design_packs: DesignPackRegistry
+ audit_links: AuditLinkRegistry
i18n_registry: I18nRegistry
inertia_config: InertiaConfig
modules: tuple[ModuleBase, ...]
diff --git a/framework/core/tests/test_audit_links.py b/framework/core/tests/test_audit_links.py
new file mode 100644
index 00000000..ba8fdc15
--- /dev/null
+++ b/framework/core/tests/test_audit_links.py
@@ -0,0 +1,57 @@
+"""Tests for AuditLink / AuditLinkRegistry.
+
+The audit log stores a table name and a primary key. This registry is how a
+module says where those rows can be opened, so the log stops being a wall of
+unactionable uuids.
+"""
+
+from __future__ import annotations
+
+import pytest
+from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
+
+
+class TestAuditLink:
+ def test_url_for_substitutes_the_id(self):
+ link = AuditLink(entity_type="User", url_template="/users/admin/{id}")
+ assert link.url_for("a91f3c2b") == "/users/admin/a91f3c2b"
+
+ def test_template_without_placeholder_is_rejected(self):
+ """Every row would otherwise link to the same page."""
+ with pytest.raises(ValueError, match="contains no"):
+ AuditLink(entity_type="User", url_template="/users/admin")
+
+ def test_label_defaults_to_empty(self):
+ assert AuditLink(entity_type="t", url_template="/t/{id}").label == ""
+
+
+class TestAuditLinkRegistry:
+ def test_get_returns_none_for_unclaimed_tables(self):
+ """Join tables and blob stores have no screen; that is not an error."""
+ assert AuditLinkRegistry().get("UserPermission") is None
+
+ def test_register_and_get(self):
+ reg = AuditLinkRegistry()
+ link = AuditLink(entity_type="User", url_template="/users/admin/{id}", label="User")
+ reg.register(link)
+ assert reg.get("User") is link
+
+ def test_conflicting_claims_raise(self):
+ """Two modules mapping one table means one of them silently loses."""
+ reg = AuditLinkRegistry()
+ reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
+ with pytest.raises(ValueError, match="Two modules claim"):
+ reg.register(AuditLink(entity_type="User", url_template="/b/{id}"))
+
+ def test_registering_the_same_link_twice_is_allowed(self):
+ """Idempotent re-registration must not break a re-entrant boot."""
+ reg = AuditLinkRegistry()
+ for _ in range(2):
+ reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
+ assert len(reg.all_links) == 1
+
+ def test_all_links_is_a_copy(self):
+ reg = AuditLinkRegistry()
+ reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
+ reg.all_links.clear()
+ assert reg.get("User") is not None
diff --git a/framework/core/tests/test_health_registry.py b/framework/core/tests/test_health_registry.py
index 1c0fb390..cbbb86b6 100644
--- a/framework/core/tests/test_health_registry.py
+++ b/framework/core/tests/test_health_registry.py
@@ -33,6 +33,41 @@ async def check_b() -> HealthCheckResult:
reg.add(HealthCheck(name="b", check=check_b))
assert len(reg.all_checks) == 2
+ async def test_checks_are_attributed_to_the_owning_module(self):
+ """The dashboard shows health per module, which needs this attribution."""
+ reg = HealthRegistry()
+
+ async def check() -> HealthCheckResult:
+ return HealthCheckResult(status=HealthStatus.HEALTHY)
+
+ reg.set_owner("FileStorage")
+ reg.add(HealthCheck(name="s3", check=check))
+ reg.set_owner("BackgroundTasks")
+ reg.add(HealthCheck(name="broker", check=check))
+
+ owners = {c.name: c.module for c in reg.all_checks}
+ assert owners == {"s3": "FileStorage", "broker": "BackgroundTasks"}
+
+ async def test_explicit_module_survives_the_current_owner(self):
+ reg = HealthRegistry()
+
+ async def check() -> HealthCheckResult:
+ return HealthCheckResult(status=HealthStatus.HEALTHY)
+
+ reg.set_owner("Dashboard")
+ reg.add(HealthCheck(name="db", check=check, module="Users"))
+ assert reg.all_checks[0].module == "Users"
+
+ async def test_unowned_checks_have_no_module(self):
+ """Checks added outside a register_health_checks hook belong to nobody."""
+ reg = HealthRegistry()
+
+ async def check() -> HealthCheckResult:
+ return HealthCheckResult(status=HealthStatus.HEALTHY)
+
+ reg.add(HealthCheck(name="db", check=check))
+ assert reg.all_checks[0].module == ""
+
async def test_check_result_defaults(self):
result = HealthCheckResult(status=HealthStatus.HEALTHY)
assert result.detail is None
diff --git a/framework/core/tests/test_menu.py b/framework/core/tests/test_menu.py
index cd9cdcfc..04db0bdc 100644
--- a/framework/core/tests/test_menu.py
+++ b/framework/core/tests/test_menu.py
@@ -114,3 +114,52 @@ async def test_group_serialized(self):
result = reg.get_for_user(is_authenticated=True)
groups = [i["group"] for i in result["sidebar"]]
assert groups == ["Administration", "System"]
+
+
+class TestPermissionFiltering:
+ """An entry that 403s on click is worse than no entry at all.
+
+ Roles alone could not express this: hard-coding ``roles=["admin"]`` hides
+ the screen from a custom role that legitimately holds the permission, while
+ still showing it to admin-adjacent roles that cannot open it.
+ """
+
+ async def test_entry_hidden_without_the_permission(self):
+ reg = MenuRegistry()
+ reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
+ result = reg.get_for_user(is_authenticated=True, permissions=["users.manage"])
+ assert result["sidebar"] == []
+
+ async def test_entry_shown_with_the_permission(self):
+ reg = MenuRegistry()
+ reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
+ result = reg.get_for_user(is_authenticated=True, permissions=["settings.view"])
+ assert [i["label"] for i in result["sidebar"]] == ["Settings"]
+
+ async def test_permission_granted_by_a_custom_role_is_enough(self):
+ """No role check involved — holding the key is what matters."""
+ reg = MenuRegistry()
+ reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
+ result = reg.get_for_user(
+ is_authenticated=True, roles=["auditor"], permissions=["settings.view"]
+ )
+ assert len(result["sidebar"]) == 1
+
+ async def test_all_declared_permissions_are_required(self):
+ reg = MenuRegistry()
+ reg.add(MenuItem(label="X", url="/x", permissions=["a.view", "a.manage"]))
+ assert reg.get_for_user(is_authenticated=True, permissions=["a.view"])["sidebar"] == []
+ both = reg.get_for_user(is_authenticated=True, permissions=["a.view", "a.manage"])
+ assert len(both["sidebar"]) == 1
+
+ async def test_no_declared_permissions_means_no_check(self):
+ """Existing entries must keep working without opting in."""
+ reg = MenuRegistry()
+ reg.add(MenuItem(label="Home", url="/"))
+ assert len(reg.get_for_user(is_authenticated=True, permissions=[])["sidebar"]) == 1
+
+ async def test_omitting_permissions_entirely_hides_gated_entries(self):
+ """A caller that passes no permissions holds none — fail closed."""
+ reg = MenuRegistry()
+ reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
+ assert reg.get_for_user(is_authenticated=True)["sidebar"] == []
diff --git a/framework/core/tests/test_services.py b/framework/core/tests/test_services.py
index 384a598d..15e9da67 100644
--- a/framework/core/tests/test_services.py
+++ b/framework/core/tests/test_services.py
@@ -31,6 +31,7 @@ async def test_services_round_trip_field_access(self) -> None:
assert s.health_registry is _SENTINEL_HEALTH
assert s.public_routes is _SENTINEL_PUBLIC_ROUTES
assert s.design_packs is _SENTINEL_DESIGN_PACKS
+ assert s.audit_links is _SENTINEL_AUDIT_LINKS
assert s.i18n_registry is _SENTINEL_I18N
assert s.inertia_config is _SENTINEL_INERTIA
assert s.modules == ()
@@ -45,6 +46,7 @@ async def test_services_round_trip_field_access(self) -> None:
_SENTINEL_HEALTH = object()
_SENTINEL_PUBLIC_ROUTES = object()
_SENTINEL_DESIGN_PACKS = object()
+_SENTINEL_AUDIT_LINKS = object()
_SENTINEL_I18N = object()
_SENTINEL_INERTIA = object()
@@ -61,6 +63,7 @@ def _make_services() -> Services:
health_registry=_SENTINEL_HEALTH, # type: ignore[arg-type]
public_routes=_SENTINEL_PUBLIC_ROUTES, # type: ignore[arg-type]
design_packs=_SENTINEL_DESIGN_PACKS, # type: ignore[arg-type]
+ audit_links=_SENTINEL_AUDIT_LINKS, # type: ignore[arg-type]
i18n_registry=_SENTINEL_I18N, # type: ignore[arg-type]
inertia_config=_SENTINEL_INERTIA, # type: ignore[arg-type]
modules=(),
diff --git a/framework/hosting/simple_module_hosting/_db_health.py b/framework/hosting/simple_module_hosting/_db_health.py
new file mode 100644
index 00000000..628248b8
--- /dev/null
+++ b/framework/hosting/simple_module_hosting/_db_health.py
@@ -0,0 +1,56 @@
+"""The host's own readiness check: can we reach the database?
+
+Registered by ``create_app`` rather than by a module, because it is the one
+dependency no request can do without — unlike a mailer or an object store,
+which a page load never touches.
+
+It also gives ``/health/ready`` something to actually report. Modules
+contribute checks for third-party services, and those are ``probe=False``
+(polling an SMTP login every 10s earns a rate-limit), so without this the
+readiness endpoint would answer "healthy" from an empty check set — a green
+light that proves nothing.
+
+It is owned by the host, not a module, so it deliberately does not light up any
+tile on the dashboard's per-module health strip — those are keyed by
+``HealthCheck.module``.
+"""
+
+from __future__ import annotations
+
+from simple_module_core.health import HealthCheck, HealthCheckResult, HealthStatus
+from sqlalchemy import text
+
+CHECK_DATABASE = "host.database"
+
+_MODULE = "Host"
+
+
+def build_database_check(db_state):
+ """Return an async check issuing the cheapest possible round trip."""
+
+ async def check() -> HealthCheckResult:
+ try:
+ async with db_state.session_factory() as session:
+ await session.execute(text("SELECT 1"))
+ except Exception as exc:
+ return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail=str(exc))
+ return HealthCheckResult(status=HealthStatus.HEALTHY, detail="Database reachable")
+
+ return check
+
+
+def register_database_check(health_registry, db_state) -> None:
+ """Add the database check to *health_registry*.
+
+ ``probe=True``: a ``SELECT 1`` against an already-pooled connection is
+ cheap enough to run on a probe timer, and it is exactly what a readiness
+ probe should be asking.
+ """
+ health_registry.add(
+ HealthCheck(
+ name=CHECK_DATABASE,
+ check=build_database_check(db_state),
+ module=_MODULE,
+ probe=True,
+ )
+ )
diff --git a/framework/hosting/simple_module_hosting/_error_handlers.py b/framework/hosting/simple_module_hosting/_error_handlers.py
index e7c280b2..4737562b 100644
--- a/framework/hosting/simple_module_hosting/_error_handlers.py
+++ b/framework/hosting/simple_module_hosting/_error_handlers.py
@@ -34,7 +34,18 @@ async def render_error_page(request: Request, status_code: int, message: str) ->
shared = getattr(request.state, "inertia_shared", None)
if shared:
inertia.share(**shared)
- response = await inertia.render("Error", {"status": status_code, "message": message})
+ # The correlation id is the only handle a user has on their own failed
+ # request — without it a support report is just "it broke". It is already
+ # on every log line for this request, so quoting it back makes the page
+ # and the logs joinable.
+ response = await inertia.render(
+ "Error",
+ {
+ "status": status_code,
+ "message": message,
+ "correlation_id": getattr(request.state, "correlation_id", "") or "",
+ },
+ )
response.status_code = status_code
return response
except InertiaVersionConflictException as exc:
diff --git a/framework/hosting/simple_module_hosting/_registrations.py b/framework/hosting/simple_module_hosting/_registrations.py
new file mode 100644
index 00000000..b1372e1c
--- /dev/null
+++ b/framework/hosting/simple_module_hosting/_registrations.py
@@ -0,0 +1,65 @@
+"""Phase 5 of boot — every module's declarative registration hooks.
+
+Extracted from ``app_builder.py`` to keep that file readable; this is the one
+place that knows the full set of hooks a module may implement, and the order
+they run in. ``app_builder.create_app`` is the only intended caller.
+"""
+
+from __future__ import annotations
+
+import inspect
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from fastapi import FastAPI
+
+
+def run_module_registrations(
+ modules: list,
+ *,
+ app: FastAPI,
+ event_bus,
+ menu_registry,
+ perm_registry,
+ ff_registry,
+ health_registry,
+ public_route_registry,
+ design_pack_registry,
+ audit_link_registry,
+) -> None:
+ """Invoke each module's registration hooks, in dependency order.
+
+ Health checks are attributed to the module that registers them, so the
+ dashboard can report health per module rather than one global number. The
+ owner is cleared afterwards: anything registered later — a module's
+ ``on_startup``, say — belongs to no module in this loop, and inheriting
+ the last one's name would be a lie.
+ """
+ for mod in modules:
+ mod.register_menu_items(menu_registry)
+ mod.register_permissions(perm_registry)
+ mod.register_feature_flags(ff_registry)
+ dispatch_event_handlers(mod, event_bus, app)
+ health_registry.set_owner(mod.meta.name)
+ mod.register_health_checks(health_registry)
+ mod.register_public_routes(public_route_registry)
+ mod.register_design_packs(design_pack_registry)
+ mod.register_audit_links(audit_link_registry)
+
+ health_registry.set_owner("")
+
+
+def dispatch_event_handlers(mod, event_bus, app: FastAPI) -> None:
+ """Call ``mod.register_event_handlers`` with or without ``app``.
+
+ Back-compat shim for modules that still override the one-arg form
+ ``(self, bus)``; passing ``app=`` to those crashes.
+ """
+ sig = inspect.signature(mod.register_event_handlers)
+ accepts_app = "app" in sig.parameters or any(
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
+ )
+ if accepts_app:
+ mod.register_event_handlers(event_bus, app=app)
+ else:
+ mod.register_event_handlers(event_bus)
diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py
index d712a5e0..15ade822 100644
--- a/framework/hosting/simple_module_hosting/app_builder.py
+++ b/framework/hosting/simple_module_hosting/app_builder.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import inspect
import logging
import os
from collections.abc import AsyncGenerator
@@ -10,6 +9,7 @@
from pathlib import Path
from fastapi import FastAPI
+from simple_module_core.audit_links import AuditLinkRegistry
from simple_module_core.design_packs import DesignPackRegistry
from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
from simple_module_core.discovery import discover_modules, select_auth_provider, topological_sort
@@ -23,6 +23,7 @@
from simple_module_db.listeners import register_listeners
from simple_module_db.session import init_db
+from simple_module_hosting._db_health import register_database_check
from simple_module_hosting._inertia_setup import setup_inertia
from simple_module_hosting._phase_helpers import (
attach_public_routes,
@@ -33,6 +34,7 @@
register_host_settings,
wire_module_routes,
)
+from simple_module_hosting._registrations import run_module_registrations
from simple_module_hosting.health import router as health_router
from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
from simple_module_hosting.migrations import check_migrations
@@ -80,22 +82,6 @@ def _resolve_project_root() -> Path:
_PROJECT_ROOT = _resolve_project_root()
-def _register_event_handlers(mod, event_bus: EventBus, app: FastAPI) -> None:
- """Dispatch to ``mod.register_event_handlers`` with or without ``app``.
-
- Back-compat shim for modules that still override the one-arg form
- ``(self, bus)``; passing ``app=`` to those crashes.
- """
- sig = inspect.signature(mod.register_event_handlers)
- accepts_app = "app" in sig.parameters or any(
- p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
- )
- if accepts_app:
- mod.register_event_handlers(event_bus, app=app)
- else:
- mod.register_event_handlers(event_bus)
-
-
def create_app(settings: Settings | None = None) -> FastAPI:
"""Build and configure the full FastAPI application.
@@ -173,6 +159,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
health_registry = HealthRegistry()
public_route_registry = PublicRouteRegistry()
design_pack_registry = DesignPackRegistry()
+ audit_link_registry = AuditLinkRegistry()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
@@ -219,14 +206,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
print_diagnostics(settings_diagnostics)
# ── Phase 5: Module registrations ──────────────────────
- for mod in modules:
- mod.register_menu_items(menu_registry)
- mod.register_permissions(perm_registry)
- mod.register_feature_flags(ff_registry)
- _register_event_handlers(mod, event_bus, app)
- mod.register_health_checks(health_registry)
- mod.register_public_routes(public_route_registry)
- mod.register_design_packs(design_pack_registry)
+ run_module_registrations(
+ modules,
+ app=app,
+ event_bus=event_bus,
+ menu_registry=menu_registry,
+ perm_registry=perm_registry,
+ ff_registry=ff_registry,
+ health_registry=health_registry,
+ public_route_registry=public_route_registry,
+ design_pack_registry=design_pack_registry,
+ audit_link_registry=audit_link_registry,
+ )
attach_public_routes(app, settings, public_route_registry)
@@ -255,6 +246,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
pool_recycle=settings.db_pool_recycle,
)
register_listeners(db_state)
+ # The host's own readiness signal, and the only probe-safe check in a
+ # default install — module checks reach third parties and are on-demand.
+ register_database_check(health_registry, db_state)
# ── Phase 7: Inertia + exception handlers ──────────────
inertia_config = setup_inertia(app, settings, modules, _PROJECT_ROOT)
@@ -288,6 +282,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
health_registry=health_registry,
public_routes=public_route_registry,
design_packs=design_pack_registry,
+ audit_links=audit_link_registry,
i18n_registry=i18n_registry,
inertia_config=inertia_config,
modules=tuple(modules),
diff --git a/framework/hosting/simple_module_hosting/health.py b/framework/hosting/simple_module_hosting/health.py
index 3abc047a..5e90b875 100644
--- a/framework/hosting/simple_module_hosting/health.py
+++ b/framework/hosting/simple_module_hosting/health.py
@@ -44,7 +44,11 @@ async def liveness() -> dict:
@router.get("/health/ready")
async def readiness(request: Request) -> dict:
registry: HealthRegistry = request.app.state.sm.health_registry
- checks = registry.all_checks
+ # Probe-safe checks only. Readiness is polled on a timer, and a check that
+ # opens an SMTP session or hits S3 on that schedule gets rate-limited and
+ # makes this endpoint's latency someone else's problem. Those dependencies
+ # are not readiness signals anyway — the app serves pages without them.
+ checks = registry.probe_checks
if not checks:
return {_KEY_STATUS: _STATUS_HEALTHY, _KEY_CHECKS: {}}
diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py
index 7d917f50..97b49b1f 100644
--- a/framework/hosting/simple_module_hosting/middleware.py
+++ b/framework/hosting/simple_module_hosting/middleware.py
@@ -274,6 +274,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"menus": self.menu_registry.get_for_user(
is_authenticated=is_authenticated,
roles=roles,
+ # Already expanded above (wildcards resolved), which is exactly
+ # what the menu filter needs to drop entries that would 403.
+ permissions=frontend_permissions,
),
"i18n": i18n_block,
}
diff --git a/framework/hosting/tests/test_db_health_check.py b/framework/hosting/tests/test_db_health_check.py
new file mode 100644
index 00000000..0499abad
--- /dev/null
+++ b/framework/hosting/tests/test_db_health_check.py
@@ -0,0 +1,40 @@
+"""The host's database readiness check.
+
+Module-contributed checks reach third parties and are on-demand, so without
+this one `/health/ready` would answer "healthy" from an empty check set — a
+green light proving nothing.
+
+It does *not* feed the dashboard's per-module health dot: that maps a check to
+a tile by `HealthCheck.module`, and this one is owned by the host ("Host"),
+which names no module. With every bundled module check now `probe=False`, the
+dots are blank by design — nothing is polling those dependencies.
+"""
+
+from __future__ import annotations
+
+import httpx
+from simple_module_core.health import HealthStatus
+from simple_module_hosting._db_health import CHECK_DATABASE
+
+
+class TestDatabaseHealthCheck:
+ async def test_registered_and_probe_safe(self, app) -> None:
+ checks = {c.name: c for c in app.state.sm.health_registry.all_checks}
+ assert CHECK_DATABASE in checks, sorted(checks)
+ assert checks[CHECK_DATABASE].probe is True
+
+ async def test_readiness_reports_a_real_check(self, client: httpx.AsyncClient) -> None:
+ """An empty `checks` block was the symptom worth preventing."""
+ resp = await client.get("/health/ready")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert CHECK_DATABASE in body["checks"], body
+ assert body["checks"][CHECK_DATABASE]["status"] == HealthStatus.HEALTHY.value
+
+ async def test_passes_against_a_live_database(self, app) -> None:
+ check = next(c for c in app.state.sm.health_registry.all_checks if c.name == CHECK_DATABASE)
+ assert (await check.check()).status is HealthStatus.HEALTHY
+
+ async def test_probe_checks_is_not_empty(self, app) -> None:
+ """Readiness reads probe_checks; an always-empty list makes it inert."""
+ assert app.state.sm.health_registry.probe_checks
diff --git a/framework/hosting/tests/test_error_page_shared_props.py b/framework/hosting/tests/test_error_page_shared_props.py
index 68d0efa7..f4ce69e1 100644
--- a/framework/hosting/tests/test_error_page_shared_props.py
+++ b/framework/hosting/tests/test_error_page_shared_props.py
@@ -61,6 +61,19 @@ async def test_error_page_keeps_its_own_props(
props = _inertia_page((await authenticated_client.get(_MISSING_PATH)).text)["props"]
assert props["status"] == _NOT_FOUND
+ async def test_error_page_carries_correlation_id(
+ self, authenticated_client: httpx.AsyncClient
+ ) -> None:
+ """The page shows this id so a support report can be joined to the logs."""
+ resp = await authenticated_client.get(_MISSING_PATH)
+ props = _inertia_page(resp.text)["props"]
+ assert props.get("correlation_id"), (
+ f"no correlation_id on error page; props={sorted(props)}"
+ )
+ # Must be the same id the response header advertises, or quoting it
+ # back would point support at a different request.
+ assert props["correlation_id"] == resp.headers.get("x-correlation-id")
+
async def test_anonymous_error_page_still_renders(self, client: httpx.AsyncClient) -> None:
"""An unauthenticated 404 must not blow up on missing shared state."""
resp = await client.get("/health/definitely-not-real")
diff --git a/host/client_app/i18n.ts b/host/client_app/i18n.ts
index 4071b1ab..a2208f50 100644
--- a/host/client_app/i18n.ts
+++ b/host/client_app/i18n.ts
@@ -2,8 +2,8 @@
* Initial wiring for @simple-module-py/i18n inside the Inertia app.
*
* Reads {locale, messages} from Inertia shared props and calls
- * configureI18n on boot; on every successful navigation, checks whether
- * the active locale changed and updates the i18next resources.
+ * configureI18n on boot; on every successful navigation, adopts whatever
+ * catalog the server chose to send (see `subscribeI18nToNavigation`).
*/
import type { PageProps } from '@inertiajs/core';
@@ -25,18 +25,20 @@ export function bootI18nFromInitialPage(props: PageProps): void {
return;
}
configureI18n({ locale: i18n.locale, messages: i18n.messages ?? {} });
- activeLocale = i18n.locale;
}
-let activeLocale: string | null = null;
-
export function subscribeI18nToNavigation(): () => void {
return router.on('success', (event) => {
const i18n = (event.detail.page.props as unknown as { i18n?: I18nSharedProps }).i18n;
if (!i18n) return;
- if (i18n.locale !== activeLocale && i18n.messages) {
+ // A non-null `messages` payload IS the server's signal that the client
+ // needs it — it sends `null` whenever the cached catalog is still good.
+ // Gating on a locale change instead drops the catalog that arrives when
+ // the *audience* changes: logging in swaps the public snapshot for one
+ // including admin-only modules, at the same locale, so every admin screen
+ // rendered raw keys ("dashboard.home.title") until a hard refresh.
+ if (i18n.messages) {
updateI18n({ locale: i18n.locale, messages: i18n.messages });
- activeLocale = i18n.locale;
}
});
}
diff --git a/host/client_app/pages/Error.tsx b/host/client_app/pages/Error.tsx
index 4f40bcab..927e5b4d 100644
--- a/host/client_app/pages/Error.tsx
+++ b/host/client_app/pages/Error.tsx
@@ -1,5 +1,6 @@
import { Head, Link } from '@inertiajs/react';
import { keys, useT } from '@simple-module-py/i18n';
+import { CopyableId } from '@simple-module-py/ui/components/CopyableId';
import { ErrorScreen } from '@simple-module-py/ui/components/ErrorScreen';
import { Button } from '@simple-module-py/ui/components/ui/button';
import { Home, LifeBuoy } from 'lucide-react';
@@ -7,9 +8,10 @@ import { Home, LifeBuoy } from 'lucide-react';
interface Props {
status: number;
message: string;
+ correlation_id?: string;
}
-function ErrorPage({ status, message }: Props) {
+function ErrorPage({ status, message, correlation_id }: Props) {
const { t } = useT();
const titles: Record = {
@@ -36,7 +38,26 @@ function ErrorPage({ status, message }: Props) {
return (
<>
-
+
+
+ {t(keys.host.error.correlation_id_label)}
+
+
+
+ ) : undefined
+ }
+ >