simple-module-hosting 0.0.30__tar.gz → 0.0.32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/.gitignore +1 -0
  2. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/PKG-INFO +4 -4
  3. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/pyproject.toml +3 -3
  4. simple_module_hosting-0.0.32/simple_module_hosting/_db_health.py +56 -0
  5. simple_module_hosting-0.0.32/simple_module_hosting/_error_handlers.py +159 -0
  6. simple_module_hosting-0.0.32/simple_module_hosting/_inertia_cache.py +104 -0
  7. simple_module_hosting-0.0.32/simple_module_hosting/_inertia_json.py +86 -0
  8. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_inertia_setup.py +5 -1
  9. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_phase_helpers.py +38 -10
  10. simple_module_hosting-0.0.32/simple_module_hosting/_registrations.py +69 -0
  11. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/app_builder.py +30 -26
  12. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/assets.py +20 -4
  13. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/bootstrap_settings.py +49 -1
  14. simple_module_hosting-0.0.32/simple_module_hosting/csrf.py +98 -0
  15. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/health.py +5 -1
  16. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/middleware.py +5 -2
  17. simple_module_hosting-0.0.32/tests/test_api_error_negotiation.py +143 -0
  18. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_auth_provider_setting.py +3 -0
  19. simple_module_hosting-0.0.32/tests/test_csp_module_sources.py +68 -0
  20. simple_module_hosting-0.0.32/tests/test_db_health_check.py +40 -0
  21. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_error_page_shared_props.py +13 -0
  22. simple_module_hosting-0.0.32/tests/test_hosting_csrf.py +98 -0
  23. simple_module_hosting-0.0.32/tests/test_inertia_cache.py +187 -0
  24. simple_module_hosting-0.0.32/tests/test_inertia_json_encoder.py +83 -0
  25. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_middleware_order.py +17 -1
  26. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_settings_i18n.py +3 -0
  27. simple_module_hosting-0.0.32/tests/test_sqlite_path_anchor.py +182 -0
  28. simple_module_hosting-0.0.30/simple_module_hosting/_error_handlers.py +0 -75
  29. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/LICENSE +0 -0
  30. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/README.md +0 -0
  31. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/__init__.py +0 -0
  32. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/__main__.py +0 -0
  33. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_host_services.py +0 -0
  34. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_hydrate_step.py +0 -0
  35. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_inertia_shared.py +0 -0
  36. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_observability.py +0 -0
  37. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/host_cli.py +0 -0
  38. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/host_settings.py +0 -0
  39. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_deps.py +0 -0
  40. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_manifest.py +0 -0
  41. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_middleware.py +0 -0
  42. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_deps.py +0 -0
  43. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_utils.py +0 -0
  44. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/logging.py +0 -0
  45. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/manifest.py +0 -0
  46. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/migrations.py +0 -0
  47. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/permissions.py +0 -0
  48. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/py.typed +0 -0
  49. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/redirects.py +0 -0
  50. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/settings.py +0 -0
  51. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/shared_props.py +0 -0
  52. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/static_files.py +0 -0
  53. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_app.py +0 -0
  54. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_branding_head.py +0 -0
  55. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_check_migrations.py +0 -0
  56. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_design_packs_wiring.py +0 -0
  57. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_health.py +0 -0
  58. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_host_cli.py +0 -0
  59. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_hosting_permissions.py +0 -0
  60. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_i18n_manifest.py +0 -0
  61. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_inertia_i18n_shared_props.py +0 -0
  62. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_inertia_manifest.py +0 -0
  63. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_inertia_shared_providers.py +0 -0
  64. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_lifespan_order.py +0 -0
  65. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_locale_middleware.py +0 -0
  66. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_logging.py +0 -0
  67. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_manifest.py +0 -0
  68. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_menu_urls_are_canonical.py +0 -0
  69. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_precompressed_static.py +0 -0
  70. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_proxy_headers.py +0 -0
  71. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_redirects.py +0 -0
  72. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_response_compression.py +0 -0
  73. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_session_cookie_security.py +0 -0
  74. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_settings_secrets.py +0 -0
  75. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_static_caching.py +0 -0
  76. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_strict_discovery_wiring.py +0 -0
  77. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_tenant_middleware.py +0 -0
  78. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_translator_dep.py +0 -0
