simple-module-hosting 0.0.34__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 (156) hide show
  1. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/PKG-INFO +6 -4
  2. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/pyproject.toml +9 -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.36/simple_module_hosting/_dev_boot.py +86 -0
  7. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_error_handlers.py +24 -16
  8. simple_module_hosting-0.0.36/simple_module_hosting/_i18n_build.py +58 -0
  9. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_setup.py +22 -75
  10. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_shared.py +33 -4
  11. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_lifespan.py +54 -1
  12. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_module_routes.py +7 -1
  13. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_phase_helpers.py +39 -71
  14. {simple_module_hosting-0.0.34 → 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.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_registrations.py +5 -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.34 → simple_module_hosting-0.0.36}/simple_module_hosting/app_builder.py +40 -65
  23. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/assets.py +50 -1
  24. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/bootstrap_settings.py +5 -0
  25. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/host_settings.py +50 -0
  26. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_manifest.py +62 -39
  27. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/inertia_deps.py +1 -1
  28. simple_module_hosting-0.0.36/simple_module_hosting/locales/en.json +24 -0
  29. simple_module_hosting-0.0.36/simple_module_hosting/locales/es.json +24 -0
  30. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/manifest.py +11 -19
  31. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/middleware.py +38 -64
  32. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/migrations.py +28 -0
  33. simple_module_hosting-0.0.36/simple_module_hosting/page_globs.py +52 -0
  34. simple_module_hosting-0.0.36/simple_module_hosting/permissions.py +183 -0
  35. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/session.py +19 -0
  36. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/setup_gate.py +60 -7
  37. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/__init__.py +72 -0
  38. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +84 -0
  39. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/components/StepForm.tsx +132 -0
  40. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/migrate.py +92 -0
  41. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/pages/Wizard.tsx +131 -0
  42. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/payloads.py +117 -0
  43. simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/routes.py +135 -0
  44. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/shared_props.py +4 -4
  45. simple_module_hosting-0.0.36/simple_module_hosting/tenancy.py +92 -0
  46. simple_module_hosting-0.0.36/simple_module_hosting/ui_locales/en.json +74 -0
  47. simple_module_hosting-0.0.36/simple_module_hosting/ui_locales/es.json +74 -0
  48. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_branding_head.py +16 -0
  49. simple_module_hosting-0.0.36/tests/test_check_migrations_expression_note.py +30 -0
  50. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_csp_module_sources.py +1 -0
  51. simple_module_hosting-0.0.36/tests/test_csp_providers.py +163 -0
  52. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_diagnostics_state.py +2 -4
  53. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_error_page_shared_props.py +13 -3
  54. simple_module_hosting-0.0.36/tests/test_grant_sources.py +122 -0
  55. simple_module_hosting-0.0.36/tests/test_home_url_default.py +35 -0
  56. simple_module_hosting-0.0.36/tests/test_hosting_error_json_and_session_cookie.py +82 -0
  57. simple_module_hosting-0.0.36/tests/test_i18n_build_overrides.py +36 -0
  58. simple_module_hosting-0.0.36/tests/test_i18n_ui_namespace.py +15 -0
  59. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_inertia_i18n_shared_props.py +1 -1
  60. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_inertia_shared_providers.py +12 -0
  61. simple_module_hosting-0.0.36/tests/test_invalidation_wiring.py +250 -0
  62. simple_module_hosting-0.0.36/tests/test_lifespan_deferred_startup.py +94 -0
  63. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_manifest.py +48 -0
  64. simple_module_hosting-0.0.36/tests/test_menu_providers_wiring.py +95 -0
  65. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_middleware_order.py +29 -2
  66. simple_module_hosting-0.0.36/tests/test_principal_permissions_claim.py +91 -0
  67. simple_module_hosting-0.0.36/tests/test_request_guard_auth_parity.py +116 -0
  68. simple_module_hosting-0.0.36/tests/test_request_guard_body_limit.py +181 -0
  69. simple_module_hosting-0.0.36/tests/test_request_guard_body_limit_middleware_read.py +41 -0
  70. simple_module_hosting-0.0.36/tests/test_request_guard_body_limit_started.py +67 -0
  71. simple_module_hosting-0.0.36/tests/test_request_guard_rate_limit.py +293 -0
  72. simple_module_hosting-0.0.36/tests/test_route_guard.py +131 -0
  73. simple_module_hosting-0.0.36/tests/test_session_vary_dedupe.py +39 -0
  74. simple_module_hosting-0.0.36/tests/test_setup_gate_single_flight.py +137 -0
  75. simple_module_hosting-0.0.36/tests/test_setup_migrate_error_redaction.py +55 -0
  76. simple_module_hosting-0.0.36/tests/test_setup_routes.py +135 -0
  77. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_strict_discovery_wiring.py +1 -1
  78. simple_module_hosting-0.0.36/tests/test_tenancy_api.py +168 -0
  79. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_tenant_middleware.py +56 -3
  80. simple_module_hosting-0.0.36/tests/test_tenant_resolver_diagnostic.py +47 -0
  81. simple_module_hosting-0.0.36/tests/test_tenant_scope_helpers.py +116 -0
  82. simple_module_hosting-0.0.36/tests/test_tenant_source.py +171 -0
  83. simple_module_hosting-0.0.36/tests/test_view_prefixes.py +39 -0
  84. simple_module_hosting-0.0.36/tsconfig.json +12 -0
  85. simple_module_hosting-0.0.34/simple_module_hosting/_inertia_json.py +0 -86
  86. simple_module_hosting-0.0.34/simple_module_hosting/_inertia_url.py +0 -131
  87. simple_module_hosting-0.0.34/simple_module_hosting/permissions.py +0 -104
  88. simple_module_hosting-0.0.34/tests/test_inertia_json_encoder.py +0 -83
  89. simple_module_hosting-0.0.34/tests/test_inertia_manifest.py +0 -71
  90. simple_module_hosting-0.0.34/tests/test_inertia_relative_url.py +0 -120
  91. simple_module_hosting-0.0.34/tests/test_setup_password_policy.py +0 -69
  92. simple_module_hosting-0.0.34/tests/test_setup_routes.py +0 -137
  93. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/.gitignore +0 -0
  94. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/LICENSE +0 -0
  95. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/README.md +0 -0
  96. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/__init__.py +0 -0
  97. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/__main__.py +0 -0
  98. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_db_health.py +0 -0
  99. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_favicon.py +0 -0
  100. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_host_services.py +0 -0
  101. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_hydrate_step.py +0 -0
  102. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_cache.py +0 -0
  103. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_observability.py +0 -0
  104. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/_secret_key.py +0 -0
  105. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/csrf.py +0 -0
  106. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/health.py +0 -0
  107. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/host_cli.py +0 -0
  108. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_deps.py +0 -0
  109. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_middleware.py +0 -0
  110. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/inertia_utils.py +0 -0
  111. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/logging.py +0 -0
  112. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/maintenance.py +0 -0
  113. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/py.typed +0 -0
  114. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/redirects.py +0 -0
  115. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/settings.py +0 -0
  116. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/simple_module_hosting/static_files.py +0 -0
  117. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_api_error_negotiation.py +0 -0
  118. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_app.py +0 -0
  119. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_auth_provider_setting.py +0 -0
  120. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_boot_drift_check.py +0 -0
  121. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_check_migrations.py +0 -0
  122. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_db_health_check.py +0 -0
  123. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_design_packs_wiring.py +0 -0
  124. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_error_status_coverage.py +0 -0
  125. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_favicon_default.py +0 -0
  126. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_health.py +0 -0
  127. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_host_cli.py +0 -0
  128. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_host_settings_fields.py +0 -0
  129. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_hosting_csrf.py +0 -0
  130. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_hosting_permissions.py +0 -0
  131. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_i18n_manifest.py +0 -0
  132. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_i18n_types_cover_installed_modules.py +0 -0
  133. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_inertia_cache.py +0 -0
  134. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_lifespan_order.py +0 -0
  135. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_locale_middleware.py +0 -0
  136. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_logging.py +0 -0
  137. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_maintenance_mode.py +0 -0
  138. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_menu_urls_are_canonical.py +0 -0
  139. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_migration_listing.py +0 -0
  140. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_migration_multihead.py +0 -0
  141. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_preapp_config.py +0 -0
  142. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_precompressed_static.py +0 -0
  143. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_proxy_headers.py +0 -0
  144. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_redirects.py +0 -0
  145. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_response_compression.py +0 -0
  146. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_secret_key_bootstrap.py +0 -0
  147. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_session_cookie_security.py +0 -0
  148. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_session_cookie_window.py +0 -0
  149. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_settings_i18n.py +0 -0
  150. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_settings_secrets.py +0 -0
  151. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_setup_gate.py +0 -0
  152. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_setup_public_routes.py +0 -0
  153. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_setup_registry.py +0 -0
  154. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_sqlite_path_anchor.py +0 -0
  155. {simple_module_hosting-0.0.34 → simple_module_hosting-0.0.36}/tests/test_static_caching.py +0 -0
  156. {simple_module_hosting-0.0.34 → 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.34
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
@@ -22,16 +22,18 @@ 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.34
31
- Requires-Dist: simple-module-db==0.0.34
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.34"
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"
@@ -23,20 +23,24 @@ 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.34",
34
- "simple_module_db==0.0.34",
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
 
@@ -59,3 +63,4 @@ packages = ["simple_module_hosting"]
59
63
  [tool.uv.sources]
60
64
  simple_module_core = { workspace = true }
61
65
  simple_module_db = { workspace = true }
66
+ simple_module_inertia = { workspace = true }
@@ -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)
@@ -0,0 +1,86 @@
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
+ i18n_unknown_overrides: dict[str, list[str]] | None = None,
39
+ diagnostics_state: DiagnosticsState,
40
+ project_root: Path,
41
+ ) -> None:
42
+ """Run the module diagnostics and emit the frontend's generated files.
43
+
44
+ ``diagnostics_state.runner`` is left behind on purpose, not just used: the
45
+ in-app Doctor screen re-invokes this exact call through ``rerun()``, and
46
+ re-deriving the arguments from a request would mean re-discovering modules.
47
+
48
+ An ERROR-level diagnostic ends the process. A developer whose module has a
49
+ duplicate name or a framework→plugin import gets one clear message here
50
+ instead of an inexplicable failure several phases later — and the same
51
+ checks run at production boot, where the app would refuse to start anyway.
52
+ """
53
+ diagnostics_state.runner = lambda: run_diagnostics(
54
+ modules,
55
+ i18n_supported_locales=settings.i18n_supported_locales,
56
+ i18n_default_locale=settings.i18n_default_locale,
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],
60
+ )
61
+ diagnostics = diagnostics_state.rerun()
62
+ errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
63
+ if diagnostics:
64
+ print_diagnostics(diagnostics)
65
+ if errors:
66
+ raise SystemExit(f"Module diagnostics: {len(errors)} error(s). Fix before continuing.")
67
+
68
+ _write_pages_manifest(modules, project_root)
69
+ emit_frontend_types_for_modules(settings, installed_modules, project_root)
70
+
71
+
72
+ def _write_pages_manifest(modules: list[ModuleBase], project_root: Path) -> None:
73
+ """Tell Vite where pages shipped inside pip-installed wheels live.
74
+
75
+ Swallows its own failure: a missing or unwritable ``client_app`` must not
76
+ stop the API from booting, and the symptom — pages the frontend cannot find
77
+ — is loud enough on its own. See ``scaffolding.py``.
78
+ """
79
+ try:
80
+ from simple_module_hosting.manifest import write_module_pages_manifest
81
+
82
+ client_app = project_root / "host" / "client_app"
83
+ if client_app.is_dir():
84
+ write_module_pages_manifest(modules, client_app)
85
+ except Exception:
86
+ logger.exception("Failed to write module pages manifest — frontend may miss pages")
@@ -9,24 +9,24 @@ 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
22
22
 
