From 8bc6aeada0040fb566fd347dd3bf92a6110ccdb7 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 26 Aug 2026 17:34:46 +0200 Subject: [PATCH 1/3] docs: catch the reference docs up to v0.0.32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteen commits landed since the docs were last swept (admin section, Inertia cache guard, maintenance mode, the i18n gate, git module sources, the default Docker image). The PRs updated the pages they touched; this covers what they left behind. Broken links first. The admin move relocated every admin *view* URL and the reference docs largely still pointed at the old ones: /settings/modules, /users/admin, /audit_log/, /feature_flags/, /dashboard/doctor, /permissions/. Retargeted against each module's actual view_prefix/admin_view_prefix. The /api/* paths are a separate contract and did not move, so they are left alone. Three of those were wrong in a second way that a URL rewrite alone would have preserved: - /settings/modules/ names a route that does not exist at all — /admin/settings/ is one master/detail page, with no per-package path. - framework-conventions' MenuItem example used group_key "ui.nav_groups.administration"; the vocabulary is access|appearance| content|system, so the example shipped a key with no catalog entry. - fixtures.md posted form data to /users/admin/invite; the endpoint is /api/users/admin/invite and takes JSON. Then the features with no docs at all: - middleware.md listed 9 of 14 middlewares. Adds ProxyHeaders, GZip, InertiaCache, Maintenance and CommitBeforeResponse to both order blocks, with a section each, and states why the innermost three are ordered as they are — Maintenance inside InertiaCache so its short-circuited 503 does not ship a per-user payload past the cache guard, CommitBeforeResponse innermost so its send-wrapper sees the response first. - lifecycle.md was missing register_admin_routes and register_audit_links, and still showed register_event_handlers' one-arg signature. - i18n.md gains label_key/group_key, the cross-locale fallback, the ci-check-untranslated gate with its three exemption forms and its documented blind spot, and the installed-vs-active type generation split. - Maintenance mode had no operator documentation anywhere; deployment.md now covers what stays reachable, that it fails open, and that it is not a security boundary. - The CLI reference never listed smpy add / update / module verify / build. - pages.md gains AdminLayout, PageShell's section prop, and the topbar / breadcrumb / palette chrome. Two factual corrections beyond the missing pieces. env-vars.md claimed task_always_eager was "the one field still read from the environment" — #281 made broker_url and result_backend env-readable too, which is what lets a container boot before any DB row exists. And i18n.md documented SM_I18N_* as app configuration; those are read only by the standalone diagnostics runner, while the app takes its locale set from DB-backed HostSettings. Also adds SM_TRUSTED_PROXY, previously undocumented in the reference despite being required behind a TLS-terminating proxy. Verified: vitepress build clean (no dead links), check_readmes and check_metadata pass. Every prefix, hook, field and route name in the diff was read out of the source rather than inferred. Claude-Session: https://claude.ai/code/session_01JJtbN97VhtDr28Fuy5JKEF --- docs/framework-conventions.md | 26 +++++++--- docs/framework/i18n.md | 86 +++++++++++++++++++++++++++++++-- docs/framework/lifecycle.md | 58 +++++++++++++++++++++- docs/framework/middleware.md | 74 +++++++++++++++++++++++++++- docs/frontend/pages.md | 34 +++++++++++++ docs/guide/quickstart.md | 3 +- docs/modules/audit_log.md | 2 +- docs/modules/dashboard.md | 16 +++--- docs/modules/feature_flags.md | 6 +-- docs/modules/file_storage.md | 2 +- docs/modules/permissions.md | 12 ++--- docs/modules/settings.md | 2 +- docs/modules/users.md | 17 ++++--- docs/reference/deployment.md | 27 +++++++++++ docs/reference/env-vars.md | 17 ++++--- docs/reference/make-commands.md | 24 +++++++++ docs/testing/fixtures.md | 2 +- 17 files changed, 358 insertions(+), 50 deletions(-) diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 1a83bbbd..377cce6c 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -239,15 +239,15 @@ way. MenuItem( label="Users", # fallback, still required label_key="users.nav.users", # module's own namespace - url="/users/admin", - group="Administration", - group_key="ui.nav_groups.administration", # shared vocabulary + url="/admin/users/", + group="Access", + group_key="ui.nav_groups.access", # shared vocabulary ) ``` Menus are translated **on the server**, in `MenuRegistry.get_for_user(translate=…)`, so the Inertia payload carries finished text and every render site (sidebar, topbar, command palette) keeps reading `item.label`. Two consequences worth knowing: an admin-audience module's labels don't need to be in the anonymous catalog snapshot to render, and a key that resolves to nothing falls back to `label` — a missing translation degrades to English, never to a raw dotted key on screen. Both fields are optional, so modules written before them keep working unchanged. - Group headers are shared across modules, so they live in the `ui` namespace (`ui.nav_groups.administration|system|content`) rather than each module inventing its own key — otherwise one module's "Administration" could translate differently from another's and split a single header in two. + Group headers are shared across modules, so they live in the `ui` namespace (`ui.nav_groups.access|appearance|content|system`) rather than each module inventing its own key — otherwise one module's "System" could translate differently from another's and split a single header in two. Those four are the whole vocabulary the bundled modules use; adding a fifth means adding the key to `packages/ui/locales/*.json` so every module can share it. - `i18n` — active locale and translation bundle. The framework does not know the shape of `auth.user`. The `auth` module registers a `principal_serializer: Callable[[UserContext], dict]` on `app.state.principal_serializer` during `register_settings(app)`; the middleware calls it with `request.state.user` to build the `auth.user` payload. Without a registered serializer, `auth.user` is `None` even when a user is authenticated. @@ -364,9 +364,9 @@ test apps) are exempt. ### Error responses (HTML vs JSON) -403/404/422/500 are content-negotiated. Requests under `/api/*` — the -documented prefix for every module's JSON surface — or with an explicit -`Accept: application/json` get a JSON body: +401, 403, 404, 419, 422, 429, 500 and 503 are content-negotiated. Requests +under `/api/*` — the documented prefix for every module's JSON surface — or +with an explicit `Accept: application/json` get a JSON body: ```json { "detail": "Permission required: pagebuilder.edit" } @@ -382,6 +382,18 @@ file-download `` hrefs are real navigations to API paths); a bare `fetch()` sends `Accept: */*` and gets JSON there. Module endpoint code doesn't opt in or out — raise `HTTPException` as usual and the handler picks the right shape. +Each of those statuses has its own copy on the error page, so a 429 reads as +"too many requests" rather than a bare "an unexpected error occurred". 401 and +419 additionally offer **sign in** as the primary action, since that is the +actual remedy — the URL comes from the auth provider via `app.state` rather +than an import (framework code must not reach into modules, `SM009`), so an app +with no provider installed simply gets no button. + +Headers the exception carries — `WWW-Authenticate` on a 401, `Retry-After` on a +429 or 503 — are passed through to both the rendered page and the JSON +fallback. Those statuses are the ones whose headers mean something, so moving +them onto the page-rendering branch would otherwise have dropped them. + ### Design packs (site-wide look) A *design pack* is a stylesheet a module ships that restyles the public site by diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md index f9c14e90..3af8bd48 100644 --- a/docs/framework/i18n.md +++ b/docs/framework/i18n.md @@ -171,14 +171,90 @@ Both are auto-discovered alongside module contributions — no manual wiring. ## Configuration -```bash -SM_I18N_DEFAULT_LOCALE=en -SM_I18N_SUPPORTED_LOCALES=en,es,de -SM_I18N_COOKIE_NAME=locale -``` +i18n config is **DB-backed**, on `HostSettings` (registered under `package="host"`) — not env vars. Edit it in the admin UI at `/admin/settings/` under the host section: + +| Field | Default | Purpose | +|---|---|---| +| `i18n_default_locale` | `en` | Language served when nothing else resolves, and the fallback every other locale layers over. | +| `i18n_supported_locales` | `["en"]` | Which locales are served at all. The `` hides itself when there is only one. | +| `i18n_cookie_name` | `locale` | Cookie the switcher writes and `LocaleMiddleware` reads first. | A pydantic validator enforces that the default locale is in the supported list. +> `SM_I18N_DEFAULT_LOCALE` / `SM_I18N_SUPPORTED_LOCALES` exist, but only the standalone diagnostics runner (`python -m simple_module_core`, i.e. `make doctor`) reads them — it has no DB to consult, so it takes the locale set from env and skips the i18n checks entirely when it is unset. Setting them does **not** change what the running app serves. + +Shipping a locale is not the same as enabling it. `es.json` files exist across this repo but `es` is not in the default supported list, so they are never loaded. Turning one on is a settings change, not a deploy. + +## Falling back across locales + +Each non-default locale's snapshot is layered **over** the default locale's before it reaches the client, so an untranslated key renders in the default language rather than as a raw dotted key. + +This matters more than it sounds. The client initialises i18next with `fallbackLng` set to the *active* locale, so there is no cross-locale fallback in the browser — a key missing from the payload renders as `dashboard.home.system_meta` on screen. The server-side `Translator` has always fallen back to the default locale, so without this the two paths disagreed: menu labels (translated server-side) rendered English while the page body showed keys. + +The practical effect is that partial translation is a **safe, incremental state**. A half-finished locale reads as a mix of two languages, not as a screen of dotted keys. + +## Translating menu labels and audit links + +Some strings are chosen in Python and rendered on every page. Those are translated **server-side** rather than shipped to the client as keys. + +`MenuItem` takes `label_key` and `group_key` alongside `label`/`group`; `MenuRegistry.get_for_user(translate=…)` resolves them, so the Inertia payload carries finished text and every render site (sidebar, topbar, ⌘K palette) keeps reading `item.label` untouched. `AuditLink.label_key` does the same for audit-log entity labels. + +```python +MenuItem( + label="Users", # fallback, still required + label_key="users.nav.users", # module's own namespace + url="/admin/users/", + group="Access", + group_key="ui.nav_groups.access", # shared vocabulary +) +``` + +Two consequences worth knowing: + +- An admin-audience module's labels don't need to be in the anonymous catalog snapshot to render, because they are resolved before the payload is built — this sidesteps the [audience](#audience) split. +- A key that resolves to nothing falls back to `label`. A missing translation degrades to English, never to a raw dotted key on screen. + +Both fields are optional, so modules written before them keep working. Group headers are shared across modules and live in the `ui` namespace (`ui.nav_groups.access|appearance|content|system`) rather than each module inventing its own — otherwise one module's "System" could translate differently from another's and split a single header in two. + +## The untranslated-string gate (`make ci-check-untranslated`) + +`SM013`–`SM016` only compare catalogs against *each other*, so with `i18n_supported_locales = ["en"]` they never fire — a module could ship a complete `en.json` that no page ever read and CI stayed green. `tsc` is equally happy with hardcoded English. This check is what notices. + +It runs in `make lint` and as its own CI job, parsing every `.tsx` under `modules/*/*/`, `packages/ui/src/` and `host/client_app/` (skipping vendored shadcn primitives under `packages/ui/src/components/ui/`, plus `.test.tsx` and `.stories.tsx`). It fails on user-visible text rendered as a literal: + +- JSX text — `