@@ -63,3 +63,4 @@ Thumbs.db
63
63
  host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
+ .verify/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simple_module_hosting
3
- Version: 0.0.30
3
+ Version: 0.0.32
4
4
  Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -27,8 +27,8 @@ Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
28
  Requires-Dist: itsdangerous>=2.0
29
29
  Requires-Dist: jinja2>=3.1
30
- Requires-Dist: simple-module-core==0.0.30
31
- Requires-Dist: simple-module-db==0.0.30
30
+ Requires-Dist: simple-module-core==0.0.32
31
+ Requires-Dist: simple-module-db==0.0.32
32
32
  Requires-Dist: starlette>=0.44
33
33
  Requires-Dist: tomlkit>=0.13
34
34
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.30"
3
+ version = "0.0.32"
4
4
  description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -30,8 +30,8 @@ dependencies = [
30
30
  # it directly — out-of-tree hosts have nothing else pulling it in.
31
31
  "itsdangerous>=2.0",
32
32
  "jinja2>=3.1",
33
- "simple_module_core==0.0.30",
34
- "simple_module_db==0.0.30",
33
+ "simple_module_core==0.0.32",
34
+ "simple_module_db==0.0.32",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
@@ -0,0 +1,56 @@
1
+ """The host's own readiness check: can we reach the database?
2
+
3
+ Registered by ``create_app`` rather than by a module, because it is the one
4
+ dependency no request can do without — unlike a mailer or an object store,
5
+ which a page load never touches.
6
+
7
+ It also gives ``/health/ready`` something to actually report. Modules
8
+ contribute checks for third-party services, and those are ``probe=False``
9
+ (polling an SMTP login every 10s earns a rate-limit), so without this the
10
+ readiness endpoint would answer "healthy" from an empty check set — a green
11
+ light that proves nothing.
12
+
13
+ It is owned by the host, not a module, so it deliberately does not light up any
14
+ tile on the dashboard's per-module health strip — those are keyed by
15
+ ``HealthCheck.module``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from simple_module_core.health import HealthCheck, HealthCheckResult, HealthStatus
21
+ from sqlalchemy import text
22
+
23
+ CHECK_DATABASE = "host.database"
24
+
25
+ _MODULE = "Host"
26
+
27
+
28
+ def build_database_check(db_state):
29
+ """Return an async check issuing the cheapest possible round trip."""
30
+
31
+ async def check() -> HealthCheckResult:
32
+ try:
33
+ async with db_state.session_factory() as session:
34
+ await session.execute(text("SELECT 1"))
35
+ except Exception as exc:
36
+ return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail=str(exc))
37
+ return HealthCheckResult(status=HealthStatus.HEALTHY, detail="Database reachable")
38
+
39
+ return check
40
+
41
+
42
+ def register_database_check(health_registry, db_state) -> None:
43
+ """Add the database check to *health_registry*.
44
+
45
+ ``probe=True``: a ``SELECT 1`` against an already-pooled connection is
46
+ cheap enough to run on a probe timer, and it is exactly what a readiness
47
+ probe should be asking.
48
+ """
49
+ health_registry.add(
50
+ HealthCheck(
51
+ name=CHECK_DATABASE,
52
+ check=build_database_check(db_state),
53
+ module=_MODULE,
54
+ probe=True,
55
+ )
56
+ )
@@ -0,0 +1,159 @@
1
+ """Framework-wide exception handlers that render Inertia error pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from fastapi.encoders import jsonable_encoder
8
+ from fastapi.exceptions import RequestValidationError
9
+ from fastapi.responses import JSONResponse
10
+ from inertia import (
11
+ Inertia,
12
+ InertiaConfig,
13
+ InertiaVersionConflictException,
14
+ inertia_version_conflict_exception_handler,
15
+ )
16
+ from simple_module_core.exceptions import NotFoundError
17
+ from starlette.exceptions import HTTPException
18
+ from starlette.requests import Request
19
+ from starlette.responses import Response
20
+
21
+ from simple_module_hosting._inertia_shared import _INERTIA_HEADER
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _INERTIA_ERROR_STATUSES = frozenset({403, 404, 500})
26
+
27
+
28
+ def _explicit_accept_q(accept: str, media_type: str) -> float | None:
29
+ """Quality the Accept header gives ``media_type`` by explicit listing, or
30
+ ``None`` when the caller never named it at all.
31
+
32
+ Only exact entries count (no ``*/*`` / ``type/*`` wildcards) — the
33
+ negotiation below cares whether the caller *named* html or json, and at
34
+ what preference, and separately whether they named it *at all* (``None``
35
+ vs. an explicit ``q=0``). Malformed q-values fall back to 1.0 per RFC 9110.
36
+ """
37
+ for part in accept.split(","):
38
+ media, _, params = part.partition(";")
39
+ if media.strip().lower() != media_type:
40
+ continue
41
+ q = 1.0
42
+ for param in params.split(";"):
43
+ name, _, value = param.partition("=")
44
+ if name.strip().lower() == "q":
45
+ try:
46
+ q = float(value.strip())
47
+ except ValueError:
48
+ q = 1.0
49
+ return q
50
+ return None
51
+
52
+
53
+ def _wants_json(request: Request) -> bool:
54
+ """API callers get JSON error bodies; browser-shaped requests get the page.
55
+
56
+ A request that explicitly accepts ``text/html`` is a browser navigation —
57
+ and those reach ``/api/*`` too (OAuth login links, file-download hrefs) —
58
+ so it always gets the rendered page, unless the caller *prefers* JSON via
59
+ q-values (``application/json, text/html;q=0.5`` is an API client keeping
60
+ an html fallback). So does an Inertia visit (``X-Inertia`` header),
61
+ whatever its Accept — an Inertia client can only consume Inertia-protocol
62
+ responses, never a bare JSON body. Otherwise ``/api/*``, the documented
63
+ prefix for every module's JSON surface (``ModuleMeta.route_prefix``),
64
+ gets JSON — a bare ``fetch()`` sends ``Accept: */*`` — as does an
65
+ explicit ``Accept: application/json`` anywhere else.
66
+
67
+ ``text/html;q=0`` rules html out *everywhere*, not just under ``/api/*``:
68
+ once the caller has explicitly said they won't accept html, this never
69
+ chooses the html page. The (already-moot) html fallback survives only
70
+ when json wasn't mentioned either — there's nothing else to offer, so a
71
+ view path keeps its pre-existing default and an ``/api/*`` path keeps
72
+ getting JSON either way.
73
+ """
74
+ if request.headers.get(_INERTIA_HEADER):
75
+ return False
76
+ accept = request.headers.get("accept", "")
77
+ html_q = _explicit_accept_q(accept, "text/html")
78
+ json_q = _explicit_accept_q(accept, "application/json")
79
+ if html_q is not None and html_q > 0 and html_q >= (json_q or 0.0):
80
+ return False
81
+ path = request.url.path
82
+ is_api = path == "/api" or path.startswith("/api/")
83
+ if html_q == 0:
84
+ return is_api if json_q is None else True
85
+ return is_api or bool(json_q)
86
+
87
+
88
+ async def render_error_page(request: Request, status_code: int, message: str) -> Response:
89
+ config: InertiaConfig = request.app.state.sm.inertia_config
90
+ try:
91
+ inertia = Inertia(request, config)
92
+ # This builds its own Inertia instead of going through get_inertia, so
93
+ # the share step has to be repeated here. Without it the error page
94
+ # renders raw translation keys (host.error.not_found_title) and loses
95
+ # auth/menus, so a signed-in user's 404 has no layout.
96
+ shared = getattr(request.state, "inertia_shared", None)
97
+ if shared:
98
+ inertia.share(**shared)
99
+ # The correlation id is the only handle a user has on their own failed
100
+ # request — without it a support report is just "it broke". It is already
101
+ # on every log line for this request, so quoting it back makes the page
102
+ # and the logs joinable.
103
+ response = await inertia.render(
104
+ "Error",
105
+ {
106
+ "status": status_code,
107
+ "message": message,
108
+ "correlation_id": getattr(request.state, "correlation_id", "") or "",
109
+ },
110
+ )
111
+ response.status_code = status_code
112
+ return response
113
+ except InertiaVersionConflictException as exc:
114
+ return await inertia_version_conflict_exception_handler(request, exc)
115
+ except Exception:
116
+ # Fallback if Inertia rendering itself fails (e.g. missing session)
117
+ logger.exception("Error page rendering failed, falling back to JSON")
118
+ return JSONResponse(
119
+ status_code=status_code, content={"detail": message or "Internal Server Error"}
120
+ )
121
+
122
+
123
+ async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
124
+ if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
125
+ detail = str(exc.detail) if exc.detail else ""
126
+ return await render_error_page(request, exc.status_code, detail)
127
+ # Preserve exception headers (WWW-Authenticate, Retry-After, ...) the way
128
+ # FastAPI's stock handler does.
129
+ return JSONResponse(
130
+ status_code=exc.status_code,
131
+ content={"detail": exc.detail},
132
+ headers=getattr(exc, "headers", None),
133
+ )
134
+
135
+
136
+ async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
137
+ if _wants_json(request):
138
+ return JSONResponse(status_code=404, content={"detail": str(exc)})
139
+ return await render_error_page(request, 404, str(exc))
140
+
141
+
142
+ async def request_validation_error_handler(
143
+ request: Request, exc: RequestValidationError
144
+ ) -> Response:
145
+ """Return an Inertia error page for browser requests with invalid params.
146
+
147
+ Same negotiation rule as 403/404/500 (``_wants_json``), so one request
148
+ never sees two different error shapes depending on the error class.
149
+ """
150
+ if _wants_json(request):
151
+ return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
152
+ return await render_error_page(request, 422, "The requested URL contains invalid parameters.")
153
+
154
+
155
+ async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
156
+ logger.exception("Unhandled exception: %s", exc)
157
+ if _wants_json(request):
158
+ return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
159
+ return await render_error_page(request, 500, "")
@@ -0,0 +1,104 @@
1
+ """Keep the Inertia payload out of caches that answer page requests.
2
+
3
+ Every Inertia route serves one URL as two representations, chosen on the
4
+ request's ``X-Inertia`` header: an HTML document for a full page load, a JSON
5
+ payload for a client-side visit. Nothing in either response says so, which
6
+ leaves a cache free to store one and hand it back for the other. Visit a page
7
+ through the SPA, then open the same URL directly, and the browser can serve the
8
+ stored payload as the document — the visitor gets
9
+ ``{"component":"...","props":{...}}`` where the page should be.
10
+
11
+ That only bites once a route marks itself cacheable, which a public-content
12
+ module reasonably does. The framework is what makes it unsafe:
13
+ :class:`~simple_module_hosting.middleware.InertiaLayoutDataMiddleware` merges
14
+ the signed-in user's ``auth`` block, their permission list and the menus their
15
+ roles resolve to into *every* Inertia payload. A route author choosing
16
+ ``Cache-Control: public`` for their page content has no way to know that, so
17
+ the guarantee belongs here rather than in each module:
18
+
19
+ * **An Inertia payload is never stored.** ``private, no-store``, and the ETag
20
+ is dropped so no cache can revalidate its way back to a copy it should not
21
+ have kept. The cost is per-visit caching on client-side navigation, which was
22
+ never safe to take — those bytes are specific to one user.
23
+ * **Both representations declare ``Vary: X-Inertia``**, so a cache that honours
24
+ Vary keeps them in separate entries instead of inferring it from ``Accept``.
25
+
26
+ ``Vary`` is added to the document only when the response is HTML, so static
27
+ assets and JSON APIs keep the validators and cache entries they had.
28
+
29
+ A module that wants its public page content cached should give the *document*
30
+ its own ``Cache-Control`` and an ETag that identifies the representation; this
31
+ middleware leaves that path alone and only governs the payload.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from starlette.datastructures import MutableHeaders
37
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
38
+
39
+ _SCOPE_HTTP = "http"
40
+ _HEADER_INERTIA = b"x-inertia"
41
+
42
+ #: What an Inertia payload is allowed to say about its own cacheability.
43
+ PAYLOAD_CACHE_CONTROL = "private, no-store"
44
+
45
+ #: The request header that selects the representation, and therefore the one
46
+ #: caches must key on.
47
+ VARY_FIELD = "X-Inertia"
48
+
49
+
50
+ def add_vary(headers: MutableHeaders, field: str = VARY_FIELD) -> None:
51
+ """Add a field to ``Vary``, keeping whatever is already listed."""
52
+ existing = headers.get("vary")
53
+ if not existing:
54
+ headers["vary"] = field
55
+ return
56
+ if any(part.strip().lower() == field.lower() for part in existing.split(",")):
57
+ return
58
+ headers["vary"] = f"{existing}, {field}"
59
+
60
+
61
+ def is_inertia_request(scope: Scope) -> bool:
62
+ """Whether this request asked for the JSON representation.
63
+
64
+ Mirrors ``fastapi-inertia``'s own ``Inertia._is_inertia_request`` exactly —
65
+ presence of the header, any value — rather than requiring it to equal
66
+ ``"true"``. The library renders JSON for *any* ``X-Inertia`` value, so a
67
+ stricter check here would disagree with it: a request the renderer treats
68
+ as Inertia would sail through uncached, undoing the whole fix.
69
+ """
70
+ return any(key == _HEADER_INERTIA for key, _ in scope.get("headers", ()))
71
+
72
+
73
+ def _is_html(headers: MutableHeaders) -> bool:
74
+ return headers.get("content-type", "").split(";", 1)[0].strip() == "text/html"
75
+
76
+
77
+ class InertiaCacheMiddleware:
78
+ """Stop an Inertia payload being cached as though it were the page."""
79
+
80
+ def __init__(self, app: ASGIApp) -> None:
81
+ self.app = app
82
+
83
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
84
+ if scope["type"] != _SCOPE_HTTP:
85
+ await self.app(scope, receive, send)
86
+ return
87
+
88
+ inertia = is_inertia_request(scope)
89
+
90
+ async def send_with_cache_rules(message: Message) -> None:
91
+ if message["type"] == "http.response.start":
92
+ headers = MutableHeaders(scope=message)
93
+ if inertia:
94
+ headers["cache-control"] = PAYLOAD_CACHE_CONTROL
95
+ del headers["etag"]
96
+ add_vary(headers)
97
+ elif _is_html(headers):
98
+ add_vary(headers)
99
+ await send(message)
100
+
101
+ await self.app(scope, receive, send_with_cache_rules)
102
+
103
+
104
+ __all__ = ["PAYLOAD_CACHE_CONTROL", "VARY_FIELD", "InertiaCacheMiddleware", "add_vary"]
@@ -0,0 +1,86 @@
1
+ """Make Inertia's JSON branch encode props the way its HTML branch does.
2
+
3
+ ``fastapi-inertia`` renders the same props two ways and configures only one of
4
+ them::
5
+
6
+ # full page load — honours InertiaConfig.json_encoder
7
+ json_string = json.dumps(page_data, cls=self._config.json_encoder)
8
+
9
+ # client-side visit — Starlette's JSONResponse, so plain json.dumps
10
+ return JSONResponse(content=await self._get_page_data(), ...)
11
+
12
+ ``InertiaJsonEncoder`` exists precisely to run the payload through FastAPI's
13
+ ``jsonable_encoder``, and the second branch never reaches it. The effect is a
14
+ route that works on a reload and 500s on every client-side visit, for any prop
15
+ the stdlib encoder cannot handle — ``Path``, ``Decimal``, ``UUID``, a dataclass,
16
+ an enum.
17
+
18
+ Settings → Modules is where this surfaced: it reflects each installed module's
19
+ pydantic settings back to the admin, and a module whose settings carry a
20
+ ``Path`` field (``PagebuilderSettings.media_root``) puts a ``PosixPath`` in the
21
+ payload::
22
+
23
+ TypeError: Object of type PosixPath is not JSON serializable
24
+
25
+ Fixing it here rather than in each module is deliberate. A module putting a
26
+ rich value in props is not doing anything wrong — the HTML branch has always
27
+ accepted it — so the asymmetry is the defect, and closing it at the point of
28
+ serialisation covers every module and every prop type at once.
29
+
30
+ Applied by wrapping the dependency published on ``app.state.inertia_dependency``,
31
+ which ``inertia_deps.get_inertia`` calls once per request. The replacement binds
32
+ to the instance, so the library's class is untouched and any other construction
33
+ path keeps the stock behaviour.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ from typing import Any
40
+
41
+ from fastapi.encoders import jsonable_encoder
42
+ from starlette.responses import JSONResponse
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ #: The headers upstream's ``_render_json`` sets, reproduced so the wrap is a
47
+ #: pure encoder change. ``InertiaCacheMiddleware`` owns the caching rules and
48
+ #: extends ``Vary`` on the way out.
49
+ _JSON_HEADERS = {"X-Inertia": "true", "Vary": "Accept"}
50
+
51
+
52
+ def json_safe_inertia_dependency(inertia_dep: Any) -> Any:
53
+ """Wrap an Inertia dependency so its JSON branch uses ``jsonable_encoder``.
54
+
55
+ The wrapped callable keeps the ``(request, client)`` shape
56
+ ``inertia_dependency_factory`` returns. An instance that doesn't expose the
57
+ private hook this relies on is handed back untouched: the failure mode is
58
+ the 500 that already happens, and refusing to boot because an upstream
59
+ attribute moved would be a worse trade.
60
+ """
61
+
62
+ def dependency(request: Any, client: Any = None) -> Any:
63
+ inertia = inertia_dep(request, client)
64
+ if hasattr(inertia, "_get_page_data"):
65
+ inertia._render_json = _render_json_for(inertia)
66
+ else: # pragma: no cover - upstream layout changed
67
+ logger.warning(
68
+ "Inertia instance has no _get_page_data; JSON responses keep the "
69
+ "stock encoder and non-JSON-native props will fail to serialise"
70
+ )
71
+ return inertia
72
+
73
+ return dependency
74
+
75
+
76
+ def _render_json_for(inertia: Any) -> Any:
77
+ """Build the instance's replacement ``_render_json``."""
78
+
79
+ async def _render_json() -> JSONResponse:
80
+ page_data = await inertia._get_page_data()
81
+ return JSONResponse(content=jsonable_encoder(page_data), headers=_JSON_HEADERS)
82
+
83
+ return _render_json
84
+
85
+
86
+ __all__ = ["json_safe_inertia_dependency"]
@@ -12,6 +12,7 @@ from fastapi import FastAPI
12
12
  from inertia import InertiaConfig, inertia_dependency_factory
13
13
  from starlette.requests import Request
14
14
 
15
+ from simple_module_hosting._inertia_json import json_safe_inertia_dependency
15
16
  from simple_module_hosting.settings import Settings
16
17
 
17
18
  logger = logging.getLogger(__name__)
@@ -184,6 +185,9 @@ def setup_inertia(
184
185
  use_flash_errors=True,
185
186
  )
186
187
 
187
- inertia_dep = inertia_dependency_factory(inertia_config)
188
+ # Upstream's JSON branch builds a Starlette JSONResponse directly, so the
189
+ # encoder configured above only ever applies to full page loads. Wrap the
190
+ # dependency so a client-side visit encodes the same props the same way.
191
+ inertia_dep = json_safe_inertia_dependency(inertia_dependency_factory(inertia_config))
188
192
  app.state.inertia_dependency = inertia_dep
189
193
  return inertia_config
@@ -21,6 +21,7 @@ from inertia import (
21
21
  )
22
22
  from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
23
23
  from simple_module_core.exceptions import NotFoundError
24
+ from simple_module_db import CommitBeforeResponseMiddleware
24
25
  from starlette.exceptions import HTTPException
25
26
  from starlette.middleware.gzip import GZipMiddleware
26
27
  from starlette.middleware.sessions import SessionMiddleware
@@ -33,6 +34,7 @@ from simple_module_hosting._error_handlers import (
33
34
  unhandled_exception_handler,
34
35
  )
35
36
  from simple_module_hosting._host_services import _HostServices
37
+ from simple_module_hosting._inertia_cache import InertiaCacheMiddleware
36
38
  from simple_module_hosting.host_settings import HostSettings
37
39
  from simple_module_hosting.i18n_middleware import LocaleMiddleware
38
40
  from simple_module_hosting.middleware import (
@@ -46,6 +48,7 @@ from simple_module_hosting.settings import Settings
46
48
  from simple_module_hosting.static_files import PrecompressedStaticFiles
47
49
 
48
50
  if TYPE_CHECKING:
51
+ from simple_module_core.csp import CspSourceRegistry
49
52
  from simple_module_core.menu import MenuRegistry
50
53
  from simple_module_core.permissions import PermissionRegistry
51
54
 
@@ -72,19 +75,46 @@ def register_exception_handlers(app: FastAPI, modules: list) -> None:
72
75
  mod.register_exception_handlers(app)
73
76
 
74
77
 
78
+ def build_csp(settings: Settings, csp_registry: CspSourceRegistry) -> str:
79
+ """Choose the CSP for this boot and fold in module-declared sources.
80
+
81
+ Development gets the Vite-widened policy; production the strict default.
82
+ ``csp_registry`` carries origins modules declared via
83
+ ``register_csp_sources`` (e.g. an external font host) — merged here so
84
+ both variants honor them. An empty registry leaves the policy unchanged.
85
+ """
86
+ base = (
87
+ SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url)
88
+ if settings.is_development
89
+ else SecurityHeadersMiddleware.DEFAULT_CSP
90
+ )
91
+ return csp_registry.extend_policy(base)
92
+
93
+
75
94
  def install_middleware(
76
95
  app: FastAPI,
77
96
  settings: Settings,
78
97
  modules: list,
79
98
  menu_registry: MenuRegistry,
80
99
  perm_registry: PermissionRegistry,
100
+ csp_registry: CspSourceRegistry,
81
101
  ) -> None:
82
102
  """Install the full middleware pipeline.
83
103
 
84
104
  Order matters: last added = first executed. Execution order:
85
105
  (ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
86
- → Security → Session → [module] → (Tenant, if multi_tenant) → Locale → Inertia.
106
+ → Security → Session → [module] → (Tenant, if multi_tenant) → Locale
107
+ → Inertia → InertiaCache → CommitBeforeResponse.
87
108
  """
109
+ # Added first, so it is innermost and its send-wrapper is the first to see
110
+ # the response: the request's DB work commits before any byte reaches the
111
+ # client, instead of in get_db's post-response exit code (GH #257).
112
+ app.add_middleware(CommitBeforeResponseMiddleware)
113
+ # Paired with InertiaLayoutDataMiddleware below, which is what puts this
114
+ # user's auth, permissions and menus into every Inertia payload: this one
115
+ # makes sure the payload that results is never stored where a page request
116
+ # can be answered with it.
117
+ app.add_middleware(InertiaCacheMiddleware)
88
118
  app.add_middleware(
89
119
  InertiaLayoutDataMiddleware,
90
120
  menu_registry=menu_registry,
@@ -102,16 +132,14 @@ def install_middleware(
102
132
  mod.register_middleware(app)
103
133
  app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
104
134
  # In dev, relax CSP so the browser can fetch @vite/client, main.tsx, and
105
- # the HMR WebSocket from the Vite origin. HSTS is also suppressed because
106
- # dev runs over plain HTTP on loopback.
135
+ # the HMR WebSocket from the Vite origin (build_csp picks the variant).
136
+ # HSTS is suppressed in dev because it runs over plain HTTP on loopback.
137
+ security_kwargs: dict[str, str | None] = {
138
+ "content_security_policy": build_csp(settings, csp_registry)
139
+ }
107
140
  if settings.is_development:
108
- app.add_middleware(
109
- SecurityHeadersMiddleware,
110
- content_security_policy=SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url),
111
- strict_transport_security=None,
112
- )
113
- else:
114
- app.add_middleware(SecurityHeadersMiddleware)
141
+ security_kwargs["strict_transport_security"] = None
142
+ app.add_middleware(SecurityHeadersMiddleware, **security_kwargs)
115
143
  # Compress response bodies. Added here so it sits inside CorrelationId and
116
144
  # RequestLogging (which set headers and read request state) but outside
117
145
  # everything that produces a body — including the /static mount, where it
@@ -0,0 +1,69 @@
1
+ """Phase 5 of boot — every module's declarative registration hooks.
2
+
3
+ Extracted from ``app_builder.py`` to keep that file readable; this is the one
4
+ place that knows the full set of hooks a module may implement, and the order
5
+ they run in. ``app_builder.create_app`` is the only intended caller.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from fastapi import FastAPI
15
+
16
+
17
+ def run_module_registrations(
18
+ modules: list,
19
+ *,
20
+ app: FastAPI,
21
+ event_bus,
22
+ menu_registry,
23
+ perm_registry,
24
+ ff_registry,
25
+ health_registry,
26
+ public_route_registry,
27
+ design_pack_registry,
28
+ audit_link_registry,
29
+ csp_registry,
30
+ ) -> None:
31
+ """Invoke each module's registration hooks, in dependency order.
32
+
33
+ Health checks are attributed to the module that registers them, so the
34
+ dashboard can report health per module rather than one global number. The
35
+ owner is cleared afterwards: anything registered later — a module's
36
+ ``on_startup``, say — belongs to no module in this loop, and inheriting
37
+ the last one's name would be a lie.
38
+ """
39
+ for mod in modules:
40
+ mod.register_menu_items(menu_registry)
41
+ mod.register_permissions(perm_registry)
42
+ mod.register_feature_flags(ff_registry)
43
+ dispatch_event_handlers(mod, event_bus, app)
44
+ health_registry.set_owner(mod.meta.name)
45
+ mod.register_health_checks(health_registry)
46
+ mod.register_public_routes(public_route_registry)
47
+ # csp before design packs — the documented lifecycle order
48
+ # (docs/framework/lifecycle.md).
49
+ mod.register_csp_sources(csp_registry)
50
+ mod.register_design_packs(design_pack_registry)
51
+ mod.register_audit_links(audit_link_registry)
52
+
53
+ health_registry.set_owner("")
54
+
55
+
56
+ def dispatch_event_handlers(mod, event_bus, app: FastAPI) -> None:
57
+ """Call ``mod.register_event_handlers`` with or without ``app``.
58
+
59
+ Back-compat shim for modules that still override the one-arg form
60
+ ``(self, bus)``; passing ``app=`` to those crashes.
61
+ """
62
+ sig = inspect.signature(mod.register_event_handlers)
63
+ accepts_app = "app" in sig.parameters or any(
64
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
65
+ )
66
+ if accepts_app:
67
+ mod.register_event_handlers(event_bus, app=app)
68
+ else:
69
+ mod.register_event_handlers(event_bus)