From cc79f4ae5eece8927965dfe74cadfe1013e67537 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 3 Nov 2026 21:08:09 +0100 Subject: [PATCH 1/7] fix(hosting,inertia,client): per-tab i18n catalogs; adapter owns Inertia cache rules; lazy layout props - client reports the catalog it holds (`X-SM-I18n: :`) and the server sends messages only on a mismatch, so a second tab is not left on the old language; non-rendering requests no longer touch the legacy session marker; one defensive reload when locale and catalog disagree (#788) - simple_module_inertia exports is_inertia_request/INERTIA_HEADER and its middleware forces `private, no-store` and drops ETag on page payloads; hosting's InertiaCacheMiddleware and its divergent header checks are gone (#790) - layout shared props (menus, providers, i18n) resolve once, on demand, for pages and error pages; permissions stay eager (#791) Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 10 + CLAUDE.md | 6 +- docs/framework/i18n.md | 15 ++ docs/framework/middleware.md | 25 ++- docs/frontend/inertia.md | 5 +- docs/frontend/shared-props.md | 34 ++-- docs/modules/site_lock.md | 2 +- .../_error_negotiation.py | 5 +- .../simple_module_hosting/_error_page.py | 4 +- .../simple_module_hosting/_inertia_cache.py | 94 --------- .../simple_module_hosting/_inertia_shared.py | 69 +++---- .../_middleware_pipeline.py | 28 +-- .../simple_module_hosting/inertia_deps.py | 3 +- .../simple_module_hosting/middleware.py | 20 +- .../simple_module_hosting/setup_gate.py | 2 +- .../setup_wizard/routes.py | 2 +- .../simple_module_hosting/shared_props.py | 3 +- framework/hosting/tests/test_grant_sources.py | 3 +- .../tests/test_hosting_inertia_cache.py | 23 +++ .../test_hosting_lazy_layout_catalogs.py | 178 ++++++++++++++++++ .../hosting/tests/test_hosting_permissions.py | 11 +- .../tests/test_inertia_i18n_shared_props.py | 71 ++++--- .../tests/test_inertia_shared_providers.py | 41 ++-- .../hosting/tests/test_maintenance_mode.py | 24 +-- .../tests/test_menu_providers_wiring.py | 35 ++-- .../hosting/tests/test_middleware_order.py | 22 +-- .../inertia/simple_module_inertia/__init__.py | 3 + .../simple_module_inertia/middleware.py | 6 +- .../inertia/simple_module_inertia/request.py | 13 +- .../tests/test_inertia_cache.py | 46 ++--- host/client_app/i18n.test.ts | 108 +++++++++++ host/client_app/i18n.ts | 31 ++- packages/ui/src/types.ts | 1 + 33 files changed, 614 insertions(+), 329 deletions(-) delete mode 100644 framework/hosting/simple_module_hosting/_inertia_cache.py create mode 100644 framework/hosting/tests/test_hosting_inertia_cache.py create mode 100644 framework/hosting/tests/test_hosting_lazy_layout_catalogs.py rename framework/{hosting => inertia}/tests/test_inertia_cache.py (83%) create mode 100644 host/client_app/i18n.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b3caa04..9aa34465b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,16 @@ All notable changes to this project are documented in this file. The format is b - **Behaviour change:** `RedactionFilter` on ordinary logging handlers masks secrets in tracebacks and stack information, including exception text not yet formatted when the filter runs (#789). +- **Behaviour change (#788):** Inertia visits report each tab's cached catalog + with `X-SM-I18n`; locale/audience mismatches resend messages, and raw requests + no longer advance legacy session markers. Clients reload a missing locale once. +- **Behaviour change (#790):** The standalone adapter owns Inertia header + detection and cache protection (`private, no-store`, no ETag, `Vary: X-Inertia`). + Hosting's duplicate cache middleware is removed; document caching is preserved. +- **Behaviour change (#791):** Layout props and module providers resolve once + when a page or error page needs them; JSON APIs/downloads skip that work. + Permission resolution remains eager. Direct layout readers can await + `resolve_inertia_shared(request)` before using `request.state.inertia_shared`. - **Behaviour change:** Inertia GETs with a missing asset-version header now receive 409 when the server version is non-empty; version conflicts include both `X-Inertia-Location` and the current `X-Inertia-Version` (#774). diff --git a/CLAUDE.md b/CLAUDE.md index 1e5bc2287..b0ae808a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,7 +220,7 @@ the host), then async `on_startup` / `on_shutdown` (reverse order). Non-hook ove **Middleware** (Starlette `add_middleware` is LIFO). Execution order per request: `(ForwardedHost if trusted proxy + allowlist) → (ProxyHeaders if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → CORS → BodyLimit → GZip → SecurityHeaders → Session → InertiaMiddleware → → Tenant -(opt-in) → Locale → InertiaLayoutData → InertiaCache → RateLimit → Setup → +(opt-in) → Locale → InertiaLayoutData → RateLimit → Setup → Maintenance → CommitBeforeResponse → app`. Rules that follow from it: - Modules at the same dependency tier: the one that sorts later wraps outermost. @@ -262,7 +262,9 @@ and prop factories (`optional`, `always`, `defer`, `merge`, `once`, `scroll`) come from `simple_module_inertia`. Shared props `auth`, `menus`, `i18n` are set by `InertiaLayoutDataMiddleware`; `auth.user` is null unless `app.state.principal_serializer` is set, and `i18n.messages` is null on every -`X-Inertia` request unless the locale or audience changed. Inertia +`X-Inertia` request when the tab reports a matching locale and audience +via `X-SM-I18n`; clients without that header use the legacy session markers. +Layout props are resolved lazily by page and error rendering; permissions stay eager. Inertia `router.post/patch/put/delete` must target view routes, never `/api/*` (`SM018`). `docs/frontend/`. diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md index b8e9a90b1..19ae33eb9 100644 --- a/docs/framework/i18n.md +++ b/docs/framework/i18n.md @@ -324,3 +324,18 @@ r = await client.get("/", cookies={"locale": "es"}) ``` The `LocaleMiddleware` resolves and the shared props carry the right bundle to the Inertia page. + +## Catalog caching across tabs + +Each tab sends `X-SM-I18n: :` on Inertia visits, describing the +catalog it actually holds. The `i18n` block includes `audience` (`public` or +`full`); messages are omitted only when the tab reports the current pair. +Full document loads always receive messages. Header-bearing requests never +write the legacy session markers. Clients without the header retain session +comparison, with marker writes limited to page-shaped requests; raw API requests +and downloads skip layout resolution entirely. + +The navigation subscriber replaces the catalog on any non-null messages, +including login/logout audience changes. If the server reports a different +locale with null messages, it reloads only `i18n` once, advertising the catalog +still held by that tab. Successful catalog delivery resets that retry guard. diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index 81838cdb7..0af80ca19 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -12,7 +12,6 @@ The actual `add_middleware` call order (in `install_middleware`) is the app.add_middleware(CommitBeforeResponseMiddleware) app.add_middleware(MaintenanceMiddleware) app.add_middleware(RateLimitMiddleware, ...) -app.add_middleware(InertiaCacheMiddleware) app.add_middleware(InertiaLayoutDataMiddleware, ...) app.add_middleware(LocaleMiddleware, ...) @@ -58,7 +57,7 @@ SecurityHeaders ↓ Session ↓ -InertiaMiddleware (303 / fragment 409 / empty-200 back / Vary) +InertiaMiddleware (303 / fragment 409 / empty-200 back / Vary / no-store) ↓ ← in whatever order each module installed them ↓ @@ -68,8 +67,6 @@ Locale ↓ InertiaLayoutData ↓ -InertiaCache - ↓ RateLimit (429 for anonymous public-route traffic) ↓ Maintenance @@ -83,7 +80,7 @@ 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` sits inside the adapter's `InertiaMiddleware`.** Its short-circuit responses receive the same cache protection and protocol normalization as page endpoints. - **`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. @@ -197,15 +194,23 @@ The resolved locale lands on `request.state.locale` and is used by `InertiaLayou ### `InertiaLayoutDataMiddleware` -Runs last (closest to the app). Populates `request.state.inertia_shared` with: +Permissions resolve eagerly for route guards. Other layout work is deferred in +`request.state.inertia_shared_factory`: menus, catalog selection, principal +serialization and module providers run only when a page needs them. Both +`get_inertia` and `render_error_page` await `resolve_inertia_shared(request)`; +its memoised task populates `request.state.inertia_shared` for existing readers. +JSON APIs and downloads do not invoke it. Custom readers should await the same +resolver before accessing the state dict. + +When a page needs layout data, populates `request.state.inertia_shared` with: - `auth.user`, `auth.isAuthenticated`, `auth.permissions` - `menus` — grouped by `MenuSection`, filtered by the current user's permissions -- `i18n` — `{ locale, bundle }` for the resolved locale +- `i18n` — `{ locale, audience, supportedLocales, messages }` for the resolved locale Inertia responses pick these up automatically via `InertiaDep` from `simple_module_hosting.inertia_deps`. -### `InertiaCacheMiddleware` +### Cache protection in `InertiaMiddleware` Keeps the Inertia payload out of caches that answer page requests. @@ -218,7 +223,7 @@ That only bites once a route marks itself cacheable, which a public-content modu 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. +The exported adapter helper `is_inertia_request(scope)` uses header presence (`"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` @@ -245,7 +250,7 @@ Pure ASGI rather than `BaseHTTPMiddleware`, because the hook point is the `send` ## Inertia protocol responses `InertiaMiddleware` comes from `simple_module_inertia` and runs just inside -`Session`, outside every module middleware and `InertiaCacheMiddleware`, so it +`Session`, outside every module middleware, so it also normalises redirects a module middleware issues (an expired session's login redirect after a `DELETE`). For requests carrying `X-Inertia`, it converts 302 and 307 redirects after diff --git a/docs/frontend/inertia.md b/docs/frontend/inertia.md index cbee069e4..5eb062552 100644 --- a/docs/frontend/inertia.md +++ b/docs/frontend/inertia.md @@ -7,7 +7,7 @@ Inertia.js turns classic server-side routing into a single-page app without a se Two moving parts: 1. **`fastapi-inertia`** — renders templates and serializes responses in Inertia's expected shape. -2. **`InertiaLayoutDataMiddleware`** (framework) — attaches shared props (`auth`, `menus`, `i18n`) to every response. +2. **`InertiaLayoutDataMiddleware`** (framework) — lazily supplies shared props (`auth`, `menus`, `i18n`) to rendered pages. ## Rendering a page @@ -129,8 +129,9 @@ Every Inertia response includes: }, i18n: { locale: string, + audience: 'public' | 'full', supportedLocales: string[], - messages: Record | null, // flat key → value; null on unchanged-locale XHR partials + messages: Record | null, // flat key → value; null when the tab reports a matching locale/audience }, } ``` diff --git a/docs/frontend/shared-props.md b/docs/frontend/shared-props.md index 1cd0da6da..35e31a344 100644 --- a/docs/frontend/shared-props.md +++ b/docs/frontend/shared-props.md @@ -24,14 +24,20 @@ interface SharedProps { // Every Inertia response also carries an `i18n` block (read via usePage().props.i18n): interface I18nSharedProps { // SharedProps['i18n'] locale: string; + audience: 'public' | 'full'; supportedLocales: string[]; - messages: Record | null; // null on Inertia XHR partials when the locale is unchanged + messages: Record | null; // null when the tab reports the same locale and audience } ``` ## How they're populated -`InertiaLayoutDataMiddleware` runs **last** in the middleware pipeline (closest to the app). For every outgoing Inertia response, it reads: +`InertiaLayoutDataMiddleware` resolves permissions eagerly and installs a memoised +async layout factory. Page dependencies and error rendering resolve that factory; +JSON API requests and downloads skip it. The resolved props remain available as +`request.state.inertia_shared`; custom readers first await +`resolve_inertia_shared(request)` from `simple_module_hosting._inertia_shared`. +For each rendered page, it reads: - `request.state.user` (set by the auth middleware) and the registered `app.state.principal_serializer` to build `auth.user`. The user's roles drive `auth.permissions`. - `menu_registry.get_for_user(...)`, filtered by the user's auth state and roles, to build `menus`. @@ -161,24 +167,18 @@ t("orders.browse.title"); t("orders.items", { count: orders.length }); // pluralization ``` +Each tab advertises its cached locale/audience via `X-SM-I18n`. A matching +Inertia visit receives `messages: null`; otherwise the server resends the catalog. +The client reloads `i18n` once if it receives a changed locale without messages. + The `i18n.messages` block is the **resolved locale's** translations merged across all modules. Switching the locale (via ``, which POSTs to `/i18n/set-locale`) triggers a full-page navigation so the messages are re-fetched. The active locale and supported locales are on `usePage().props.i18n` (`locale`, `supportedLocales`). ## Extending shared props -If your module needs to inject a new shared prop (e.g. feature flags), do **not** mutate `request.state.inertia_shared` from random handlers — wrap in a dedicated middleware: - -```python -class FeatureFlagsSharedPropsMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request, call_next): - flags = request.app.state.sm.feature_flags.evaluate_all( - principal=getattr(request.state, "user", None) - ) - if not hasattr(request.state, "inertia_shared"): - request.state.inertia_shared = {} - request.state.inertia_shared["flags"] = flags - return await call_next(request) -``` - -Register in `register_middleware`. Because it runs *before* `InertiaLayoutDataMiddleware` (framework middleware runs first on the way in), the flag dict is already attached when the framework middleware merges its own props. +Register a sync or async provider with +`simple_module_hosting.register_inertia_shared_provider(app, provider)` from a +module lifecycle hook. It runs once when shared props are resolved, and its +returned dict is merged after the built-in props. Reserved keys cannot be +overwritten; provider failures are logged and skipped. On the client, a module-specific shared prop is read with `usePageProps<{ flags: Flags }>()`; props every app needs belong in `SharedProps` (`packages/ui/src/types.ts`). diff --git a/docs/modules/site_lock.md b/docs/modules/site_lock.md index 9fa87df51..96853ad4e 100644 --- a/docs/modules/site_lock.md +++ b/docs/modules/site_lock.md @@ -17,7 +17,7 @@ This is not user authentication — that is what [`auth`](/modules/auth), [`user Both dependencies are load-bearing. `Settings` lets `register_module_settings` reach the module registry. `Auth` makes this module sort *after* the auth module, and since middleware is installed in topological order while Starlette's `add_middleware` is LIFO, sorting later means wrapping **outermost** — so `SiteLockMiddleware` executes *before* `AuthMiddleware`. -That ordering is the point of the design: an anonymous visitor gets the gate rather than a redirect to the login page, so a locked site never reveals it has one. The order is pinned by `framework/hosting/tests/test_middleware_order.py`. +That ordering is the point of the design: an anonymous visitor gets the gate rather than a redirect to the login page, so a locked site never reveals it has one. The order is pinned by `framework/hosting/tests/test_hosting_middleware_order.py`. ## Routes diff --git a/framework/hosting/simple_module_hosting/_error_negotiation.py b/framework/hosting/simple_module_hosting/_error_negotiation.py index 7ecee488e..c8ec75fd3 100644 --- a/framework/hosting/simple_module_hosting/_error_negotiation.py +++ b/framework/hosting/simple_module_hosting/_error_negotiation.py @@ -1,9 +1,8 @@ """Choose JSON or HTML for framework error responses.""" +from simple_module_inertia import is_inertia_request from starlette.requests import Request -from simple_module_hosting._inertia_shared import _INERTIA_HEADER - def _explicit_accept_q(accept: str, media_type: str) -> float | None: """Quality the Accept header gives ``media_type`` by explicit listing, or @@ -51,7 +50,7 @@ def _wants_json(request: Request) -> bool: view path keeps its pre-existing default and an ``/api/*`` path keeps getting JSON either way. """ - if request.headers.get(_INERTIA_HEADER): + if is_inertia_request(request.scope): return False accept = request.headers.get("accept", "") html_q = _explicit_accept_q(accept, "text/html") diff --git a/framework/hosting/simple_module_hosting/_error_page.py b/framework/hosting/simple_module_hosting/_error_page.py index c44e6aaa6..97f9fc2da 100644 --- a/framework/hosting/simple_module_hosting/_error_page.py +++ b/framework/hosting/simple_module_hosting/_error_page.py @@ -17,6 +17,8 @@ from starlette.requests import Request from starlette.responses import Response +from simple_module_hosting._inertia_shared import resolve_inertia_shared + logger = logging.getLogger(__name__) # Statuses whose remedy is "sign in", so the page offers that as its primary @@ -103,7 +105,7 @@ async def render_error_page( # repeated here. Without it the error page renders raw translation keys # (ui.error_page.not_found_title) and loses auth/menus, so a signed-in # user's 404 has no layout. - shared = getattr(request.state, "inertia_shared", None) + shared = await resolve_inertia_shared(request) if shared: inertia.share(**shared) # The correlation id is the only handle a user has on their own failed diff --git a/framework/hosting/simple_module_hosting/_inertia_cache.py b/framework/hosting/simple_module_hosting/_inertia_cache.py deleted file mode 100644 index 9f791e673..000000000 --- a/framework/hosting/simple_module_hosting/_inertia_cache.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Keep 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 it back for 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. The framework is what makes it unsafe: -:class:`~simple_module_hosting.middleware.InertiaLayoutDataMiddleware` merges -the signed-in user's ``auth`` block, their permission list and the menus their -roles resolve to into *every* Inertia payload. A route author choosing -``Cache-Control: public`` for their page content has no way to know that, so -the guarantee belongs here rather than in each module: - -* **An Inertia payload is never stored.** ``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 instead of inferring it from ``Accept``. - -``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 identifies the representation; this -middleware leaves that path alone and only governs the payload. -""" - -from __future__ import annotations - -from simple_module_inertia.middleware import add_vary -from starlette.datastructures import MutableHeaders -from starlette.types import ASGIApp, Message, Receive, Scope, Send - -_SCOPE_HTTP = "http" -_HEADER_INERTIA = b"x-inertia" - -#: What an Inertia payload is allowed to say about its own cacheability. -PAYLOAD_CACHE_CONTROL = "private, no-store" - -#: The request header that selects the representation, and therefore the one -#: caches must key on. -VARY_FIELD = "X-Inertia" - - -def is_inertia_request(scope: Scope) -> bool: - """Whether this request asked for the JSON representation. - - Mirrors ``fastapi-inertia``'s own ``Inertia._is_inertia_request`` exactly — - presence of the header, any value — rather than requiring it to equal - ``"true"``. The library renders JSON for *any* ``X-Inertia`` value, so a - stricter check here would disagree with it: a request the renderer treats - as Inertia would sail through uncached, undoing the whole fix. - """ - return any(key == _HEADER_INERTIA for key, _ in scope.get("headers", ())) - - -def _is_html(headers: MutableHeaders) -> bool: - return headers.get("content-type", "").split(";", 1)[0].strip() == "text/html" - - -class InertiaCacheMiddleware: - """Stop an Inertia payload being cached as though it were the page.""" - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] != _SCOPE_HTTP: - await self.app(scope, receive, send) - return - - inertia = is_inertia_request(scope) - - async def send_with_cache_rules(message: Message) -> None: - if message["type"] == "http.response.start": - headers = MutableHeaders(scope=message) - if inertia: - headers["cache-control"] = PAYLOAD_CACHE_CONTROL - del headers["etag"] - add_vary(headers) - elif _is_html(headers): - add_vary(headers) - await send(message) - - await self.app(scope, receive, send_with_cache_rules) - - -__all__ = ["PAYLOAD_CACHE_CONTROL", "VARY_FIELD", "InertiaCacheMiddleware", "add_vary"] diff --git a/framework/hosting/simple_module_hosting/_inertia_shared.py b/framework/hosting/simple_module_hosting/_inertia_shared.py index fae6c0749..4970a6f41 100644 --- a/framework/hosting/simple_module_hosting/_inertia_shared.py +++ b/framework/hosting/simple_module_hosting/_inertia_shared.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import inspect import logging from collections.abc import Callable @@ -9,6 +10,7 @@ from simple_module_core.app_state import get_inertia_shared_providers, get_services from simple_module_core.i18n import Translator +from simple_module_inertia import is_inertia_request from starlette.datastructures import Headers from starlette.requests import Request from starlette.types import Scope @@ -19,8 +21,7 @@ _I18N_SESSION_LOCALE_KEY = "__i18n_locale" _I18N_SESSION_AUDIENCE_KEY = "__i18n_audience" -_INERTIA_HEADER = "x-inertia" -_INERTIA_HEADER_TRUE = "true" +I18N_CATALOG_HEADER = "X-SM-I18n" #: Shared-prop key the frontend API client reads its ``X-CSRF-Token`` from (#504). CSRF_SHARED_PROP = "csrfToken" @@ -35,9 +36,9 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = Rules: * No registry / no locale → serve an empty English block and log once. - * Inertia XHR partials (``X-Inertia: true``) reuse the client-side - cached messages; send ``messages: None`` unless the locale — or the - audience, see below — differs from what was last served this session. + * Inertia visits (header presence) report their tab's cached catalog in + ``X-SM-I18n: :``. Send messages only on a mismatch; + clients without that header retain the legacy session comparison. * Full page loads and locale transitions ship the complete dict. * Anonymous visitors receive the public snapshot: catalogs of modules declaring ``i18n_audience="admin"`` are withheld (issue #248). A login @@ -58,36 +59,25 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = ) return {"locale": "en", "supportedLocales": ["en"], "messages": {}} - is_inertia = Headers(scope=scope).get(_INERTIA_HEADER) == _INERTIA_HEADER_TRUE + is_inertia = is_inertia_request(scope) audience = "full" if is_authenticated else "public" - session_dict = scope.get("session") - # When the session is absent (pre-session-middleware routes, WebSocket - # upgrades), treat locale as "unchanged" so Inertia XHR requests still - # skip the messages payload. Non-Inertia requests will always ship them - # regardless of the session state. - if session_dict is not None: - # Remembering the locale only pays off for a client that will come back - # with an Inertia XHR (to skip re-sending the catalog). A bare API or - # health-check call from a cookie-less client will not, and writing - # here would make Starlette's SessionMiddleware emit a two-week - # Set-Cookie on every anonymous response, which keeps shared caches - # from storing it (GH #349). So persist only when a session already - # exists (it has data) or the caller is page-shaped. - persist = bool(session_dict) or is_inertia or _accepts_html(scope) - last_locale = session_dict.get(_I18N_SESSION_LOCALE_KEY) - locale_changed = last_locale != locale - if locale_changed and persist: - session_dict[_I18N_SESSION_LOCALE_KEY] = locale - last_audience = session_dict.get(_I18N_SESSION_AUDIENCE_KEY) - audience_changed = last_audience != audience - if audience_changed and persist: - session_dict[_I18N_SESSION_AUDIENCE_KEY] = audience + cached_catalog = Headers(scope=scope).get(I18N_CATALOG_HEADER) + if cached_catalog is not None: + send_messages = not is_inertia or cached_catalog != f"{locale}:{audience}" else: - locale_changed = False - audience_changed = False - send_messages = (not is_inertia) or locale_changed or audience_changed + session = scope.get("session") + changed = session is not None and ( + session.get(_I18N_SESSION_LOCALE_KEY) != locale + or session.get(_I18N_SESSION_AUDIENCE_KEY) != audience + ) + # Legacy clients remember only page-shaped requests, never raw fetches. + if session is not None and (is_inertia or _accepts_html(scope)): + session[_I18N_SESSION_LOCALE_KEY] = locale + session[_I18N_SESSION_AUDIENCE_KEY] = audience + send_messages = not is_inertia or changed return { "locale": locale, + "audience": audience, "supportedLocales": registry.available_locales(), "messages": ( registry.messages_snapshot(locale, include_admin=is_authenticated) @@ -116,9 +106,8 @@ def lazy_csrf_shared_prop( ) -> dict[str, Callable[[], str]]: """Like :func:`csrf_shared_prop`, but the token is resolved at render time. - The shared props are built for every request; a JSON call or a download - never renders a page, so minting eagerly would write the session (a - ``Set-Cookie``) on responses that have no use for the token. Inertia calls + The token stays lazy even after layout resolution, so an Inertia partial + that excludes it does not mint an unused session token. Inertia calls the callable only when it serialises props. """ if not is_authenticated or request.scope.get("session") is None: @@ -191,3 +180,15 @@ def default_home_url(app: Any) -> str: sm = get_services(app) prefix = getattr(sm, "view_prefixes", {}).get("Dashboard") return f"{prefix}/" if prefix else "/" + + +async def resolve_inertia_shared(request: Request) -> dict: + """Build layout props once on demand, keeping the resolved state dict public.""" + factory = getattr(request.state, "inertia_shared_factory", None) + if factory is not None: + task = getattr(request.state, "inertia_shared_task", None) + if task is None: + task = asyncio.create_task(factory()) + request.state.inertia_shared_task = task + request.state.inertia_shared = await task + return getattr(request.state, "inertia_shared", None) or {} diff --git a/framework/hosting/simple_module_hosting/_middleware_pipeline.py b/framework/hosting/simple_module_hosting/_middleware_pipeline.py index 1574c85de..bfa39cf87 100644 --- a/framework/hosting/simple_module_hosting/_middleware_pipeline.py +++ b/framework/hosting/simple_module_hosting/_middleware_pipeline.py @@ -16,7 +16,6 @@ from simple_module_hosting._body_limit import BodyLimitMiddleware from simple_module_hosting._cors import CorsMiddleware, host_policy from simple_module_hosting._forwarded_host import ForwardedHostMiddleware -from simple_module_hosting._inertia_cache import InertiaCacheMiddleware from simple_module_hosting._rate_limit import RateLimitMiddleware from simple_module_hosting._route_access import API_PREFIX from simple_module_hosting.i18n_middleware import LocaleMiddleware @@ -90,42 +89,25 @@ def install_middleware( (ForwardedHost, if trusted_proxy and forwarded_host_allowlist) → (ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging → CORS → BodyLimit → GZip → Security → Session → InertiaMiddleware → [module] → - (Tenant, if multi_tenant) → Locale → InertiaLayoutData → InertiaCache → + (Tenant, if multi_tenant) → Locale → InertiaLayoutData → RateLimit → Setup → Maintenance → CommitBeforeResponse. """ # Added first, so it is innermost and its send-wrapper is the first to see # the response: the request's DB work commits before any byte reaches the # client, instead of in get_db's post-response exit code (GH #257). app.add_middleware(CommitBeforeResponseMiddleware) - # Inside InertiaCache, so its short-circuit is still governed by it. The - # maintenance 503 renders through Inertia and carries this user's auth - # block and menus like any other payload; short-circuiting *outside* the - # cache guard would ship exactly the per-user payload GH #272 exists to - # keep out of caches. Added before Inertia so it *executes* after it: the - # page needs the shared props (auth, menus, i18n) to render with a layout, - # and auth + locale — both further out — to know who is asking and in which - # language to answer. + # Auth, locale and lazy layout resolution must precede the maintenance page. + # The outer adapter middleware applies protocol and cache rules to it. app.add_middleware(MaintenanceMiddleware) - # Added after Maintenance so it *executes* before it: an install that has - # never been set up has nothing meaningful to put into maintenance mode, - # and the setup redirect should win. Inside InertiaCache for the same - # reason Maintenance is — this short-circuits, and the redirect must not - # be stored by any cache. + # An install awaiting setup takes precedence over maintenance mode. app.add_middleware(SetupMiddleware) - # Added before InertiaCache so it executes just after it: auth has run (it - # can tell anonymous from signed-in), locale/shared props are in place for - # the error page, and a 429 is still marked non-cacheable. + # Auth and locale are available when rendering a rate-limit error page. app.add_middleware( RateLimitMiddleware, public_rate=settings.rate_limit_public, authenticated_rate=settings.rate_limit_authenticated, store=rate_limit_store, ) - # Paired with InertiaLayoutDataMiddleware below, which is what puts this - # user's auth, permissions and menus into every Inertia payload: this one - # makes sure the payload that results is never stored where a page request - # can be answered with it. - app.add_middleware(InertiaCacheMiddleware) app.add_middleware( InertiaLayoutDataMiddleware, menu_registry=menu_registry, diff --git a/framework/hosting/simple_module_hosting/inertia_deps.py b/framework/hosting/simple_module_hosting/inertia_deps.py index 64a3d980d..a7311db21 100644 --- a/framework/hosting/simple_module_hosting/inertia_deps.py +++ b/framework/hosting/simple_module_hosting/inertia_deps.py @@ -7,6 +7,7 @@ from fastapi import Depends, Request from simple_module_inertia import Inertia +from simple_module_hosting._inertia_shared import resolve_inertia_shared from simple_module_hosting.token_guard import GATE_PAGE, mark_token_gate @@ -19,7 +20,7 @@ async def get_inertia(request: Request) -> Inertia: inertia_dep = request.app.state.inertia_dependency inertia = inertia_dep(request, None) - shared = getattr(request.state, "inertia_shared", None) + shared = await resolve_inertia_shared(request) if shared: inertia.share(**shared) diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index 8976dff88..78f42f48d 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -204,7 +204,8 @@ class InertiaLayoutDataMiddleware: """Inject shared data (auth, menus, i18n) into every Inertia response. This middleware reads the user from ``request.state.user`` (set by auth middleware) - and populates ``request.state.inertia_shared`` for the Inertia render function. + and installs an async factory; page resolution populates + ``request.state.inertia_shared`` for the Inertia render function. The ``auth.user`` projection is produced by a module-registered serializer looked up on ``request.app.state.principal_serializer``. Without one, @@ -232,15 +233,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # May demote the principal (a credential its account no longer backs). resolved = await resolve_request_permissions(request, self.permission_registry) user = getattr(request.state, "user", None) - if is_token_principal(user): - # An API token never legitimately renders a page, so the layout - # describes the anonymous visitor: no account, roles, permissions, - # menus or memberships on a 403 page or a public one (N2). The - # cached ``resolved`` set above still gates the token's API calls. - shared = await self._shared(request, scope, None, set()) - else: - shared = await self._shared(request, scope, user, resolved) - request.state.inertia_shared = shared + + # Permissions stay eager for route guards; layout work waits for rendering. + async def build_shared() -> dict: + if is_token_principal(user): + return await self._shared(request, scope, None, set()) + return await self._shared(request, scope, user, resolved) + + request.state.inertia_shared_factory = build_shared await self.app(scope, receive, send) diff --git a/framework/hosting/simple_module_hosting/setup_gate.py b/framework/hosting/simple_module_hosting/setup_gate.py index 3bc521aa0..2435ef1df 100644 --- a/framework/hosting/simple_module_hosting/setup_gate.py +++ b/framework/hosting/simple_module_hosting/setup_gate.py @@ -5,7 +5,7 @@ :class:`~simple_module_core.setup_steps.SetupStep` reports incomplete, this redirects to ``/setup``. -Sits inside ``InertiaCache`` in the middleware pipeline so its redirect is +Sits inside ``InertiaMiddleware`` in the middleware pipeline so its redirect is never stored by a cache, and outside ``Maintenance`` — an install that is not set up has nothing to put into maintenance. """ diff --git a/framework/hosting/simple_module_hosting/setup_wizard/routes.py b/framework/hosting/simple_module_hosting/setup_wizard/routes.py index 2d3deaca5..31a0f1bde 100644 --- a/framework/hosting/simple_module_hosting/setup_wizard/routes.py +++ b/framework/hosting/simple_module_hosting/setup_wizard/routes.py @@ -85,7 +85,7 @@ async def setup_index( }, ) # The document embeds the session's CSRF token and connection diagnostics; - # the Inertia payload is already no-store via InertiaCache, the HTML is not. + # the Inertia payload is already no-store via InertiaMiddleware, the HTML is not. response.headers["Cache-Control"] = "no-store" return response diff --git a/framework/hosting/simple_module_hosting/shared_props.py b/framework/hosting/simple_module_hosting/shared_props.py index f4ed85b85..0bf4600ec 100644 --- a/framework/hosting/simple_module_hosting/shared_props.py +++ b/framework/hosting/simple_module_hosting/shared_props.py @@ -7,7 +7,8 @@ keeping the ``SM009`` framework→plugin import ban intact. A provider is ``Callable[[Request], dict]`` or an ``async`` one. It must be cheap -and total — it runs for every request. :class:`InertiaLayoutDataMiddleware` merges each provider's +and total — it runs once when a page needs shared props. +:class:`InertiaLayoutDataMiddleware` merges each provider's returned dict into the ``shared`` payload after the built-in ``auth``/``menus``/ ``i18n`` blocks; a provider that raises is skipped and logged, never failing the request. diff --git a/framework/hosting/tests/test_grant_sources.py b/framework/hosting/tests/test_grant_sources.py index 73941b71e..1f7113e93 100644 --- a/framework/hosting/tests/test_grant_sources.py +++ b/framework/hosting/tests/test_grant_sources.py @@ -8,6 +8,7 @@ from httpx import ASGITransport, AsyncClient from simple_module_core.menu import MenuRegistry from simple_module_core.permissions import PermissionRegistry +from simple_module_hosting._inertia_shared import resolve_inertia_shared from simple_module_hosting.middleware import InertiaLayoutDataMiddleware from simple_module_hosting.permissions import RequiresPermission, resolve_principal_permissions @@ -69,7 +70,7 @@ async def test_frontend_permissions_include_source(self): async def inner_app(scope, receive, send): from starlette.requests import Request - captured["shared"] = Request(scope).state.inertia_shared + captured["shared"] = await resolve_inertia_shared(Request(scope)) mw = InertiaLayoutDataMiddleware( inner_app, menu_registry=MenuRegistry(), permission_registry=_registry(_grants_view) diff --git a/framework/hosting/tests/test_hosting_inertia_cache.py b/framework/hosting/tests/test_hosting_inertia_cache.py new file mode 100644 index 000000000..09911bda7 --- /dev/null +++ b/framework/hosting/tests/test_hosting_inertia_cache.py @@ -0,0 +1,23 @@ +"""Cache safety through the real hosting middleware pipeline.""" + +import httpx + +_INERTIA = {"X-Inertia": "true"} +_OK = 200 + + +def _vary_fields(response: httpx.Response) -> set[str]: + return {part.strip().lower() for part in response.headers.get("vary", "").split(",")} + + +class TestAgainstTheRealApp: + async def test_a_rendered_view_never_returns_a_storable_payload( + self, authenticated_client: httpx.AsyncClient + ) -> None: + """End to end, through the whole middleware pipeline.""" + resp = await authenticated_client.get("/dashboard/", headers=_INERTIA) + + assert resp.status_code == _OK + assert resp.headers["content-type"].startswith("application/json") + assert resp.headers["cache-control"] == "private, no-store" + assert "x-inertia" in _vary_fields(resp) diff --git a/framework/hosting/tests/test_hosting_lazy_layout_catalogs.py b/framework/hosting/tests/test_hosting_lazy_layout_catalogs.py new file mode 100644 index 000000000..6b77786d7 --- /dev/null +++ b/framework/hosting/tests/test_hosting_lazy_layout_catalogs.py @@ -0,0 +1,178 @@ +"""Regressions for tab-local catalogs and lazy layout work (#788, #791).""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from simple_module_core.i18n import I18nRegistry +from simple_module_core.menu import MenuRegistry +from simple_module_core.permissions import PermissionRegistry +from simple_module_hosting._inertia_shared import build_i18n_block +from simple_module_hosting.middleware import InertiaLayoutDataMiddleware +from simple_module_hosting.shared_props import register_inertia_shared_provider +from starlette.requests import Request + + +def _request(headers=(), session=None): + registry = I18nRegistry(default_locale="en", supported_locales=["en", "de"]) + registry._messages = {"de": {"hello": "Hallo"}} + app = FastAPI() + app.state.sm = SimpleNamespace(i18n_registry=registry) + return Request( + { + "type": "http", + "app": app, + "headers": list(headers), + "session": session if session is not None else {}, + "state": {"locale": "de"}, + } + ) + + +def test_each_tab_reports_its_own_catalog(): + session = {"__i18n_locale": "en", "__i18n_audience": "full"} + for cached, messages in ( + (b"en:full", {"hello": "Hallo"}), + (b"de:full", None), + (b"en:full", {"hello": "Hallo"}), + (b"de:public", {"hello": "Hallo"}), + ): + request = _request([(b"x-inertia", b"1"), (b"x-sm-i18n", cached)], session) + block = build_i18n_block(request.scope, request) + assert block["messages"] == messages + assert block["audience"] == "full" + assert session == {"__i18n_locale": "en", "__i18n_audience": "full"} + + +def test_non_page_request_does_not_update_existing_session(): + session = {"__i18n_locale": "en", "__i18n_audience": "full"} + request = _request(session=session) + build_i18n_block(request.scope, request) + assert session["__i18n_locale"] == "en" + + +def test_non_true_inertia_header_uses_legacy_marker(): + request = _request([(b"x-inertia", b"1")], {"__i18n_locale": "de", "__i18n_audience": "full"}) + assert build_i18n_block(request.scope, request)["messages"] is None + + +async def test_api_request_does_no_layout_work(): + app = FastAPI() + provider = AsyncMock(return_value={"branding": "Acme"}) + menu = MenuRegistry() + menu.get_for_request = AsyncMock(return_value={}) + register_inertia_shared_provider(app, provider) + app.add_middleware( + InertiaLayoutDataMiddleware, menu_registry=menu, permission_registry=PermissionRegistry() + ) + + @app.middleware("http") + async def authenticate(request, call_next): + request.state.user = SimpleNamespace(id="u1", roles=["admin"]) + request.scope["session"] = {"__i18n_locale": "en"} + request.state.locale = "de" + return await call_next(request) + + @app.get("/api/example") + async def api(request: Request): + assert request.state.resolved_permissions == {"*"} + assert request.scope["session"] == {"__i18n_locale": "en"} + return {"ok": True} + + async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: + assert (await client.get("/api/example")).json() == {"ok": True} + provider.assert_not_called() + menu.get_for_request.assert_not_called() + + +async def test_page_and_error_render_resolve_layout_once(tmp_path): + from fastapi.templating import Jinja2Templates + from simple_module_hosting._error_page import render_error_page + from simple_module_hosting._inertia_shared import resolve_inertia_shared + from simple_module_hosting.inertia_deps import get_inertia + from simple_module_inertia import InertiaConfig, inertia_dependency_factory + from starlette.middleware.sessions import SessionMiddleware + + app = FastAPI() + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry._messages = {"en": {"hello": "Hello"}} + config = InertiaConfig(templates=Jinja2Templates(directory=tmp_path), version="") + app.state.sm = SimpleNamespace(i18n_registry=registry, inertia_config=config) + app.state.inertia_dependency = inertia_dependency_factory(config) + app.state.principal_serializer = lambda user: {"id": user.id} + calls = [] + + async def provider(request): + calls.append(request.url.path) + return {"branding": "Acme"} + + register_inertia_shared_provider(app, provider) + app.add_middleware( + InertiaLayoutDataMiddleware, + menu_registry=MenuRegistry(), + permission_registry=PermissionRegistry(), + ) + + class SeedUser: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + request = Request(scope) + request.state.user = SimpleNamespace(id="u1", roles=["admin"]) + request.state.locale = "en" + await self.app(scope, receive, send) + + app.add_middleware(SeedUser) + app.add_middleware(SessionMiddleware, secret_key="test-secret") + + @app.get("/page") + async def page(request: Request): + inertia = await get_inertia(request) + shared = await resolve_inertia_shared(request) + assert shared is request.state.inertia_shared + assert shared is await resolve_inertia_shared(request) + return await inertia.render("Page") + + @app.get("/error") + async def error(request: Request): + return await render_error_page(request, 404, "Missing") + + async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: + for path, status in (("/page", 200), ("/error", 404)): + response = await client.get(path, headers={"X-Inertia": "1", "X-SM-I18n": "en:public"}) + assert response.status_code == status + props = response.json()["props"] + assert props["auth"]["user"] == {"id": "u1"} + assert "menus" in props + assert props["i18n"]["messages"] == {"hello": "Hello"} + assert props["csrfToken"] + assert props["branding"] == "Acme" + assert calls == ["/page", "/error"] + + +def test_empty_inertia_header_still_negotiates_an_error_page(): + from simple_module_hosting._error_negotiation import _wants_json + + request = Request( + { + "type": "http", + "path": "/api/example", + "headers": [(b"x-inertia", b"")], + } + ) + assert _wants_json(request) is False + + +def test_catalog_header_does_not_create_an_anonymous_session(): + request = _request([(b"x-inertia", b"1"), (b"x-sm-i18n", b"en:public")]) + block = build_i18n_block(request.scope, request, is_authenticated=False) + assert block["audience"] == "public" + assert block["messages"] == {"hello": "Hallo"} + assert request.session == {} + + +def test_document_always_sends_catalog_even_with_matching_header(): + request = _request([(b"x-sm-i18n", b"de:full")]) + assert build_i18n_block(request.scope, request)["messages"] == {"hello": "Hallo"} diff --git a/framework/hosting/tests/test_hosting_permissions.py b/framework/hosting/tests/test_hosting_permissions.py index a3667cddd..9027a5151 100644 --- a/framework/hosting/tests/test_hosting_permissions.py +++ b/framework/hosting/tests/test_hosting_permissions.py @@ -8,6 +8,7 @@ from httpx import ASGITransport, AsyncClient from simple_module_core.menu import MenuRegistry from simple_module_core.permissions import PermissionRegistry +from simple_module_hosting._inertia_shared import resolve_inertia_shared from simple_module_hosting.middleware import InertiaLayoutDataMiddleware from simple_module_hosting.permissions import RequiresPermission @@ -65,7 +66,7 @@ async def inner_app(scope, receive, send): req = Request(scope) captured["resolved"] = req.state.resolved_permissions - captured["shared"] = req.state.inertia_shared + captured["shared"] = await resolve_inertia_shared(req) mw = InertiaLayoutDataMiddleware(inner_app, menu_registry=menu_reg, permission_registry=reg) scope = _http_scope(roles=["user"]) @@ -86,7 +87,7 @@ async def inner_app(scope, receive, send): req = Request(scope) captured["resolved"] = req.state.resolved_permissions - captured["shared"] = req.state.inertia_shared + captured["shared"] = await resolve_inertia_shared(req) mw = InertiaLayoutDataMiddleware(inner_app, menu_registry=menu_reg, permission_registry=reg) scope = _http_scope(roles=None) # no user @@ -105,7 +106,7 @@ async def inner_app(scope, receive, send): from starlette.requests import Request req = Request(scope) - captured["shared"] = req.state.inertia_shared + captured["shared"] = await resolve_inertia_shared(req) mw = InertiaLayoutDataMiddleware(inner_app, menu_registry=menu_reg, permission_registry=reg) await mw(_http_scope(roles=["user"]), _noop_receive, _noop_send) @@ -123,7 +124,7 @@ async def inner_app(scope, receive, send): from starlette.requests import Request req = Request(scope) - captured["shared"] = req.state.inertia_shared + captured["shared"] = await resolve_inertia_shared(req) mw = InertiaLayoutDataMiddleware(inner_app, menu_registry=menu_reg, permission_registry=reg) scope = _http_scope( @@ -145,7 +146,7 @@ async def inner_app(scope, receive, send): from starlette.requests import Request req = Request(scope) - captured["shared"] = req.state.inertia_shared + captured["shared"] = await resolve_inertia_shared(req) mw = InertiaLayoutDataMiddleware(inner_app, menu_registry=menu_reg, permission_registry=reg) scope = _http_scope(roles=["admin"]) diff --git a/framework/hosting/tests/test_inertia_i18n_shared_props.py b/framework/hosting/tests/test_inertia_i18n_shared_props.py index aaef7edf6..38beb690f 100644 --- a/framework/hosting/tests/test_inertia_i18n_shared_props.py +++ b/framework/hosting/tests/test_inertia_i18n_shared_props.py @@ -3,15 +3,16 @@ from __future__ import annotations from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient from simple_module_core.i18n import I18nRegistry from simple_module_core.menu import MenuRegistry from simple_module_core.permissions import PermissionRegistry +from simple_module_hosting._inertia_shared import resolve_inertia_shared from simple_module_hosting.i18n_middleware import LocaleMiddleware from simple_module_hosting.middleware import InertiaLayoutDataMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.requests import Request from starlette.responses import JSONResponse -from starlette.testclient import TestClient def _resolved(shared: dict) -> dict: @@ -32,8 +33,8 @@ def _build_app() -> FastAPI: app.state.sm = SimpleNamespace(i18n_registry=reg) @app.get("/shared") - def shared(request: Request) -> JSONResponse: - return JSONResponse(_resolved(request.state.inertia_shared)) + async def shared(request: Request) -> JSONResponse: + return JSONResponse(_resolved(await resolve_inertia_shared(request))) # Stack order (last added = outermost = runs first): # LocaleMiddleware (outer) -> InertiaLayoutDataMiddleware (inner) @@ -51,9 +52,9 @@ def shared(request: Request) -> JSONResponse: return app -def test_inertia_shared_props_include_i18n_block_for_default_locale() -> None: - client = TestClient(_build_app()) - resp = client.get("/shared") +async def test_inertia_shared_props_include_i18n_block_for_default_locale() -> None: + client = AsyncClient(transport=ASGITransport(app=_build_app()), base_url="http://test") + resp = await client.get("/shared") body = resp.json() assert "i18n" in body assert body["i18n"]["locale"] == "en" @@ -61,9 +62,11 @@ def test_inertia_shared_props_include_i18n_block_for_default_locale() -> None: assert body["i18n"]["messages"] == {"hello": "Hello"} -def test_inertia_shared_props_reflect_cookie_locale() -> None: - client = TestClient(_build_app(), cookies={"locale": "es"}) - resp = client.get("/shared") +async def test_inertia_shared_props_reflect_cookie_locale() -> None: + client = AsyncClient( + transport=ASGITransport(app=_build_app()), base_url="http://test", cookies={"locale": "es"} + ) + resp = await client.get("/shared") body = resp.json() assert body["i18n"]["locale"] == "es" assert body["i18n"]["messages"] == {"hello": "Hola"} @@ -94,8 +97,8 @@ def _build_audience_app(tmp_path) -> FastAPI: app.state.sm = SimpleNamespace(i18n_registry=reg) @app.get("/shared") - def shared(request: Request) -> JSONResponse: - return JSONResponse(_resolved(request.state.inertia_shared)) + async def shared(request: Request) -> JSONResponse: + return JSONResponse(_resolved(await resolve_inertia_shared(request))) app.add_middleware( InertiaLayoutDataMiddleware, @@ -124,35 +127,43 @@ async def __call__(self, scope, receive, send): return app -def test_anonymous_visitors_receive_only_public_catalogs(tmp_path) -> None: - client = TestClient(_build_audience_app(tmp_path)) - body = client.get("/shared").json() +async def test_anonymous_visitors_receive_only_public_catalogs(tmp_path) -> None: + client = AsyncClient( + transport=ASGITransport(app=_build_audience_app(tmp_path)), base_url="http://test" + ) + body = (await client.get("/shared")).json() assert body["i18n"]["messages"] == {"pages.title": "Pages"} -def test_authenticated_users_receive_admin_catalogs_too(tmp_path) -> None: - client = TestClient(_build_audience_app(tmp_path)) - body = client.get("/shared", headers={"X-Test-Auth": "1"}).json() +async def test_authenticated_users_receive_admin_catalogs_too(tmp_path) -> None: + client = AsyncClient( + transport=ASGITransport(app=_build_audience_app(tmp_path)), base_url="http://test" + ) + body = (await client.get("/shared", headers={"X-Test-Auth": "1"})).json() assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"} -def test_login_mid_session_reships_messages_on_an_inertia_partial(tmp_path) -> None: +async def test_login_mid_session_reships_messages_on_an_inertia_partial(tmp_path) -> None: """An Inertia partial normally skips messages — but not right after login, or the freshly-authenticated client would keep the anonymous catalog.""" - client = TestClient(_build_audience_app(tmp_path)) - client.get("/shared") # anonymous full load seeds the session audience - body = client.get("/shared", headers={"X-Test-Auth": "1", "X-Inertia": "true"}).json() + client = AsyncClient( + transport=ASGITransport(app=_build_audience_app(tmp_path)), base_url="http://test" + ) + (await client.get("/shared")) # anonymous full load seeds the session audience + body = (await client.get("/shared", headers={"X-Test-Auth": "1", "X-Inertia": "true"})).json() assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"} -def test_inertia_partial_with_unchanged_audience_still_skips_messages(tmp_path) -> None: - client = TestClient(_build_audience_app(tmp_path)) - client.get("/shared", headers={"Accept": "text/html"}) # a real page load - body = client.get("/shared", headers={"X-Inertia": "true"}).json() +async def test_inertia_partial_with_unchanged_audience_still_skips_messages(tmp_path) -> None: + client = AsyncClient( + transport=ASGITransport(app=_build_audience_app(tmp_path)), base_url="http://test" + ) + (await client.get("/shared", headers={"Accept": "text/html"})) # a real page load + body = (await client.get("/shared", headers={"X-Inertia": "true"})).json() assert body["i18n"]["messages"] is None -def test_inertia_shared_props_fallback_when_registry_missing( +async def test_inertia_shared_props_fallback_when_registry_missing( caplog, ) -> None: """When app.state.i18n_registry is absent, the middleware logs a warning and @@ -164,8 +175,8 @@ def test_inertia_shared_props_fallback_when_registry_missing( # Intentionally do NOT set app.state.i18n_registry. @app.get("/shared") - def shared(request: Request) -> JSONResponse: - return JSONResponse(_resolved(request.state.inertia_shared)) + async def shared(request: Request) -> JSONResponse: + return JSONResponse(_resolved(await resolve_inertia_shared(request))) app.add_middleware( InertiaLayoutDataMiddleware, @@ -176,8 +187,8 @@ def shared(request: Request) -> JSONResponse: app.add_middleware(SessionMiddleware, secret_key="test-secret") with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"): - client = TestClient(app) - resp = client.get("/shared") + client = AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + resp = await client.get("/shared") body = resp.json() assert body["i18n"] == {"locale": "en", "supportedLocales": ["en"], "messages": {}} diff --git a/framework/hosting/tests/test_inertia_shared_providers.py b/framework/hosting/tests/test_inertia_shared_providers.py index 0bfaf1579..1ffc03242 100644 --- a/framework/hosting/tests/test_inertia_shared_providers.py +++ b/framework/hosting/tests/test_inertia_shared_providers.py @@ -11,22 +11,23 @@ import logging from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient from simple_module_core.menu import MenuRegistry from simple_module_core.permissions import PermissionRegistry +from simple_module_hosting._inertia_shared import resolve_inertia_shared from simple_module_hosting.middleware import InertiaLayoutDataMiddleware from simple_module_hosting.shared_props import register_inertia_shared_provider from starlette.middleware.sessions import SessionMiddleware from starlette.requests import Request from starlette.responses import JSONResponse -from starlette.testclient import TestClient def _build_app() -> FastAPI: app = FastAPI() @app.get("/shared") - def shared(request: Request) -> JSONResponse: - return JSONResponse(request.state.inertia_shared) + async def shared(request: Request) -> JSONResponse: + return JSONResponse(await resolve_inertia_shared(request)) app.add_middleware( InertiaLayoutDataMiddleware, @@ -37,11 +38,13 @@ def shared(request: Request) -> JSONResponse: return app -def test_registered_provider_dict_merged_into_shared() -> None: +async def test_registered_provider_dict_merged_into_shared() -> None: app = _build_app() register_inertia_shared_provider(app, lambda _req: {"branding": {"appName": "Acme"}}) - body = TestClient(app).get("/shared").json() + body = ( + await AsyncClient(transport=ASGITransport(app=app), base_url="http://test").get("/shared") + ).json() assert body["branding"] == {"appName": "Acme"} # Built-in blocks still present. @@ -49,7 +52,7 @@ def test_registered_provider_dict_merged_into_shared() -> None: assert "menus" in body -def test_provider_that_raises_is_skipped_not_fatal(caplog) -> None: +async def test_provider_that_raises_is_skipped_not_fatal(caplog) -> None: app = _build_app() def boom(_req: Request) -> dict: @@ -59,7 +62,9 @@ def boom(_req: Request) -> dict: register_inertia_shared_provider(app, lambda _req: {"branding": {"appName": "Acme"}}) with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"): - resp = TestClient(app).get("/shared") + resp = await AsyncClient(transport=ASGITransport(app=app), base_url="http://test").get( + "/shared" + ) assert resp.status_code == 200 body = resp.json() @@ -69,13 +74,17 @@ def boom(_req: Request) -> dict: assert any("shared-prop" in rec.message.lower() for rec in caplog.records) -def test_no_providers_leaves_shared_unchanged() -> None: - body = TestClient(_build_app()).get("/shared").json() +async def test_no_providers_leaves_shared_unchanged() -> None: + body = ( + await AsyncClient(transport=ASGITransport(app=_build_app()), base_url="http://test").get( + "/shared" + ) + ).json() assert "branding" not in body assert "auth" in body and "menus" in body -def test_provider_cannot_clobber_framework_keys(caplog) -> None: +async def test_provider_cannot_clobber_framework_keys(caplog) -> None: app = _build_app() # A misbehaving provider tries to overwrite the framework-owned auth block. register_inertia_shared_provider( @@ -83,7 +92,11 @@ def test_provider_cannot_clobber_framework_keys(caplog) -> None: ) with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"): - body = TestClient(app).get("/shared").json() + body = ( + await AsyncClient(transport=ASGITransport(app=app), base_url="http://test").get( + "/shared" + ) + ).json() # auth stays the framework's dict; only the non-reserved key is added. assert isinstance(body["auth"], dict) @@ -91,7 +104,7 @@ def test_provider_cannot_clobber_framework_keys(caplog) -> None: assert any("reserved shared-prop" in rec.message for rec in caplog.records) -def test_async_provider_is_awaited() -> None: +async def test_async_provider_is_awaited() -> None: """A provider may be async — branding resolves the request's tenant (#373).""" app = _build_app() @@ -100,4 +113,6 @@ async def provider(_req: Request) -> dict: register_inertia_shared_provider(app, provider) - assert TestClient(app).get("/shared").json()["branding"] == {"appName": "Tenant"} + assert ( + await AsyncClient(transport=ASGITransport(app=app), base_url="http://test").get("/shared") + ).json()["branding"] == {"appName": "Tenant"} diff --git a/framework/hosting/tests/test_maintenance_mode.py b/framework/hosting/tests/test_maintenance_mode.py index 2799cb320..721f0dfc7 100644 --- a/framework/hosting/tests/test_maintenance_mode.py +++ b/framework/hosting/tests/test_maintenance_mode.py @@ -4,8 +4,8 @@ import httpx import pytest -from simple_module_hosting._inertia_cache import InertiaCacheMiddleware from simple_module_hosting.maintenance import MaintenanceMiddleware +from simple_module_inertia import InertiaMiddleware from starlette.applications import Starlette from starlette.responses import PlainTextResponse from starlette.routing import Route @@ -35,7 +35,7 @@ def _build_app( user: _User | None = None, provider: _Provider | None = _Provider(), public_routes=None, - with_inertia_cache: bool = False, + with_inertia_middleware: bool = False, ) -> Starlette: async def ok(request): return PlainTextResponse("app reached") @@ -67,12 +67,12 @@ class _AuthState: app.state.public_routes = public_routes app.add_middleware(MaintenanceMiddleware) - # Real pipeline order: InertiaCache sits directly outside Maintenance, so + # InertiaMiddleware wraps Maintenance in the real pipeline, so # its send-wrapper is what actually receives the 503's response messages. # Added second so it wraps Maintenance and is itself wrapped by _SeedUser, - # matching install_middleware's (... -> InertiaCache -> Maintenance -> ...). - if with_inertia_cache: - app.add_middleware(InertiaCacheMiddleware) + # matching install_middleware's (... -> InertiaMiddleware -> Maintenance -> ...). + if with_inertia_middleware: + app.add_middleware(InertiaMiddleware) # Stands in for AuthMiddleware, which runs further out and is what puts the # resolved user on request.state. Added last so it executes first, exactly # as the real pipeline orders them. @@ -241,11 +241,11 @@ async def test_no_registry_still_gates(self) -> None: assert resp.status_code == 503 -class TestInertiaCacheOrdering: - """Pins the exact regression `test_middleware_order.py` guards structurally: - with `InertiaCacheMiddleware` sitting directly outside `MaintenanceMiddleware` +class TestInertiaMiddlewareOrdering: + """Pins the exact regression `test_hosting_middleware_order.py` guards structurally: + with `InertiaMiddleware` sitting directly outside `MaintenanceMiddleware` (real pipeline order), the 503's own short-circuited response — not just a - response from `self.app` — must still pass through InertiaCache's + response from `self.app` — must still pass through InertiaMiddleware's send-wrapper. Built here rather than asserted from order alone because a correct middleware *order* does not by itself prove a short-circuiting middleware's response reaches the wrapper outside it; the ASGI `send` @@ -253,7 +253,7 @@ class TestInertiaCacheOrdering: """ async def test_maintenance_503_gets_private_no_store_for_an_inertia_request(self) -> None: - app = _build_app(enabled=True, with_inertia_cache=True) + app = _build_app(enabled=True, with_inertia_middleware=True) resp = await _get(app, "/protected", headers={"X-Inertia": "true"}) assert resp.status_code == 503 assert resp.headers["cache-control"] == "private, no-store" @@ -263,7 +263,7 @@ async def test_maintenance_503_gets_private_no_store_for_an_inertia_request(self async def test_maintenance_503_is_not_forced_private_for_a_plain_json_request(self) -> None: """Only the Inertia representation needs the cache guard — a bare API caller's 503 keeps whatever caching (none, here) it already had.""" - app = _build_app(enabled=True, with_inertia_cache=True) + app = _build_app(enabled=True, with_inertia_middleware=True) resp = await _get(app, "/api/thing") assert resp.status_code == 503 assert "cache-control" not in resp.headers diff --git a/framework/hosting/tests/test_menu_providers_wiring.py b/framework/hosting/tests/test_menu_providers_wiring.py index d3f65cbdc..b17d6f607 100644 --- a/framework/hosting/tests/test_menu_providers_wiring.py +++ b/framework/hosting/tests/test_menu_providers_wiring.py @@ -6,21 +6,22 @@ from types import SimpleNamespace from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient from simple_module_core.menu import MenuItem, MenuRegistry from simple_module_core.permissions import PermissionRegistry +from simple_module_hosting._inertia_shared import resolve_inertia_shared from simple_module_hosting.middleware import InertiaLayoutDataMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.requests import Request from starlette.responses import JSONResponse -from starlette.testclient import TestClient -def _build(menu: MenuRegistry) -> TestClient: +def _build(menu: MenuRegistry) -> AsyncClient: app = FastAPI() @app.get("/shared") - def shared(request: Request) -> JSONResponse: - return JSONResponse(request.state.inertia_shared["menus"]) + async def shared(request: Request) -> JSONResponse: + return JSONResponse((await resolve_inertia_shared(request))["menus"]) app.add_middleware( InertiaLayoutDataMiddleware, menu_registry=menu, permission_registry=PermissionRegistry() @@ -36,14 +37,14 @@ async def fake_auth(request: Request, call_next): return await call_next(request) app.add_middleware(SessionMiddleware, secret_key="s") - return TestClient(app) + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test") def _labels(body: dict, section: str = "sidebar") -> list[str]: return [i["label"] for i in body[section]] -def test_async_provider_sees_tenant_and_is_per_request() -> None: +async def test_async_provider_sees_tenant_and_is_per_request() -> None: menu = MenuRegistry() menu.add(MenuItem(label="Static", url="/s", order=10)) @@ -52,27 +53,27 @@ async def provider(request: Request) -> list[MenuItem]: menu.add_provider(provider) client = _build(menu) - a = client.get("/shared", headers={"x-role": "user", "x-tenant": "a"}).json() - b = client.get("/shared", headers={"x-role": "user", "x-tenant": "b"}).json() + a = (await client.get("/shared", headers={"x-role": "user", "x-tenant": "a"})).json() + b = (await client.get("/shared", headers={"x-role": "user", "x-tenant": "b"})).json() assert _labels(a) == ["T-a", "Static"] # merged and sorted by order assert _labels(b) == ["T-b", "Static"] -def test_sync_provider_and_role_filtering() -> None: +async def test_sync_provider_and_role_filtering() -> None: menu = MenuRegistry() menu.add_provider( lambda _r: [MenuItem(label="Admins", url="/a", roles=["admin"]), MenuItem("All", "/x")] ) client = _build(menu) - assert _labels(client.get("/shared", headers={"x-role": "user"}).json()) == ["All"] - assert sorted(_labels(client.get("/shared", headers={"x-role": "admin"}).json())) == [ + assert _labels((await client.get("/shared", headers={"x-role": "user"})).json()) == ["All"] + assert sorted(_labels((await client.get("/shared", headers={"x-role": "admin"})).json())) == [ "Admins", "All", ] - assert _labels(client.get("/shared").json()) == [] # anonymous: requires_auth + assert _labels((await client.get("/shared")).json()) == [] # anonymous: requires_auth -def test_a_scoped_admin_token_gets_the_anonymous_menu() -> None: +async def test_a_scoped_admin_token_gets_the_anonymous_menu() -> None: """A token never renders a page, so its layout is the anonymous visitor's.""" menu = MenuRegistry() menu.add_provider( @@ -83,11 +84,11 @@ def test_a_scoped_admin_token_gets_the_anonymous_menu() -> None: ] ) client = _build(menu) - body = client.get("/shared", headers={"x-role": "admin", "x-scoped": "1"}).json() + body = (await client.get("/shared", headers={"x-role": "admin", "x-scoped": "1"})).json() assert _labels(body) == ["Public"] -def test_failing_provider_is_isolated(caplog) -> None: +async def test_failing_provider_is_isolated(caplog) -> None: menu = MenuRegistry() menu.add(MenuItem(label="Static", url="/s")) @@ -97,13 +98,13 @@ async def boom(_r: Request) -> list[MenuItem]: menu.add_provider(boom) menu.add_provider(lambda _r: [MenuItem(label="Ok", url="/ok", order=-1)]) with caplog.at_level(logging.WARNING): - resp = _build(menu).get("/shared", headers={"x-role": "user"}) + resp = await _build(menu).get("/shared", headers={"x-role": "user"}) assert resp.status_code == 200 assert _labels(resp.json()) == ["Ok", "Static"] assert any("Menu provider" in r.getMessage() for r in caplog.records) -def test_remove_static_items_invalidates_cache() -> None: +async def test_remove_static_items_invalidates_cache() -> None: menu = MenuRegistry() menu.add_many([MenuItem(label="A", url="/a"), MenuItem(label="B", url="/b")]) assert [i.label for i in menu.all_items] == ["A", "B"] diff --git a/framework/hosting/tests/test_middleware_order.py b/framework/hosting/tests/test_middleware_order.py index 2c2912e5b..9a535ccc9 100644 --- a/framework/hosting/tests/test_middleware_order.py +++ b/framework/hosting/tests/test_middleware_order.py @@ -5,25 +5,13 @@ (ForwardedHost → ProxyHeaders, opt-in; pinned in test_forwarded_host.py) CorrelationId → RequestLogging → CORS → BodyLimit → GZip → Security → Session → InertiaMiddleware → → Tenant (opt-in) → Locale - → InertiaLayoutData → InertiaCache → RateLimit → Setup → Maintenance + → InertiaLayoutData → RateLimit → Setup → Maintenance → CommitBeforeResponse → app Setup runs before Maintenance: an install that has never been set up has nothing meaningful to put into maintenance mode, so the setup redirect wins. -It is inside InertiaCache for the same reason Maintenance is — it -short-circuits, and its redirect must not be stored by any cache. - -InertiaCache sits directly inside InertiaLayoutData, the middleware that puts -this user's auth block, permissions and menus into every Inertia payload. Being -inside it means its send-wrapper sees the response before anything else can -read the headers, and pairing the two keeps "what makes the payload per-user" -and "what stops the payload being cached" from drifting apart. - -Maintenance sits inside InertiaCache rather than outside it. Its 503 is an -Inertia payload carrying the same per-user auth block and menus, and it is -produced by short-circuiting — so if it sat outside, that payload would never -pass through the cache guard and would ship storable, which is the exact bug -InertiaCache exists to prevent. +The outer adapter InertiaMiddleware protects both redirects and page payloads +from caching and varies HTML documents on X-Inertia. Tenant/Locale must see ``request.state.user`` set by AuthMiddleware so DB queries get filtered correctly; CorrelationId must wrap everything so @@ -62,7 +50,7 @@ — including the /static mount, which is where compression pays off most. BodyLimit sits just inside RequestLogging (a 413 is still logged) and outside GZip and every module, so an oversized body is refused before anything reads -it. RateLimit sits inside InertiaCache, after auth and locale/layout data: it +it. RateLimit sits after auth and locale/layout data: it must know whether the caller is signed in, and its 429 page renders through Inertia with the shared props. @@ -94,7 +82,6 @@ "TenantMiddleware", "LocaleMiddleware", "InertiaLayoutDataMiddleware", - "InertiaCacheMiddleware", "RateLimitMiddleware", "SetupMiddleware", "MaintenanceMiddleware", @@ -116,7 +103,6 @@ "AuthMiddleware", "LocaleMiddleware", "InertiaLayoutDataMiddleware", - "InertiaCacheMiddleware", "RateLimitMiddleware", "SetupMiddleware", "MaintenanceMiddleware", diff --git a/framework/inertia/simple_module_inertia/__init__.py b/framework/inertia/simple_module_inertia/__init__.py index b2e32f5d2..a717cfb8e 100644 --- a/framework/inertia/simple_module_inertia/__init__.py +++ b/framework/inertia/simple_module_inertia/__init__.py @@ -24,11 +24,13 @@ prepend, scroll, ) +from simple_module_inertia.request import INERTIA_HEADER, is_inertia_request from simple_module_inertia.response import InertiaResponse from simple_module_inertia.templating import InertiaExtension from simple_module_inertia.validation import errors_from_validation, scope_errors __all__ = [ + "INERTIA_HEADER", "Head", "Inertia", "InertiaConfig", @@ -45,6 +47,7 @@ "inertia_dependency_factory", "inertia_request_validation_exception_handler", "inertia_version_conflict_exception_handler", + "is_inertia_request", "lazy", "merge", "once", diff --git a/framework/inertia/simple_module_inertia/middleware.py b/framework/inertia/simple_module_inertia/middleware.py index d66a01901..e20a0d8a6 100644 --- a/framework/inertia/simple_module_inertia/middleware.py +++ b/framework/inertia/simple_module_inertia/middleware.py @@ -4,6 +4,7 @@ from starlette.requests import Request from starlette.types import ASGIApp, Message, Receive, Scope, Send +from simple_module_inertia.request import is_inertia_request from simple_module_inertia.response import back_url # Redirect headers that describe a body the 409 no longer has. @@ -44,7 +45,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return request_headers = Headers(scope=scope) - inertia = "x-inertia" in request_headers + inertia = is_inertia_request(scope) pending: Message | None = None discard_body = False empty_sent = False @@ -57,6 +58,9 @@ async def send_response(message: Message) -> None: html = headers.get("content-type", "").split(";", 1)[0].strip() == "text/html" if inertia or "x-inertia" in headers or html: add_vary(headers) + if inertia or "x-inertia" in headers: + headers["cache-control"] = "private, no-store" + del headers["etag"] if inertia: code = message["status"] target = headers.get("location", "") diff --git a/framework/inertia/simple_module_inertia/request.py b/framework/inertia/simple_module_inertia/request.py index b9d410233..9ccbe015e 100644 --- a/framework/inertia/simple_module_inertia/request.py +++ b/framework/inertia/simple_module_inertia/request.py @@ -11,6 +11,17 @@ from dataclasses import dataclass from typing import Literal +from starlette.datastructures import Headers +from starlette.types import Scope + +INERTIA_HEADER = "X-Inertia" + + +def is_inertia_request(scope: Scope) -> bool: + """The adapter selects JSON whenever the request header is present.""" + return INERTIA_HEADER in Headers(scope=scope) + + MergeIntent = Literal["append", "prepend"] @@ -39,7 +50,7 @@ def from_headers(cls, headers: Mapping[str, str], method: str) -> InertiaRequest lower = {k.lower(): v for k, v in headers.items()} intent = (lower.get("x-inertia-infinite-scroll-merge-intent") or "").lower() return cls( - is_inertia="x-inertia" in lower, + is_inertia=INERTIA_HEADER.lower() in lower, version=lower.get("x-inertia-version"), partial_component=lower.get("x-inertia-partial-component") or None, partial_data=_csv(lower.get("x-inertia-partial-data")), diff --git a/framework/hosting/tests/test_inertia_cache.py b/framework/inertia/tests/test_inertia_cache.py similarity index 83% rename from framework/hosting/tests/test_inertia_cache.py rename to framework/inertia/tests/test_inertia_cache.py index 655d87bc7..29f14de13 100644 --- a/framework/hosting/tests/test_inertia_cache.py +++ b/framework/inertia/tests/test_inertia_cache.py @@ -12,9 +12,9 @@ import httpx import pytest -from simple_module_hosting._inertia_cache import ( - PAYLOAD_CACHE_CONTROL, - InertiaCacheMiddleware, +from simple_module_inertia import ( + INERTIA_HEADER, + InertiaMiddleware, is_inertia_request, ) from starlette.applications import Starlette @@ -56,7 +56,7 @@ async def asset(request): return Response(b"body{}", media_type="text/css", headers={"ETag": _SHARED_ETAG}) app = Starlette(routes=[Route("/p/home", page), Route("/static/app.css", asset)]) - app.add_middleware(InertiaCacheMiddleware) + app.add_middleware(InertiaMiddleware) return app @@ -76,7 +76,7 @@ async def test_payload_is_not_stored_even_when_the_route_says_public( resp = await client.get("/p/home", headers=_INERTIA) assert resp.status_code == _OK - assert resp.headers["cache-control"] == PAYLOAD_CACHE_CONTROL + assert resp.headers["cache-control"] == "private, no-store" async def test_payload_carries_no_validator(self, cache_client: httpx.AsyncClient) -> None: """A shared ETag lets a cache revalidate its way back to the payload. @@ -111,10 +111,11 @@ class TestMatchesUpstreamDetection: through this middleware uncached, reopening the exact leak the fix closes. """ - def test_any_header_value_counts_as_inertia(self) -> None: - scope = {"type": "http", "headers": [(b"x-inertia", b"false")]} - + @pytest.mark.parametrize("value", [b"true", b"1", b"false", b""]) + def test_any_header_value_counts_as_inertia(self, value: bytes) -> None: + scope = {"type": "http", "headers": [(INERTIA_HEADER.lower().encode(), value)]} assert is_inertia_request(scope) is True + assert is_inertia_request({"type": "http", "headers": []}) is False async def test_a_non_true_value_still_gets_the_safety_headers(self) -> None: """A route that mirrors upstream's own (presence-only) detection. @@ -134,14 +135,14 @@ async def page(request): return response app = Starlette(routes=[Route("/p/home", page)]) - app.add_middleware(InertiaCacheMiddleware) + app.add_middleware(InertiaMiddleware) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.get("/p/home", headers={"X-Inertia": "false"}) assert resp.headers["content-type"].startswith("application/json") - assert resp.headers["cache-control"] == PAYLOAD_CACHE_CONTROL + assert resp.headers["cache-control"] == "private, no-store" assert "etag" not in resp.headers @@ -174,14 +175,17 @@ async def test_static_assets_are_untouched(self, cache_client: httpx.AsyncClient assert "x-inertia" not in _vary_fields(resp) -class TestAgainstTheRealApp: - async def test_a_rendered_view_never_returns_a_storable_payload( - self, authenticated_client: httpx.AsyncClient - ) -> None: - """End to end, through the whole middleware pipeline.""" - resp = await authenticated_client.get("/dashboard/", headers=_INERTIA) - - assert resp.status_code == _OK - assert resp.headers["content-type"].startswith("application/json") - assert resp.headers["cache-control"] == PAYLOAD_CACHE_CONTROL - assert "x-inertia" in _vary_fields(resp) +async def test_response_marked_inertia_is_protected_without_request_header(): + async def payload(scope, receive, send): + await JSONResponse( + {"component": "Home", "props": {}}, + headers={"X-Inertia": "true", "Cache-Control": _PUBLIC_CACHE, "ETag": _SHARED_ETAG}, + )(scope, receive, send) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=InertiaMiddleware(payload)), base_url="http://test" + ) as client: + response = await client.get("/") + assert response.headers["cache-control"] == "private, no-store" + assert "etag" not in response.headers + assert "x-inertia" in _vary_fields(response) diff --git a/host/client_app/i18n.test.ts b/host/client_app/i18n.test.ts new file mode 100644 index 000000000..7cdef8bc1 --- /dev/null +++ b/host/client_app/i18n.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, expect, it, vi } from 'vitest'; + +const { handlers, reload, language, update } = vi.hoisted(() => ({ + handlers: {} as { + before: (event: { detail: { visit: { headers: Record } } }) => void; + success: (event: { + detail: { + page: { + props: { + i18n: { + locale: string; + audience: string; + messages: Record | null; + }; + }; + }; + }; + }) => void; + }, + reload: vi.fn(), + language: { value: 'en' }, + update: vi.fn(), +})); +vi.mock('@inertiajs/react', () => ({ + router: { + on: vi.fn((name, handler) => { + handlers[name] = handler; + return vi.fn(); + }), + reload, + }, +})); +vi.mock('@simple-module-py/i18n', () => ({ + configureI18n: vi.fn(), + activeLocale: () => language.value, + updateI18n: update, +})); + +import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n'; + +beforeEach(() => { + vi.clearAllMocks(); + language.value = 'en'; + bootI18nFromInitialPage({ + i18n: { locale: 'en', audience: 'public', supportedLocales: ['en'], messages: {} }, + }); + subscribeI18nToNavigation(); +}); + +it('sends the catalog held by this tab and updates its audience after login', () => { + const visit = { headers: {} as Record }; + handlers.before({ detail: { visit } }); + expect(visit.headers['X-SM-I18n']).toBe('en:public'); + handlers.success({ + detail: { page: { props: { i18n: { locale: 'en', audience: 'full', messages: {} } } } }, + }); + handlers.before({ detail: { visit } }); + expect(visit.headers['X-SM-I18n']).toBe('en:full'); + expect(update).toHaveBeenCalledWith({ locale: 'en', messages: {} }); +}); + +it('reloads a missing catalog once and keeps advertising the catalog it holds', () => { + const event = { + detail: { page: { props: { i18n: { locale: 'de', audience: 'public', messages: null } } } }, + }; + handlers.success(event); + handlers.success(event); + expect(reload).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledWith({ only: ['i18n'] }); + const visit = { headers: {} as Record }; + handlers.before({ detail: { visit } }); + expect(visit.headers['X-SM-I18n']).toBe('en:public'); +}); + +it('does not reload a matching locale with omitted messages', () => { + handlers.success({ + detail: { page: { props: { i18n: { locale: 'en', audience: 'public', messages: null } } } }, + }); + expect(reload).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); +}); + +it('resets the retry guard after receiving a catalog', () => { + const missing = { + detail: { page: { props: { i18n: { locale: 'de', audience: 'public', messages: null } } } }, + }; + handlers.success(missing); + handlers.success({ + detail: { page: { props: { i18n: { locale: 'de', audience: 'public', messages: {} } } } }, + }); + language.value = 'de'; + handlers.success({ + detail: { page: { props: { i18n: { locale: 'es', audience: 'public', messages: null } } } }, + }); + expect(reload).toHaveBeenCalledTimes(2); +}); + +it('forces catalog delivery on recovery when the initial page had no audience', () => { + bootI18nFromInitialPage({}); + const visit = { headers: {} as Record }; + handlers.before({ detail: { visit } }); + expect(visit.headers).toEqual({}); + handlers.success({ + detail: { page: { props: { i18n: { locale: 'de', audience: 'public', messages: null } } } }, + }); + handlers.before({ detail: { visit } }); + expect(visit.headers['X-SM-I18n']).toBe(''); +}); diff --git a/host/client_app/i18n.ts b/host/client_app/i18n.ts index 4cd3edb6d..58035849c 100644 --- a/host/client_app/i18n.ts +++ b/host/client_app/i18n.ts @@ -7,11 +7,15 @@ */ import { router } from '@inertiajs/react'; -import { configureI18n, updateI18n } from '@simple-module-py/i18n'; +import { activeLocale, configureI18n, updateI18n } from '@simple-module-py/i18n'; import type { SharedProps } from '@simple-module-py/ui/types'; +let cachedCatalog: string | undefined; + export function bootI18nFromInitialPage(props: Partial): void { const i18n = props.i18n; + cachedCatalog = + i18n?.messages != null && i18n.audience ? `${i18n.locale}:${i18n.audience}` : undefined; if (!i18n) { configureI18n({ locale: 'en', messages: {} }); return; @@ -20,17 +24,26 @@ export function bootI18nFromInitialPage(props: Partial): void { } export function subscribeI18nToNavigation(): () => void { - return router.on('success', (event) => { + let reloading = false; + const stopBefore = router.on('before', (event) => { + if (cachedCatalog !== undefined || reloading) { + event.detail.visit.headers['X-SM-I18n'] = cachedCatalog ?? ''; + } + }); + const stopSuccess = router.on('success', (event) => { const i18n = event.detail.page.props.i18n; if (!i18n) return; - // 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) { + if (i18n.messages != null) { updateI18n({ locale: i18n.locale, messages: i18n.messages }); + cachedCatalog = i18n.audience ? `${i18n.locale}:${i18n.audience}` : undefined; + reloading = false; + } else if (i18n.locale !== activeLocale() && !reloading) { + reloading = true; + router.reload({ only: ['i18n'] }); } }); + return () => { + stopBefore(); + stopSuccess(); + }; } diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index ed0197392..d48212053 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -48,6 +48,7 @@ export interface BrandingShared { export interface I18nSharedProps { locale: string; + audience?: 'public' | 'full'; supportedLocales: string[]; // ``null`` on Inertia XHR visits where the backend skipped the messages // payload because the client already has them from the initial page load. From 5a696304c90acc7f2e80d42993643443cfcf4fdb Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 3 Nov 2026 21:11:08 +0100 Subject: [PATCH 2/7] fix(hosting): resolve lazy layout props in the request context the middleware saw A 500 page resolves them after TenantMiddleware unbound its tenant. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- .../simple_module_hosting/_inertia_shared.py | 3 ++- .../simple_module_hosting/middleware.py | 5 +++++ .../test_hosting_lazy_layout_catalogs.py | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/framework/hosting/simple_module_hosting/_inertia_shared.py b/framework/hosting/simple_module_hosting/_inertia_shared.py index 4970a6f41..9ed351261 100644 --- a/framework/hosting/simple_module_hosting/_inertia_shared.py +++ b/framework/hosting/simple_module_hosting/_inertia_shared.py @@ -188,7 +188,8 @@ async def resolve_inertia_shared(request: Request) -> dict: if factory is not None: task = getattr(request.state, "inertia_shared_task", None) if task is None: - task = asyncio.create_task(factory()) + context = getattr(request.state, "inertia_shared_context", None) + task = asyncio.create_task(factory(), context=context) request.state.inertia_shared_task = task request.state.inertia_shared = await task return getattr(request.state, "inertia_shared", None) or {} diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index 78f42f48d..22628c46e 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -9,6 +9,7 @@ from __future__ import annotations +import contextvars import logging import secrets from collections.abc import Callable @@ -241,6 +242,10 @@ async def build_shared() -> dict: return await self._shared(request, scope, user, resolved) request.state.inertia_shared_factory = build_shared + # Resolved later (a page render, or the 500 page after TenantMiddleware + # has unbound its tenant): run it in the context this middleware saw, so + # providers see the same tenant and user the eager build used to. + request.state.inertia_shared_context = contextvars.copy_context() await self.app(scope, receive, send) diff --git a/framework/hosting/tests/test_hosting_lazy_layout_catalogs.py b/framework/hosting/tests/test_hosting_lazy_layout_catalogs.py index 6b77786d7..5513d6520 100644 --- a/framework/hosting/tests/test_hosting_lazy_layout_catalogs.py +++ b/framework/hosting/tests/test_hosting_lazy_layout_catalogs.py @@ -176,3 +176,23 @@ def test_catalog_header_does_not_create_an_anonymous_session(): def test_document_always_sends_catalog_even_with_matching_header(): request = _request([(b"x-sm-i18n", b"de:full")]) assert build_i18n_block(request.scope, request)["messages"] == {"hello": "Hallo"} + + +async def test_late_resolution_runs_in_the_middleware_context(): + """The 500 page resolves after TenantMiddleware unbound its tenant; providers must + still see the request's tenant (the context captured by the layout middleware).""" + import contextvars + + from simple_module_db.tenancy import current_tenant_id + from simple_module_hosting._inertia_shared import resolve_inertia_shared + + async def factory(): + return {"tenant": current_tenant_id.get()} + + token = current_tenant_id.set("tenant-a") + captured = contextvars.copy_context() + current_tenant_id.reset(token) # what leaving TenantMiddleware does + request = SimpleNamespace( + state=SimpleNamespace(inertia_shared_factory=factory, inertia_shared_context=captured) + ) + assert (await resolve_inertia_shared(request))["tenant"] == "tenant-a" From 388c408c8ebcbd11f6e654d220710f2e9583e5f6 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 3 Nov 2026 21:14:17 +0100 Subject: [PATCH 3/7] refactor(optimize): one payload check; one catalog-key helper; restore the GH #272 rationale Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- .../simple_module_hosting/_middleware_pipeline.py | 4 +++- framework/inertia/simple_module_inertia/middleware.py | 7 +++++-- host/client_app/i18n.ts | 10 +++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/framework/hosting/simple_module_hosting/_middleware_pipeline.py b/framework/hosting/simple_module_hosting/_middleware_pipeline.py index bfa39cf87..f2ae2f7d2 100644 --- a/framework/hosting/simple_module_hosting/_middleware_pipeline.py +++ b/framework/hosting/simple_module_hosting/_middleware_pipeline.py @@ -97,7 +97,9 @@ def install_middleware( # client, instead of in get_db's post-response exit code (GH #257). app.add_middleware(CommitBeforeResponseMiddleware) # Auth, locale and lazy layout resolution must precede the maintenance page. - # The outer adapter middleware applies protocol and cache rules to it. + # The outer adapter middleware applies protocol and cache rules to it: the + # 503 renders through Inertia with this user's auth block and menus, so it + # must stay non-cacheable like any other payload (GH #272). app.add_middleware(MaintenanceMiddleware) # An install awaiting setup takes precedence over maintenance mode. app.add_middleware(SetupMiddleware) diff --git a/framework/inertia/simple_module_inertia/middleware.py b/framework/inertia/simple_module_inertia/middleware.py index e20a0d8a6..b6d91ce81 100644 --- a/framework/inertia/simple_module_inertia/middleware.py +++ b/framework/inertia/simple_module_inertia/middleware.py @@ -56,9 +56,12 @@ async def send_response(message: Message) -> None: message.setdefault("headers", []) headers = MutableHeaders(scope=message) html = headers.get("content-type", "").split(";", 1)[0].strip() == "text/html" - if inertia or "x-inertia" in headers or html: + payload = inertia or "x-inertia" in headers + if payload or html: add_vary(headers) - if inertia or "x-inertia" in headers: + if payload: + # The payload carries this user's auth, permissions and menus: + # it must never answer a page request from a cache (GH #272). headers["cache-control"] = "private, no-store" del headers["etag"] if inertia: diff --git a/host/client_app/i18n.ts b/host/client_app/i18n.ts index 58035849c..9fe8eb4c7 100644 --- a/host/client_app/i18n.ts +++ b/host/client_app/i18n.ts @@ -12,10 +12,14 @@ import type { SharedProps } from '@simple-module-py/ui/types'; let cachedCatalog: string | undefined; +/** `:` of a catalog the server actually sent, else undefined. */ +function catalogKey(i18n: SharedProps['i18n'] | undefined): string | undefined { + return i18n?.messages != null && i18n.audience ? `${i18n.locale}:${i18n.audience}` : undefined; +} + export function bootI18nFromInitialPage(props: Partial): void { const i18n = props.i18n; - cachedCatalog = - i18n?.messages != null && i18n.audience ? `${i18n.locale}:${i18n.audience}` : undefined; + cachedCatalog = catalogKey(i18n); if (!i18n) { configureI18n({ locale: 'en', messages: {} }); return; @@ -35,7 +39,7 @@ export function subscribeI18nToNavigation(): () => void { if (!i18n) return; if (i18n.messages != null) { updateI18n({ locale: i18n.locale, messages: i18n.messages }); - cachedCatalog = i18n.audience ? `${i18n.locale}:${i18n.audience}` : undefined; + cachedCatalog = catalogKey(i18n); reloading = false; } else if (i18n.locale !== activeLocale() && !reloading) { reloading = true; From b101ab5a6d285713948c888105bef8b6aa9957de Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 3 Nov 2026 21:16:32 +0100 Subject: [PATCH 4/7] fix(hosting,auth,branding,users): one Inertia-visit check everywhere (#790) Setup gate, auth login-redirect, branding shared props and the demo guard used truthiness or == 'true'; they now use is_inertia_request (presence), re-exported from simple_module_hosting.inertia_deps for modules. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- framework/hosting/simple_module_hosting/inertia_deps.py | 7 ++++++- framework/hosting/simple_module_hosting/setup_gate.py | 5 ++--- modules/auth/auth/middleware.py | 3 ++- modules/branding/branding/shared_props.py | 4 +++- modules/users/users/demo/guard.py | 3 ++- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/framework/hosting/simple_module_hosting/inertia_deps.py b/framework/hosting/simple_module_hosting/inertia_deps.py index a7311db21..f0dc3bc0a 100644 --- a/framework/hosting/simple_module_hosting/inertia_deps.py +++ b/framework/hosting/simple_module_hosting/inertia_deps.py @@ -5,7 +5,7 @@ from typing import Annotated from fastapi import Depends, Request -from simple_module_inertia import Inertia +from simple_module_inertia import Inertia, is_inertia_request from simple_module_hosting._inertia_shared import resolve_inertia_shared from simple_module_hosting.token_guard import GATE_PAGE, mark_token_gate @@ -32,3 +32,8 @@ async def get_inertia(request: Request) -> Inertia: mark_token_gate(get_inertia, GATE_PAGE) InertiaDep = Annotated[Inertia, Depends(get_inertia)] + + +# Re-exported so modules detect an Inertia visit the same way the adapter does +# without depending on simple_module_inertia directly. +__all__ = ["InertiaDep", "get_inertia", "is_inertia_request"] diff --git a/framework/hosting/simple_module_hosting/setup_gate.py b/framework/hosting/simple_module_hosting/setup_gate.py index 2435ef1df..6674fe0e1 100644 --- a/framework/hosting/simple_module_hosting/setup_gate.py +++ b/framework/hosting/simple_module_hosting/setup_gate.py @@ -17,7 +17,7 @@ import time from simple_module_core.app_state import get_migration_status, get_services -from starlette.datastructures import Headers +from simple_module_inertia import is_inertia_request from starlette.responses import RedirectResponse from starlette.types import ASGIApp, Receive, Scope, Send @@ -243,8 +243,7 @@ async def _redirect(self, scope: Scope, receive: Receive, send: Send) -> None: choke on the wizard's HTML document. The 409 tells it to do a full page visit instead. """ - headers = Headers(scope=scope) - if headers.get("x-inertia"): + if is_inertia_request(scope): response = RedirectResponse(SETUP_PATH, status_code=409) response.headers["X-Inertia-Location"] = SETUP_PATH else: diff --git a/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py index 4b2ec341f..96e1ace0e 100644 --- a/modules/auth/auth/middleware.py +++ b/modules/auth/auth/middleware.py @@ -14,6 +14,7 @@ from simple_module_core.app_state import get_openapi_url from simple_module_core.redirect_safety import SESSION_NEXT_KEY, safe_next_or_none from simple_module_db.listeners import current_user_id +from simple_module_hosting.inertia_deps import is_inertia_request from simple_module_hosting.path_matching import matches_path from simple_module_hosting.public_access import is_public as route_is_public from starlette.requests import Request @@ -48,7 +49,7 @@ def _is_navigation(request: Request) -> bool: clients, scripts) keep the previous behaviour. """ headers = request.headers - if headers.get("x-inertia") == "true": + if is_inertia_request(request.scope): return True dest = headers.get("sec-fetch-dest") if dest is not None: diff --git a/modules/branding/branding/shared_props.py b/modules/branding/branding/shared_props.py index ac73fe3d7..714481235 100644 --- a/modules/branding/branding/shared_props.py +++ b/modules/branding/branding/shared_props.py @@ -9,6 +9,8 @@ from typing import TYPE_CHECKING +from simple_module_hosting.inertia_deps import is_inertia_request + from branding.constants import ( ASSET_VERSION_QUERY_KEY, FAVICON_URL, @@ -75,7 +77,7 @@ def renders_a_page(request: Request) -> bool: ``/api/``, SM018). Elsewhere an Inertia visit, or anything a browser could be navigating with (``text/html``, ``*/*``, or no ``Accept``), counts. """ - if request.headers.get("x-inertia"): + if is_inertia_request(request.scope): return True path = request.url.path if path == "/api" or path.startswith(_NO_PAGE_PREFIXES): diff --git a/modules/users/users/demo/guard.py b/modules/users/users/demo/guard.py index 8858a50d2..23123a132 100644 --- a/modules/users/users/demo/guard.py +++ b/modules/users/users/demo/guard.py @@ -26,6 +26,7 @@ from typing import TYPE_CHECKING from uuid import UUID +from simple_module_hosting.inertia_deps import is_inertia_request from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.types import ASGIApp, Receive, Scope, Send @@ -106,7 +107,7 @@ def _refusal(scope: Scope) -> Response: writes — get the 403 and can show the reason inline. """ headers = Request(scope).headers - if headers.get("x-inertia"): + if is_inertia_request(scope): return Response( status_code=409, headers={"X-Inertia-Location": headers.get("referer") or "/"}, From 85ec2efd22719bc8fb1d8012771c742e43482ba8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 3 Nov 2026 21:19:22 +0100 Subject: [PATCH 5/7] fix(branding): presence check without a full request scope Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- framework/hosting/simple_module_hosting/inertia_deps.py | 4 ++-- modules/branding/branding/shared_props.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/framework/hosting/simple_module_hosting/inertia_deps.py b/framework/hosting/simple_module_hosting/inertia_deps.py index f0dc3bc0a..4e867129d 100644 --- a/framework/hosting/simple_module_hosting/inertia_deps.py +++ b/framework/hosting/simple_module_hosting/inertia_deps.py @@ -5,7 +5,7 @@ from typing import Annotated from fastapi import Depends, Request -from simple_module_inertia import Inertia, is_inertia_request +from simple_module_inertia import INERTIA_HEADER, Inertia, is_inertia_request from simple_module_hosting._inertia_shared import resolve_inertia_shared from simple_module_hosting.token_guard import GATE_PAGE, mark_token_gate @@ -36,4 +36,4 @@ async def get_inertia(request: Request) -> Inertia: # Re-exported so modules detect an Inertia visit the same way the adapter does # without depending on simple_module_inertia directly. -__all__ = ["InertiaDep", "get_inertia", "is_inertia_request"] +__all__ = ["INERTIA_HEADER", "InertiaDep", "get_inertia", "is_inertia_request"] diff --git a/modules/branding/branding/shared_props.py b/modules/branding/branding/shared_props.py index 714481235..57d7b059c 100644 --- a/modules/branding/branding/shared_props.py +++ b/modules/branding/branding/shared_props.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from simple_module_hosting.inertia_deps import is_inertia_request +from simple_module_hosting.inertia_deps import INERTIA_HEADER from branding.constants import ( ASSET_VERSION_QUERY_KEY, @@ -77,7 +77,7 @@ def renders_a_page(request: Request) -> bool: ``/api/``, SM018). Elsewhere an Inertia visit, or anything a browser could be navigating with (``text/html``, ``*/*``, or no ``Accept``), counts. """ - if is_inertia_request(request.scope): + if INERTIA_HEADER in request.headers: # presence, like is_inertia_request return True path = request.url.path if path == "/api" or path.startswith(_NO_PAGE_PREFIXES): From a43351c7d389ba3a602d47441d2bf983e3ae1bd9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 3 Nov 2026 21:27:28 +0100 Subject: [PATCH 6/7] fix: address code review findings (round 1, pass 1) - error page renders without layout props when building them fails - i18n recovery reload clears its flag when the visit finishes - adapter error handler uses is_inertia_request - docs: shared props are resolved lazily (resolve_inertia_shared) Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- docs/framework-conventions.md | 2 +- docs/frontend/inertia.md | 2 +- docs/modules/site_lock.md | 2 +- framework/hosting/simple_module_hosting/_error_page.py | 8 +++++++- framework/hosting/tests/test_maintenance_mode.py | 2 +- framework/inertia/simple_module_inertia/errors.py | 3 ++- host/client_app/i18n.test.ts | 2 +- host/client_app/i18n.ts | 7 ++++++- 8 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index f4a5b84cf..0383e996c 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -345,7 +345,7 @@ way. ### Shared props -`InertiaLayoutDataMiddleware` populates `request.state.inertia_shared` with: +`InertiaLayoutDataMiddleware` prepares these lazily; page and error rendering resolve them once (`await resolve_inertia_shared(request)`, from `simple_module_hosting._inertia_shared`) into `request.state.inertia_shared`: - `auth.user`, `auth.isAuthenticated`, `auth.permissions` (expanded from roles). - `menus` — grouped by `MenuSection` (sidebar, adminSidebar, navbar, userDropdown), role-filtered. diff --git a/docs/frontend/inertia.md b/docs/frontend/inertia.md index 5eb062552..dc454e1a3 100644 --- a/docs/frontend/inertia.md +++ b/docs/frontend/inertia.md @@ -33,7 +33,7 @@ async def browse(inertia: InertiaDep, service: OrderServiceDep): Breakdown: -- `InertiaDep` (from `simple_module_hosting.inertia_deps`) is a request-scoped dependency that resolves the `Inertia` instance from `app.state.inertia_dependency` (configured at boot) and shares in `request.state.inertia_shared`. +- `InertiaDep` (from `simple_module_hosting.inertia_deps`) is a request-scoped dependency that resolves the `Inertia` instance from `app.state.inertia_dependency` (configured at boot) and shares the layout props (`auth`, `menus`, `i18n`, …), which it resolves lazily via `resolve_inertia_shared(request)`; do not read `request.state.inertia_shared` without awaiting that first. - The first argument to `render` is the **page key** — `"Orders/Browse"` maps to `modules/orders/orders/pages/Browse.tsx`. See [Pages & discovery](/frontend/pages). - `props` is a plain dict serialized to JSON. Use `model_dump(mode="json")` on SQLModel DTOs to get JSON-safe types (`Decimal → str`, `datetime → iso string`). diff --git a/docs/modules/site_lock.md b/docs/modules/site_lock.md index 96853ad4e..9fa87df51 100644 --- a/docs/modules/site_lock.md +++ b/docs/modules/site_lock.md @@ -17,7 +17,7 @@ This is not user authentication — that is what [`auth`](/modules/auth), [`user Both dependencies are load-bearing. `Settings` lets `register_module_settings` reach the module registry. `Auth` makes this module sort *after* the auth module, and since middleware is installed in topological order while Starlette's `add_middleware` is LIFO, sorting later means wrapping **outermost** — so `SiteLockMiddleware` executes *before* `AuthMiddleware`. -That ordering is the point of the design: an anonymous visitor gets the gate rather than a redirect to the login page, so a locked site never reveals it has one. The order is pinned by `framework/hosting/tests/test_hosting_middleware_order.py`. +That ordering is the point of the design: an anonymous visitor gets the gate rather than a redirect to the login page, so a locked site never reveals it has one. The order is pinned by `framework/hosting/tests/test_middleware_order.py`. ## Routes diff --git a/framework/hosting/simple_module_hosting/_error_page.py b/framework/hosting/simple_module_hosting/_error_page.py index 97f9fc2da..22f1abe8c 100644 --- a/framework/hosting/simple_module_hosting/_error_page.py +++ b/framework/hosting/simple_module_hosting/_error_page.py @@ -105,7 +105,13 @@ async def render_error_page( # repeated here. Without it the error page renders raw translation keys # (ui.error_page.not_found_title) and loses auth/menus, so a signed-in # user's 404 has no layout. - shared = await resolve_inertia_shared(request) + try: + shared = await resolve_inertia_shared(request) + except Exception: + # Building the layout is what failed (a DB outage in the menus, say): + # still render the page, just without the shared layout props. + logger.warning("Error page: layout props unavailable", exc_info=True) + shared = {} if shared: inertia.share(**shared) # The correlation id is the only handle a user has on their own failed diff --git a/framework/hosting/tests/test_maintenance_mode.py b/framework/hosting/tests/test_maintenance_mode.py index 721f0dfc7..a38a38196 100644 --- a/framework/hosting/tests/test_maintenance_mode.py +++ b/framework/hosting/tests/test_maintenance_mode.py @@ -242,7 +242,7 @@ async def test_no_registry_still_gates(self) -> None: class TestInertiaMiddlewareOrdering: - """Pins the exact regression `test_hosting_middleware_order.py` guards structurally: + """Pins the exact regression `test_middleware_order.py` guards structurally: with `InertiaMiddleware` sitting directly outside `MaintenanceMiddleware` (real pipeline order), the 503's own short-circuited response — not just a response from `self.app` — must still pass through InertiaMiddleware's diff --git a/framework/inertia/simple_module_inertia/errors.py b/framework/inertia/simple_module_inertia/errors.py index 13860f1ab..1df9a5683 100644 --- a/framework/inertia/simple_module_inertia/errors.py +++ b/framework/inertia/simple_module_inertia/errors.py @@ -7,6 +7,7 @@ from fastapi.exceptions import RequestValidationError from starlette.responses import RedirectResponse +from simple_module_inertia.request import is_inertia_request from simple_module_inertia.response import back_url, version_conflict from simple_module_inertia.validation import errors_from_validation, scope_errors @@ -30,7 +31,7 @@ async def inertia_request_validation_exception_handler( request: Request, exc: RequestValidationError ) -> Response: """Flash validation errors and send the browser back, scoped by error bag.""" - if "x-inertia" not in request.headers: + if not is_inertia_request(request.scope): return await request_validation_exception_handler(request, exc) errors = errors_from_validation(exc.errors(), request_source=True) diff --git a/host/client_app/i18n.test.ts b/host/client_app/i18n.test.ts index 7cdef8bc1..7884694e9 100644 --- a/host/client_app/i18n.test.ts +++ b/host/client_app/i18n.test.ts @@ -66,7 +66,7 @@ it('reloads a missing catalog once and keeps advertising the catalog it holds', handlers.success(event); handlers.success(event); expect(reload).toHaveBeenCalledTimes(1); - expect(reload).toHaveBeenCalledWith({ only: ['i18n'] }); + expect(reload).toHaveBeenCalledWith(expect.objectContaining({ only: ['i18n'] })); const visit = { headers: {} as Record }; handlers.before({ detail: { visit } }); expect(visit.headers['X-SM-I18n']).toBe('en:public'); diff --git a/host/client_app/i18n.ts b/host/client_app/i18n.ts index 9fe8eb4c7..dc6e18700 100644 --- a/host/client_app/i18n.ts +++ b/host/client_app/i18n.ts @@ -43,7 +43,12 @@ export function subscribeI18nToNavigation(): () => void { reloading = false; } else if (i18n.locale !== activeLocale() && !reloading) { reloading = true; - router.reload({ only: ['i18n'] }); + router.reload({ + only: ['i18n'], + onFinish: () => { + reloading = false; + }, + }); } }); return () => { From 4cee8a8251c9b270c255b1363b8032ae2dcfb147 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 3 Nov 2026 21:38:21 +0100 Subject: [PATCH 7/7] fix: address code review findings (round 1, pass 2) - OIDC and Keycloak logout views use is_inertia_request (presence) - resolve_inertia_shared exported from simple_module_hosting.inertia_deps; docs and CHANGELOG point there - shipped skill simple-module-inertia-pages: lazy shared props, per-tab i18n catalog header and audience Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 2 +- docs/framework-conventions.md | 2 +- docs/frontend/shared-props.md | 2 +- .../hosting/simple_module_hosting/inertia_deps.py | 12 +++++++++--- modules/keycloak/keycloak/endpoints/views.py | 4 ++-- modules/oidc/oidc/endpoints/views.py | 4 ++-- skills/simple-module-inertia-pages/SKILL.md | 4 ++-- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa34465b..4091d18cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ All notable changes to this project are documented in this file. The format is b - **Behaviour change (#791):** Layout props and module providers resolve once when a page or error page needs them; JSON APIs/downloads skip that work. Permission resolution remains eager. Direct layout readers can await - `resolve_inertia_shared(request)` before using `request.state.inertia_shared`. + `resolve_inertia_shared(request)` (from `simple_module_hosting.inertia_deps`) before using `request.state.inertia_shared`. - **Behaviour change:** Inertia GETs with a missing asset-version header now receive 409 when the server version is non-empty; version conflicts include both `X-Inertia-Location` and the current `X-Inertia-Version` (#774). diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 0383e996c..cddffebef 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -345,7 +345,7 @@ way. ### Shared props -`InertiaLayoutDataMiddleware` prepares these lazily; page and error rendering resolve them once (`await resolve_inertia_shared(request)`, from `simple_module_hosting._inertia_shared`) into `request.state.inertia_shared`: +`InertiaLayoutDataMiddleware` prepares these lazily; page and error rendering resolve them once (`await resolve_inertia_shared(request)`, from `simple_module_hosting.inertia_deps`) into `request.state.inertia_shared`: - `auth.user`, `auth.isAuthenticated`, `auth.permissions` (expanded from roles). - `menus` — grouped by `MenuSection` (sidebar, adminSidebar, navbar, userDropdown), role-filtered. diff --git a/docs/frontend/shared-props.md b/docs/frontend/shared-props.md index 35e31a344..410fcdadb 100644 --- a/docs/frontend/shared-props.md +++ b/docs/frontend/shared-props.md @@ -36,7 +36,7 @@ interface I18nSharedProps { // SharedProps['i18n'] async layout factory. Page dependencies and error rendering resolve that factory; JSON API requests and downloads skip it. The resolved props remain available as `request.state.inertia_shared`; custom readers first await -`resolve_inertia_shared(request)` from `simple_module_hosting._inertia_shared`. +`resolve_inertia_shared(request)` from `simple_module_hosting.inertia_deps`. For each rendered page, it reads: - `request.state.user` (set by the auth middleware) and the registered `app.state.principal_serializer` to build `auth.user`. The user's roles drive `auth.permissions`. diff --git a/framework/hosting/simple_module_hosting/inertia_deps.py b/framework/hosting/simple_module_hosting/inertia_deps.py index 4e867129d..f03262a4b 100644 --- a/framework/hosting/simple_module_hosting/inertia_deps.py +++ b/framework/hosting/simple_module_hosting/inertia_deps.py @@ -34,6 +34,12 @@ async def get_inertia(request: Request) -> Inertia: InertiaDep = Annotated[Inertia, Depends(get_inertia)] -# Re-exported so modules detect an Inertia visit the same way the adapter does -# without depending on simple_module_inertia directly. -__all__ = ["INERTIA_HEADER", "InertiaDep", "get_inertia", "is_inertia_request"] +# Re-exported so modules detect an Inertia visit the same way the adapter does and +# can await the layout props, without importing private hosting modules. +__all__ = [ + "INERTIA_HEADER", + "InertiaDep", + "get_inertia", + "is_inertia_request", + "resolve_inertia_shared", +] diff --git a/modules/keycloak/keycloak/endpoints/views.py b/modules/keycloak/keycloak/endpoints/views.py index 8010597b7..0eccbc4b1 100644 --- a/modules/keycloak/keycloak/endpoints/views.py +++ b/modules/keycloak/keycloak/endpoints/views.py @@ -6,7 +6,7 @@ from auth.oidc_common.renewal_outcomes import forget_outcomes from fastapi import APIRouter, Request -from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.inertia_deps import InertiaDep, is_inertia_request from simple_module_inertia.response import location from starlette.responses import RedirectResponse, Response @@ -77,7 +77,7 @@ async def logout(request: Request) -> Response: logout_url = client.build_logout_url( post_logout_redirect_uri=f"{base_url}{_LOGGED_OUT_PATH}", ) - if request.headers.get("X-Inertia"): + if is_inertia_request(request.scope): # An Inertia XHR cannot follow a redirect to the IdP (CSP connect-src); # 409 + X-Inertia-Location makes the client do a full-page visit. return location(logout_url) diff --git a/modules/oidc/oidc/endpoints/views.py b/modules/oidc/oidc/endpoints/views.py index 9dcbcbcf4..52d93fa2a 100644 --- a/modules/oidc/oidc/endpoints/views.py +++ b/modules/oidc/oidc/endpoints/views.py @@ -4,7 +4,7 @@ from auth.oidc_common.renewal_outcomes import forget_outcomes from fastapi import APIRouter, Request -from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.inertia_deps import InertiaDep, is_inertia_request from simple_module_inertia.response import location from starlette.responses import RedirectResponse @@ -54,6 +54,6 @@ async def logout(request: Request): destination = client.build_logout_url(post_logout_redirect_uri=destination) except Exception: logger.warning("OIDC logout unavailable; local session cleared") - if request.headers.get("X-Inertia"): + if is_inertia_request(request.scope): return location(destination) return RedirectResponse(destination, status_code=303) diff --git a/skills/simple-module-inertia-pages/SKILL.md b/skills/simple-module-inertia-pages/SKILL.md index ee10a5f5e..6f079c340 100644 --- a/skills/simple-module-inertia-pages/SKILL.md +++ b/skills/simple-module-inertia-pages/SKILL.md @@ -27,7 +27,7 @@ Boot regenerates it too; mid-session adds need the manual call before HMR sees t ## Shared props (no setup needed) -`InertiaLayoutDataMiddleware` populates these on every Inertia response: +`InertiaLayoutDataMiddleware` prepares these for every page render (lazily: a JSON `/api` call never builds them; code that renders outside `InertiaDep` awaits `resolve_inertia_shared(request)` from `simple_module_hosting.inertia_deps`): | Prop | Shape | Populated from | |---|---|---| @@ -35,7 +35,7 @@ Boot regenerates it too; mid-session adds need the manual call before HMR sees t | `auth.isAuthenticated` | bool | `request.state.user` presence | | `auth.permissions` | string[] | Roles → permissions expansion via the framework's permission registry | | `menus` | `{ sidebar, adminSidebar, navbar, userDropdown }` | All modules' `register_menu_items()` output, auth/role-filtered | -| `i18n` | `{ locale, supportedLocales, messages }` | Active locale (`request.state.locale`) + flattened message bundle for that locale (`messages` is `null` on Inertia XHR partials when the locale is unchanged) | +| `i18n` | `{ locale, supportedLocales, audience, messages }` | Active locale (`request.state.locale`) + flattened message bundle for that locale and audience (`messages` is `null` on an Inertia visit whose `X-SM-I18n` header reports the same locale and audience; the host client sends it per tab) | **Typing them in TSX.** A page receives its own props as typed component arguments (`export default function Browse({ items }: Props)`). Shared props are typed globally: `usePage().props.auth` is already a `SharedProps['auth']`. Components and layouts that need page props too use `usePageProps()` from `@simple-module-py/ui` (it returns `Props & SharedProps & { errors }`). Never write `usePage<{ props: X }>().props as unknown as X`; `make lint` rejects `as unknown as` outside tests unless the line carries `// cast-ok: `.