simple-module-hosting 0.0.33__tar.gz → 0.0.35__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 (108) hide show
  1. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/PKG-INFO +4 -4
  2. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/pyproject.toml +5 -4
  3. simple_module_hosting-0.0.35/simple_module_hosting/_dev_boot.py +83 -0
  4. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_error_handlers.py +18 -7
  5. simple_module_hosting-0.0.35/simple_module_hosting/_favicon.py +94 -0
  6. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_inertia_setup.py +30 -73
  7. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_phase_helpers.py +12 -6
  8. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_registrations.py +2 -0
  9. simple_module_hosting-0.0.35/simple_module_hosting/_tenant.py +97 -0
  10. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/app_builder.py +16 -25
  11. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/host_settings.py +25 -2
  12. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/i18n_manifest.py +20 -5
  13. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/inertia_deps.py +1 -1
  14. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/manifest.py +4 -17
  15. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/middleware.py +3 -57
  16. simple_module_hosting-0.0.35/simple_module_hosting/page_globs.py +52 -0
  17. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/permissions.py +36 -15
  18. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/session.py +35 -10
  19. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_branding_head.py +32 -6
  20. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_csp_module_sources.py +1 -0
  21. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_diagnostics_state.py +2 -4
  22. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_error_page_shared_props.py +46 -3
  23. simple_module_hosting-0.0.35/tests/test_favicon_default.py +72 -0
  24. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_host_settings_fields.py +2 -1
  25. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_i18n_manifest.py +52 -1
  26. simple_module_hosting-0.0.35/tests/test_invalidation_wiring.py +250 -0
  27. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_manifest.py +48 -0
  28. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_middleware_order.py +10 -0
  29. simple_module_hosting-0.0.35/tests/test_proxy_headers.py +224 -0
  30. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_session_cookie_window.py +37 -0
  31. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_strict_discovery_wiring.py +1 -1
  32. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_tenant_middleware.py +56 -3
  33. simple_module_hosting-0.0.35/tests/test_tenant_scope_helpers.py +99 -0
  34. simple_module_hosting-0.0.33/simple_module_hosting/_inertia_json.py +0 -86
  35. simple_module_hosting-0.0.33/tests/test_inertia_json_encoder.py +0 -83
  36. simple_module_hosting-0.0.33/tests/test_inertia_manifest.py +0 -71
  37. simple_module_hosting-0.0.33/tests/test_proxy_headers.py +0 -122
  38. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/.gitignore +0 -0
  39. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/LICENSE +0 -0
  40. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/README.md +0 -0
  41. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/__init__.py +0 -0
  42. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/__main__.py +0 -0
  43. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_db_health.py +0 -0
  44. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_host_services.py +0 -0
  45. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_hydrate_step.py +0 -0
  46. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_inertia_cache.py +0 -0
  47. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_inertia_shared.py +0 -0
  48. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_lifespan.py +0 -0
  49. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_module_routes.py +0 -0
  50. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_observability.py +0 -0
  51. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_preapp_config.py +0 -0
  52. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_secret_key.py +0 -0
  53. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/assets.py +0 -0
  54. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/bootstrap_settings.py +0 -0
  55. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/csrf.py +0 -0
  56. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/health.py +0 -0
  57. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/host_cli.py +0 -0
  58. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/i18n_deps.py +0 -0
  59. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/i18n_middleware.py +0 -0
  60. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/inertia_utils.py +0 -0
  61. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/logging.py +0 -0
  62. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/maintenance.py +0 -0
  63. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/migrations.py +0 -0
  64. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/py.typed +0 -0
  65. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/redirects.py +0 -0
  66. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/settings.py +0 -0
  67. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/setup_gate.py +0 -0
  68. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/shared_props.py +0 -0
  69. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/static_files.py +0 -0
  70. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_api_error_negotiation.py +0 -0
  71. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_app.py +0 -0
  72. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_auth_provider_setting.py +0 -0
  73. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_boot_drift_check.py +0 -0
  74. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_check_migrations.py +0 -0
  75. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_db_health_check.py +0 -0
  76. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_design_packs_wiring.py +0 -0
  77. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_error_status_coverage.py +0 -0
  78. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_health.py +0 -0
  79. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_host_cli.py +0 -0
  80. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_hosting_csrf.py +0 -0
  81. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_hosting_permissions.py +0 -0
  82. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_i18n_types_cover_installed_modules.py +0 -0
  83. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_inertia_cache.py +0 -0
  84. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_inertia_i18n_shared_props.py +0 -0
  85. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_inertia_shared_providers.py +0 -0
  86. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_lifespan_order.py +0 -0
  87. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_locale_middleware.py +0 -0
  88. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_logging.py +0 -0
  89. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_maintenance_mode.py +0 -0
  90. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_menu_urls_are_canonical.py +0 -0
  91. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_migration_listing.py +0 -0
  92. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_migration_multihead.py +0 -0
  93. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_preapp_config.py +0 -0
  94. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_precompressed_static.py +0 -0
  95. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_redirects.py +0 -0
  96. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_response_compression.py +0 -0
  97. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_secret_key_bootstrap.py +0 -0
  98. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_session_cookie_security.py +0 -0
  99. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_settings_i18n.py +0 -0
  100. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_settings_secrets.py +0 -0
  101. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_gate.py +0 -0
  102. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_password_policy.py +0 -0
  103. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_public_routes.py +0 -0
  104. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_registry.py +0 -0
  105. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_routes.py +0 -0
  106. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_sqlite_path_anchor.py +0 -0
  107. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_static_caching.py +0 -0
  108. {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/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.35
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
@@ -22,13 +22,13 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: click>=8.1
25
- Requires-Dist: fastapi-inertia>=1.0
26
25
  Requires-Dist: fastapi>=0.115
27
26
  Requires-Dist: httpx>=0.27
28
27
  Requires-Dist: itsdangerous>=2.0
29
28
  Requires-Dist: jinja2>=3.1
30
- Requires-Dist: simple-module-core==0.0.33
31
- Requires-Dist: simple-module-db==0.0.33
29
+ Requires-Dist: simple-module-core==0.0.35
30
+ Requires-Dist: simple-module-db==0.0.35
31
+ Requires-Dist: simple-module-inertia==0.0.35
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.35"
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"
@@ -23,15 +23,15 @@ classifiers = [
23
23
  dependencies = [
24
24
  "click>=8.1",
25
25
  "fastapi>=0.115",
26
- "fastapi-inertia>=1.0",
27
26
  "httpx>=0.27",
28
27
  # SessionMiddleware (starlette.middleware.sessions) imports itsdangerous at
29
28
  # module import time; starlette treats it as an optional extra, so declare
30
29
  # it directly — out-of-tree hosts have nothing else pulling it in.
31
30
  "itsdangerous>=2.0",
32
31
  "jinja2>=3.1",
33
- "simple_module_core==0.0.33",
34
- "simple_module_db==0.0.33",
32
+ "simple_module_core==0.0.35",
33
+ "simple_module_db==0.0.35",
34
+ "simple_module_inertia==0.0.35",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
@@ -59,3 +59,4 @@ packages = ["simple_module_hosting"]
59
59
  [tool.uv.sources]
60
60
  simple_module_core = { workspace = true }
61
61
  simple_module_db = { workspace = true }
62
+ simple_module_inertia = { workspace = true }
@@ -0,0 +1,83 @@
1
+ """Development-only boot work: the diagnostics run and the frontend artifacts.
2
+
3
+ Extracted from ``app_builder`` so that file reads as the phase sequence and
4
+ nothing else. Everything here is gated on ``settings.is_development`` at the one
5
+ call site, and everything here reads or writes the *source tree* — a deployed
6
+ wheel has no ``host/client_app`` to write a manifest into and no module sources
7
+ for the AST checks to walk, which is why none of it belongs on a production boot
8
+ path.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
18
+
19
+ from simple_module_hosting.i18n_manifest import emit_frontend_types_for_modules
20
+
21
+ if TYPE_CHECKING:
22
+ from simple_module_core import ModuleBase
23
+ from simple_module_core.services import DiagnosticsState
24
+
25
+ from simple_module_hosting.settings import Settings
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ __all__ = ["run_dev_boot"]
30
+
31
+
32
+ def run_dev_boot(
33
+ settings: Settings,
34
+ modules: list[ModuleBase],
35
+ installed_modules: list[ModuleBase],
36
+ *,
37
+ i18n_extra,
38
+ diagnostics_state: DiagnosticsState,
39
+ project_root: Path,
40
+ ) -> None:
41
+ """Run the module diagnostics and emit the frontend's generated files.
42
+
43
+ ``diagnostics_state.runner`` is left behind on purpose, not just used: the
44
+ in-app Doctor screen re-invokes this exact call through ``rerun()``, and
45
+ re-deriving the arguments from a request would mean re-discovering modules.
46
+
47
+ An ERROR-level diagnostic ends the process. A developer whose module has a
48
+ duplicate name or a framework→plugin import gets one clear message here
49
+ instead of an inexplicable failure several phases later — and the same
50
+ checks run at production boot, where the app would refuse to start anyway.
51
+ """
52
+ diagnostics_state.runner = lambda: run_diagnostics(
53
+ modules,
54
+ i18n_supported_locales=settings.i18n_supported_locales,
55
+ i18n_default_locale=settings.i18n_default_locale,
56
+ i18n_extra_sources=i18n_extra,
57
+ )
58
+ diagnostics = diagnostics_state.rerun()
59
+ errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
60
+ if diagnostics:
61
+ print_diagnostics(diagnostics)
62
+ if errors:
63
+ raise SystemExit(f"Module diagnostics: {len(errors)} error(s). Fix before continuing.")
64
+
65
+ _write_pages_manifest(modules, project_root)
66
+ emit_frontend_types_for_modules(settings, installed_modules, project_root)
67
+
68
+
69
+ def _write_pages_manifest(modules: list[ModuleBase], project_root: Path) -> None:
70
+ """Tell Vite where pages shipped inside pip-installed wheels live.
71
+
72
+ Swallows its own failure: a missing or unwritable ``client_app`` must not
73
+ stop the API from booting, and the symptom — pages the frontend cannot find
74
+ — is loud enough on its own. See ``scaffolding.py``.
75
+ """
76
+ try:
77
+ from simple_module_hosting.manifest import write_module_pages_manifest
78
+
79
+ client_app = project_root / "host" / "client_app"
80
+ if client_app.is_dir():
81
+ write_module_pages_manifest(modules, client_app)
82
+ except Exception:
83
+ logger.exception("Failed to write module pages manifest — frontend may miss pages")
@@ -9,13 +9,13 @@ from http import HTTPStatus
9
9
  from fastapi.encoders import jsonable_encoder
10
10
  from fastapi.exceptions import RequestValidationError
11
11
  from fastapi.responses import JSONResponse
12
- from inertia import (
12
+ from simple_module_core.exceptions import NotFoundError
13
+ from simple_module_inertia import (
13
14
  Inertia,
14
15
  InertiaConfig,
15
16
  InertiaVersionConflictException,
16
17
  inertia_version_conflict_exception_handler,
17
18
  )
18
- from simple_module_core.exceptions import NotFoundError
19
19
  from starlette.exceptions import HTTPException
20
20
  from starlette.requests import Request
21
21
  from starlette.responses import Response
@@ -172,11 +172,22 @@ async def render_error_page(
172
172
  # thing that is missing when the app is half-built, and an error page
173
173
  # that raises while reporting an error leaves the caller with nothing.
174
174
  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.
175
+ # Prefer the app's configured dependency over constructing Inertia
176
+ # directly, so the error page is built the same way as every other
177
+ # page. The raw construction is the fallback for a half-built app —
178
+ # the case this whole handler exists to survive. Both paths emit a
179
+ # root-relative page url: the adapter does that by construction now,
180
+ # so the fallback no longer needs a separate patch to avoid the
181
+ # cross-scheme `pushState` SecurityError behind a TLS proxy.
182
+ inertia_dep = getattr(request.app.state, "inertia_dependency", None)
183
+ if inertia_dep is not None:
184
+ inertia = inertia_dep(request, None)
185
+ else:
186
+ inertia = Inertia(request, config)
187
+ # This does not go through get_inertia, so the share step has to be
188
+ # repeated here. Without it the error page renders raw translation keys
189
+ # (host.error.not_found_title) and loses auth/menus, so a signed-in
190
+ # user's 404 has no layout.
180
191
  shared = getattr(request.state, "inertia_shared", None)
181
192
  if shared:
182
193
  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"]
@@ -1,18 +1,15 @@
1
- """Configure fastapi-inertia with the Jinja2 template."""
1
+ """Configure simple_module_inertia with the Jinja2 template."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import hashlib
6
- import json
7
5
  import logging
8
- import tempfile
9
6
  from pathlib import Path
10
7
 
11
8
  from fastapi import FastAPI
12
- from inertia import InertiaConfig, inertia_dependency_factory
9
+ from simple_module_inertia import InertiaConfig, inertia_dependency_factory
13
10
  from starlette.requests import Request
14
11
 
15
- from simple_module_hosting._inertia_json import json_safe_inertia_dependency
12
+ from simple_module_hosting._favicon import default_favicon_data_uri
16
13
  from simple_module_hosting.settings import Settings
17
14
 
18
15
  logger = logging.getLogger(__name__)
@@ -26,6 +23,12 @@ _ROOT_DIRECTORY = "."
26
23
  # branding's own default so the unbranded title is identical everywhere.
27
24
  _DEFAULT_APP_NAME = "SimpleModule"
28
25
 
26
+ # Built assets are served from the "/static" mount under "dist/", so production
27
+ # asset URLs are prefixed with "static/dist". The manifest is read where Vite
28
+ # writes it; the adapter looks the entry up by Vite's own key.
29
+ _ASSETS_PREFIX = "static/dist"
30
+ _VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json"
31
+
29
32
 
30
33
  def branding_head(request: Request) -> dict:
31
34
  """Branding metadata for the root template's ``<head>``.
@@ -40,72 +43,38 @@ def branding_head(request: Request) -> dict:
40
43
  The favicon URL is read from the module rather than assembled here: branding
41
44
  owns its route shape, and framework code must not reach into a plugin
42
45
  (SM009). ``BrandingHead`` still applies it client-side too, so a favicon
43
- changed at runtime updates without a reload.
46
+ changed at runtime updates without a reload. When nothing has been uploaded
47
+ — the state every install starts in — it falls back to a generated mark
48
+ rather than ``None``, because omitting the tag sends the browser to
49
+ ``/favicon.ico``, which this app does not serve.
44
50
  """
45
51
  services = getattr(request.app.state, "branding", None)
46
52
  settings = getattr(services, "settings", None)
47
- if settings is None:
48
- return {"app_name": _DEFAULT_APP_NAME, "theme_color": None, "favicon_url": None}
53
+ app_name = getattr(settings, "app_name", "") or _DEFAULT_APP_NAME
54
+ accent = getattr(settings, "primary_color", "") or ""
55
+ favicon_url = getattr(services, "favicon_url", None) or default_favicon_data_uri(
56
+ app_name, accent
57
+ )
49
58
  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),
59
+ "app_name": app_name,
60
+ "theme_color": accent or None,
61
+ "favicon_url": favicon_url,
53
62
  }
54
63
 
55
64
 
56
- # Built assets are served from the "/static" mount under "dist/", so production
57
- # asset URLs are prefixed with "static/dist".
58
- _ASSETS_PREFIX = "static/dist"
59
- _VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json"
60
-
61
-
62
65
  def _prod_manifest_path(project_root: Path) -> str:
63
- """Return a manifest path fastapi-inertia can read in production.
64
-
65
- fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"``
66
- (here ``"./main.tsx"``), but Vite keys its manifest by the entry's path
67
- relative to the Vite root (``"main.tsx"``) — so the raw Vite manifest would
68
- ``KeyError``. Read it, re-key the ``isEntry`` chunk under the key
69
- fastapi-inertia expects, and write the normalized copy beside the build
70
- output (falling back to a temp file if that dir is read-only). Returns ``""``
71
- when no built manifest exists, leaving production assets unconfigured rather
72
- than crashing at import time.
73
- """
66
+ """The Vite manifest to read in production, or ``""`` when none is built."""
74
67
  candidates = [
75
68
  project_root / "host" / _VITE_MANIFEST_RELPATH,
76
69
  project_root / _VITE_MANIFEST_RELPATH,
77
70
  ]
78
- vite_manifest = next((p for p in candidates if p.is_file()), None)
79
- if vite_manifest is None:
71
+ found = next((p for p in candidates if p.is_file()), None)
72
+ if found is None:
80
73
  logger.warning(
81
74
  "Production Vite manifest not found (looked in %s)", [str(c) for c in candidates]
82
75
  )
83
76
  return ""
84
- try:
85
- data = json.loads(vite_manifest.read_text())
86
- expected_key = f"{_ROOT_DIRECTORY}/{_ENTRYPOINT_FILENAME}"
87
- if expected_key not in data:
88
- entry = next((v for v in data.values() if v.get("isEntry")), None)
89
- if entry is None:
90
- # No entry to re-key: degrade gracefully (same as no-manifest)
91
- # rather than returning a path that KeyErrors at render time.
92
- logger.warning("No isEntry chunk in Vite manifest %s", vite_manifest)
93
- return ""
94
- data = {**data, expected_key: entry}
95
- out = vite_manifest.parent / "inertia-manifest.json"
96
- try:
97
- out.write_text(json.dumps(data))
98
- except OSError:
99
- # Build dir read-only (e.g. immutable container layer): fall back to
100
- # a temp file keyed by the source manifest path so multiple apps on
101
- # one host don't clobber each other's normalized manifests.
102
- digest = hashlib.sha1(str(vite_manifest).encode()).hexdigest()[:12]
103
- out = Path(tempfile.gettempdir()) / f"sm-inertia-manifest-{digest}.json"
104
- out.write_text(json.dumps(data))
105
- return str(out)
106
- except Exception:
107
- logger.exception("Failed to prepare production Inertia manifest from %s", vite_manifest)
108
- return ""
77
+ return str(found)
109
78
 
110
79
 
111
80
  def setup_inertia(
@@ -114,7 +83,7 @@ def setup_inertia(
114
83
  modules: list,
115
84
  project_root: Path,
116
85
  ) -> InertiaConfig | None:
117
- """Configure fastapi-inertia and attach the dependency factory to app.state.
86
+ """Configure the Inertia adapter and attach the dependency factory to app.state.
118
87
 
119
88
  Two host layouts are supported: ``host/templates`` (the framework's
120
89
  own host package) and ``templates`` at the project root (what
@@ -157,14 +126,9 @@ def setup_inertia(
157
126
  # Expose branding metadata to the root template (pre-hydration head tags).
158
127
  templates.env.globals["branding_head"] = branding_head
159
128
 
160
- # fastapi-inertia only switches to the asset manifest when environment
161
- # equals the literal string "production". Anything else (staging, qa,
162
- # ...) would render a /main.tsx <script> tag served by the SPA fallback
163
- # as text/html, breaking module loading. Normalize:
164
- # * `development`/`testing` → keep the dev-server path (Vite serves
165
- # /main.tsx directly).
166
- # * Anything else (staging, production, qa, ...) → use the production
167
- # manifest so built assets are referenced.
129
+ # The adapter only switches to the asset manifest when environment equals
130
+ # the literal "production"; normalise anything non-dev (staging, qa, ...)
131
+ # to that so built assets are referenced rather than a Vite dev url.
168
132
  from simple_module_core.environments import NON_PROD_ENVIRONMENTS
169
133
 
170
134
  use_dev_server = settings.environment in NON_PROD_ENVIRONMENTS
@@ -174,8 +138,6 @@ def setup_inertia(
174
138
  environment=inertia_environment,
175
139
  version=_INERTIA_VERSION,
176
140
  dev_url=settings.vite_dev_url if use_dev_server else "",
177
- # Production reads built assets from the Vite manifest; dev serves them
178
- # from the Vite dev server, so these only matter when not use_dev_server.
179
141
  manifest_json_path="" if use_dev_server else _prod_manifest_path(project_root),
180
142
  assets_prefix="" if use_dev_server else _ASSETS_PREFIX,
181
143
  templates=templates,
@@ -184,10 +146,5 @@ def setup_inertia(
184
146
  root_directory=_ROOT_DIRECTORY,
185
147
  use_flash_errors=True,
186
148
  )
187
-
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))
192
- app.state.inertia_dependency = inertia_dep
149
+ app.state.inertia_dependency = inertia_dependency_factory(inertia_config)
193
150
  return inertia_config
@@ -14,13 +14,13 @@ from typing import TYPE_CHECKING
14
14
  from fastapi import FastAPI
15
15
  from fastapi.exceptions import RequestValidationError
16
16
  from fastapi.staticfiles import StaticFiles
17
- from inertia import (
18
- InertiaVersionConflictException,
19
- inertia_version_conflict_exception_handler,
20
- )
21
17
  from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
22
18
  from simple_module_core.exceptions import NotFoundError
23
19
  from simple_module_db import CommitBeforeResponseMiddleware
20
+ from simple_module_inertia import (
21
+ InertiaVersionConflictException,
22
+ inertia_version_conflict_exception_handler,
23
+ )
24
24
  from starlette.exceptions import HTTPException
25
25
  from starlette.middleware.gzip import GZipMiddleware
26
26
  from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
@@ -145,6 +145,10 @@ def install_middleware(
145
145
  )
146
146
  if settings.multi_tenant:
147
147
  app.add_middleware(TenantMiddleware, header=settings.tenant_header or None)
148
+ elif settings.default_tenant:
149
+ # Single-tenant host with tenant-scoped tables: every request is the
150
+ # one tenant (#359). Never used with multi_tenant — no shared fallback.
151
+ app.add_middleware(TenantMiddleware, fixed=settings.default_tenant)
148
152
  for mod in modules:
149
153
  mod.register_middleware(app)
150
154
  app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
@@ -167,8 +171,10 @@ def install_middleware(
167
171
  app.add_middleware(RequestLoggingMiddleware)
168
172
  app.add_middleware(CorrelationIdMiddleware)
169
173
  # 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
174
+ # reads them, so request logs see the real client IP rather than the
175
+ # proxy's. Inertia no longer needs this: its page url is
176
+ # root-relative, so pushState can't see a cross-scheme url either way (it
177
+ # used to throw a SecurityError on every page — GH #223). Gated on an
172
178
  # explicit trust setting — never trust forwarded headers by default.
173
179
  if settings.trusted_proxy:
174
180
  app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=settings.trusted_proxy)
@@ -19,6 +19,7 @@ def run_module_registrations(
19
19
  *,
20
20
  app: FastAPI,
21
21
  event_bus,
22
+ invalidation_bus,
22
23
  menu_registry,
23
24
  perm_registry,
24
25
  ff_registry,
@@ -42,6 +43,7 @@ def run_module_registrations(
42
43
  mod.register_permissions(perm_registry)
43
44
  mod.register_feature_flags(ff_registry)
44
45
  dispatch_event_handlers(mod, event_bus, app)
46
+ mod.register_invalidations(invalidation_bus, app)
45
47
  health_registry.set_owner(mod.meta.name)
46
48
  mod.register_health_checks(health_registry)
47
49
  mod.register_public_routes(public_route_registry)
@@ -0,0 +1,97 @@
1
+ """Tenant-context middleware: which tenant a request acts for."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ from simple_module_db import current_tenant_id, is_valid_tenant_id
8
+ from starlette.datastructures import Headers
9
+ from starlette.requests import Request
10
+ from starlette.types import ASGIApp, Receive, Scope, Send
11
+
12
+ _SCOPE_HTTP = "http"
13
+
14
+ TENANT_HEADER = "X-Tenant-ID"
15
+
16
+
17
+ TenantResolver = Callable[[Request], Awaitable[str | None]]
18
+ """Module-owned tenant resolution, registered as ``app.state.tenant_resolver``.
19
+
20
+ Returns the tenant the request acts for, or ``None``. It owns *every* source —
21
+ membership, session, subdomain, header — so it is also where each is
22
+ validated. Without one the middleware falls back to the principal's
23
+ ``tenant_id`` claim."""
24
+
25
+
26
+ class TenantMiddleware:
27
+ """Establish the request's tenant context.
28
+
29
+ Sets the ``current_tenant_id`` context var so that DB queries on
30
+ :class:`~simple_module_db.mixins.MultiTenantMixin` models are
31
+ automatically filtered, and new objects get ``tenant_id`` populated.
32
+
33
+ Also stores the resolved value on ``request.state.tenant_id``.
34
+
35
+ Resolution:
36
+
37
+ 1. If a module registered ``app.state.tenant_resolver`` (the ``tenants``
38
+ module does), its answer is final — including ``None``.
39
+ 2. Otherwise the authenticated user's ``tenant_id`` attribute (from the
40
+ auth token claims).
41
+ 3. Otherwise, for **anonymous** requests only, the configured header —
42
+ useful for API clients and tests. Pass ``header=None`` (the default)
43
+ to disable it. An authenticated user is never allowed to pick a tenant
44
+ by header: a user with no tenant of their own would otherwise be able
45
+ to name anyone's.
46
+ """
47
+
48
+ def __init__(
49
+ self, app: ASGIApp, *, header: str | None = None, fixed: str | None = None
50
+ ) -> None:
51
+ self.app = app
52
+ self.header = header
53
+ # Single-tenant hosts: one tenant for every request, nothing resolved.
54
+ self.fixed = fixed
55
+
56
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
57
+ if scope["type"] != _SCOPE_HTTP:
58
+ await self.app(scope, receive, send)
59
+ return
60
+
61
+ request = Request(scope)
62
+ tenant_id = await self._resolve(request, scope)
63
+ request.state.tenant_id = tenant_id
64
+
65
+ if tenant_id is not None:
66
+ token = current_tenant_id.set(tenant_id)
67
+ try:
68
+ await self.app(scope, receive, send)
69
+ finally:
70
+ current_tenant_id.reset(token)
71
+ return
72
+
73
+ await self.app(scope, receive, send)
74
+
75
+ async def _resolve(self, request: Request, scope: Scope) -> str | None:
76
+ if self.fixed is not None:
77
+ return self.fixed
78
+ app = scope.get("app")
79
+ resolver: TenantResolver | None = getattr(
80
+ getattr(app, "state", None), "tenant_resolver", None
81
+ )
82
+ if resolver is not None:
83
+ return await resolver(request)
84
+
85
+ user = getattr(request.state, "user", None)
86
+ if user is not None:
87
+ return getattr(user, "tenant_id", None)
88
+
89
+ if self.header:
90
+ value = Headers(scope=scope).get(self.header)
91
+ # Unvalidated, an over-long value is a 500 on the first stamped
92
+ # write (VARCHAR(50)) and any junk becomes a tenant name (#366).
93
+ return value if is_valid_tenant_id(value) else None
94
+ return None
95
+
96
+
97
+ __all__ = ["TENANT_HEADER", "TenantMiddleware", "TenantResolver"]