simple-module-hosting 0.0.30__tar.gz → 0.0.31__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 (74) hide show
  1. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/.gitignore +1 -0
  2. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/PKG-INFO +4 -4
  3. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/pyproject.toml +3 -3
  4. simple_module_hosting-0.0.31/simple_module_hosting/_db_health.py +56 -0
  5. simple_module_hosting-0.0.31/simple_module_hosting/_error_handlers.py +159 -0
  6. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_phase_helpers.py +32 -10
  7. simple_module_hosting-0.0.31/simple_module_hosting/_registrations.py +69 -0
  8. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/app_builder.py +30 -26
  9. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/assets.py +20 -4
  10. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/bootstrap_settings.py +49 -1
  11. simple_module_hosting-0.0.31/simple_module_hosting/csrf.py +98 -0
  12. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/health.py +5 -1
  13. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/middleware.py +5 -2
  14. simple_module_hosting-0.0.31/tests/test_api_error_negotiation.py +143 -0
  15. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_auth_provider_setting.py +3 -0
  16. simple_module_hosting-0.0.31/tests/test_csp_module_sources.py +68 -0
  17. simple_module_hosting-0.0.31/tests/test_db_health_check.py +40 -0
  18. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_error_page_shared_props.py +13 -0
  19. simple_module_hosting-0.0.31/tests/test_hosting_csrf.py +98 -0
  20. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_middleware_order.py +9 -1
  21. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_settings_i18n.py +3 -0
  22. simple_module_hosting-0.0.31/tests/test_sqlite_path_anchor.py +182 -0
  23. simple_module_hosting-0.0.30/simple_module_hosting/_error_handlers.py +0 -75
  24. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/LICENSE +0 -0
  25. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/README.md +0 -0
  26. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/__init__.py +0 -0
  27. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/__main__.py +0 -0
  28. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_host_services.py +0 -0
  29. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_hydrate_step.py +0 -0
  30. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_inertia_setup.py +0 -0
  31. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_inertia_shared.py +0 -0
  32. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/_observability.py +0 -0
  33. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/host_cli.py +0 -0
  34. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/host_settings.py +0 -0
  35. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/i18n_deps.py +0 -0
  36. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/i18n_manifest.py +0 -0
  37. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/i18n_middleware.py +0 -0
  38. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/inertia_deps.py +0 -0
  39. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/inertia_utils.py +0 -0
  40. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/logging.py +0 -0
  41. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/manifest.py +0 -0
  42. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/migrations.py +0 -0
  43. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/permissions.py +0 -0
  44. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/py.typed +0 -0
  45. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/redirects.py +0 -0
  46. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/settings.py +0 -0
  47. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/shared_props.py +0 -0
  48. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/simple_module_hosting/static_files.py +0 -0
  49. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_app.py +0 -0
  50. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_branding_head.py +0 -0
  51. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_check_migrations.py +0 -0
  52. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_design_packs_wiring.py +0 -0
  53. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_health.py +0 -0
  54. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_host_cli.py +0 -0
  55. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_hosting_permissions.py +0 -0
  56. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_i18n_manifest.py +0 -0
  57. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_inertia_i18n_shared_props.py +0 -0
  58. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_inertia_manifest.py +0 -0
  59. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_inertia_shared_providers.py +0 -0
  60. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_lifespan_order.py +0 -0
  61. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_locale_middleware.py +0 -0
  62. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_logging.py +0 -0
  63. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_manifest.py +0 -0
  64. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_menu_urls_are_canonical.py +0 -0
  65. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_precompressed_static.py +0 -0
  66. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_proxy_headers.py +0 -0
  67. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_redirects.py +0 -0
  68. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_response_compression.py +0 -0
  69. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_session_cookie_security.py +0 -0
  70. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_settings_secrets.py +0 -0
  71. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_static_caching.py +0 -0
  72. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_strict_discovery_wiring.py +0 -0
  73. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_tenant_middleware.py +0 -0
  74. {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.31}/tests/test_translator_dep.py +0 -0
@@ -63,3 +63,4 @@ Thumbs.db
63
63
  host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
+ .verify/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simple_module_hosting
3
- Version: 0.0.30
3
+ Version: 0.0.31
4
4
  Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -27,8 +27,8 @@ Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
28
  Requires-Dist: itsdangerous>=2.0
29
29
  Requires-Dist: jinja2>=3.1