23
23
  from simple_module_hosting._inertia_shared import _INERTIA_HEADER
24
- from simple_module_hosting._inertia_url import patch_relative_page_url
24
+ from simple_module_hosting._observability import CorrelationIdMiddleware
25
25
  from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
26
26
 
27
27
  logger = logging.getLogger(__name__)
28
28
 
29
- _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})
30
30
 
31
31
  # Statuses whose remedy is "sign in", so the page offers that as its primary
32
32
  # action rather than sending the visitor to the landing page.
@@ -174,20 +174,17 @@ async def render_error_page(
174
174
  # that raises while reporting an error leaves the caller with nothing.
175
175
  config: InertiaConfig = request.app.state.sm.inertia_config
176
176
  # Prefer the app's configured dependency over constructing Inertia
177
- # directly: it carries the framework's wraps, and an error page built
178
- # around them is an error page rendered differently from every other
179
- # page. That is how the 404 kept throwing the cross-scheme `pushState`
180
- # SecurityError after the page url was made relative everywhere else.
181
- # The raw construction stays as the fallback for a half-built app —
182
- # the case this whole handler exists to survive — but still goes
183
- # through the same url-relativizing patch directly: a fallback that
184
- # skipped it would reintroduce the exact bug this module exists to
185
- # fix, just for the one request that hit it before setup finished.
177
+ # directly, so the error page is built the same way as every other
178
+ # page. The raw construction is the fallback for a half-built app —
179
+ # the case this whole handler exists to survive. Both paths emit a
180
+ # root-relative page url: the adapter does that by construction now,
181
+ # so the fallback no longer needs a separate patch to avoid the
182
+ # cross-scheme `pushState` SecurityError behind a TLS proxy.
186
183
  inertia_dep = getattr(request.app.state, "inertia_dependency", None)
187
184
  if inertia_dep is not None:
188
185
  inertia = inertia_dep(request, None)
189
186
  else:
190
- inertia = patch_relative_page_url(Inertia(request, config))
187
+ inertia = Inertia(request, config)
191
188
  # This does not go through get_inertia, so the share step has to be
192
189
  # repeated here. Without it the error page renders raw translation keys
193
190
  # (host.error.not_found_title) and loses auth/menus, so a signed-in
@@ -282,5 +279,16 @@ async def request_validation_error_handler(
282
279
  async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
283
280
  logger.exception("Unhandled exception: %s", exc)
284
281
  if _wants_json(request):
285
- return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
286
- 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