Save

` +- An allowlisted text attribute — `title`, `placeholder`, `aria-label`, `label`, `description`, `alt`, `emptyText`, `confirmLabel`, … The list is deliberately closed: `className`, `variant`, `role` and `type` carry machine tokens, and flagging those would train people to reach for the exemption instead of the catalog. +- A `toast.*` / `confirm` argument +- Copy hidden in a ternary — `cond ? 'Enabled' : 'Disabled'` + +It parses rather than greps, which costs one devDependency (`@babel/parser`). No regex over JSX can distinguish `

Save

` from the `Promise` in a type annotation, and one that tries flags both. + +### Exempting a legitimate literal + +Three escape hatches, in order of preference: + +| How | When | +|---|---| +| Wrap in `` / `
` | Code samples, identifiers, terminal output — recognised automatically, no comment needed. |
+| `// i18n-exempt: ` | One line. A truncated token echo, a JSON example placeholder. |
+| `// i18n-exempt-file: ` | A whole file. Reserved for dev-only fixtures like `DemoPlaceholders.tsx`. |
+
+Always give a reason — the comment is the only record of why the string is allowed to stay English.
+
+### Known blind spot
+
+A string that reaches the screen through a variable or config object (`const THEME = { mobileTitleLabel: 'Admin' }`) is invisible to this check. Catching it needs taint analysis; guessing instead would produce the false positives that get a check switched off. Documented rather than papered over.
+
+The detection logic lives in `scripts/lib/untranslated-strings.mjs` behind its own unit tests, so a later "fix" to one of its heuristics cannot quietly stop it detecting anything.
+
+## Type generation
+
+`packages/i18n/src/keys.generated.ts` is generated over every **installed** module, not just the ones the running host activated, and `make ci-js-typecheck` runs `tsc -p` for each `modules/*/tsconfig.json` and `packages/*/tsconfig.json` in the workspace. (A module shipping `.tsx` with no `tsconfig.json` fails the target outright, rather than being silently skipped — the same gap `SM017` reports.)
+
+That distinction is load-bearing: with a filtered union, translating an inactive auth provider's pages (say `keycloak`, when this host runs `users`) would break its build on the next regeneration while the app itself ran fine. The runtime registry stays filtered — an inactive module's strings are typed, never served.
+
+`t()` needs literal keys to typecheck; a key assembled at runtime won't resolve.
+
 ## Diagnostics (`SM013`–`SM016`)
 
 App boot runs `I18nDiagnostics` against every declared locale dir:
diff --git a/docs/framework/lifecycle.md b/docs/framework/lifecycle.md
index b2b264da..4c9e3d06 100644
--- a/docs/framework/lifecycle.md
+++ b/docs/framework/lifecycle.md
@@ -14,9 +14,11 @@ register_health_checks
 register_public_routes
 register_csp_sources
 register_design_packs
+register_audit_links
 register_exception_handlers
 register_middleware
 register_routes
+register_admin_routes       (only when meta.admin_view_prefix is set)
 -- on_startup (async, after middleware is installed)
 ```
 
@@ -99,12 +101,12 @@ if flags.is_enabled("orders.new_checkout", tenant_id=request.state.tenant_id):
     ...
 ```
 
-## `register_event_handlers(bus)`
+## `register_event_handlers(bus, app=None)`
 
 Subscribe to events on the in-process `EventBus`. Handlers can be sync or async; the bus awaits async ones.
 
 ```python
-def register_event_handlers(self, bus: EventBus) -> None:
+def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
     from orders.contracts.events import OrderPlaced
 
     bus.subscribe(OrderPlaced, self._on_order_placed)
@@ -115,6 +117,8 @@ async def _on_order_placed(self, event: OrderPlaced) -> None: ...
 
 Handlers are keyed by the exact event type and run concurrently on publish. See [Events](/framework/events).
 
+`app` is optional. Take it when a handler needs `app.state.sm.db.session_factory` to persist on the framework's engine rather than building its own. The framework inspects your signature and calls the one-argument form `(self, bus)` when that is what you declared, so modules written before `app` existed keep working unchanged.
+
 ## `register_health_checks(registry)`
 
 Register named async checks. They're surfaced at `/health/ready`:
@@ -191,6 +195,56 @@ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None
 
 The framework **auto-applies** `ModuleMeta.route_prefix` / `view_prefix` — `create_app` constructs each router already prefixed (`APIRouter(prefix=module.meta.route_prefix)`), so your `include_router` calls usually pass no further prefix (add one only for a sub-grouping *inside* the module's own prefix).
 
+## `register_admin_routes(admin_router)`
+
+A **second** view router, for modules that serve both public and admin pages and so cannot express both under one `view_prefix`. Called only when `ModuleMeta.admin_view_prefix` is set; the router arrives pre-built with that prefix.
+
+```python
+meta = ModuleMeta(
+    name="Users",
+    view_prefix="/users",              # sign-in, self-service
+    admin_view_prefix="/admin/users",  # management CRUD
+)
+
+
+def register_admin_routes(self, admin_router: APIRouter) -> None:
+    from users.admin.views import router as admin_views
+
+    admin_router.include_router(admin_views)
+```
+
+A module gets exactly one view router, which is fine for a pure-admin module — it just points `view_prefix` at `/admin/...` and needs none of this. The second router exists for the cases where that doesn't work: `users` keeps `/users/login` public while its CRUD lives at `/admin/users`, and `dashboard` keeps `/dashboard/` while Doctor lives at `/admin/doctor`.
+
+Both the field and the hook are additive and default to no-op, so a module written before they existed is unaffected.
+
+> Putting a screen in the admin section means moving **three** things together: the URL (here), the menu registration (`MenuSection.ADMIN_SIDEBAR` in `register_menu_items`), and the layout the page renders in (`AdminLayout`). Change one and you get a page whose sidebar no longer lists it — a failure nothing about the diff makes obvious.
+
+## `register_audit_links(registry)`
+
+Teach the audit log how to link an entry back to the entity it describes, so a row reads as a link to the record rather than a bare type name.
+
+```python
+def register_audit_links(self, registry: AuditLinkRegistry) -> None:
+    from users.models import User
+
+    registry.register(
+        AuditLink(
+            entity_type=User.__name__,  # class name — NOT __tablename__
+            url_template="/admin/users/{id}",
+            label="User",
+            label_key="users.audit.user",
+        )
+    )
+```
+
+`entity_type` matches `AuditEntry.entity_type`, which `snapshot_changes` writes as `type(obj).__name__`. Keying it off `__tablename__` (`"users_user"`) produces a link that never matches — and nothing errors: an unmatched lookup falls back to rendering `entity_type` as a plain label, so the row still shows text while silently never becoming a link. Using `Model.__name__` rather than a string literal makes a rename impossible to get wrong.
+
+`url_template` must contain the literal `{id}` placeholder, substituted with the entity id. A template without it raises at boot rather than pointing every row at the same page.
+
+`label_key` translates the label the same way `MenuItem.label_key` does, falling back to `label` when the key resolves to nothing. Rows are rendered server-side, so the audit view translates these before they reach the page.
+
+The registry maps class names to URL templates and nothing more: it does not check that the row still exists or that the reader may open it. A link to a deleted record lands on the target screen's own 404, and permissions are enforced by the target route as usual.
+
 ## `on_startup()` / `on_shutdown()`
 
 Async lifespan hooks that run after all modules are registered.
diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md
index 1773b899..9cfae91d 100644
--- a/docs/framework/middleware.md
+++ b/docs/framework/middleware.md
@@ -9,6 +9,9 @@ The actual `add_middleware` call order (in `install_middleware`) is the
 
 ```python
 # Added first → executed last (closest to the app)