30
- Requires-Dist: simple-module-core==0.0.30
31
- Requires-Dist: simple-module-db==0.0.30
30
+ Requires-Dist: simple-module-core==0.0.31
31
+ Requires-Dist: simple-module-db==0.0.31
32
32
  Requires-Dist: starlette>=0.44
33
33
  Requires-Dist: tomlkit>=0.13
34
34
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.30"
3
+ version = "0.0.31"
4
4
  description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -30,8 +30,8 @@ dependencies = [
30
30
  # it directly — out-of-tree hosts have nothing else pulling it in.
31
31
  "itsdangerous>=2.0",
32
32
  "jinja2>=3.1",
33
- "simple_module_core==0.0.30",
34
- "simple_module_db==0.0.30",
33
+ "simple_module_core==0.0.31",
34
+ "simple_module_db==0.0.31",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
@@ -0,0 +1,56 @@
1
+ """The host's own readiness check: can we reach the database?
2
+
3
+ Registered by ``create_app`` rather than by a module, because it is the one
4
+ dependency no request can do without — unlike a mailer or an object store,
5
+ which a page load never touches.
6
+
7
+ It also gives ``/health/ready`` something to actually report. Modules
8
+ contribute checks for third-party services, and those are ``probe=False``
9
+ (polling an SMTP login every 10s earns a rate-limit), so without this the
10
+ readiness endpoint would answer "healthy" from an empty check set — a green
11
+ light that proves nothing.
12
+
13
+ It is owned by the host, not a module, so it deliberately does not light up any
14
+ tile on the dashboard's per-module health strip — those are keyed by
15
+ ``HealthCheck.module``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from simple_module_core.health import HealthCheck, HealthCheckResult, HealthStatus
21
+ from sqlalchemy import text
22
+
23
+ CHECK_DATABASE = "host.database"
24
+
25
+ _MODULE = "Host"
26
+
27
+
28
+ def build_database_check(db_state):
29
+ """Return an async check issuing the cheapest possible round trip."""
30
+
31
+ async def check() -> HealthCheckResult:
32
+ try:
33
+ async with db_state.session_factory() as session:
34
+ await session.execute(text("SELECT 1"))
35
+ except Exception as exc:
36
+ return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail=str(exc))
37
+ return HealthCheckResult(status=HealthStatus.HEALTHY, detail="Database reachable")
38
+
39
+ return check
40
+
41
+
42
+ def register_database_check(health_registry, db_state) -> None:
43
+ """Add the database check to *health_registry*.
44
+
45
+ ``probe=True``: a ``SELECT 1`` against an already-pooled connection is
46
+ cheap enough to run on a probe timer, and it is exactly what a readiness
47
+ probe should be asking.
48
+ """
49
+ health_registry.add(
50
+ HealthCheck(
51
+ name=CHECK_DATABASE,
52
+ check=build_database_check(db_state),
53
+ module=_MODULE,
54
+ probe=True,
55
+ )
56
+ )
@@ -0,0 +1,159 @@
1
+ """Framework-wide exception handlers that render Inertia error pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from fastapi.encoders import jsonable_encoder
8
+ from fastapi.exceptions import RequestValidationError
9
+ from fastapi.responses import JSONResponse
10
+ from inertia import (
11
+ Inertia,
12
+ InertiaConfig,
13
+ InertiaVersionConflictException,
14
+ inertia_version_conflict_exception_handler,
15
+ )
16
+ from simple_module_core.exceptions import NotFoundError
17
+ from starlette.exceptions import HTTPException
18
+ from starlette.requests import Request
19
+ from starlette.responses import Response
20
+
21
+ from simple_module_hosting._inertia_shared import _INERTIA_HEADER
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _INERTIA_ERROR_STATUSES = frozenset({403, 404, 500})
26
+
27
+
28
+ def _explicit_accept_q(accept: str, media_type: str) -> float | None:
29
+ """Quality the Accept header gives ``media_type`` by explicit listing, or
30
+ ``None`` when the caller never named it at all.
31
+
32
+ Only exact entries count (no ``*/*`` / ``type/*`` wildcards) — the
33
+ negotiation below cares whether the caller *named* html or json, and at
34
+ what preference, and separately whether they named it *at all* (``None``
35
+ vs. an explicit ``q=0``). Malformed q-values fall back to 1.0 per RFC 9110.
36
+ """
37
+ for part in accept.split(","):
38
+ media, _, params = part.partition(";")
39
+ if media.strip().lower() != media_type:
40
+ continue
41
+ q = 1.0
42
+ for param in params.split(";"):
43
+ name, _, value = param.partition("=")
44
+ if name.strip().lower() == "q":
45
+ try:
46
+ q = float(value.strip())
47
+ except ValueError:
48
+ q = 1.0
49
+ return q
50
+ return None
51
+
52
+
53
+ def _wants_json(request: Request) -> bool:
54
+ """API callers get JSON error bodies; browser-shaped requests get the page.
55
+
56
+ A request that explicitly accepts ``text/html`` is a browser navigation —
57
+ and those reach ``/api/*`` too (OAuth login links, file-download hrefs) —
58
+ so it always gets the rendered page, unless the caller *prefers* JSON via
59
+ q-values (``application/json, text/html;q=0.5`` is an API client keeping
60
+ an html fallback). So does an Inertia visit (``X-Inertia`` header),
61
+ whatever its Accept — an Inertia client can only consume Inertia-protocol
62
+ responses, never a bare JSON body. Otherwise ``/api/*``, the documented
63
+ prefix for every module's JSON surface (``ModuleMeta.route_prefix``),
64
+ gets JSON — a bare ``fetch()`` sends ``Accept: */*`` — as does an
65
+ explicit ``Accept: application/json`` anywhere else.
66
+
67
+ ``text/html;q=0`` rules html out *everywhere*, not just under ``/api/*``:
68
+ once the caller has explicitly said they won't accept html, this never
69
+ chooses the html page. The (already-moot) html fallback survives only
70
+ when json wasn't mentioned either — there's nothing else to offer, so a
71
+ view path keeps its pre-existing default and an ``/api/*`` path keeps
72
+ getting JSON either way.
73
+ """
74
+ if request.headers.get(_INERTIA_HEADER):
75
+ return False
76
+ accept = request.headers.get("accept", "")
77
+ html_q = _explicit_accept_q(accept, "text/html")
78
+ json_q = _explicit_accept_q(accept, "application/json")
79
+ if html_q is not None and html_q > 0 and html_q >= (json_q or 0.0):
80
+ return False
81
+ path = request.url.path
82
+ is_api = path == "/api" or path.startswith("/api/")
83
+ if html_q == 0:
84
+ return is_api if json_q is None else True
85
+ return is_api or bool(json_q)
86
+
87
+
88
+ async def render_error_page(request: Request, status_code: int, message: str) -> Response:
89
+ config: InertiaConfig = request.app.state.sm.inertia_config
90
+ try:
91
+ inertia = Inertia(request, config)
92
+ # This builds its own Inertia instead of going through get_inertia, so
93
+ # the share step has to be repeated here. Without it the error page
94
+ # renders raw translation keys (host.error.not_found_title) and loses
95
+ # auth/menus, so a signed-in user's 404 has no layout.
96
+ shared = getattr(request.state, "inertia_shared", None)
97
+ if shared:
98
+ inertia.share(**shared)
99
+ # The correlation id is the only handle a user has on their own failed
100
+ # request — without it a support report is just "it broke". It is already
101
+ # on every log line for this request, so quoting it back makes the page
102
+ # and the logs joinable.
103
+ response = await inertia.render(
104
+ "Error",
105
+ {
106
+ "status": status_code,
107
+ "message": message,
108
+ "correlation_id": getattr(request.state, "correlation_id", "") or "",
109
+ },
110
+ )
111
+ response.status_code = status_code
112
+ return response
113
+ except InertiaVersionConflictException as exc:
114
+ return await inertia_version_conflict_exception_handler(request, exc)
115
+ except Exception:
116
+ # Fallback if Inertia rendering itself fails (e.g. missing session)
117
+ logger.exception("Error page rendering failed, falling back to JSON")
118
+ return JSONResponse(
119
+ status_code=status_code, content={"detail": message or "Internal Server Error"}
120
+ )
121
+
122
+
123
+ async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
124
+ if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
125
+ detail = str(exc.detail) if exc.detail else ""
126
+ return await render_error_page(request, exc.status_code, detail)
127
+ # Preserve exception headers (WWW-Authenticate, Retry-After, ...) the way
128
+ # FastAPI's stock handler does.
129
+ return JSONResponse(
130
+ status_code=exc.status_code,
131
+ content={"detail": exc.detail},
132
+ headers=getattr(exc, "headers", None),
133
+ )
134
+
135
+
136
+ async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
137
+ if _wants_json(request):
138
+ return JSONResponse(status_code=404, content={"detail": str(exc)})
139
+ return await render_error_page(request, 404, str(exc))
140
+
141
+
142
+ async def request_validation_error_handler(
143
+ request: Request, exc: RequestValidationError
144
+ ) -> Response:
145
+ """Return an Inertia error page for browser requests with invalid params.
146
+
147
+ Same negotiation rule as 403/404/500 (``_wants_json``), so one request
148
+ never sees two different error shapes depending on the error class.
149
+ """
150
+ if _wants_json(request):
151
+ return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
152
+ return await render_error_page(request, 422, "The requested URL contains invalid parameters.")
153
+
154
+
155
+ async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
156
+ logger.exception("Unhandled exception: %s", exc)
157
+ if _wants_json(request):
158
+ return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
159
+ return await render_error_page(request, 500, "")
@@ -21,6 +21,7 @@ from inertia import (
21
21
  )
22
22
  from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
23
23
  from simple_module_core.exceptions import NotFoundError
24
+ from simple_module_db import CommitBeforeResponseMiddleware
24
25
  from starlette.exceptions import HTTPException
25
26
  from starlette.middleware.gzip import GZipMiddleware
26
27
  from starlette.middleware.sessions import SessionMiddleware
@@ -46,6 +47,7 @@ from simple_module_hosting.settings import Settings
46
47
  from simple_module_hosting.static_files import PrecompressedStaticFiles
47
48
 
48
49
  if TYPE_CHECKING:
50
+ from simple_module_core.csp import CspSourceRegistry
49
51
  from simple_module_core.menu import MenuRegistry
50
52
  from simple_module_core.permissions import PermissionRegistry
51
53
 
@@ -72,19 +74,41 @@ def register_exception_handlers(app: FastAPI, modules: list) -> None:
72
74
  mod.register_exception_handlers(app)
73
75
 
74
76
 
77
+ def build_csp(settings: Settings, csp_registry: CspSourceRegistry) -> str:
78
+ """Choose the CSP for this boot and fold in module-declared sources.
79
+
80
+ Development gets the Vite-widened policy; production the strict default.
81
+ ``csp_registry`` carries origins modules declared via
82
+ ``register_csp_sources`` (e.g. an external font host) — merged here so
83
+ both variants honor them. An empty registry leaves the policy unchanged.
84
+ """
85
+ base = (
86
+ SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url)
87
+ if settings.is_development
88
+ else SecurityHeadersMiddleware.DEFAULT_CSP
89
+ )
90
+ return csp_registry.extend_policy(base)
91
+
92
+
75
93
  def install_middleware(
76
94
  app: FastAPI,
77
95
  settings: Settings,
78
96
  modules: list,
79
97
  menu_registry: MenuRegistry,
80
98
  perm_registry: PermissionRegistry,
99
+ csp_registry: CspSourceRegistry,
81
100
  ) -> None:
82
101
  """Install the full middleware pipeline.
83
102
 
84
103
  Order matters: last added = first executed. Execution order:
85
104
  (ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
86
- → Security → Session → [module] → (Tenant, if multi_tenant) → Locale → Inertia.
105
+ → Security → Session → [module] → (Tenant, if multi_tenant) → Locale
106
+ → Inertia → CommitBeforeResponse.
87
107
  """
108
+ # Added first, so it is innermost and its send-wrapper is the first to see
109
+ # the response: the request's DB work commits before any byte reaches the
110
+ # client, instead of in get_db's post-response exit code (GH #257).
111
+ app.add_middleware(CommitBeforeResponseMiddleware)
88
112
  app.add_middleware(
89
113
  InertiaLayoutDataMiddleware,
90
114
  menu_registry=menu_registry,
@@ -102,16 +126,14 @@ def install_middleware(
102
126
  mod.register_middleware(app)
103
127
  app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
104
128
  # In dev, relax CSP so the browser can fetch @vite/client, main.tsx, and
105
- # the HMR WebSocket from the Vite origin. HSTS is also suppressed because
106
- # dev runs over plain HTTP on loopback.
129
+ # the HMR WebSocket from the Vite origin (build_csp picks the variant).
130
+ # HSTS is suppressed in dev because it runs over plain HTTP on loopback.
131
+ security_kwargs: dict[str, str | None] = {
132
+ "content_security_policy": build_csp(settings, csp_registry)
133
+ }
107
134
  if settings.is_development:
108
- app.add_middleware(
109
- SecurityHeadersMiddleware,
110
- content_security_policy=SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url),
111
- strict_transport_security=None,
112
- )
113
- else:
114
- app.add_middleware(SecurityHeadersMiddleware)
135
+ security_kwargs["strict_transport_security"] = None
136
+ app.add_middleware(SecurityHeadersMiddleware, **security_kwargs)
115
137
  # Compress response bodies. Added here so it sits inside CorrelationId and
116
138
  # RequestLogging (which set headers and read request state) but outside
117
139
  # everything that produces a body — including the /static mount, where it
@@ -0,0 +1,69 @@
1
+ """Phase 5 of boot — every module's declarative registration hooks.
2
+
3
+ Extracted from ``app_builder.py`` to keep that file readable; this is the one
4
+ place that knows the full set of hooks a module may implement, and the order
5
+ they run in. ``app_builder.create_app`` is the only intended caller.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from fastapi import FastAPI
15
+
16
+
17
+ def run_module_registrations(
18
+ modules: list,
19
+ *,
20
+ app: FastAPI,
21
+ event_bus,
22
+ menu_registry,
23
+ perm_registry,
24
+ ff_registry,
25
+ health_registry,
26
+ public_route_registry,
27
+ design_pack_registry,
28
+ audit_link_registry,
29
+ csp_registry,
30
+ ) -> None:
31
+ """Invoke each module's registration hooks, in dependency order.
32
+
33
+ Health checks are attributed to the module that registers them, so the
34
+ dashboard can report health per module rather than one global number. The
35
+ owner is cleared afterwards: anything registered later — a module's
36
+ ``on_startup``, say — belongs to no module in this loop, and inheriting
37
+ the last one's name would be a lie.
38
+ """
39
+ for mod in modules:
40
+ mod.register_menu_items(menu_registry)
41
+ mod.register_permissions(perm_registry)
42
+ mod.register_feature_flags(ff_registry)
43
+ dispatch_event_handlers(mod, event_bus, app)
44
+ health_registry.set_owner(mod.meta.name)
45
+ mod.register_health_checks(health_registry)
46
+ mod.register_public_routes(public_route_registry)
47
+ # csp before design packs — the documented lifecycle order
48
+ # (docs/framework/lifecycle.md).
49
+ mod.register_csp_sources(csp_registry)
50
+ mod.register_design_packs(design_pack_registry)
51
+ mod.register_audit_links(audit_link_registry)
52
+
53
+ health_registry.set_owner("")
54
+
55
+
56
+ def dispatch_event_handlers(mod, event_bus, app: FastAPI) -> None:
57
+ """Call ``mod.register_event_handlers`` with or without ``app``.
58
+
59
+ Back-compat shim for modules that still override the one-arg form
60
+ ``(self, bus)``; passing ``app=`` to those crashes.
61
+ """
62
+ sig = inspect.signature(mod.register_event_handlers)
63
+ accepts_app = "app" in sig.parameters or any(
64
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
65
+ )
66
+ if accepts_app:
67
+ mod.register_event_handlers(event_bus, app=app)
68
+ else:
69
+ mod.register_event_handlers(event_bus)
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import inspect
6
5
  import logging
7
6
  import os
8
7
  from collections.abc import AsyncGenerator
@@ -10,6 +9,8 @@ from contextlib import asynccontextmanager
10
9
  from pathlib import Path
11
10
 
12
11
  from fastapi import FastAPI
12
+ from simple_module_core import CspSourceRegistry
13
+ from simple_module_core.audit_links import AuditLinkRegistry
13
14
  from simple_module_core.design_packs import DesignPackRegistry
14
15
  from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
15
16
  from simple_module_core.discovery import discover_modules, select_auth_provider, topological_sort
@@ -23,6 +24,7 @@ from simple_module_core.services import Services
23
24
  from simple_module_db.listeners import register_listeners
24
25
  from simple_module_db.session import init_db
25
26
 
27
+ from simple_module_hosting._db_health import register_database_check
26
28
  from simple_module_hosting._inertia_setup import setup_inertia
27
29
  from simple_module_hosting._phase_helpers import (
28
30
  attach_public_routes,
@@ -33,6 +35,7 @@ from simple_module_hosting._phase_helpers import (
33
35
  register_host_settings,
34
36
  wire_module_routes,
35
37
  )
38
+ from simple_module_hosting._registrations import run_module_registrations
36
39
  from simple_module_hosting.health import router as health_router
37
40
  from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
38
41
  from simple_module_hosting.migrations import check_migrations
@@ -66,6 +69,10 @@ def _resolve_project_root() -> Path:
66
69
  Falls back to ``parents[3]`` for the in-tree dev loop only when the walk
67
70
  finds nothing — which still keeps ``framework/`` users working without
68
71
  setting the env var explicitly.
72
+
73
+ Compare ``simple_module_core.dotenv.find_env_file``: both honor
74
+ ``SM_PROJECT_ROOT`` first, but this anchors the static/i18n root while
75
+ that anchors which ``.env`` loads — different sentinels, kept separate.
69
76
  """
70
77
  override = os.environ.get(_ENV_PROJECT_ROOT)
71
78
  if override:
@@ -80,22 +87,6 @@ def _resolve_project_root() -> Path:
80
87
  _PROJECT_ROOT = _resolve_project_root()
81
88
 
82
89
 
83
- def _register_event_handlers(mod, event_bus: EventBus, app: FastAPI) -> None:
84
- """Dispatch to ``mod.register_event_handlers`` with or without ``app``.
85
-
86
- Back-compat shim for modules that still override the one-arg form
87
- ``(self, bus)``; passing ``app=`` to those crashes.
88
- """
89
- sig = inspect.signature(mod.register_event_handlers)
90
- accepts_app = "app" in sig.parameters or any(
91
- p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
92
- )
93
- if accepts_app:
94
- mod.register_event_handlers(event_bus, app=app)
95
- else:
96
- mod.register_event_handlers(event_bus)
97
-
98
-
99
90
  def create_app(settings: Settings | None = None) -> FastAPI:
100
91
  """Build and configure the full FastAPI application.
101
92
 
@@ -173,6 +164,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
173
164
  health_registry = HealthRegistry()
174
165
  public_route_registry = PublicRouteRegistry()
175
166
  design_pack_registry = DesignPackRegistry()
167
+ audit_link_registry = AuditLinkRegistry()
176
168
 
177
169
  @asynccontextmanager
178
170
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
@@ -219,14 +211,20 @@ def create_app(settings: Settings | None = None) -> FastAPI:
219
211
  print_diagnostics(settings_diagnostics)
220
212
 
221
213
  # ── Phase 5: Module registrations ──────────────────────
222
- for mod in modules:
223
- mod.register_menu_items(menu_registry)
224
- mod.register_permissions(perm_registry)
225
- mod.register_feature_flags(ff_registry)
226
- _register_event_handlers(mod, event_bus, app)
227
- mod.register_health_checks(health_registry)
228
- mod.register_public_routes(public_route_registry)
229
- mod.register_design_packs(design_pack_registry)
214
+ csp_registry = CspSourceRegistry()
215
+ run_module_registrations(
216
+ modules,
217
+ app=app,
218
+ event_bus=event_bus,
219
+ menu_registry=menu_registry,
220
+ perm_registry=perm_registry,
221
+ ff_registry=ff_registry,
222
+ health_registry=health_registry,
223
+ public_route_registry=public_route_registry,
224
+ design_pack_registry=design_pack_registry,
225
+ audit_link_registry=audit_link_registry,
226
+ csp_registry=csp_registry,
227
+ )
230
228
 
231
229
  attach_public_routes(app, settings, public_route_registry)
232
230
 
@@ -255,6 +253,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
255
253
  pool_recycle=settings.db_pool_recycle,
256
254
  )
257
255
  register_listeners(db_state)
256
+ # The host's own readiness signal, and the only probe-safe check in a
257
+ # default install — module checks reach third parties and are on-demand.
258
+ register_database_check(health_registry, db_state)
258
259
 
259
260
  # ── Phase 7: Inertia + exception handlers ──────────────
260
261
  inertia_config = setup_inertia(app, settings, modules, _PROJECT_ROOT)
@@ -263,7 +264,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
263
264
  register_exception_handlers(app, modules)
264
265
 
265
266
  # ── Phase 8: Middleware pipeline ───────────────────────
266
- install_middleware(app, settings, modules, menu_registry, perm_registry)
267
+ install_middleware(
268
+ app, settings, modules, menu_registry, perm_registry, csp_registry=csp_registry
269
+ )
267
270
 
268
271
  # ── Phase 9: Routes, health, static files ──────────────
269
272
  for mod in modules:
@@ -288,6 +291,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
288
291
  health_registry=health_registry,
289
292
  public_routes=public_route_registry,
290
293
  design_packs=design_pack_registry,
294
+ audit_links=audit_link_registry,
291
295
  i18n_registry=i18n_registry,
292
296
  inertia_config=inertia_config,
293
297
  modules=tuple(modules),
@@ -47,6 +47,8 @@ logger = logging.getLogger(__name__)
47
47
  THEME_CSS = "theme.css"
48
48
  STYLES_CSS = "styles.css"
49
49
  PACKAGE_JSON = "package.json"
50
+ PAGES_DIR = "pages"
51
+ COMPONENTS_DIR = "components"
50
52
 
51
53
 
52
54
  @dataclass(frozen=True)
@@ -60,6 +62,7 @@ class ModuleAssets:
60
62
  theme_css: Path | None
61
63
  styles_css: Path | None
62
64
  npm_name: str | None = None
65
+ components_dir: Path | None = None
63
66
 
64
67
 
65
68
  def find_npm_name(pkg_root: Path) -> str | None:
@@ -114,7 +117,8 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]:
114
117
  "Module '%s': package %s not importable — skipping", mod.meta.name, pkg_name
115
118
  )
116
119
  continue
117
- pages_dir = pkg_root / "pages"
120
+ pages_dir = pkg_root / PAGES_DIR
121
+ components_dir = pkg_root / COMPONENTS_DIR
118
122
  theme = pkg_root / THEME_CSS
119
123
  styles = pkg_root / STYLES_CSS
120
124
  entry = ModuleAssets(
@@ -125,8 +129,9 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]:
125
129
  theme_css=theme.resolve() if theme.is_file() else None,
126
130
  styles_css=styles.resolve() if styles.is_file() else None,
127
131
  npm_name=find_npm_name(pkg_root),
132
+ components_dir=components_dir.resolve() if components_dir.is_dir() else None,
128
133
  )
129
- if entry.pages_dir or entry.theme_css or entry.styles_css:
134
+ if entry.pages_dir or entry.theme_css or entry.styles_css or entry.components_dir:
130
135
  result.append(entry)
131
136
  return result
132
137
 
@@ -157,13 +162,23 @@ def render_modules_css(
157
162
  ``@import`` is emitted for *every* module, in-repo and wheel alike, because
158
163
  there is no static-glob equivalent for CSS.
159
164
 
165
+ Both ``pages/`` and ``components/`` are scanned. A wheel module's widgets
166
+ live under ``components/``, and leaving them out meant every host had to
167
+ hand-write a ``@source`` pointing into ``.venv`` — which cannot be written
168
+ portably: POSIX venvs nest under ``lib/python3.x/site-packages`` while
169
+ Windows uses ``Lib/site-packages``. Tailwind accepts a glob that matches
170
+ nothing without complaint, so the hand-written POSIX path silently dropped
171
+ every widget class on Windows. Emitting the resolved absolute path here
172
+ works on both. See GH #258.
173
+
160
174
  Every path is absolute, so nothing here depends on the host's
161
175
  ``vite.config.ts`` — see the module docstring for why that matters.
162
176
  """
163
177
  source_lines = [
164
- f'@source "{e.pages_dir.as_posix()}/**/*.{{ts,tsx}}";'
178
+ f'@source "{d.as_posix()}/**/*.{{ts,tsx}}";'
165
179
  for e in assets
166
- if e.pages_dir and not in_repo(e.pages_dir)
180
+ for d in (e.pages_dir, e.components_dir)
181
+ if d and not in_repo(d)
167
182
  ]
168
183
  theme_lines = [f'@import "{e.theme_css.as_posix()}";' for e in assets if e.theme_css]
169
184
  style_lines = [
@@ -205,6 +220,7 @@ def render_assets_json(assets: Sequence[ModuleAssets]) -> str:
205
220
  "package_name": e.package_name,
206
221
  "package": e.package_dir.as_posix(),
207
222
  "pages": e.pages_dir.as_posix() if e.pages_dir else None,
223
+ "components": e.components_dir.as_posix() if e.components_dir else None,
208
224
  "theme": e.theme_css.as_posix() if e.theme_css else None,
209
225
  "styles": e.styles_css.as_posix() if e.styles_css else None,
210
226
  "npm_name": e.npm_name,