simple-module-hosting 0.0.35__tar.gz → 0.0.36__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 (152) hide show
  1. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/PKG-INFO +6 -4
  2. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/pyproject.toml +8 -4
  3. simple_module_hosting-0.0.36/simple_module_hosting/_body_limit.py +191 -0
  4. simple_module_hosting-0.0.36/simple_module_hosting/_boot_checks.py +47 -0
  5. simple_module_hosting-0.0.36/simple_module_hosting/_csp_dynamic.py +99 -0
  6. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_dev_boot.py +3 -0
  7. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_error_handlers.py +15 -3
  8. simple_module_hosting-0.0.36/simple_module_hosting/_i18n_build.py +58 -0
  9. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_setup.py +5 -1
  10. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_shared.py +33 -4
  11. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_lifespan.py +54 -1
  12. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_module_routes.py +7 -1
  13. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_phase_helpers.py +32 -68
  14. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_preapp_config.py +1 -1
  15. simple_module_hosting-0.0.36/simple_module_hosting/_project_root.py +39 -0
  16. simple_module_hosting-0.0.36/simple_module_hosting/_rate_limit.py +187 -0
  17. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_registrations.py +3 -0
  18. simple_module_hosting-0.0.36/simple_module_hosting/_request_guard_responses.py +50 -0
  19. simple_module_hosting-0.0.36/simple_module_hosting/_route_guard.py +100 -0
  20. simple_module_hosting-0.0.36/simple_module_hosting/_settings_registration.py +70 -0
  21. simple_module_hosting-0.0.36/simple_module_hosting/_tenant.py +197 -0
  22. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/app_builder.py +24 -40
  23. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/assets.py +50 -1
  24. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/bootstrap_settings.py +5 -0
  25. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/host_settings.py +35 -1
  26. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_manifest.py +62 -39
  27. simple_module_hosting-0.0.36/simple_module_hosting/locales/en.json +24 -0
  28. simple_module_hosting-0.0.36/simple_module_hosting/locales/es.json +24 -0
  29. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/manifest.py +7 -2
  30. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/middleware.py +36 -8
  31. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/migrations.py +28 -0
  32. simple_module_hosting-0.0.36/simple_module_hosting/permissions.py +183 -0
  33. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/session.py +19 -0
  34. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/setup_gate.py +60 -7
  35. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/__init__.py +72 -0
  36. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +84 -0
  37. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/components/StepForm.tsx +132 -0
  38. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/migrate.py +92 -0
  39. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/pages/Wizard.tsx +131 -0
  40. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/payloads.py +117 -0
  41. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/routes.py +135 -0
  42. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/shared_props.py +4 -4
  43. simple_module_hosting-0.0.36/simple_module_hosting/tenancy.py +92 -0
  44. simple_module_hosting-0.0.36/simple_module_hosting/ui_locales/en.json +74 -0
  45. simple_module_hosting-0.0.36/simple_module_hosting/ui_locales/es.json +74 -0
  46. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_branding_head.py +16 -0
  47. simple_module_hosting-0.0.36/tests/test_check_migrations_expression_note.py +30 -0
  48. simple_module_hosting-0.0.36/tests/test_csp_providers.py +163 -0
  49. simple_module_hosting-0.0.36/tests/test_grant_sources.py +122 -0
  50. simple_module_hosting-0.0.36/tests/test_home_url_default.py +35 -0
  51. simple_module_hosting-0.0.36/tests/test_hosting_error_json_and_session_cookie.py +82 -0
  52. simple_module_hosting-0.0.36/tests/test_i18n_build_overrides.py +36 -0
  53. simple_module_hosting-0.0.36/tests/test_i18n_ui_namespace.py +15 -0
  54. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_inertia_i18n_shared_props.py +1 -1
  55. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_inertia_shared_providers.py +12 -0
  56. simple_module_hosting-0.0.36/tests/test_lifespan_deferred_startup.py +94 -0
  57. simple_module_hosting-0.0.36/tests/test_menu_providers_wiring.py +95 -0
  58. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_middleware_order.py +19 -2
  59. simple_module_hosting-0.0.36/tests/test_principal_permissions_claim.py +91 -0
  60. simple_module_hosting-0.0.36/tests/test_request_guard_auth_parity.py +116 -0
  61. simple_module_hosting-0.0.36/tests/test_request_guard_body_limit.py +181 -0
  62. simple_module_hosting-0.0.36/tests/test_request_guard_body_limit_middleware_read.py +41 -0
  63. simple_module_hosting-0.0.36/tests/test_request_guard_body_limit_started.py +67 -0
  64. simple_module_hosting-0.0.36/tests/test_request_guard_rate_limit.py +293 -0
  65. simple_module_hosting-0.0.36/tests/test_route_guard.py +131 -0
  66. simple_module_hosting-0.0.36/tests/test_session_vary_dedupe.py +39 -0
  67. simple_module_hosting-0.0.36/tests/test_setup_gate_single_flight.py +137 -0
  68. simple_module_hosting-0.0.36/tests/test_setup_migrate_error_redaction.py +55 -0
  69. simple_module_hosting-0.0.36/tests/test_setup_routes.py +135 -0
  70. simple_module_hosting-0.0.36/tests/test_tenancy_api.py +168 -0
  71. simple_module_hosting-0.0.36/tests/test_tenant_resolver_diagnostic.py +47 -0
  72. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_tenant_scope_helpers.py +17 -0
  73. simple_module_hosting-0.0.36/tests/test_tenant_source.py +171 -0
  74. simple_module_hosting-0.0.36/tests/test_view_prefixes.py +39 -0
  75. simple_module_hosting-0.0.36/tsconfig.json +12 -0
  76. simple_module_hosting-0.0.35/simple_module_hosting/_tenant.py +0 -97
  77. simple_module_hosting-0.0.35/simple_module_hosting/permissions.py +0 -104
  78. simple_module_hosting-0.0.35/tests/test_setup_password_policy.py +0 -69
  79. simple_module_hosting-0.0.35/tests/test_setup_routes.py +0 -137
  80. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/.gitignore +0 -0
  81. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/LICENSE +0 -0
  82. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/README.md +0 -0
  83. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/__init__.py +0 -0
  84. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/__main__.py +0 -0
  85. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_db_health.py +0 -0
  86. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_favicon.py +0 -0
  87. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_host_services.py +0 -0
  88. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_hydrate_step.py +0 -0
  89. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_cache.py +0 -0
  90. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_observability.py +0 -0
  91. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_secret_key.py +0 -0
  92. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/csrf.py +0 -0
  93. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/health.py +0 -0
  94. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/host_cli.py +0 -0
  95. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_deps.py +0 -0
  96. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_middleware.py +0 -0
  97. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/inertia_deps.py +0 -0
  98. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/inertia_utils.py +0 -0
  99. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/logging.py +0 -0
  100. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/maintenance.py +0 -0
  101. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/page_globs.py +0 -0
  102. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/py.typed +0 -0
  103. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/redirects.py +0 -0
  104. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/settings.py +0 -0
  105. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/static_files.py +0 -0
  106. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_api_error_negotiation.py +0 -0
  107. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_app.py +0 -0
  108. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_auth_provider_setting.py +0 -0
  109. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_boot_drift_check.py +0 -0
  110. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_check_migrations.py +0 -0
  111. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_csp_module_sources.py +0 -0
  112. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_db_health_check.py +0 -0
  113. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_design_packs_wiring.py +0 -0
  114. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_diagnostics_state.py +0 -0
  115. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_error_page_shared_props.py +0 -0
  116. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_error_status_coverage.py +0 -0
  117. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_favicon_default.py +0 -0
  118. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_health.py +0 -0
  119. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_host_cli.py +0 -0
  120. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_host_settings_fields.py +0 -0
  121. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_hosting_csrf.py +0 -0
  122. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_hosting_permissions.py +0 -0
  123. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_i18n_manifest.py +0 -0
  124. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_i18n_types_cover_installed_modules.py +0 -0
  125. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_inertia_cache.py +0 -0
  126. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_invalidation_wiring.py +0 -0
  127. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_lifespan_order.py +0 -0
  128. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_locale_middleware.py +0 -0
  129. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_logging.py +0 -0
  130. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_maintenance_mode.py +0 -0
  131. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_manifest.py +0 -0
  132. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_menu_urls_are_canonical.py +0 -0
  133. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_migration_listing.py +0 -0
  134. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_migration_multihead.py +0 -0
  135. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_preapp_config.py +0 -0
  136. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_precompressed_static.py +0 -0
  137. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_proxy_headers.py +0 -0
  138. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_redirects.py +0 -0
  139. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_response_compression.py +0 -0
  140. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_secret_key_bootstrap.py +0 -0
  141. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_session_cookie_security.py +0 -0
  142. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_session_cookie_window.py +0 -0
  143. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_settings_i18n.py +0 -0
  144. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_settings_secrets.py +0 -0
  145. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_setup_gate.py +0 -0
  146. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_setup_public_routes.py +0 -0
  147. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_setup_registry.py +0 -0
  148. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_sqlite_path_anchor.py +0 -0
  149. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_static_caching.py +0 -0
  150. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_strict_discovery_wiring.py +0 -0
  151. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_tenant_middleware.py +0 -0
  152. {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/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.35
3
+ Version: 0.0.36
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
@@ -26,12 +26,14 @@ Requires-Dist: fastapi>=0.115
26
26
  Requires-Dist: httpx>=0.27
27
27
  Requires-Dist: itsdangerous>=2.0
28
28
  Requires-Dist: jinja2>=3.1
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
29
+ Requires-Dist: simple-module-core==0.0.36
30
+ Requires-Dist: simple-module-db==0.0.36
31
+ Requires-Dist: simple-module-inertia==0.0.36
32
32
  Requires-Dist: starlette>=0.44
33
33
  Requires-Dist: tomlkit>=0.13
34
34
  Requires-Dist: uvicorn[standard]>=0.34
35
+ Provides-Extra: redis
36
+ Requires-Dist: redis>=5; extra == 'redis'
35
37
  Description-Content-Type: text/markdown
36
38
 
37
39
  # simple_module_hosting
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.35"
3
+ version = "0.0.36"
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"
@@ -29,14 +29,18 @@ dependencies = [
29
29
  # it directly — out-of-tree hosts have nothing else pulling it in.
30
30
  "itsdangerous>=2.0",
31
31
  "jinja2>=3.1",
32
- "simple_module_core==0.0.35",
33
- "simple_module_db==0.0.35",
34
- "simple_module_inertia==0.0.35",
32
+ "simple_module_core==0.0.36",
33
+ "simple_module_db==0.0.36",
34
+ "simple_module_inertia==0.0.36",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
38
38
  ]
39
39
 
40
+ [project.optional-dependencies]
41
+ # Shared rate-limiter store (SM_REDIS_URL). Without it the limiter counts per worker.
42
+ redis = ["redis>=5"]
43
+
40
44
  [project.scripts]
41
45
  sm-host = "simple_module_hosting.host_cli:app"
42
46
 
@@ -0,0 +1,191 @@
1
+ """Request-body size guard (GH #345).
2
+
3
+ Pure ASGI. Refuses an oversized body *before* a route reads it, so the limit
4
+ protects the process and not just the database:
5
+
6
+ * ``Content-Length`` over the ceiling is refused outright, body unread.
7
+ * No (or a lying) ``Content-Length`` — chunked transfer — is caught by counting
8
+ the bytes the app actually pulls through ``receive`` and aborting once the
9
+ ceiling is crossed.
10
+
11
+ The ceiling is the global ``max_request_body_bytes`` unless a module declared a
12
+ per-path override with ``register_body_limits``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+
19
+ from simple_module_core.body_limits import BodyLimitRegistry
20
+ from starlette.datastructures import Headers
21
+ from starlette.exceptions import HTTPException
22
+ from starlette.requests import Request
23
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
24
+
25
+ from simple_module_hosting._error_handlers import _wants_json
26
+ from simple_module_hosting._request_guard_responses import send_guard_response
27
+
28
+ logger = logging.getLogger("simple_module.request_guard")
29
+
30
+ _MESSAGE = "Request body is too large."
31
+
32
+
33
+ class _BodyTooLargeError(Exception):
34
+ pass
35
+
36
+
37
+ class BodyLimitMiddleware:
38
+ def __init__(
39
+ self,
40
+ app: ASGIApp,
41
+ *,
42
+ max_bytes: int,
43
+ registry: BodyLimitRegistry | None = None,
44
+ ) -> None:
45
+ self.app = app
46
+ self.max_bytes = max_bytes
47
+ self.registry = registry
48
+
49
+ def _ceiling(self, scope: Scope) -> int:
50
+ override = None
51
+ if self.registry is not None:
52
+ override = self.registry.limit_for(scope["method"], scope["path"], scope.get("app"))
53
+ return self.max_bytes if override is None else override
54
+
55
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
56
+ if scope["type"] != "http":
57
+ await self.app(scope, receive, send)
58
+ return
59
+ limit = self._ceiling(scope)
60
+ if limit <= 0:
61
+ await self.app(scope, receive, send)
62
+ return
63
+
64
+ declared = Headers(scope=scope).get("content-length")
65
+ too_big = (
66
+ declared is not None
67
+ and declared.isascii()
68
+ and declared.isdigit()
69
+ and int(declared) > limit
70
+ )
71
+ # A browser-shaped request is answered with the Inertia error page, which
72
+ # needs the session / locale / shared props the layers *inside* this one
73
+ # set up. So it is not refused here: the body read raises an
74
+ # ``HTTPException(413)`` that the app's own handler renders in full.
75
+ # (FastAPI re-raises HTTPException from body parsing untouched.) API
76
+ # callers are refused right here, before anything runs.
77
+ if not self._wants_json(scope):
78
+ await self._handle_page(scope, receive, send, limit, too_big)
79
+ return
80
+ if too_big:
81
+ self._log(scope, limit, int(declared))
82
+ await self._refuse(scope, receive, send, limit)
83
+ return
84
+
85
+ seen = 0
86
+ exceeded = False
87
+ refused = False
88
+ started = False
89
+
90
+ async def counting_receive() -> Message:
91
+ nonlocal seen, exceeded
92
+ message = await receive()
93
+ if message["type"] == "http.request":
94
+ seen += len(message.get("body", b""))
95
+ if seen > limit:
96
+ exceeded = True
97
+ raise _BodyTooLargeError
98
+ return message
99
+
100
+ async def guarded_send(message: Message) -> None:
101
+ nonlocal refused, started
102
+ if exceeded and not started:
103
+ # The app is answering a body we cut off (often with a 400 from
104
+ # its own parser). Swallow it; the 413 below is the real answer.
105
+ if not refused:
106
+ refused = True
107
+ self._log(scope, limit, seen)
108
+ await self._refuse(scope, receive, send, limit)
109
+ return
110
+ # Once the app's own response has started it is passed through whole:
111
+ # swallowing the rest would leave the client hanging on a half-sent
112
+ # response. (Any exception it raises for the cut-off body is re-raised
113
+ # below, which aborts the connection instead.)
114
+ if message["type"] == "http.response.start":
115
+ started = True
116
+ await send(message)
117
+
118
+ try:
119
+ await self.app(scope, counting_receive, guarded_send)
120
+ except Exception:
121
+ if not exceeded or started:
122
+ if exceeded:
123
+ self._log(scope, limit, seen)
124
+ raise
125
+ if exceeded and not refused and not started:
126
+ self._log(scope, limit, seen)
127
+ await self._refuse(scope, receive, send, limit)
128
+
129
+ @staticmethod
130
+ def _wants_json(scope: Scope) -> bool:
131
+ return _wants_json(Request(scope))
132
+
133
+ async def _handle_page(
134
+ self, scope: Scope, receive: Receive, send: Send, limit: int, too_big: bool
135
+ ) -> None:
136
+ seen = 0
137
+
138
+ async def receive_or_413() -> Message:
139
+ nonlocal seen
140
+ if too_big:
141
+ self._log(scope, limit, seen)
142
+ raise HTTPException(status_code=413, detail=_MESSAGE)
143
+ message = await receive()
144
+ if message["type"] == "http.request":
145
+ seen += len(message.get("body", b""))
146
+ if seen > limit:
147
+ self._log(scope, limit, seen)
148
+ raise HTTPException(status_code=413, detail=_MESSAGE)
149
+ return message
150
+
151
+ started = False
152
+
153
+ async def tracking_send(message: Message) -> None:
154
+ nonlocal started
155
+ if message["type"] == "http.response.start":
156
+ started = True
157
+ await send(message)
158
+
159
+ try:
160
+ await self.app(scope, receive_or_413, tracking_send)
161
+ except HTTPException as exc:
162
+ # Raised by a middleware that reads the body *outside* the router's
163
+ # exception handling (e.g. a form gate). Without this it would
164
+ # surface as a bare 500 from ServerErrorMiddleware.
165
+ if exc.status_code != 413 or started:
166
+ raise
167
+ await self._refuse(scope, receive, send, limit)
168
+
169
+ @staticmethod
170
+ def _log(scope: Scope, limit: int, size: int) -> None:
171
+ cid = scope.get("state", {}).get("correlation_id", "")
172
+ logger.warning(
173
+ "Request body over limit: %s %s (%d > %d bytes) correlation_id=%s",
174
+ scope["method"],
175
+ scope["path"],
176
+ size,
177
+ limit,
178
+ cid,
179
+ )
180
+
181
+ @staticmethod
182
+ async def _refuse(scope: Scope, receive: Receive, send: Send, limit: int) -> None:
183
+ await send_guard_response(
184
+ scope,
185
+ receive,
186
+ send,
187
+ 413,
188
+ _MESSAGE,
189
+ headers={"Connection": "close"},
190
+ extra={"max_bytes": limit},
191
+ )
@@ -0,0 +1,47 @@
1
+ """Diagnostics that need the built app, so cannot run from ``make doctor``.
2
+
3
+ The module diagnostics in Phase 2 see only module classes and the source tree.
4
+ A check about what modules *registered* has to wait until after Phase 5, and
5
+ unlike the source-tree checks it is meaningful in production too.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from simple_module_core.diagnostics import Diagnostic, check_tenant_resolver, print_diagnostics
14
+
15
+ if TYPE_CHECKING:
16
+ from fastapi import FastAPI
17
+ from simple_module_core.services import DiagnosticsState
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _tenant_checks(app: FastAPI, settings: Any) -> list[Diagnostic]:
23
+ return check_tenant_resolver(
24
+ multi_tenant=bool(getattr(settings, "multi_tenant", False)),
25
+ resolver=getattr(app.state, "tenant_resolver", None),
26
+ )
27
+
28
+
29
+ def report_tenant_resolution(
30
+ app: FastAPI, settings: Any, diagnostics_state: DiagnosticsState
31
+ ) -> list[Diagnostic]:
32
+ """SM025: report, and fold into the Doctor screen's results when it has a runner."""
33
+ found = _tenant_checks(app, settings)
34
+ if found:
35
+ if settings.is_development:
36
+ print_diagnostics(found)
37
+ else:
38
+ for diag in found:
39
+ logger.warning("%s", diag)
40
+ runner = diagnostics_state.runner
41
+ if runner is not None:
42
+ diagnostics_state.runner = lambda: [*runner(), *_tenant_checks(app, settings)]
43
+ diagnostics_state.results = [*diagnostics_state.results, *found]
44
+ return found
45
+
46
+
47
+ __all__ = ["report_tenant_resolution"]
@@ -0,0 +1,99 @@
1
+ """Per-response evaluation of module CSP providers (#434).
2
+
3
+ ``register_csp_sources`` runs before DB settings hydrate, so origins that come
4
+ from a setting are declared as providers (``registry.add_provider(fn)``) and
5
+ evaluated here on each response. The finished policy is cached by the *value*
6
+ the providers return, so a settings edit changes the key on the next response
7
+ without any invalidation wiring.
8
+
9
+ Fail closed: a provider that raises, or a pair that fails the same validation
10
+ ``CspSourceRegistry.add`` applies, contributes nothing — the static policy
11
+ still ships. Each distinct failure is logged once per process.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Any
18
+
19
+ from simple_module_core.csp import CspSourceRegistry, is_valid_source
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _CACHE_MAX = 32
24
+ _LOGGED_MAX = 256
25
+
26
+
27
+ class DynamicCsp:
28
+ """Builds the CSP for one response from a static base plus provider output."""
29
+
30
+ def __init__(self, base: str, registry: CspSourceRegistry) -> None:
31
+ self.base = base
32
+ self._registry = registry
33
+ self._providers = registry.providers
34
+ self._cache: dict[frozenset[tuple[str, str]], str] = {}
35
+ self._logged: set[str] = set()
36
+
37
+ def policy_for(self, app: Any) -> str:
38
+ pairs = frozenset(self._collect(app))
39
+ if not pairs:
40
+ return self.base
41
+ policy = self._cache.get(pairs)
42
+ if policy is None:
43
+ if len(self._cache) >= _CACHE_MAX:
44
+ self._cache.clear()
45
+ # Sorted so the clause order is stable for a given set of pairs.
46
+ try:
47
+ policy = self._registry.extend_policy(self.base, extra=sorted(pairs))
48
+ except Exception as exc:
49
+ # Last line of the fail-closed guarantee: this runs inside the
50
+ # response for every page, so a bug here must cost the extra
51
+ # origins, never the response.
52
+ self._warn_once(
53
+ f"build:{type(exc).__name__}:{exc}",
54
+ "CSP provider policy build failed (%r); serving the static policy",
55
+ exc,
56
+ )
57
+ return self.base
58
+ self._cache[pairs] = policy
59
+ return policy
60
+
61
+ def _collect(self, app: Any) -> list[tuple[str, str]]:
62
+ out: list[tuple[str, str]] = []
63
+ for provider in self._providers:
64
+ name = getattr(provider, "__qualname__", repr(provider))
65
+ try:
66
+ pairs = list(provider(app))
67
+ except Exception as exc:
68
+ self._warn_once(
69
+ f"{name}:{type(exc).__name__}:{exc}",
70
+ "CSP provider %s raised %r; its sources are skipped",
71
+ name,
72
+ exc,
73
+ )
74
+ continue
75
+ for pair in pairs:
76
+ if (
77
+ isinstance(pair, tuple)
78
+ and len(pair) == 2
79
+ and isinstance(pair[0], str)
80
+ and isinstance(pair[1], str)
81
+ and is_valid_source(pair[0], pair[1])
82
+ ):
83
+ out.append((pair[0], pair[1].strip()))
84
+ else:
85
+ self._warn_once(
86
+ f"{name}:{pair!r}",
87
+ "CSP provider %s returned invalid pair %r; dropped",
88
+ name,
89
+ pair,
90
+ )
91
+ return out
92
+
93
+ def _warn_once(self, key: str, msg: str, *args: object) -> None:
94
+ if key in self._logged:
95
+ return
96
+ if len(self._logged) >= _LOGGED_MAX:
97
+ self._logged.clear()
98
+ self._logged.add(key)
99
+ logger.warning(msg, *args)
@@ -35,6 +35,7 @@ def run_dev_boot(
35
35
  installed_modules: list[ModuleBase],
36
36
  *,
37
37
  i18n_extra,
38
+ i18n_unknown_overrides: dict[str, list[str]] | None = None,
38
39
  diagnostics_state: DiagnosticsState,
39
40
  project_root: Path,
40
41
  ) -> None:
@@ -54,6 +55,8 @@ def run_dev_boot(
54
55
  i18n_supported_locales=settings.i18n_supported_locales,
55
56
  i18n_default_locale=settings.i18n_default_locale,
56
57
  i18n_extra_sources=i18n_extra,
58
+ i18n_unknown_overrides=i18n_unknown_overrides,
59
+ database_dialect=settings.database_url.split(":", 1)[0].split("+", 1)[0],
57
60
  )
58
61
  diagnostics = diagnostics_state.rerun()
59
62
  errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
@@ -21,11 +21,12 @@ 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._observability import CorrelationIdMiddleware
24
25
  from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
25
26
 
26
27
  logger = logging.getLogger(__name__)
27
28
 
28
- _INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 419, 422, 429, 500, 503})
29
+ _INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 413, 419, 422, 429, 500, 503})
29
30
 
30
31
  # Statuses whose remedy is "sign in", so the page offers that as its primary
31
32
  # action rather than sending the visitor to the landing page.
@@ -278,5 +279,16 @@ async def request_validation_error_handler(
278
279
  async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
279
280
  logger.exception("Unhandled exception: %s", exc)
280
281
  if _wants_json(request):
281
- return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
282
- return await render_error_page(request, 500, "")
282
+ response: Response = JSONResponse(
283
+ status_code=500, content={"detail": "Internal Server Error"}
284
+ )
285
+ else:
286
+ response = await render_error_page(request, 500, "")
287
+ # A catch-all `Exception` handler runs in Starlette's ServerErrorMiddleware,
288
+ # which wraps *outside* CorrelationIdMiddleware, so the response never
289
+ # passes through it. Without this the 500 — the one response a caller most
290
+ # needs to quote back to support — is the one without the header.
291
+ cid = getattr(request.state, "correlation_id", "")
292
+ if cid:
293
+ response.headers.setdefault(CorrelationIdMiddleware.HEADER, cid)
294
+ return response
@@ -0,0 +1,58 @@
1
+ """Construct the i18n registry from module, host and UI sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from simple_module_core import ModuleBase
8
+ from simple_module_core.i18n import I18nRegistry
9
+
10
+ from simple_module_hosting.settings import Settings
11
+
12
+
13
+ def build_i18n_registry(
14
+ settings: Settings,
15
+ modules: list[ModuleBase],
16
+ project_root: Path,
17
+ ) -> tuple[I18nRegistry, list[tuple[str, str, Path]]]:
18
+ """Construct the i18n registry from module + host + UI sources.
19
+
20
+ Returns ``(registry, extra_sources)`` where ``extra_sources`` is the list
21
+ of ``(reporter_name, namespace, dir)`` triples the diagnostic runner
22
+ needs to validate non-module locale directories.
23
+ """
24
+ registry = I18nRegistry(
25
+ default_locale=settings.i18n_default_locale,
26
+ supported_locales=settings.i18n_supported_locales,
27
+ )
28
+ extra_sources: list[tuple[str, str, Path]] = []
29
+
30
+ for mod in modules:
31
+ audience = getattr(mod.meta, "i18n_audience", "public")
32
+ for namespace, locale_dir in mod.locale_dirs().items():
33
+ registry.add_source(namespace, locale_dir, audience=audience)
34
+
35
+ # The framework's own catalog — the setup wizard ships with this package,
36
+ # so its strings must too, whatever host is serving it.
37
+ hosting_locales = Path(__file__).resolve().parent / "locales"
38
+ registry.add_source("hosting", hosting_locales)
39
+ extra_sources.append(("simple_module_hosting", "hosting", hosting_locales))
40
+
41
+ host_locales = project_root / "host" / "locales"
42
+ if host_locales.is_dir():
43
+ registry.add_source("host", host_locales)
44
+ extra_sources.append(("host", "host", host_locales))
45
+
46
+ overrides = host_locales / "overrides"
47
+ if overrides.is_dir():
48
+ registry.add_overrides(overrides)
49
+
50
+ # Shared UI strings ship inside this package (not ``packages/ui``) so a
51
+ # scaffolded app, which has no ``packages/ui`` checkout, still resolves
52
+ # every ``ui.*`` key.
53
+ ui_locales = Path(__file__).resolve().parent / "ui_locales"
54
+ registry.add_source("ui", ui_locales)
55
+ extra_sources.append(("simple_module_hosting/ui", "ui", ui_locales))
56
+
57
+ registry.load()
58
+ return registry, extra_sources
@@ -48,7 +48,11 @@ def branding_head(request: Request) -> dict:
48
48
  rather than ``None``, because omitting the tag sends the browser to
49
49
  ``/favicon.ico``, which this app does not serve.
50
50
  """
51
- services = getattr(request.app.state, "branding", None)
51
+ # A per-request object (same duck shape) wins: branding resolves a tenant's
52
+ # own theme into ``request.state.branding`` from its shared-props provider.
53
+ services = getattr(getattr(request, "state", None), "branding", None) or getattr(
54
+ request.app.state, "branding", None
55
+ )
52
56
  settings = getattr(services, "settings", None)
53
57
  app_name = getattr(settings, "app_name", "") or _DEFAULT_APP_NAME
54
58
  accent = getattr(settings, "primary_color", "") or ""
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import inspect
5
6
  import logging
6
7
  from collections.abc import Callable
7
8
  from typing import Any
@@ -19,6 +20,10 @@ _INERTIA_HEADER = "x-inertia"
19
20
  _INERTIA_HEADER_TRUE = "true"
20
21
 
21
22
 
23
+ def _accepts_html(scope: Scope) -> bool:
24
+ return "text/html" in Headers(scope=scope).get("accept", "").lower()
25
+
26
+
22
27
  def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = True) -> dict:
23
28
  """Assemble the ``i18n`` shared-props block for the current request.
24
29
 
@@ -56,13 +61,21 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool =
56
61
  # skip the messages payload. Non-Inertia requests will always ship them
57
62
  # regardless of the session state.
58
63
  if session_dict is not None:
64
+ # Remembering the locale only pays off for a client that will come back
65
+ # with an Inertia XHR (to skip re-sending the catalog). A bare API or
66
+ # health-check call from a cookie-less client will not, and writing
67
+ # here would make Starlette's SessionMiddleware emit a two-week
68
+ # Set-Cookie on every anonymous response, which keeps shared caches
69
+ # from storing it (GH #349). So persist only when a session already
70
+ # exists (it has data) or the caller is page-shaped.
71
+ persist = bool(session_dict) or is_inertia or _accepts_html(scope)
59
72
  last_locale = session_dict.get(_I18N_SESSION_LOCALE_KEY)
60
73
  locale_changed = last_locale != locale
61
- if locale_changed:
74
+ if locale_changed and persist:
62
75
  session_dict[_I18N_SESSION_LOCALE_KEY] = locale
63
76
  last_audience = session_dict.get(_I18N_SESSION_AUDIENCE_KEY)
64
77
  audience_changed = last_audience != audience
65
- if audience_changed:
78
+ if audience_changed and persist:
66
79
  session_dict[_I18N_SESSION_AUDIENCE_KEY] = audience
67
80
  else:
68
81
  locale_changed = False
@@ -103,19 +116,22 @@ def build_menu_translator(request: Request) -> Callable[[str], str] | None:
103
116
  return translator.t
104
117
 
105
118
 
106
- def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None:
119
+ async def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None:
107
120
  """Merge module-registered Inertia shared-prop providers into ``shared`` in place.
108
121
 
109
122
  Providers are read off ``app.state.inertia_shared_providers`` (never importing
110
123
  the plugin — preserves SM009). A provider that raises is skipped and logged; a
111
124
  provider may not clobber a framework-owned key (auth/menus/i18n) or an earlier
112
- provider's key.
125
+ provider's key. A provider may be ``async`` (its result is awaited) — a
126
+ per-tenant lookup cannot be answered from a process-wide object.
113
127
  """
114
128
  providers = getattr(app.state, "inertia_shared_providers", None) or ()
115
129
  for provider in providers:
116
130
  name = getattr(provider, "__name__", provider)
117
131
  try:
118
132
  extra = provider(request)
133
+ if inspect.isawaitable(extra):
134
+ extra = await extra
119
135
  except Exception: # a bad provider must not break the page render
120
136
  logger.warning("shared-prop provider %r raised; skipping", name, exc_info=True)
121
137
  continue
@@ -128,3 +144,16 @@ def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> Non
128
144
  )
129
145
  continue
130
146
  shared[key] = value
147
+
148
+
149
+ def default_home_url(app: Any) -> str:
150
+ """Where the layouts' "home" links point when no module publishes ``homeUrl``.
151
+
152
+ The users module publishes its configured landing URL and wins; this covers
153
+ installs without it (Keycloak). Dashboard is the conventional landing page,
154
+ named by string from ``sm.view_prefixes`` so nothing is imported (SM009);
155
+ ``/`` is a host route in every app, so the link can never 404.
156
+ """
157
+ sm = getattr(app.state, "sm", None)
158
+ prefix = getattr(sm, "view_prefixes", {}).get("Dashboard")
159
+ return f"{prefix}/" if prefix else "/"