simple-module-hosting 0.0.33__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 (105) hide show
  1. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/PKG-INFO +3 -3
  2. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/pyproject.toml +3 -3
  3. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_error_handlers.py +20 -5
  4. simple_module_hosting-0.0.34/simple_module_hosting/_favicon.py +94 -0
  5. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_setup.py +24 -10
  6. simple_module_hosting-0.0.34/simple_module_hosting/_inertia_url.py +131 -0
  7. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_phase_helpers.py +4 -2
  8. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/host_settings.py +9 -2
  9. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_manifest.py +20 -5
  10. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/permissions.py +36 -15
  11. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/session.py +35 -10
  12. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_branding_head.py +32 -6
  13. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_error_page_shared_props.py +33 -0
  14. simple_module_hosting-0.0.34/tests/test_favicon_default.py +72 -0
  15. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_host_settings_fields.py +2 -1
  16. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_i18n_manifest.py +52 -1
  17. simple_module_hosting-0.0.34/tests/test_inertia_relative_url.py +120 -0
  18. simple_module_hosting-0.0.34/tests/test_proxy_headers.py +224 -0
  19. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_session_cookie_window.py +37 -0
  20. simple_module_hosting-0.0.33/tests/test_proxy_headers.py +0 -122
  21. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/.gitignore +0 -0
  22. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/LICENSE +0 -0
  23. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/README.md +0 -0
  24. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/__init__.py +0 -0
  25. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/__main__.py +0 -0
  26. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_db_health.py +0 -0
  27. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_host_services.py +0 -0
  28. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_hydrate_step.py +0 -0
  29. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_cache.py +0 -0
  30. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_json.py +0 -0
  31. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_shared.py +0 -0
  32. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_lifespan.py +0 -0
  33. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_module_routes.py +0 -0
  34. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_observability.py +0 -0
  35. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_preapp_config.py +0 -0
  36. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_registrations.py +0 -0
  37. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_secret_key.py +0 -0
  38. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/app_builder.py +0 -0
  39. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/assets.py +0 -0
  40. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/bootstrap_settings.py +0 -0
  41. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/csrf.py +0 -0
  42. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/health.py +0 -0
  43. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/host_cli.py +0 -0
  44. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_deps.py +0 -0
  45. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_middleware.py +0 -0
  46. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/inertia_deps.py +0 -0
  47. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/inertia_utils.py +0 -0
  48. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/logging.py +0 -0
  49. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/maintenance.py +0 -0
  50. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/manifest.py +0 -0
  51. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/middleware.py +0 -0
  52. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/migrations.py +0 -0
  53. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/py.typed +0 -0
  54. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/redirects.py +0 -0
  55. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/settings.py +0 -0
  56. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/setup_gate.py +0 -0
  57. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/shared_props.py +0 -0
  58. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/static_files.py +0 -0
  59. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_api_error_negotiation.py +0 -0
  60. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_app.py +0 -0
  61. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_auth_provider_setting.py +0 -0
  62. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_boot_drift_check.py +0 -0
  63. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_check_migrations.py +0 -0
  64. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_csp_module_sources.py +0 -0
  65. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_db_health_check.py +0 -0
  66. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_design_packs_wiring.py +0 -0
  67. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_diagnostics_state.py +0 -0
  68. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_error_status_coverage.py +0 -0
  69. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_health.py +0 -0
  70. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_host_cli.py +0 -0
  71. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_hosting_csrf.py +0 -0
  72. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_hosting_permissions.py +0 -0
  73. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_i18n_types_cover_installed_modules.py +0 -0
  74. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_cache.py +0 -0
  75. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_i18n_shared_props.py +0 -0
  76. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_json_encoder.py +0 -0
  77. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_manifest.py +0 -0
  78. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_shared_providers.py +0 -0
  79. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_lifespan_order.py +0 -0
  80. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_locale_middleware.py +0 -0
  81. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_logging.py +0 -0
  82. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_maintenance_mode.py +0 -0
  83. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_manifest.py +0 -0
  84. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_menu_urls_are_canonical.py +0 -0
  85. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_middleware_order.py +0 -0
  86. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_migration_listing.py +0 -0
  87. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_migration_multihead.py +0 -0
  88. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_preapp_config.py +0 -0
  89. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_precompressed_static.py +0 -0
  90. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_redirects.py +0 -0
  91. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_response_compression.py +0 -0
  92. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_secret_key_bootstrap.py +0 -0
  93. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_session_cookie_security.py +0 -0
  94. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_settings_i18n.py +0 -0
  95. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_settings_secrets.py +0 -0
  96. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_gate.py +0 -0
  97. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_password_policy.py +0 -0
  98. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_public_routes.py +0 -0
  99. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_registry.py +0 -0
  100. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_routes.py +0 -0
  101. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_sqlite_path_anchor.py +0 -0
  102. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_static_caching.py +0 -0
  103. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_strict_discovery_wiring.py +0 -0
  104. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_tenant_middleware.py +0 -0
  105. {simple_module_hosting-0.0.33 → 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.33
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.33
31
- Requires-Dist: simple-module-db==0.0.33
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.33"
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.33",
34
- "simple_module_db==0.0.33",
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",
@@ -21,6 +21,7 @@ from starlette.requests import Request
21
21
  from starlette.responses import Response
22
22
 
23
23
  from simple_module_hosting._inertia_shared import _INERTIA_HEADER
24
+ from simple_module_hosting._inertia_url import patch_relative_page_url
24
25
  from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
25
26
 
26
27
  logger = logging.getLogger(__name__)
@@ -172,11 +173,25 @@ async def render_error_page(
172
173
  # thing that is missing when the app is half-built, and an error page
173
174
  # that raises while reporting an error leaves the caller with nothing.
174
175
  config: InertiaConfig = request.app.state.sm.inertia_config
175
- inertia = Inertia(request, config)
176
- # This builds its own Inertia instead of going through get_inertia, so
177
- # the share step has to be repeated here. Without it the error page
178
- # renders raw translation keys (host.error.not_found_title) and loses
179
- # auth/menus, so a signed-in user's 404 has no layout.
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.
180
195
  shared = getattr(request.state, "inertia_shared", None)
181
196
  if shared:
182
197
  inertia.share(**shared)
@@ -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
@@ -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"]
@@ -167,8 +167,10 @@ def install_middleware(
167
167
  app.add_middleware(RequestLoggingMiddleware)
168
168
  app.add_middleware(CorrelationIdMiddleware)
169
169
  # Outermost: rewrite scheme/client from X-Forwarded-* before anything else
170
- # reads them, so request logs see the real client IP and Inertia's absolute
171
- # page url carries the proxy-terminated scheme (GH #223). Gated on an
170
+ # reads them, so request logs see the real client IP rather than the
171
+ # proxy's. Inertia no longer needs this: its page url is
172
+ # root-relative, so pushState can't see a cross-scheme url either way (it
173
+ # used to throw a SecurityError on every page — GH #223). Gated on an
172
174
  # explicit trust setting — never trust forwarded headers by default.
173
175
  if settings.trusted_proxy:
174
176
  app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=settings.trusted_proxy)
@@ -58,8 +58,15 @@ class HostSettings(BaseSettings):
58
58
  # the container is only reachable through a single proxy), or a comma-
59
59
  # separated list of proxy IPs / CIDRs. Drives uvicorn's
60
60
  # ProxyHeadersMiddleware so request.url.scheme reflects X-Forwarded-Proto
61
- # behind a TLS-terminating proxy — without it Inertia's pushState throws a
62
- # cross-scheme SecurityError and login breaks (GH #223).
61
+ # and request logs record the real client IP rather than the proxy's.
62
+ # Recommended behind a TLS-terminating proxy, not required for
63
+ # Inertia: the page url is root-relative, so pushState can't see a
64
+ # cross-scheme url regardless (it used to throw a SecurityError on every
65
+ # page — GH #223). Still required for anything else that reads
66
+ # request.url.scheme or calls request.url_for(...) to build an absolute
67
+ # url, e.g. OAuth's callback_url and the locale cookie's `secure` flag —
68
+ # left unset behind such a proxy, an OAuth redirect_uri ships as http://
69
+ # and most providers reject it.
63
70
  trusted_proxy: str | None = None
64
71
 
65
72
  auth_provider: str = DEFAULT_AUTH_PROVIDER
@@ -66,6 +66,8 @@ def emit_frontend_types_for_modules(
66
66
  settings: Settings,
67
67
  installed_modules: list[ModuleBase],
68
68
  project_root: Path,
69
+ *,
70
+ strict: bool = False,
69
71
  ) -> None:
70
72
  """Emit the TS key union covering every *installed* module.
71
73
 
@@ -77,20 +79,33 @@ def emit_frontend_types_for_modules(
77
79
  inactive module's strings are typed, never served.
78
80
  """
79
81
  registry, _ = build_i18n_registry(settings, installed_modules, project_root)
80
- emit_frontend_types(registry, project_root)
82
+ emit_frontend_types(registry, project_root, strict=strict)
81
83
 
82
84
 
83
- def emit_frontend_types(registry: I18nRegistry, project_root: Path) -> None:
85
+ def emit_frontend_types(
86
+ registry: I18nRegistry, project_root: Path, *, strict: bool = False
87
+ ) -> None:
84
88
  """Write the TS augmentation files into @simple-module-py/i18n if present.
85
89
 
86
90
  Logs but does not raise on failure — stale types are preferable to a
87
91
  broken boot. Dev-loop only; callers should gate on ``is_development``.
92
+
93
+ ``strict=True`` re-raises instead, for a tool whose whole job is this
94
+ regeneration (``make gen-i18n``). There a swallowed failure only defers the
95
+ error to a ``tsc`` run several steps removed from the cause. Under strict a
96
+ missing package directory is an error too, where on the boot path it is the
97
+ normal shape of a wheel-installed app that ships no i18n workspace.
88
98
  """
99
+ pkg_src = project_root / "packages" / "i18n" / "src"
100
+ if not pkg_src.is_dir():
101
+ if strict:
102
+ raise FileNotFoundError(f"{pkg_src} does not exist — nothing to regenerate")
103
+ return
89
104
  try:
90
- pkg_src = project_root / "packages" / "i18n" / "src"
91
- if pkg_src.is_dir():
92
- write_generated_resources(registry, pkg_src)
105
+ write_generated_resources(registry, pkg_src)
93
106
  except Exception:
107
+ if strict:
108
+ raise
94
109
  logger.exception("Failed to write generated-resources.ts — frontend types will be stale")
95
110
 
96
111
 
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from fastapi import HTTPException, Request
6
- from simple_module_core.permissions import DEFAULT_ROLE_PERMISSIONS, WILDCARD
6
+ from simple_module_core.permissions import DEFAULT_ROLE_PERMISSIONS, WILDCARD, grants
7
7
 
8
8
  __all__ = [
9
9
  "DEFAULT_ROLE_PERMISSIONS",
@@ -12,6 +12,7 @@ __all__ = [
12
12
  "RequiresPermission",
13
13
  "expand_permissions",
14
14
  "resolve_permissions",
15
+ "resolved_permissions_for",
15
16
  ]
16
17
 
17
18
  # How a denial spells the missing permission in ``HTTPException.detail``.
@@ -45,6 +46,39 @@ def expand_permissions(
45
46
  return sorted(resolved)
46
47
 
47
48
 
49
+ def resolved_permissions_for(request: Request) -> set[str]:
50
+ """The permission set this request's principal holds, wildcard included.
51
+
52
+ Prefers what ``InertiaLayoutDataMiddleware`` already resolved and cached on
53
+ ``request.state``; falls back to resolving from the registry's role map when
54
+ the middleware is not in the stack (a bare router under a test transport),
55
+ caching the result so a route with several gates resolves once.
56
+
57
+ Anonymous requests hold nothing. Exposed because a handler sometimes has to
58
+ gate part of its *response* rather than the route — the audit log's entity
59
+ labels, for one — and that decision must read the same permission set the
60
+ door did.
61
+
62
+ Role-derived only, matching ``RequiresPermission``: the ``permissions``
63
+ module's direct per-user grants are its own dependency's business, and a
64
+ caller wanting those consults ``permissions.deps.RequiresPermission``.
65
+ """
66
+ cached: set[str] | None = getattr(request.state, "resolved_permissions", None)
67
+ if cached is not None:
68
+ return cached
69
+
70
+ user = getattr(request.state, "user", None)
71
+ if user is None:
72
+ return set()
73
+
74
+ sm = getattr(getattr(request.app, "state", None), "sm", None)
75
+ perm_registry = getattr(sm, "permissions", None) if sm is not None else None
76
+ role_map = perm_registry.role_map if perm_registry is not None else None
77
+ permissions = resolve_permissions(user.roles, role_map=role_map)
78
+ request.state.resolved_permissions = permissions
79
+ return permissions
80
+
81
+
48
82
  class RequiresPermission:
49
83
  """FastAPI dependency that enforces a specific permission.
50
84
 
@@ -63,20 +97,7 @@ class RequiresPermission:
63
97
  if user is None:
64
98
  raise HTTPException(status_code=401, detail="Authentication required")
65
99
 
66
- # Use cached permissions from middleware if available
67
- permissions: set[str] | None = getattr(request.state, "resolved_permissions", None)
68
- if permissions is None:
69
- # Fallback: middleware did not run — consult registry role_map if available
70
- sm = getattr(getattr(request.app, "state", None), "sm", None)
71
- perm_registry = getattr(sm, "permissions", None) if sm is not None else None
72
- role_map = perm_registry.role_map if perm_registry is not None else None
73
- permissions = resolve_permissions(user.roles, role_map=role_map)
74
- request.state.resolved_permissions = permissions
75
-
76
- if WILDCARD in permissions:
77
- return
78
-
79
- if self.permission not in permissions:
100
+ if not grants(resolved_permissions_for(request), self.permission):
80
101
  raise HTTPException(
81
102
  status_code=403,
82
103
  detail=f"{PERMISSION_DENIED_PREFIX}{self.permission}",
@@ -61,9 +61,11 @@ off disk. Recording the deadline in the payload puts the ceiling back per
61
61
  session: the auth provider treats a session past it as signed out, whatever
62
62
  the signer is still willing to verify.
63
63
 
64
- Written by whoever signs the user in, via :func:`stamp_session_expiry`.
65
- Sessions minted before this existed carry no key and are accepted as legacy —
66
- failing closed on absence would sign the entire user base out on deploy.
64
+ Written by whoever signs the user in, via :func:`stamp_session_expiry`. A
65
+ session minted before this existed carries no key; the auth provider stamps one
66
+ on first sight (:func:`ensure_session_expiry`) rather than either rejecting it —
67
+ which would sign the entire user base out on deploy — or accepting it forever,
68
+ which would leave the hole this key was added to close.
67
69
  """
68
70
 
69
71
  SESSION_COOKIE_MAX_AGE_KEY = "session_cookie_max_age"
@@ -176,17 +178,40 @@ def stamp_session_expiry(session: MutableMapping[str, Any], max_age_seconds: int
176
178
  return deadline
177
179
 
178
180
 
181
+ def ensure_session_expiry(session: MutableMapping[str, Any], max_age_seconds: int) -> int:
182
+ """Return this session's deadline, stamping one if it has none.
183
+
184
+ The migration path for sessions minted before :func:`stamp_session_expiry`
185
+ existed. Failing closed on absence would sign the whole user base out at
186
+ deploy time; accepting absence forever leaves open the hole the deadline was
187
+ added to close. Stamping on first sight does neither — the session keeps
188
+ working, and from that request on it carries the same bound as one minted
189
+ today.
190
+
191
+ It cannot widen anything: an existing deadline is returned untouched, and a
192
+ stamp written now is still capped by the signature window, which the signer
193
+ enforces from the session's *original* mint time. So a legacy session's real
194
+ ceiling stays what it always was, and one signature window after this ships
195
+ there are no unstamped sessions left for :func:`session_has_expired` to
196
+ decide about.
197
+ """
198
+ recorded = session.get(SESSION_EXPIRES_AT_KEY) if isinstance(session, Mapping) else None
199
+ if not isinstance(recorded, bool) and isinstance(recorded, (int, float)):
200
+ return int(recorded)
201
+ return stamp_session_expiry(session, max_age_seconds)
202
+
203
+
179
204
  def session_has_expired(session: Any) -> bool:
180
- """True only if the session records a deadline that has passed.
205
+ """True if the session has no deadline, or records one that has passed.
181
206
 
182
- Fail-open on a missing or unreadable key: it means the session predates
183
- :func:`stamp_session_expiry`, and the alternative is signing every current
184
- user out at deploy time. Revisit once deployed sessions have all turned
185
- over — at that point absence should mean expired.
207
+ Fails **closed** on a missing or unreadable key. That is safe because the
208
+ only caller stamps first — see :func:`ensure_session_expiry` — so absence
209
+ here means the session never went through that path, which is not something
210
+ to give the benefit of the doubt to.
186
211
  """
187
212
  if not isinstance(session, Mapping):
188
- return False
213
+ return True
189
214
  recorded = session.get(SESSION_EXPIRES_AT_KEY)
190
215
  if isinstance(recorded, bool) or not isinstance(recorded, (int, float)):
191
- return False
216
+ return True
192
217
  return recorded <= time.time()
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from types import SimpleNamespace
6
6
 
7
+ from simple_module_hosting._favicon import default_favicon_data_uri
7
8
  from simple_module_hosting._inertia_setup import branding_head
8
9
 
9
10
 
@@ -16,7 +17,11 @@ def _request(branding: object | None) -> SimpleNamespace:
16
17
 
17
18
  def test_defaults_when_branding_not_installed() -> None:
18
19
  meta = branding_head(_request(None))
19
- assert meta == {"app_name": "SimpleModule", "theme_color": None, "favicon_url": None}
20
+ assert meta == {
21
+ "app_name": "SimpleModule",
22
+ "theme_color": None,
23
+ "favicon_url": default_favicon_data_uri("SimpleModule"),
24
+ }
20
25
 
21
26
 
22
27
  def test_reads_app_name_and_theme_color() -> None:
@@ -29,7 +34,11 @@ def test_reads_app_name_and_theme_color() -> None:
29
34
  def test_blank_values_fall_back() -> None:
30
35
  settings = SimpleNamespace(app_name="", primary_color="")
31
36
  meta = branding_head(_request(SimpleNamespace(settings=settings)))
32
- assert meta == {"app_name": "SimpleModule", "theme_color": None, "favicon_url": None}
37
+ assert meta == {
38
+ "app_name": "SimpleModule",
39
+ "theme_color": None,
40
+ "favicon_url": default_favicon_data_uri("SimpleModule"),
41
+ }
33
42
 
34
43
 
35
44
  def test_favicon_url_comes_from_the_module_not_from_here() -> None:
@@ -42,8 +51,25 @@ def test_favicon_url_comes_from_the_module_not_from_here() -> None:
42
51
  assert branding_head(_request(services))["favicon_url"] == "/api/branding/favicon?v=abc"
43
52
 
44
53
 
45
- def test_favicon_url_is_none_on_a_host_without_that_attribute() -> None:
46
- # An older branding release exposes no favicon_url; the shell just omits
47
- # the link rather than erroring, and BrandingHead still sets it client-side.
54
+ def test_an_uploaded_favicon_always_wins_over_the_default() -> None:
55
+ services = SimpleNamespace(
56
+ settings=SimpleNamespace(app_name="Acme", primary_color="#1a7dd1"),
57
+ favicon_url="/api/branding/favicon?v=abc",
58
+ )
59
+ assert branding_head(_request(services))["favicon_url"] == "/api/branding/favicon?v=abc"
60
+
61
+
62
+ def test_a_host_without_that_attribute_still_gets_a_favicon() -> None:
63
+ # An older branding release exposes no favicon_url. Omitting the link tag
64
+ # sends the browser to /favicon.ico, which this app does not route — a 404
65
+ # (or a 302 to the login page) in the console on every full page load — so
66
+ # the generated mark stands in.
48
67
  services = SimpleNamespace(settings=SimpleNamespace(app_name="Acme", primary_color=""))
49
- assert branding_head(_request(services))["favicon_url"] is None
68
+ assert branding_head(_request(services))["favicon_url"] == default_favicon_data_uri("Acme")
69
+
70
+
71
+ def test_the_default_follows_the_configured_brand_colour() -> None:
72
+ services = SimpleNamespace(settings=SimpleNamespace(app_name="Acme", primary_color="#1a7dd1"))
73
+ favicon = branding_head(_request(services))["favicon_url"]
74
+ assert favicon == default_favicon_data_uri("Acme", "#1a7dd1")
75
+ assert favicon != default_favicon_data_uri("Acme")