+app.add_middleware(CommitBeforeResponseMiddleware)
+app.add_middleware(MaintenanceMiddleware)
+app.add_middleware(InertiaCacheMiddleware)
 app.add_middleware(InertiaLayoutDataMiddleware, ...)
 app.add_middleware(LocaleMiddleware, ...)
 
@@ -20,18 +23,26 @@ for module in discovered_modules:
 
 app.add_middleware(SessionMiddleware, secret_key=...)
 app.add_middleware(SecurityHeadersMiddleware, ...)
+app.add_middleware(GZipMiddleware, minimum_size=...)
 app.add_middleware(RequestLoggingMiddleware)
 app.add_middleware(CorrelationIdMiddleware)
+
+if settings.trusted_proxy:
+    app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=...)
 # Added last → executed first (outermost wrapper)
 ```
 
 ## Execution order (per request)
 
 ```
+ProxyHeaders                (only if SM_TRUSTED_PROXY is set)
+  ↓
 CorrelationId
   ↓
 RequestLogging
   ↓
+GZip
+  ↓
 SecurityHeaders
   ↓
 Session
@@ -44,13 +55,33 @@ Locale
   ↓
 InertiaLayoutData
   ↓
+InertiaCache
+  ↓
+Maintenance
+  ↓
+CommitBeforeResponse
+  ↓
 app (route handler)
 ```
 
 The response flows back up in the reverse of this order.
 
+The last three are ordered relative to each other for reasons worth stating, because each is the kind of thing that looks arbitrary until it breaks:
+
+- **`Maintenance` sits *inside* `InertiaCache`.** Its 503 is an Inertia payload produced by short-circuiting, carrying this user's auth block and menus like any other. Short-circuiting outside the cache guard would ship exactly the per-user payload that guard exists to keep out of caches. Because its `self.app` is the middleware below it, the 503 still travels back out through `InertiaCache`'s send-wrapper and picks up the same headers.
+- **`Maintenance` runs *after* `InertiaLayoutData`, `Locale` and auth.** It needs the shared props to render with a layout instead of bare, the locale to answer in the right language, and the resolved user to know whether the caller is an admin who should pass through.
+- **`CommitBeforeResponse` is innermost.** It hooks the `send` channel, so being added first makes its wrapper the first to see the response — which is what lets the commit land before any byte is written.
+
 ## What each built-in does
 
+### `ProxyHeadersMiddleware` *(opt-in)*
+
+uvicorn's own, installed **only** when `SM_TRUSTED_PROXY` is set — forwarded headers are never trusted by default. It sits outermost so the `X-Forwarded-*`-corrected scheme and client IP reach everything downstream: request logs record the real client rather than the proxy, and `request.url.scheme` reflects `X-Forwarded-Proto`.
+
+Behind a TLS-terminating proxy this is **required**, not a nicety. Without it the app believes it is serving `http` while the browser is on `https`, Inertia's `pushState` sees a cross-scheme URL, throws a `SecurityError`, and login breaks.
+
+Set it to a comma-separated list of proxy IPs/CIDRs, or `*` to trust any peer — correct when the container is only reachable through one proxy, wrong when anything else can connect.
+
 ### `CorrelationIdMiddleware`
 
 Reads the `X-Correlation-ID` header (or generates a UUID4 hex) and makes the value available three ways:
@@ -91,6 +122,10 @@ No per-handler `bind()` and no middleware of your own — the framework's middle
 
 Emits a structured log line per request with method, path, status, duration, and correlation ID. Respects `SM_LOG_FORMAT` (plain vs JSON) and `SM_LOG_LEVEL`.
 
+### `GZipMiddleware`
+
+Starlette's, compressing any response body over `COMPRESSION_MIN_BYTES` (500). Placed inside `CorrelationId` and `RequestLogging` — which set headers and read request state — but outside everything that produces a body, **including the `/static` mount**, which is where it earns its place: the built CSS is ~139 KB raw against ~21 KB gzipped, and the JS bundle compresses about 3×. Uncompressed assets dominated cold page load, several times larger than anything on the server request path.
+
 ### `SecurityHeadersMiddleware`
 
 Sets conservative defaults: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection: 0` (the legacy auditor is disabled in favour of CSP), plus a default CSP and — outside development — HSTS. In development the CSP is widened for the Vite dev origin and HSTS is suppressed. Modules that load assets from an external origin extend the policy through the [`register_csp_sources`](lifecycle.md#register_csp_sourcesregistry) hook; both the dev and production variants honor those origins. Override on a per-route basis with your own response headers.
@@ -122,6 +157,43 @@ Runs last (closest to the app). Populates `request.state.inertia_shared` with:
 
 Inertia responses pick these up automatically via `InertiaDep` from `simple_module_hosting.inertia_deps`.
 
+### `InertiaCacheMiddleware`
+
+Keeps the Inertia payload out of caches that answer page requests.
+
+Every Inertia route serves one URL as two representations, chosen on the request's `X-Inertia` header: an HTML document for a full page load, a JSON payload for a client-side visit. Nothing in either response says so, which leaves a cache free to store one and hand back the other — visit a page through the SPA, then open the same URL directly, and the browser can serve the stored payload as the document. The visitor gets `{"component":"...","props":{...}}` where the page should be.
+
+That only bites once a route marks itself cacheable, which a public-content module reasonably does. What makes it unsafe is the framework: `InertiaLayoutDataMiddleware` merges the signed-in user's `auth` block, permission list and menus into **every** payload. A route author choosing `Cache-Control: public` for their own page content has no way to know that — which makes this a disclosure bug, not just a broken page — so the guarantee lives here rather than in each module:
+
+- **An Inertia payload is never stored.** `Cache-Control: private, no-store`, and the ETag is dropped so no cache can revalidate its way back to a copy it should not have kept. The cost is per-visit caching on client-side navigation, which was never safe to take: those bytes are specific to one user.
+- **Both representations declare `Vary: X-Inertia`**, so a cache that honours `Vary` keeps them in separate entries. `Vary` is added to the document only when the response is HTML, so static assets and JSON APIs keep the validators and cache entries they had.
+
+A module that wants its public page content cached should give the **document** its own `Cache-Control` and an ETag. That path is left alone — this middleware only governs the payload.
+
+The Inertia-request predicate mirrors `fastapi-inertia`'s own, which is presence-only (`"X-Inertia" in headers`), not an equality check against `true`. The two must agree: if the library renders JSON for `X-Inertia: 1` while this middleware doesn't recognise it as Inertia, the payload goes back marked however the route marked it — reopening the leak by changing one header value.
+
+### `MaintenanceMiddleware`
+
+Serves everyone but admins a 503 page while `maintenance_mode` is set on `HostSettings`. See [Maintenance mode](/reference/deployment#maintenance-mode) for operating it.
+
+The flag is DB-backed rather than an env var on purpose: flipping it must not require a redeploy, which is exactly the moment you least want one.
+
+Admins pass through — someone has to be able to reach the settings screen and switch it back off. For the same reason the auth provider's own routes stay open, so an admin who was signed *out* when the switch flipped can still sign in. Three prefixes stay reachable regardless: `/health` (so orchestrators don't kill the pod mid-maintenance), `/static/` and `/i18n/` (or the 503 renders unstyled and untranslated). Module routes registered through `register_public_routes` are honoured too, which is how branding's logo and favicon keep the maintenance page on-brand.
+
+It **fails open** on missing config: a configuration gap taking the site down is the exact failure this feature would otherwise cause.
+
+The middleware marks the request (`request.state.maintenance = True`) so the error page can tell a planned outage from the same status arriving unbidden. That matters most when the operator sets no message, which is the case where the page has nothing else to say.
+
+### `CommitBeforeResponseMiddleware`
+
+Finalizes the request's DB sessions at the ASGI `http.response.start` message — the last point still inside the request, late enough that response serialization has already run, early enough that a commit failure can still become a 500.
+
+It exists because FastAPI runs a `yield` dependency's exit code *after* the response is delivered. `get_db` used to commit there, so a client that created a row and immediately read it back in a second request lost the race and got a deterministic 404 (GH #257).
+
+Pure ASGI rather than `BaseHTTPMiddleware`, because the hook point is the `send` channel rather than the response object. `get_db` keeps the same commit in its own exit code as a fallback for when the middleware isn't in the stack; the session is claimed once, so whichever runs first wins.
+
+> A request normally has exactly one session, since FastAPI caches the dependency. With several — `Depends(get_db, use_cache=False)` — a failure part-way through leaves the earlier commits durable while the client sees a 500. There is no cross-session atomicity to recover short of two-phase commit; the logged `db.session.commit_failed` is what makes it diagnosable.
+
 ## Module middleware ordering
 
 When two modules at the **same dependency tier** both call `app.add_middleware(...)` in their `register_middleware` hook, the framework invokes their hooks in topological order with a stable tiebreaker (module name). Because `add_middleware` is LIFO, the module that sorts **later** wraps its middleware **outermost** — so it runs **first** on the request.
@@ -130,7 +202,7 @@ Concretely, if modules `alpha` and `beta` both register middleware:
 
 - `alpha` runs first (alphabetical tiebreaker, no `depends_on`).
 - `beta.register_middleware` runs last, so its middleware is the outermost wrap.
-- On a request: `beta.mw → alpha.mw → tenant → locale → app`.
+- On a request: `beta.mw → alpha.mw → tenant → locale → inertia → … → app`.
 
 If you need a specific relative order, express it with `ModuleMeta.depends_on`. **Do not rely on names** — another module could be installed tomorrow that sorts differently.
 
diff --git a/docs/frontend/pages.md b/docs/frontend/pages.md
index 88ca6098..7f64a5c8 100644
--- a/docs/frontend/pages.md
+++ b/docs/frontend/pages.md
@@ -133,6 +133,40 @@ Browse.layout = (page: ReactNode) => {page}.settings` is replaced with a fresh instance built from DB overrides + pydantic defaults during the host's hydrate phase, before `on_startup` runs.
 - **Env-var migration**: `smpy settings import-from-env` scans every registered class's `env_prefix` and seeds matching `SM_*` env vars as SYSTEM-scoped rows. Idempotent.
-- **Admin editing**: registered fields appear at `/settings/modules/` with type-aware inputs.
+- **Admin editing**: registered fields appear under that package at `/admin/settings/` with type-aware inputs.
 - **Hot reload**: saving via the admin UI calls `apply_changes_and_reload`, which validates the diff against the pydantic class, persists deltas, swaps the live `app.state..settings`, and publishes [`SettingsReloaded`](#events) so dependents (SMTP clients, Celery configs, …) can rebuild.
 
 ### Read settings at request time (generic K/V)
diff --git a/docs/modules/users.md b/docs/modules/users.md
index 97a55534..4c083f25 100644
--- a/docs/modules/users.md
+++ b/docs/modules/users.md
@@ -80,12 +80,15 @@ Authenticated:
 - `GET /users/me` → `Users/Profile`
 - `PATCH /users/me` → form action (redirects)
 
-Admin (`users.manage`):
+Admin (`users.manage`) — served from the module's `admin_view_prefix`, `/admin/users`:
 
-- `GET /users/admin` → `Users/Users/Index`
-- `GET /users/admin/invite` → `Users/Users/Invite`
-- `GET /users/admin/create` → `Users/Users/Create`
-- `GET /users/admin/{user_id}` → `Users/Users/Edit`
+- `GET /admin/users/` → `Users/Users/Index`
+- `GET /admin/users/add` → `Users/Users/AddPeople` — one form for both flows; `?mode=create|invite` picks which
+- `GET /admin/users/{user_id}` → `Users/Users/Edit`
+
+The separate Create and Invite pages merged into `AddPeople`. Their old paths survive as 307 redirects carrying the mode: `GET /admin/users/invite` → `/admin/users/add?mode=invite`, and `GET /admin/users/create` → `/admin/users/add?mode=create`.
+
+These pages moved out of `/users/admin` when the admin screens were gathered under `/admin` — the pre-move URLs still 301 for now, but link to the new ones. The `/api/users/admin/*` endpoints below are a separate contract and did **not** move.
 
 ## Public contracts
 
@@ -168,7 +171,7 @@ DB-backed via `register_module_settings`. Two values are read **only** from the
 
 Both **must** be replaced with non-placeholder values in production — the boot-time check refuses to start otherwise.
 
-Everything else is DB-backed (initial values are pydantic defaults; edit at `/settings/modules/users`):
+Everything else is DB-backed (initial values are pydantic defaults; edit under Users at `/admin/settings/`):
 
 | Field | Default |
 |---|---|
@@ -260,7 +263,7 @@ Two paths to seed the first admin:
 
 ## OAuth providers
 
-OAuth/OIDC sign-in is **DB-settings-driven**: a provider is enabled simply by setting its `client_id` + `client_secret` (and, for generic OIDC, a discovery URL) in the settings UI at `/settings/modules/users`. No code change or restart — `register_event_handlers` rebuilds the client cache (`app.state.users.oauth_clients`) on the `SettingsReloaded` event, so changes take effect live. Providers with no credentials are silently skipped.
+OAuth/OIDC sign-in is **DB-settings-driven**: a provider is enabled simply by setting its `client_id` + `client_secret` (and, for generic OIDC, a discovery URL) in the settings UI under Users at `/admin/settings/`. No code change or restart — `register_event_handlers` rebuilds the client cache (`app.state.users.oauth_clients`) on the `SettingsReloaded` event, so changes take effect live. Providers with no credentials are silently skipped.
 
 Built-in provider keys (the `{provider}` URL segment):
 
diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md
index 10671967..61acf7da 100644
--- a/docs/reference/deployment.md
+++ b/docs/reference/deployment.md
@@ -200,6 +200,33 @@ Outside `development`/`testing` (i.e. any other `SM_ENVIRONMENT`), the app serve
 - **Manifest-based Inertia rendering.** In dev/testing the page loads `main.tsx` from the Vite dev server; otherwise the app reads the built Vite manifest and emits content-hashed `