simple-module-hosting 0.0.32__tar.gz → 0.0.34__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 (110) hide show
  1. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/PKG-INFO +3 -3
  2. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/pyproject.toml +3 -3
  3. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/__init__.py +2 -0
  4. simple_module_hosting-0.0.34/simple_module_hosting/_error_handlers.py +286 -0
  5. simple_module_hosting-0.0.34/simple_module_hosting/_favicon.py +94 -0
  6. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_setup.py +24 -10
  7. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_shared.py +26 -0
  8. simple_module_hosting-0.0.34/simple_module_hosting/_inertia_url.py +131 -0
  9. simple_module_hosting-0.0.34/simple_module_hosting/_lifespan.py +130 -0
  10. simple_module_hosting-0.0.34/simple_module_hosting/_module_routes.py +70 -0
  11. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_phase_helpers.py +38 -38
  12. simple_module_hosting-0.0.34/simple_module_hosting/_preapp_config.py +227 -0
  13. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_registrations.py +4 -0
  14. simple_module_hosting-0.0.34/simple_module_hosting/_secret_key.py +154 -0
  15. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/app_builder.py +32 -34
  16. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/bootstrap_settings.py +33 -70
  17. simple_module_hosting-0.0.34/simple_module_hosting/host_settings.py +133 -0
  18. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_manifest.py +37 -4
  19. simple_module_hosting-0.0.34/simple_module_hosting/maintenance.py +125 -0
  20. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/middleware.py +6 -1
  21. simple_module_hosting-0.0.34/simple_module_hosting/migrations.py +217 -0
  22. simple_module_hosting-0.0.34/simple_module_hosting/permissions.py +104 -0
  23. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/redirects.py +12 -10
  24. simple_module_hosting-0.0.34/simple_module_hosting/session.py +217 -0
  25. simple_module_hosting-0.0.34/simple_module_hosting/setup_gate.py +187 -0
  26. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_auth_provider_setting.py +25 -0
  27. simple_module_hosting-0.0.34/tests/test_boot_drift_check.py +99 -0
  28. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_branding_head.py +32 -6
  29. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_check_migrations.py +22 -10
  30. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_csp_module_sources.py +1 -0
  31. simple_module_hosting-0.0.34/tests/test_diagnostics_state.py +112 -0
  32. simple_module_hosting-0.0.34/tests/test_error_page_shared_props.py +216 -0
  33. simple_module_hosting-0.0.34/tests/test_error_status_coverage.py +229 -0
  34. simple_module_hosting-0.0.34/tests/test_favicon_default.py +72 -0
  35. simple_module_hosting-0.0.34/tests/test_host_settings_fields.py +96 -0
  36. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_i18n_manifest.py +52 -1
  37. simple_module_hosting-0.0.34/tests/test_i18n_types_cover_installed_modules.py +66 -0
  38. simple_module_hosting-0.0.34/tests/test_inertia_relative_url.py +120 -0
  39. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_lifespan_order.py +3 -1
  40. simple_module_hosting-0.0.34/tests/test_maintenance_mode.py +270 -0
  41. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_middleware_order.py +23 -1
  42. simple_module_hosting-0.0.34/tests/test_migration_listing.py +106 -0
  43. simple_module_hosting-0.0.34/tests/test_migration_multihead.py +154 -0
  44. simple_module_hosting-0.0.34/tests/test_preapp_config.py +144 -0
  45. simple_module_hosting-0.0.34/tests/test_proxy_headers.py +224 -0
  46. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_redirects.py +29 -0
  47. simple_module_hosting-0.0.34/tests/test_secret_key_bootstrap.py +93 -0
  48. simple_module_hosting-0.0.34/tests/test_session_cookie_window.py +252 -0
  49. simple_module_hosting-0.0.34/tests/test_setup_gate.py +230 -0
  50. simple_module_hosting-0.0.34/tests/test_setup_password_policy.py +69 -0
  51. simple_module_hosting-0.0.34/tests/test_setup_public_routes.py +68 -0
  52. simple_module_hosting-0.0.34/tests/test_setup_registry.py +102 -0
  53. simple_module_hosting-0.0.34/tests/test_setup_routes.py +137 -0
  54. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_strict_discovery_wiring.py +4 -3
  55. simple_module_hosting-0.0.32/simple_module_hosting/_error_handlers.py +0 -159
  56. simple_module_hosting-0.0.32/simple_module_hosting/host_settings.py +0 -33
  57. simple_module_hosting-0.0.32/simple_module_hosting/migrations.py +0 -65
  58. simple_module_hosting-0.0.32/simple_module_hosting/permissions.py +0 -75
  59. simple_module_hosting-0.0.32/tests/test_error_page_shared_props.py +0 -80
  60. simple_module_hosting-0.0.32/tests/test_proxy_headers.py +0 -119
  61. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/.gitignore +0 -0
  62. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/LICENSE +0 -0
  63. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/README.md +0 -0
  64. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/__main__.py +0 -0
  65. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_db_health.py +0 -0
  66. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_host_services.py +0 -0
  67. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_hydrate_step.py +0 -0
  68. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_cache.py +0 -0
  69. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_json.py +0 -0
  70. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_observability.py +0 -0
  71. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/assets.py +0 -0
  72. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/csrf.py +0 -0
  73. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/health.py +0 -0
  74. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/host_cli.py +0 -0
  75. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_deps.py +0 -0
  76. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_middleware.py +0 -0
  77. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/inertia_deps.py +0 -0
  78. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/inertia_utils.py +0 -0
  79. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/logging.py +0 -0
  80. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/manifest.py +0 -0
  81. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/py.typed +0 -0
  82. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/settings.py +0 -0
  83. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/shared_props.py +0 -0
  84. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/static_files.py +0 -0
  85. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_api_error_negotiation.py +0 -0
  86. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_app.py +0 -0
  87. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_db_health_check.py +0 -0
  88. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_design_packs_wiring.py +0 -0
  89. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_health.py +0 -0
  90. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_host_cli.py +0 -0
  91. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_hosting_csrf.py +0 -0
  92. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_hosting_permissions.py +0 -0
  93. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_cache.py +0 -0
  94. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_i18n_shared_props.py +0 -0
  95. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_json_encoder.py +0 -0
  96. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_manifest.py +0 -0
  97. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_shared_providers.py +0 -0
  98. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_locale_middleware.py +0 -0
  99. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_logging.py +0 -0
  100. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_manifest.py +0 -0
  101. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_menu_urls_are_canonical.py +0 -0
  102. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_precompressed_static.py +0 -0
  103. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_response_compression.py +0 -0
  104. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_session_cookie_security.py +0 -0
  105. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_settings_i18n.py +0 -0
  106. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_settings_secrets.py +0 -0
  107. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_sqlite_path_anchor.py +0 -0
  108. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_static_caching.py +0 -0
  109. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_tenant_middleware.py +0 -0
  110. {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_translator_dep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_hosting
3
- Version: 0.0.32
3
+ Version: 0.0.34
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.32
31
- Requires-Dist: simple-module-db==0.0.32
30
+ Requires-Dist: simple-module-core==0.0.34
31
+ Requires-Dist: simple-module-db==0.0.34
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.32"
3
+ version = "0.0.34"
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.32",
34
- "simple_module_db==0.0.32",
33
+ "simple_module_core==0.0.34",
34
+ "simple_module_db==0.0.34",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
@@ -1,5 +1,6 @@
1
1
  """SimpleModule Hosting - App builder, module loader, middleware pipeline."""
2
2
 
3
+ from simple_module_hosting._preapp_config import merge_host_settings
3
4
  from simple_module_hosting.app_builder import create_app
4
5
  from simple_module_hosting.logging import correlation_id, setup_logging
5
6
  from simple_module_hosting.settings import Settings
@@ -13,6 +14,7 @@ __all__ = [
13
14
  "SharedPropsProvider",
14
15
  "correlation_id",
15
16
  "create_app",
17
+ "merge_host_settings",
16
18
  "register_inertia_shared_provider",
17
19
  "setup_logging",
18
20
  ]
@@ -0,0 +1,286 @@
1
+ """Framework-wide exception handlers that render Inertia error pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from http import HTTPStatus
8
+
9
+ from fastapi.encoders import jsonable_encoder
10
+ from fastapi.exceptions import RequestValidationError
11
+ from fastapi.responses import JSONResponse
12
+ from inertia import (
13
+ Inertia,
14
+ InertiaConfig,
15
+ InertiaVersionConflictException,
16
+ inertia_version_conflict_exception_handler,
17
+ )
18
+ from simple_module_core.exceptions import NotFoundError
19
+ from starlette.exceptions import HTTPException
20
+ from starlette.requests import Request
21
+ from starlette.responses import Response
22
+
23
+ from simple_module_hosting._inertia_shared import _INERTIA_HEADER
24
+ from simple_module_hosting._inertia_url import patch_relative_page_url
25
+ from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 419, 422, 429, 500, 503})
30
+
31
+ # Statuses whose remedy is "sign in", so the page offers that as its primary
32
+ # action rather than sending the visitor to the landing page.
33
+ _SIGN_IN_STATUSES = frozenset({401, 419})
34
+
35
+
36
+ def _specific_detail(status_code: int, message: str) -> str:
37
+ """The detail, unless it only restates the status.
38
+
39
+ Starlette defaults ``HTTPException.detail`` to ``HTTPStatus(code).phrase``,
40
+ so a plain ``HTTPException(404)`` reaches the page carrying "Not Found" and
41
+ a bare ``HTTPException(403)`` carrying "Forbidden". The page prefers a
42
+ server message over its own catalog description — right when the message
43
+ says something ("Administrator access required"), useless when it is the
44
+ title again in Title Case, and it made the page's own 404/403 sentences
45
+ unreachable on the very paths that produce them.
46
+
47
+ Only an exact, case-insensitive match is dropped: anything a caller
48
+ actually wrote survives, including a sentence that merely starts with the
49
+ phrase. Non-standard codes (419) have no phrase to collide with.
50
+ """
51
+ try:
52
+ phrase = HTTPStatus(status_code).phrase
53
+ except ValueError:
54
+ return message
55
+ return "" if message.strip().casefold() == phrase.casefold() else message
56
+
57
+
58
+ def _required_permission(message: str) -> str | None:
59
+ """The permission named by a ``RequiresPermission`` denial, if this is one.
60
+
61
+ The 403 page says "Your role doesn't include ``<perm>``. Ask an admin to
62
+ grant it." — it needs the bare permission name, not the sentence the guard
63
+ wrote for logs and API clients. Reading it back off the detail (using the
64
+ guard's own exported prefix) keeps ``RequiresPermission`` as the single
65
+ place that knows the name; the alternative, stashing it on
66
+ ``request.state``, only works when the exception travels the one code path
67
+ that set it, and 403s are also raised by hand.
68
+
69
+ Anything else — a role-gated ``/admin`` 403, a hand-raised
70
+ ``HTTPException(403, "Administrator access required")`` — yields ``None``,
71
+ and the page falls back to copy that names no permission.
72
+ """
73
+ if not message.startswith(PERMISSION_DENIED_PREFIX):
74
+ return None
75
+ return message[len(PERMISSION_DENIED_PREFIX) :].strip() or None
76
+
77
+
78
+ def _login_url(request: Request) -> str | None:
79
+ """Best-effort login URL for the sign-in statuses.
80
+
81
+ Read off ``app.state`` rather than imported: the auth provider is a plugin
82
+ concern and ``SM009`` forbids framework code importing ``modules/*``. An
83
+ app with no auth provider installed simply gets no sign-in button.
84
+ """
85
+ auth_state = getattr(request.app.state, "auth", None)
86
+ provider = getattr(auth_state, "auth_provider", None)
87
+ if provider is None:
88
+ return None
89
+ try:
90
+ return provider.get_login_url(request)
91
+ except Exception:
92
+ # A broken provider must not turn an error page into a second error.
93
+ logger.exception("Auth provider failed to supply a login URL")
94
+ return None
95
+
96
+
97
+ def _explicit_accept_q(accept: str, media_type: str) -> float | None:
98
+ """Quality the Accept header gives ``media_type`` by explicit listing, or
99
+ ``None`` when the caller never named it at all.
100
+
101
+ Only exact entries count (no ``*/*`` / ``type/*`` wildcards) — the
102
+ negotiation below cares whether the caller *named* html or json, and at
103
+ what preference, and separately whether they named it *at all* (``None``
104
+ vs. an explicit ``q=0``). Malformed q-values fall back to 1.0 per RFC 9110.
105
+ """
106
+ for part in accept.split(","):
107
+ media, _, params = part.partition(";")
108
+ if media.strip().lower() != media_type:
109
+ continue
110
+ q = 1.0
111
+ for param in params.split(";"):
112
+ name, _, value = param.partition("=")
113
+ if name.strip().lower() == "q":
114
+ try:
115
+ q = float(value.strip())
116
+ except ValueError:
117
+ q = 1.0
118
+ return q
119
+ return None
120
+
121
+
122
+ def _wants_json(request: Request) -> bool:
123
+ """API callers get JSON error bodies; browser-shaped requests get the page.
124
+
125
+ A request that explicitly accepts ``text/html`` is a browser navigation —
126
+ and those reach ``/api/*`` too (OAuth login links, file-download hrefs) —
127
+ so it always gets the rendered page, unless the caller *prefers* JSON via
128
+ q-values (``application/json, text/html;q=0.5`` is an API client keeping
129
+ an html fallback). So does an Inertia visit (``X-Inertia`` header),
130
+ whatever its Accept — an Inertia client can only consume Inertia-protocol
131
+ responses, never a bare JSON body. Otherwise ``/api/*``, the documented
132
+ prefix for every module's JSON surface (``ModuleMeta.route_prefix``),
133
+ gets JSON — a bare ``fetch()`` sends ``Accept: */*`` — as does an
134
+ explicit ``Accept: application/json`` anywhere else.
135
+
136
+ ``text/html;q=0`` rules html out *everywhere*, not just under ``/api/*``:
137
+ once the caller has explicitly said they won't accept html, this never
138
+ chooses the html page. The (already-moot) html fallback survives only
139
+ when json wasn't mentioned either — there's nothing else to offer, so a
140
+ view path keeps its pre-existing default and an ``/api/*`` path keeps
141
+ getting JSON either way.
142
+ """
143
+ if request.headers.get(_INERTIA_HEADER):
144
+ return False
145
+ accept = request.headers.get("accept", "")
146
+ html_q = _explicit_accept_q(accept, "text/html")
147
+ json_q = _explicit_accept_q(accept, "application/json")
148
+ if html_q is not None and html_q > 0 and html_q >= (json_q or 0.0):
149
+ return False
150
+ path = request.url.path
151
+ is_api = path == "/api" or path.startswith("/api/")
152
+ if html_q == 0:
153
+ return is_api if json_q is None else True
154
+ return is_api or bool(json_q)
155
+
156
+
157
+ async def render_error_page(
158
+ request: Request,
159
+ status_code: int,
160
+ message: str,
161
+ headers: Mapping[str, str] | None = None,
162
+ ) -> Response:
163
+ """Render the Inertia error page for *status_code*.
164
+
165
+ ``headers`` carries an ``HTTPException``'s own headers through to the
166
+ response. A rendered page is still the same status as the JSON body it
167
+ replaces, so ``WWW-Authenticate`` on a 401 and ``Retry-After`` on a
168
+ 429/503 have to survive the switch — widening the set of statuses that
169
+ render a page must not quietly narrow what those responses carry.
170
+ """
171
+ try:
172
+ # Inside the try, not above it: this lookup is exactly the kind of
173
+ # thing that is missing when the app is half-built, and an error page
174
+ # that raises while reporting an error leaves the caller with nothing.
175
+ config: InertiaConfig = request.app.state.sm.inertia_config
176
+ # Prefer the app's configured dependency over constructing Inertia
177
+ # directly: it carries the framework's wraps, and an error page built
178
+ # around them is an error page rendered differently from every other
179
+ # page. That is how the 404 kept throwing the cross-scheme `pushState`
180
+ # SecurityError after the page url was made relative everywhere else.
181
+ # The raw construction stays as the fallback for a half-built app —
182
+ # the case this whole handler exists to survive — but still goes
183
+ # through the same url-relativizing patch directly: a fallback that
184
+ # skipped it would reintroduce the exact bug this module exists to
185
+ # fix, just for the one request that hit it before setup finished.
186
+ inertia_dep = getattr(request.app.state, "inertia_dependency", None)
187
+ if inertia_dep is not None:
188
+ inertia = inertia_dep(request, None)
189
+ else:
190
+ inertia = patch_relative_page_url(Inertia(request, config))
191
+ # This does not go through get_inertia, so the share step has to be
192
+ # repeated here. Without it the error page renders raw translation keys
193
+ # (host.error.not_found_title) and loses auth/menus, so a signed-in
194
+ # user's 404 has no layout.
195
+ shared = getattr(request.state, "inertia_shared", None)
196
+ if shared:
197
+ inertia.share(**shared)
198
+ # The correlation id is the only handle a user has on their own failed
199
+ # request — without it a support report is just "it broke". It is already
200
+ # on every log line for this request, so quoting it back makes the page
201
+ # and the logs joinable.
202
+ response = await inertia.render(
203
+ "Error",
204
+ {
205
+ "status": status_code,
206
+ # Not the raw detail: a default one is just the status phrase,
207
+ # and the page renders any message it is given ahead of its own
208
+ # copy. See `_specific_detail`.
209
+ "message": _specific_detail(status_code, message),
210
+ "correlation_id": getattr(request.state, "correlation_id", "") or "",
211
+ # Always present, null when no permission is involved: the page
212
+ # branches on it, and an absent prop is indistinguishable there
213
+ # from one that was never wired.
214
+ "required_permission": _required_permission(message),
215
+ "login_url": (_login_url(request) if status_code in _SIGN_IN_STATUSES else None),
216
+ # Set by MaintenanceMiddleware. A planned outage reads very
217
+ # differently from the same status code arriving unbidden.
218
+ "maintenance": bool(getattr(request.state, "maintenance", False)),
219
+ },
220
+ )
221
+ response.status_code = status_code
222
+ _apply_headers(response, headers)
223
+ return response
224
+ except InertiaVersionConflictException as exc:
225
+ return await inertia_version_conflict_exception_handler(request, exc)
226
+ except Exception:
227
+ # Fallback if Inertia rendering itself fails (e.g. missing session)
228
+ logger.exception("Error page rendering failed, falling back to JSON")
229
+ return JSONResponse(
230
+ status_code=status_code,
231
+ content={"detail": message or "Internal Server Error"},
232
+ headers=dict(headers) if headers else None,
233
+ )
234
+
235
+
236
+ def _apply_headers(response: Response, headers: Mapping[str, str] | None) -> None:
237
+ """Copy exception headers onto an already-built response.
238
+
239
+ Set rather than appended: these are single-valued response headers, and
240
+ a duplicate ``Retry-After`` is worse than none.
241
+ """
242
+ if not headers:
243
+ return
244
+ for key, value in headers.items():
245
+ response.headers[key] = value
246
+
247
+
248
+ async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
249
+ # Preserve exception headers (WWW-Authenticate, Retry-After, ...) the way
250
+ # FastAPI's stock handler does — on the rendered page as well as the JSON
251
+ # body, since both answer with the same status.
252
+ headers = getattr(exc, "headers", None)
253
+ if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
254
+ detail = str(exc.detail) if exc.detail else ""
255
+ return await render_error_page(request, exc.status_code, detail, headers)
256
+ return JSONResponse(
257
+ status_code=exc.status_code,
258
+ content={"detail": exc.detail},
259
+ headers=headers,
260
+ )
261
+
262
+
263
+ async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
264
+ if _wants_json(request):
265
+ return JSONResponse(status_code=404, content={"detail": str(exc)})
266
+ return await render_error_page(request, 404, str(exc))
267
+
268
+
269
+ async def request_validation_error_handler(
270
+ request: Request, exc: RequestValidationError
271
+ ) -> Response:
272
+ """Return an Inertia error page for browser requests with invalid params.
273
+
274
+ Same negotiation rule as 403/404/500 (``_wants_json``), so one request
275
+ never sees two different error shapes depending on the error class.
276
+ """
277
+ if _wants_json(request):
278
+ return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
279
+ return await render_error_page(request, 422, "The requested URL contains invalid parameters.")
280
+
281
+
282
+ async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
283
+ logger.exception("Unhandled exception: %s", exc)
284
+ if _wants_json(request):
285
+ return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
286
+ return await render_error_page(request, 500, "")
@@ -0,0 +1,94 @@
1
+ """The favicon an install has before anyone uploads one.
2
+
3
+ Without a default, ``index.html`` emitted no ``<link rel="icon">`` at all, so
4
+ every browser fell back to its implicit ``/favicon.ico`` request — which this
5
+ app does not route. Anonymous visitors got a 302 to the login page (an HTML
6
+ document offered as an image), authenticated ones a 404, and either way a
7
+ console error on every full page load, on a brand-new install that has done
8
+ nothing wrong.
9
+
10
+ Rendered as an SVG ``data:`` URI rather than a file so there is nothing to
11
+ mount, nothing to exempt from auth, and nothing to ship in the image: the
12
+ static mount is a build artifact (``host/static/dist`` is generated), and a
13
+ route would need its own public-route rule. ``img-src`` already allows
14
+ ``data:``, so the strict CSP is untouched.
15
+
16
+ The mark mirrors ``BrandingMark``'s fallback badge — the app's initial on the
17
+ brand gradient — so the tab icon and the in-app logo are the same thing, for
18
+ whatever the app is named. An uploaded favicon still wins; this is only the
19
+ floor.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from functools import lru_cache
25
+ from urllib.parse import quote
26
+ from xml.sax.saxutils import escape as _xml_escape
27
+
28
+ #: sRGB for `--color-primary-600` / `--color-primary-800`
29
+ #: (`oklch(0.59 0.14 158)` / `oklch(0.42 0.09 175)`), the two stops of
30
+ #: `BRAND_ACCENT`. Hard-coded because a data URI cannot read a CSS custom
31
+ #: property — kept beside the tokens they mirror in `ui/styles/globals.css`.
32
+ _ACCENT_FROM = "#00955c"
33
+ _ACCENT_TO = "#005c4a"
34
+
35
+ _SVG = (
36
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
37
+ '<defs><linearGradient id="a" x1="0" y1="0" x2="1" y2="1">'
38
+ '<stop offset="0" stop-color="{from_}"/>'
39
+ '<stop offset="1" stop-color="{to}"/>'
40
+ "</linearGradient></defs>"
41
+ '<rect width="64" height="64" rx="14" fill="url(#a)"/>'
42
+ '<text x="32" y="45" text-anchor="middle" fill="#fff" font-size="34" '
43
+ 'font-weight="700" font-family="system-ui,-apple-system,Segoe UI,'
44
+ 'Roboto,sans-serif">{initial}</text>'
45
+ "</svg>"
46
+ )
47
+
48
+
49
+ @lru_cache(maxsize=64)
50
+ def default_favicon_data_uri(app_name: str, accent: str = "") -> str:
51
+ """An SVG data URI showing ``app_name``'s initial on the brand gradient.
52
+
53
+ ``accent`` is branding's configured primary colour: when set it replaces
54
+ both gradient stops with the flat brand colour, so a customised install's
55
+ tab icon matches the rest of its chrome. Already validated as a hex colour
56
+ by ``BrandingSettings``; anything else is ignored rather than trusted into
57
+ the markup.
58
+
59
+ Pure function of its two arguments, cached: ``branding_head`` calls it on
60
+ every full page render, and with no favicon uploaded — the state every
61
+ install starts in — that means every single page load rebuilds and
62
+ re-quotes the same SVG. The cache is keyed on ``(app_name, accent)``, both
63
+ of which only change when an admin edits branding settings.
64
+ """
65
+ initial = _initial(app_name)
66
+ from_, to = (accent, accent) if _is_hex_colour(accent) else (_ACCENT_FROM, _ACCENT_TO)
67
+ svg = _SVG.format(from_=from_, to=to, initial=initial)
68
+ # Everything structural is encoded — `#` above all, or the browser reads
69
+ # the gradient reference as the URI's fragment and the shape loses its
70
+ # fill, but also `"`, `<`, `>` and spaces so the result stays a single
71
+ # valid attribute value without relying on the template's escaping. The
72
+ # safe set is only characters that are already legal unencoded in a URI.
73
+ return "data:image/svg+xml," + quote(svg, safe="/:=,;()-_.'")
74
+
75
+
76
+ def _initial(app_name: str) -> str:
77
+ """First character of the app name, XML-escaped, defaulting to ``S``.
78
+
79
+ Matches ``BrandingMark``'s ``appName.trim().charAt(0).toUpperCase() || 'S'``
80
+ so the tab and the sidebar badge never disagree.
81
+ """
82
+ raw = (app_name or "").strip()[:1].upper() or "S"
83
+ return _xml_escape(raw)
84
+
85
+
86
+ def _is_hex_colour(value: str) -> bool:
87
+ """Whether ``value`` is a ``#rgb``/``#rrggbb`` literal safe to interpolate."""
88
+ if not value.startswith("#"):
89
+ return False
90
+ body = value[1:]
91
+ return len(body) in (3, 6) and all(c in "0123456789abcdefABCDEF" for c in body)
92
+
93
+
94
+ __all__ = ["default_favicon_data_uri"]
@@ -12,7 +12,9 @@ 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._favicon import default_favicon_data_uri
15
16
  from simple_module_hosting._inertia_json import json_safe_inertia_dependency
17
+ from simple_module_hosting._inertia_url import relative_page_url_dependency
16
18
  from simple_module_hosting.settings import Settings
17
19
 
18
20
  logger = logging.getLogger(__name__)
@@ -40,16 +42,22 @@ def branding_head(request: Request) -> dict:
40
42
  The favicon URL is read from the module rather than assembled here: branding
41
43
  owns its route shape, and framework code must not reach into a plugin
42
44
  (SM009). ``BrandingHead`` still applies it client-side too, so a favicon
43
- changed at runtime updates without a reload.
45
+ changed at runtime updates without a reload. When nothing has been uploaded
46
+ — the state every install starts in — it falls back to a generated mark
47
+ rather than ``None``, because omitting the tag sends the browser to
48
+ ``/favicon.ico``, which this app does not serve.
44
49
  """
45
50
  services = getattr(request.app.state, "branding", None)
46
51
  settings = getattr(services, "settings", None)
47
- if settings is None:
48
- return {"app_name": _DEFAULT_APP_NAME, "theme_color": None, "favicon_url": None}
52
+ app_name = getattr(settings, "app_name", "") or _DEFAULT_APP_NAME
53
+ accent = getattr(settings, "primary_color", "") or ""
54
+ favicon_url = getattr(services, "favicon_url", None) or default_favicon_data_uri(
55
+ app_name, accent
56
+ )
49
57
  return {
50
- "app_name": getattr(settings, "app_name", "") or _DEFAULT_APP_NAME,
51
- "theme_color": getattr(settings, "primary_color", "") or None,
52
- "favicon_url": getattr(services, "favicon_url", None),
58
+ "app_name": app_name,
59
+ "theme_color": accent or None,
60
+ "favicon_url": favicon_url,
53
61
  }
54
62
 
55
63
 
@@ -185,9 +193,15 @@ def setup_inertia(
185
193
  use_flash_errors=True,
186
194
  )
187
195
 
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))
196
+ # Two wraps over the stock dependency, each closing a gap in upstream:
197
+ # * JSON branch builds a Starlette JSONResponse directly, so the encoder
198
+ # configured above only ever applies to full page loads — wrap it so a
199
+ # client-side visit encodes the same props the same way.
200
+ # * The page url is absolute, which the browser rejects for pushState
201
+ # behind a TLS-terminating proxy — wrap it back to the root-relative
202
+ # path the Inertia protocol specifies.
203
+ inertia_dep = relative_page_url_dependency(
204
+ json_safe_inertia_dependency(inertia_dependency_factory(inertia_config))
205
+ )
192
206
  app.state.inertia_dependency = inertia_dep
193
207
  return inertia_config
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from collections.abc import Callable
6
7
  from typing import Any
7
8
 
9
+ from simple_module_core.i18n import Translator
8
10
  from starlette.datastructures import Headers
9
11
  from starlette.requests import Request
10
12
  from starlette.types import Scope
@@ -77,6 +79,30 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool =
77
79
  }
78
80
 
79
81
 
82
+ def build_menu_translator(request: Request) -> Callable[[str], str] | None:
83
+ """Return a locale-bound translator for menu labels, or None if unavailable.
84
+
85
+ Menu labels are translated on the server rather than in the client: the
86
+ payload then carries finished text, so the sidebar, topbar and command
87
+ palette all keep rendering ``item.label`` unchanged. It also sidesteps the
88
+ catalog audience split — an admin-audience module's labels never have to be
89
+ present in the anonymous snapshot to be readable.
90
+
91
+ Mirrors ``build_i18n_block``'s defensive lookups: test fixtures build apps
92
+ with a partial ``app.state.sm``, and a missing registry must degrade to the
93
+ literal labels rather than raise on every request.
94
+ """
95
+ sm = getattr(request.app.state, "sm", None)
96
+ registry = getattr(sm, "i18n_registry", None) if sm is not None else None
97
+ locale = getattr(request.state, "locale", None)
98
+ if registry is None or locale is None:
99
+ return None
100
+ settings = getattr(sm, "settings", None)
101
+ default_locale = getattr(settings, "i18n_default_locale", "en") if settings else "en"
102
+ translator = Translator(registry, locale=locale, default_locale=default_locale)
103
+ return translator.t
104
+
105
+
80
106
  def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None:
81
107
  """Merge module-registered Inertia shared-prop providers into ``shared`` in place.
82
108
 
@@ -0,0 +1,131 @@
1
+ """Make Inertia's page ``url`` root-relative, as the protocol specifies.
2
+
3
+ ``fastapi-inertia`` builds the page object with the *absolute* request url::
4
+
5
+ page_data = {
6
+ "component": self._component,
7
+ "props": await self._build_props(),
8
+ "url": str(self._request.url), # http://host:8000/dashboard/
9
+ "version": self._config.version,
10
+ }
11
+
12
+ The Inertia protocol says otherwise — every official adapter emits a
13
+ root-relative path (Laravel's ``$request->getRequestUri()``, Rails'
14
+ ``request.original_fullpath``), and the documented page object is
15
+ ``{"component": "Event", "props": {...}, "url": "/events/80", ...}``. The
16
+ client hands that value straight to ``history.pushState``/``replaceState``,
17
+ which resolves a relative url against the current document and so can never
18
+ disagree with it.
19
+
20
+ An absolute url can, and behind a TLS-terminating reverse proxy it always
21
+ does. The proxy speaks https to the browser and http to the container, so
22
+ ``request.url`` is ``http://…`` while the document origin is ``https://…``,
23
+ and the browser rejects the state write::
24
+
25
+ SecurityError: Failed to execute 'pushState' on 'History': A history state
26
+ object with URL 'http://example.com/' cannot be created in a document with
27
+ origin 'https://example.com' and URL 'https://example.com/'
28
+
29
+ That fires on *every* page — the initial ``replaceState`` and every subsequent
30
+ visit — so history state is never written: back/forward navigation and scroll
31
+ restoration silently stop working, and the console fills with the error. The
32
+ throw escapes as an unhandled rejection, because the client's
33
+ ``isHistoryThrottleError`` guard matches the string ``"history.pushState"``
34
+ while the browser's message reads ``"Failed to execute 'pushState'"``.
35
+
36
+ ``SM_TRUSTED_PROXY`` fixes the scheme by trusting ``X-Forwarded-Proto``, and is
37
+ still worth setting — it is what makes request logs record the real client IP.
38
+ But an install should not need it merely to have working history, and trusting
39
+ forwarded headers is a security decision that must stay opt-in: the default
40
+ image is documented as runnable standalone, where a spoofed ``X-Forwarded-For``
41
+ would poison the audit log. Emitting the relative url the protocol asks for
42
+ removes the dependency entirely, whatever the deployment looks like.
43
+
44
+ Patched at ``_get_page_data`` because it is the single choke point: upstream's
45
+ SSR, JSON and full-page-load branches all build their payload from it. The
46
+ replacement binds to the instance, so the library's class is untouched.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import logging
52
+ from typing import Any
53
+ from urllib.parse import urlsplit
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ def patch_relative_page_url(inertia: Any) -> Any:
59
+ """Patch a single Inertia instance in place so its page ``url`` is
60
+ root-relative, and return it.
61
+
62
+ Shared by :func:`relative_page_url_dependency` (the normal per-request
63
+ path) and by callers that already hold a bare instance — the error
64
+ handler's half-built-app fallback — so there is exactly one place that
65
+ knows how to apply the patch rather than a second copy of this hasattr
66
+ check.
67
+
68
+ An instance that doesn't expose the hook is handed back untouched: the
69
+ failure mode is the absolute url that already ships, and refusing to boot
70
+ because an upstream attribute moved would be a worse trade.
71
+ """
72
+ if hasattr(inertia, "_get_page_data"):
73
+ inertia._get_page_data = _page_data_for(inertia)
74
+ else: # pragma: no cover - upstream layout changed
75
+ logger.warning(
76
+ "Inertia instance has no _get_page_data; the page url stays "
77
+ "absolute and history writes will fail behind a TLS proxy"
78
+ )
79
+ return inertia
80
+
81
+
82
+ def relative_page_url_dependency(inertia_dep: Any) -> Any:
83
+ """Wrap an Inertia dependency so its page ``url`` is root-relative.
84
+
85
+ Keeps the ``(request, client)`` shape ``inertia_dependency_factory``
86
+ returns, and composes with the other wraps in either order — each patches a
87
+ different instance attribute, and the JSON wrap looks ``_get_page_data`` up
88
+ on the instance at call time.
89
+ """
90
+
91
+ def dependency(request: Any, client: Any = None) -> Any:
92
+ return patch_relative_page_url(inertia_dep(request, client))
93
+
94
+ return dependency
95
+
96
+
97
+ def _page_data_for(inertia: Any) -> Any:
98
+ """Build the instance's replacement ``_get_page_data``."""
99
+ stock = inertia._get_page_data
100
+
101
+ async def _get_page_data() -> dict:
102
+ page_data = await stock()
103
+ url = page_data.get("url")
104
+ if isinstance(url, str):
105
+ page_data["url"] = to_relative_url(url)
106
+ return page_data
107
+
108
+ return _get_page_data
109
+
110
+
111
+ def to_relative_url(url: str) -> str:
112
+ """Reduce an absolute url to the path-and-query the protocol wants.
113
+
114
+ A url that is already relative is returned unchanged, so this is safe to
115
+ apply twice and safe to apply to a payload upstream may one day fix.
116
+ Anything unparseable is passed through rather than mangled — a wrong url is
117
+ still better than a crash on the render path.
118
+ """
119
+ try:
120
+ parts = urlsplit(url)
121
+ except ValueError: # pragma: no cover - urlsplit is near-total
122
+ return url
123
+ if not parts.scheme and not parts.netloc:
124
+ return url
125
+ relative = parts.path or "/"
126
+ if parts.query:
127
+ relative = f"{relative}?{parts.query}"
128
+ return relative
129
+
130
+
131
+ __all__ = ["patch_relative_page_url", "relative_page_url_dependency", "to_relative_url"]