simple-module-hosting 0.0.36__tar.gz → 0.0.38__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 (224) hide show
  1. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/.gitignore +2 -2
  2. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/PKG-INFO +7 -5
  3. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/pyproject.toml +7 -5
  4. simple_module_hosting-0.0.38/simple_module_hosting/_anonymous.py +27 -0
  5. simple_module_hosting-0.0.38/simple_module_hosting/_api_version.py +37 -0
  6. simple_module_hosting-0.0.38/simple_module_hosting/_app_boot.py +107 -0
  7. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_boot_checks.py +18 -1
  8. simple_module_hosting-0.0.38/simple_module_hosting/_cors.py +76 -0
  9. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_error_handlers.py +9 -24
  10. simple_module_hosting-0.0.38/simple_module_hosting/_forwarded_host.py +135 -0
  11. simple_module_hosting-0.0.38/simple_module_hosting/_host_settings_support.py +108 -0
  12. simple_module_hosting-0.0.38/simple_module_hosting/_hydrate_step.py +51 -0
  13. simple_module_hosting-0.0.38/simple_module_hosting/_i18n_target.py +40 -0
  14. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_inertia_setup.py +10 -2
  15. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_inertia_shared.py +33 -0
  16. simple_module_hosting-0.0.38/simple_module_hosting/_insecure_transport.py +66 -0
  17. simple_module_hosting-0.0.38/simple_module_hosting/_lease_redis.py +149 -0
  18. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_lifespan.py +30 -16
  19. simple_module_hosting-0.0.36/simple_module_hosting/_phase_helpers.py → simple_module_hosting-0.0.38/simple_module_hosting/_middleware_pipeline.py +47 -100
  20. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_module_routes.py +30 -9
  21. simple_module_hosting-0.0.38/simple_module_hosting/_openapi.py +145 -0
  22. simple_module_hosting-0.0.38/simple_module_hosting/_phase_helpers.py +110 -0
  23. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_preapp_config.py +89 -80
  24. simple_module_hosting-0.0.38/simple_module_hosting/_process_services.py +18 -0
  25. simple_module_hosting-0.0.38/simple_module_hosting/_rate_limit.py +140 -0
  26. simple_module_hosting-0.0.38/simple_module_hosting/_rate_limit_keys.py +67 -0
  27. simple_module_hosting-0.0.38/simple_module_hosting/_rate_limit_store.py +126 -0
  28. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_registrations.py +42 -5
  29. simple_module_hosting-0.0.38/simple_module_hosting/_route_access.py +118 -0
  30. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_route_guard.py +19 -17
  31. simple_module_hosting-0.0.38/simple_module_hosting/_route_paths.py +72 -0
  32. simple_module_hosting-0.0.38/simple_module_hosting/_secret_key.py +211 -0
  33. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_settings_registration.py +5 -20
  34. simple_module_hosting-0.0.38/simple_module_hosting/_spa_mount.py +148 -0
  35. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_tenant.py +61 -17
  36. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/app_builder.py +37 -63
  37. simple_module_hosting-0.0.38/simple_module_hosting/db_options.py +32 -0
  38. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/host_cli.py +41 -0
  39. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/host_settings.py +101 -39
  40. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/i18n_manifest.py +26 -18
  41. simple_module_hosting-0.0.38/simple_module_hosting/i18n_routes.py +52 -0
  42. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/inertia_deps.py +6 -0
  43. simple_module_hosting-0.0.38/simple_module_hosting/leases.py +55 -0
  44. simple_module_hosting-0.0.38/simple_module_hosting/locales/de.json +28 -0
  45. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/locales/en.json +4 -0
  46. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/locales/es.json +5 -1
  47. simple_module_hosting-0.0.38/simple_module_hosting/log_sinks.py +238 -0
  48. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/logging.py +2 -3
  49. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/maintenance.py +4 -3
  50. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/manifest.py +3 -1
  51. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/middleware.py +33 -16
  52. simple_module_hosting-0.0.38/simple_module_hosting/pagination.py +60 -0
  53. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/permissions.py +93 -19
  54. simple_module_hosting-0.0.38/simple_module_hosting/rate_limit_deps.py +46 -0
  55. simple_module_hosting-0.0.38/simple_module_hosting/resource_deps.py +106 -0
  56. simple_module_hosting-0.0.38/simple_module_hosting/sentry.py +169 -0
  57. simple_module_hosting-0.0.38/simple_module_hosting/sentry_scrub.py +153 -0
  58. simple_module_hosting-0.0.38/simple_module_hosting/sentry_scrub_text.py +110 -0
  59. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/session.py +8 -0
  60. simple_module_hosting-0.0.38/simple_module_hosting/settings.py +15 -0
  61. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +10 -16
  62. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/components/StepForm.tsx +15 -22
  63. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/routes.py +2 -2
  64. simple_module_hosting-0.0.38/simple_module_hosting/token_guard.py +255 -0
  65. simple_module_hosting-0.0.38/simple_module_hosting/ui_locales/de.json +110 -0
  66. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/ui_locales/en.json +36 -0
  67. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/ui_locales/es.json +36 -0
  68. simple_module_hosting-0.0.38/tests/_hosting_settings_db.py +51 -0
  69. simple_module_hosting-0.0.38/tests/_resacc_models.py +79 -0
  70. simple_module_hosting-0.0.38/tests/_sentry_transport.py +34 -0
  71. simple_module_hosting-0.0.38/tests/conftest.py +15 -0
  72. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_app.py +9 -10
  73. simple_module_hosting-0.0.38/tests/test_boot_config_hardening.py +150 -0
  74. simple_module_hosting-0.0.38/tests/test_build_time_settings_marked.py +75 -0
  75. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_csp_module_sources.py +2 -1
  76. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_error_page_shared_props.py +52 -4
  77. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_error_status_coverage.py +10 -6
  78. simple_module_hosting-0.0.38/tests/test_forwarded_host.py +158 -0
  79. simple_module_hosting-0.0.38/tests/test_hosting_cors_api.py +61 -0
  80. simple_module_hosting-0.0.38/tests/test_hosting_cors_unset.py +19 -0
  81. simple_module_hosting-0.0.38/tests/test_hosting_csrf_shared_prop.py +85 -0
  82. simple_module_hosting-0.0.38/tests/test_hosting_lease_backends.py +169 -0
  83. simple_module_hosting-0.0.38/tests/test_hosting_lease_selection.py +48 -0
  84. simple_module_hosting-0.0.38/tests/test_hosting_log_sinks.py +209 -0
  85. simple_module_hosting-0.0.38/tests/test_hosting_openapi.py +158 -0
  86. simple_module_hosting-0.0.38/tests/test_hosting_openapi_security.py +114 -0
  87. simple_module_hosting-0.0.38/tests/test_hosting_pagination_deps.py +51 -0
  88. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_hosting_permissions.py +2 -2
  89. simple_module_hosting-0.0.38/tests/test_hosting_sentry.py +136 -0
  90. simple_module_hosting-0.0.38/tests/test_hosting_sentry_scrub.py +180 -0
  91. simple_module_hosting-0.0.38/tests/test_hosting_sentry_scrub_gaps.py +210 -0
  92. simple_module_hosting-0.0.38/tests/test_i18n_manifest_published_target.py +74 -0
  93. simple_module_hosting-0.0.38/tests/test_i18n_routes.py +113 -0
  94. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_inertia_i18n_shared_props.py +8 -3
  95. simple_module_hosting-0.0.38/tests/test_insecure_transport_warning.py +86 -0
  96. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_invalidation_wiring.py +13 -13
  97. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_lifespan_order.py +2 -2
  98. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_logging.py +8 -8
  99. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_menu_providers_wiring.py +17 -1
  100. simple_module_hosting-0.0.38/tests/test_merge_host_settings_strict.py +52 -0
  101. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_middleware_order.py +4 -1
  102. simple_module_hosting-0.0.38/tests/test_native_link_prefixes_strip.py +13 -0
  103. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_preapp_config.py +6 -30
  104. simple_module_hosting-0.0.38/tests/test_preapp_invalid_values.py +64 -0
  105. simple_module_hosting-0.0.38/tests/test_preapp_read_failures.py +80 -0
  106. simple_module_hosting-0.0.38/tests/test_rate_limit_rules_redis.py +25 -0
  107. simple_module_hosting-0.0.38/tests/test_rate_limit_rules_registry.py +191 -0
  108. simple_module_hosting-0.0.38/tests/test_rate_limit_store_shared.py +149 -0
  109. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_request_guard_rate_limit.py +3 -2
  110. simple_module_hosting-0.0.38/tests/test_resource_deps.py +134 -0
  111. simple_module_hosting-0.0.38/tests/test_resource_policy_owned.py +113 -0
  112. simple_module_hosting-0.0.38/tests/test_resource_policy_parity.py +68 -0
  113. simple_module_hosting-0.0.38/tests/test_resource_types_wiring.py +31 -0
  114. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_route_guard.py +1 -1
  115. simple_module_hosting-0.0.38/tests/test_route_guard_fastapi_contract.py +128 -0
  116. simple_module_hosting-0.0.38/tests/test_route_paths.py +89 -0
  117. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_secret_key_bootstrap.py +3 -22
  118. simple_module_hosting-0.0.38/tests/test_server_rendered_head.py +63 -0
  119. simple_module_hosting-0.0.38/tests/test_session_cookie_secure.py +81 -0
  120. simple_module_hosting-0.0.38/tests/test_spa_mounts_541.py +271 -0
  121. simple_module_hosting-0.0.38/tests/test_tenant_resolver_types.py +70 -0
  122. simple_module_hosting-0.0.38/tests/test_token_guard_effective_route.py +150 -0
  123. simple_module_hosting-0.0.38/tests/test_token_route_guard.py +205 -0
  124. simple_module_hosting-0.0.38/tests-js/hosting_SetupWizard.test.tsx +93 -0
  125. simple_module_hosting-0.0.36/simple_module_hosting/_hydrate_step.py +0 -39
  126. simple_module_hosting-0.0.36/simple_module_hosting/_rate_limit.py +0 -187
  127. simple_module_hosting-0.0.36/simple_module_hosting/_secret_key.py +0 -154
  128. simple_module_hosting-0.0.36/simple_module_hosting/settings.py +0 -10
  129. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/LICENSE +0 -0
  130. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/README.md +0 -0
  131. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/__init__.py +0 -0
  132. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/__main__.py +0 -0
  133. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_body_limit.py +0 -0
  134. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_csp_dynamic.py +0 -0
  135. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_db_health.py +0 -0
  136. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_dev_boot.py +0 -0
  137. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_favicon.py +0 -0
  138. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_host_services.py +0 -0
  139. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_i18n_build.py +0 -0
  140. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_inertia_cache.py +0 -0
  141. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_observability.py +0 -0
  142. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_project_root.py +0 -0
  143. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/_request_guard_responses.py +0 -0
  144. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/assets.py +0 -0
  145. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/bootstrap_settings.py +0 -0
  146. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/csrf.py +0 -0
  147. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/health.py +0 -0
  148. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/i18n_deps.py +0 -0
  149. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/i18n_middleware.py +0 -0
  150. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/inertia_utils.py +0 -0
  151. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/migrations.py +0 -0
  152. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/page_globs.py +0 -0
  153. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/py.typed +0 -0
  154. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/redirects.py +0 -0
  155. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_gate.py +0 -0
  156. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/__init__.py +0 -0
  157. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/migrate.py +0 -0
  158. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/pages/Wizard.tsx +0 -0
  159. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/payloads.py +0 -0
  160. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/shared_props.py +0 -0
  161. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/static_files.py +0 -0
  162. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/simple_module_hosting/tenancy.py +0 -0
  163. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_api_error_negotiation.py +0 -0
  164. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_auth_provider_setting.py +0 -0
  165. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_boot_drift_check.py +0 -0
  166. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_branding_head.py +0 -0
  167. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_check_migrations.py +0 -0
  168. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_check_migrations_expression_note.py +0 -0
  169. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_csp_providers.py +0 -0
  170. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_db_health_check.py +0 -0
  171. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_design_packs_wiring.py +0 -0
  172. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_diagnostics_state.py +0 -0
  173. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_favicon_default.py +0 -0
  174. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_grant_sources.py +0 -0
  175. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_health.py +0 -0
  176. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_home_url_default.py +0 -0
  177. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_host_cli.py +0 -0
  178. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_host_settings_fields.py +0 -0
  179. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_hosting_csrf.py +0 -0
  180. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_hosting_error_json_and_session_cookie.py +0 -0
  181. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_i18n_build_overrides.py +0 -0
  182. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_i18n_manifest.py +0 -0
  183. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_i18n_types_cover_installed_modules.py +0 -0
  184. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_i18n_ui_namespace.py +0 -0
  185. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_inertia_cache.py +0 -0
  186. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_inertia_shared_providers.py +0 -0
  187. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_lifespan_deferred_startup.py +0 -0
  188. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_locale_middleware.py +0 -0
  189. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_maintenance_mode.py +0 -0
  190. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_manifest.py +0 -0
  191. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_menu_urls_are_canonical.py +0 -0
  192. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_migration_listing.py +0 -0
  193. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_migration_multihead.py +0 -0
  194. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_precompressed_static.py +0 -0
  195. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_principal_permissions_claim.py +0 -0
  196. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_proxy_headers.py +0 -0
  197. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_redirects.py +0 -0
  198. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_request_guard_auth_parity.py +0 -0
  199. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_request_guard_body_limit.py +0 -0
  200. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_request_guard_body_limit_middleware_read.py +0 -0
  201. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_request_guard_body_limit_started.py +0 -0
  202. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_response_compression.py +0 -0
  203. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_session_cookie_security.py +0 -0
  204. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_session_cookie_window.py +0 -0
  205. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_session_vary_dedupe.py +0 -0
  206. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_settings_i18n.py +0 -0
  207. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_settings_secrets.py +0 -0
  208. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_setup_gate.py +0 -0
  209. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_setup_gate_single_flight.py +0 -0
  210. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_setup_migrate_error_redaction.py +0 -0
  211. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_setup_public_routes.py +0 -0
  212. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_setup_registry.py +0 -0
  213. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_setup_routes.py +0 -0
  214. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_sqlite_path_anchor.py +0 -0
  215. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_static_caching.py +0 -0
  216. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_strict_discovery_wiring.py +0 -0
  217. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_tenancy_api.py +0 -0
  218. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_tenant_middleware.py +0 -0
  219. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_tenant_resolver_diagnostic.py +0 -0
  220. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_tenant_scope_helpers.py +0 -0
  221. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_tenant_source.py +0 -0
  222. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_translator_dep.py +0 -0
  223. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tests/test_view_prefixes.py +0 -0
  224. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.38}/tsconfig.json +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -64,3 +62,5 @@ host/client_app/.playwright-cli/*
64
62
  .superpowers/
65
63
  .qa/
66
64
  .verify/
65
+ /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_hosting
3
- Version: 0.0.36
3
+ Version: 0.0.38
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,18 +22,20 @@ 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>=0.115
25
+ Requires-Dist: fastapi<0.157,>=0.140
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.36
30
- Requires-Dist: simple-module-db==0.0.36
31
- Requires-Dist: simple-module-inertia==0.0.36
29
+ Requires-Dist: simple-module-core==0.0.38
30
+ Requires-Dist: simple-module-db==0.0.38
31
+ Requires-Dist: simple-module-inertia==0.0.38
32
32
  Requires-Dist: starlette>=0.44
33
33
  Requires-Dist: tomlkit>=0.13
34
34
  Requires-Dist: uvicorn[standard]>=0.34
35
35
  Provides-Extra: redis
36
36
  Requires-Dist: redis>=5; extra == 'redis'
37
+ Provides-Extra: sentry
38
+ Requires-Dist: sentry-sdk[celery,fastapi]<3,>=2.20; extra == 'sentry'
37
39
  Description-Content-Type: text/markdown
38
40
 
39
41
  # simple_module_hosting
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.36"
3
+ version = "0.0.38"
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"
@@ -22,16 +22,16 @@ classifiers = [
22
22
  ]
23
23
  dependencies = [
24
24
  "click>=8.1",
25
- "fastapi>=0.115",
25
+ "fastapi>=0.140,<0.157",
26
26
  "httpx>=0.27",
27
27
  # SessionMiddleware (starlette.middleware.sessions) imports itsdangerous at
28
28
  # module import time; starlette treats it as an optional extra, so declare
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.36",
33
- "simple_module_db==0.0.36",
34
- "simple_module_inertia==0.0.36",
32
+ "simple_module_core==0.0.38",
33
+ "simple_module_db==0.0.38",
34
+ "simple_module_inertia==0.0.38",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
@@ -40,6 +40,8 @@ dependencies = [
40
40
  [project.optional-dependencies]
41
41
  # Shared rate-limiter store (SM_REDIS_URL). Without it the limiter counts per worker.
42
42
  redis = ["redis>=5"]
43
+ # Error reporting (SM_SENTRY_DSN). Never imported unless the DSN is set.
44
+ sentry = ["sentry-sdk[fastapi,celery]>=2.20,<3"]
43
45
 
44
46
  [project.scripts]
45
47
  sm-host = "simple_module_hosting.host_cli:app"
@@ -0,0 +1,27 @@
1
+ """Demote a request's principal to the anonymous visitor, mid-request.
2
+
3
+ Used when a credential authenticated but must not act as its account here: an
4
+ API token on a public route (``token_guard``), or a token whose owner no
5
+ longer holds the permission it requires (``permissions``). "Anonymous" means
6
+ all of it — no user, no permissions, no audit identity, and the tenant an
7
+ anonymous request would have resolved to rather than the one the credential
8
+ pinned.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from simple_module_db.listeners import current_user_id
14
+ from starlette.requests import HTTPConnection
15
+
16
+ from simple_module_hosting._tenant import reresolve_tenant
17
+
18
+
19
+ async def demote_to_anonymous(request: HTTPConnection) -> None:
20
+ """Drop the principal and re-run tenant resolution as anonymous."""
21
+ request.state.user = None
22
+ request.state.resolved_permissions = set()
23
+ current_user_id.set(None)
24
+ await reresolve_tenant(request)
25
+
26
+
27
+ __all__ = ["demote_to_anonymous"]
@@ -0,0 +1,37 @@
1
+ """Default ``HostSettings.api_version``: the host project's own version."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def default_api_version() -> str:
7
+ """The host project's version, else ``0.0.0``.
8
+
9
+ This repo's host is the installed ``simple-module-host`` distribution. A
10
+ scaffolded host is ``package = false`` (never installed), so there is no
11
+ distribution to ask: read ``[project].version`` from its ``pyproject.toml``
12
+ under ``SM_PROJECT_ROOT`` (or the cwd) instead.
13
+ """
14
+ import tomllib
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ from simple_module_core.dotenv import find_env_file
18
+
19
+ try:
20
+ return version("simple-module-host")
21
+ except PackageNotFoundError:
22
+ pass
23
+ # Same project-root convention as .env (SM_PROJECT_ROOT, else a bounded walk
24
+ # up from the cwd), so a CLI run from host/ finds the same project as the app.
25
+ root = find_env_file().parent
26
+ for candidate in (root / "host" / "pyproject.toml", root / "pyproject.toml"):
27
+ try:
28
+ project = tomllib.loads(candidate.read_text(encoding="utf-8")).get("project")
29
+ except (OSError, ValueError): # missing/unreadable, bad UTF-8, bad TOML
30
+ continue
31
+ if not isinstance(project, dict):
32
+ continue
33
+ # The first file declaring a [project] is the host's: a dynamic version
34
+ # must not fall through to an unrelated (workspace-root) project's.
35
+ found = project.get("version")
36
+ return found if isinstance(found, str) and found else "0.0.0"
37
+ return "0.0.0"
@@ -0,0 +1,107 @@
1
+ """Boot-phase helpers for ``create_app``: app construction, database init, static mount."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from fastapi import Depends, FastAPI
9
+ from simple_module_core.design_packs import DesignPackRegistry
10
+ from simple_module_core.feature_flags import FeatureFlagRegistry
11
+ from simple_module_core.health import HealthRegistry
12
+ from simple_module_core.menu import MenuRegistry
13
+ from simple_module_core.permissions import PermissionRegistry
14
+ from simple_module_core.public_routes import PublicRouteRegistry
15
+ from simple_module_db.listeners import register_listeners
16
+ from simple_module_db.session import init_db
17
+
18
+ from simple_module_hosting._db_health import register_database_check
19
+ from simple_module_hosting._openapi import (
20
+ API_OPENAPI_URL,
21
+ install_openapi_filter,
22
+ make_unique_id_function,
23
+ openapi_served,
24
+ )
25
+ from simple_module_hosting._process_services import configure_process_services
26
+ from simple_module_hosting.db_options import database_options
27
+ from simple_module_hosting.settings import Settings
28
+ from simple_module_hosting.static_files import PrecompressedStaticFiles
29
+ from simple_module_hosting.token_guard import token_route_guard
30
+
31
+ logger = logging.getLogger("simple_module_hosting.app_builder")
32
+
33
+ _DOCS_URL = "/api/docs"
34
+ _REDOC_URL = "/api/redoc"
35
+ _STATIC_MOUNT_PATH = "/static"
36
+ _STATIC_DIR_NAME = "static"
37
+
38
+
39
+ def build_fastapi_app(settings: Settings, lifespan) -> FastAPI:
40
+ serve_openapi = openapi_served(
41
+ is_development=settings.is_development, openapi_public=settings.openapi_public
42
+ )
43
+ app = FastAPI(
44
+ title=settings.api_title,
45
+ version=settings.api_version,
46
+ description=settings.api_description,
47
+ openapi_url=API_OPENAPI_URL if serve_openapi else None,
48
+ generate_unique_id_function=make_unique_id_function(),
49
+ docs_url=_DOCS_URL if settings.is_development else None,
50
+ redoc_url=_REDOC_URL if settings.is_development else None,
51
+ lifespan=lifespan,
52
+ # API tokens reach only permission-gated, non-page routes (or ones
53
+ # marked ``TokenAllowed``); see ``token_guard``.
54
+ dependencies=[Depends(token_route_guard)],
55
+ )
56
+
57
+ install_openapi_filter(app) # AuthMiddleware reads openapi_url: anonymous iff served
58
+ app.state.openapi_url = API_OPENAPI_URL if serve_openapi else None
59
+ return app
60
+
61
+
62
+ def log_registration_summary(
63
+ menu_registry: MenuRegistry,
64
+ perm_registry: PermissionRegistry,
65
+ ff_registry: FeatureFlagRegistry,
66
+ health_registry: HealthRegistry,
67
+ public_route_registry: PublicRouteRegistry,
68
+ design_pack_registry: DesignPackRegistry,
69
+ ) -> None:
70
+ logger.info(
71
+ "Registered %d menu items, %d permissions, %d feature flags, "
72
+ "%d health checks, %d public routes, %d design packs",
73
+ len(menu_registry.all_items),
74
+ len(perm_registry.all_permissions),
75
+ len(ff_registry.all_flags),
76
+ len(health_registry.all_checks),
77
+ len(public_route_registry.routes),
78
+ len(design_pack_registry.all()),
79
+ )
80
+
81
+
82
+ def init_database(settings: Settings, health_registry: HealthRegistry):
83
+ db_state = init_db(
84
+ settings.database_url,
85
+ echo=settings.debug,
86
+ **database_options(settings),
87
+ )
88
+ # Multi-tenant installs fail closed: a tenant-scoped query with no tenant
89
+ # context raises instead of reading every tenant's rows.
90
+ db_state.tenant_strict = settings.multi_tenant
91
+ # Where unbound / all_tenants() writes land on a single-tenant install:
92
+ # its ``default_tenant`` when set, so they stay visible to its requests.
93
+ if not settings.multi_tenant and settings.default_tenant:
94
+ db_state.default_tenant_id = settings.default_tenant
95
+ register_listeners(db_state)
96
+ configure_process_services(settings.redis_url, engine=db_state.engine)
97
+ # The host's own readiness signal, and the only probe-safe check in a
98
+ # default install — module checks reach third parties and are on-demand.
99
+ register_database_check(health_registry, db_state)
100
+ return db_state
101
+
102
+
103
+ def mount_host_static(app: FastAPI, project_root: Path) -> None:
104
+ static_dir = project_root / "host" / _STATIC_DIR_NAME
105
+ if static_dir.is_dir():
106
+ static = PrecompressedStaticFiles(directory=static_dir)
107
+ app.mount(_STATIC_MOUNT_PATH, static, name=_STATIC_DIR_NAME)
@@ -1,5 +1,8 @@
1
1
  """Diagnostics that need the built app, so cannot run from ``make doctor``.
2
2
 
3
+ SM025 runs in every environment; SM031 (an unguarded ``/api`` route) needs the
4
+ finished route table, so it runs at the end of a development boot only.
5
+
3
6
  The module diagnostics in Phase 2 see only module classes and the source tree.
4
7
  A check about what modules *registered* has to wait until after Phase 5, and
5
8
  unlike the source-tree checks it is meaningful in production too.
@@ -12,6 +15,8 @@ from typing import TYPE_CHECKING, Any
12
15
 
13
16
  from simple_module_core.diagnostics import Diagnostic, check_tenant_resolver, print_diagnostics
14
17
 
18
+ from simple_module_hosting._route_access import check_unguarded_api_routes
19
+
15
20
  if TYPE_CHECKING:
16
21
  from fastapi import FastAPI
17
22
  from simple_module_core.services import DiagnosticsState
@@ -44,4 +49,16 @@ def report_tenant_resolution(
44
49
  return found
45
50
 
46
51
 
47
- __all__ = ["report_tenant_resolution"]
52
+ def report_unguarded_routes(app: FastAPI, diagnostics_state: DiagnosticsState) -> list[Diagnostic]:
53
+ """SM031 (dev boot only; needs the finished route table): print and fold into Doctor."""
54
+ found = check_unguarded_api_routes(app)
55
+ if found:
56
+ print_diagnostics(found)
57
+ runner = diagnostics_state.runner
58
+ if runner is not None:
59
+ diagnostics_state.runner = lambda: [*runner(), *check_unguarded_api_routes(app)]
60
+ diagnostics_state.results = [*diagnostics_state.results, *found]
61
+ return found
62
+
63
+
64
+ __all__ = ["report_tenant_resolution", "report_unguarded_routes"]
@@ -0,0 +1,76 @@
1
+ """CORS dispatch (#527).
2
+
3
+ One Starlette ``CORSMiddleware`` is built per distinct :class:`CorsPolicy`; each
4
+ request is routed to the one whose policy the registry matches for its method and
5
+ path (most specific wins). A request no policy covers passes through untouched,
6
+ so a path nobody opted in never gains CORS headers.
7
+
8
+ Installed between RequestLogging and BodyLimit, so a preflight is answered before
9
+ auth, rate limit, setup or maintenance can reject it, and a 413 from BodyLimit
10
+ still carries the CORS headers the browser needs to read it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from simple_module_core.cors import CorsPolicy, CorsPolicyRegistry
16
+ from starlette.middleware.cors import CORSMiddleware
17
+ from starlette.types import ASGIApp, Receive, Scope, Send
18
+
19
+ from simple_module_hosting.host_settings import HostSettings
20
+
21
+
22
+ def host_policy(settings: HostSettings) -> CorsPolicy | None:
23
+ """The ``/api/`` policy from ``cors_*`` settings; ``None`` when no origin is set."""
24
+ # Every field is read even when unset: they are restart-required, and the
25
+ # build-time-settings guard learns that from the reads.
26
+ fields = (
27
+ settings.cors_allow_origins,
28
+ settings.cors_allow_credentials,
29
+ settings.cors_allow_methods,
30
+ settings.cors_allow_headers,
31
+ settings.cors_expose_headers,
32
+ settings.cors_max_age,
33
+ )
34
+ origins, credentials, methods, headers, expose, max_age = fields
35
+ if not origins:
36
+ return None
37
+ return CorsPolicy(
38
+ origins=tuple(origins),
39
+ allow_credentials=credentials,
40
+ methods=tuple(methods),
41
+ headers=tuple(headers),
42
+ expose_headers=tuple(expose),
43
+ max_age=max_age,
44
+ )
45
+
46
+
47
+ class CorsMiddleware:
48
+ def __init__(self, app: ASGIApp, *, registry: CorsPolicyRegistry) -> None:
49
+ self.app = app
50
+ self.registry = registry
51
+ self._handlers: dict[CorsPolicy, ASGIApp] = {}
52
+
53
+ def _handler(self, policy: CorsPolicy) -> ASGIApp:
54
+ handler = self._handlers.get(policy)
55
+ if handler is None:
56
+ handler = CORSMiddleware(
57
+ self.app,
58
+ allow_origins=list(policy.origins),
59
+ allow_credentials=policy.allow_credentials,
60
+ allow_methods=list(policy.methods),
61
+ allow_headers=list(policy.headers),
62
+ expose_headers=list(policy.expose_headers),
63
+ max_age=policy.max_age,
64
+ )
65
+ self._handlers[policy] = handler
66
+ return handler
67
+
68
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
69
+ if scope["type"] != "http" or len(self.registry) == 0:
70
+ await self.app(scope, receive, send)
71
+ return
72
+ policy = self.registry.match(scope["method"], scope["path"])
73
+ if policy is None:
74
+ await self.app(scope, receive, send)
75
+ return
76
+ await self._handler(policy)(scope, receive, send)
@@ -10,6 +10,7 @@ from fastapi.encoders import jsonable_encoder
10
10
  from fastapi.exceptions import RequestValidationError
11
11
  from fastapi.responses import JSONResponse
12
12
  from simple_module_core.exceptions import NotFoundError
13
+ from simple_module_core.permission_denied import PermissionDenied
13
14
  from simple_module_inertia import (
14
15
  Inertia,
15
16
  InertiaConfig,
@@ -22,7 +23,6 @@ from starlette.responses import Response
22
23
 
23
24
  from simple_module_hosting._inertia_shared import _INERTIA_HEADER
24
25
  from simple_module_hosting._observability import CorrelationIdMiddleware
25
- from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
26
26
 
27
27
  logger = logging.getLogger(__name__)
28
28
 
@@ -55,26 +55,6 @@ def _specific_detail(status_code: int, message: str) -> str:
55
55
  return "" if message.strip().casefold() == phrase.casefold() else message
56
56
 
57
57
 
58
- def _required_permission(message: str) -> str | None:
59
- """The permission named by a ``RequiresPermission`` denial, if this is one.
60
-
61
- The 403 page says "Your role doesn't include ``<perm>``. Ask an admin to
62
- grant it." — it needs the bare permission name, not the sentence the guard
63
- wrote for logs and API clients. Reading it back off the detail (using the
64
- guard's own exported prefix) keeps ``RequiresPermission`` as the single
65
- place that knows the name; the alternative, stashing it on
66
- ``request.state``, only works when the exception travels the one code path
67
- that set it, and 403s are also raised by hand.
68
-
69
- Anything else — a role-gated ``/admin`` 403, a hand-raised
70
- ``HTTPException(403, "Administrator access required")`` — yields ``None``,
71
- and the page falls back to copy that names no permission.
72
- """
73
- if not message.startswith(PERMISSION_DENIED_PREFIX):
74
- return None
75
- return message[len(PERMISSION_DENIED_PREFIX) :].strip() or None
76
-
77
-
78
58
  def _login_url(request: Request) -> str | None:
79
59
  """Best-effort login URL for the sign-in statuses.
80
60
 
@@ -159,9 +139,13 @@ async def render_error_page(
159
139
  status_code: int,
160
140
  message: str,
161
141
  headers: Mapping[str, str] | None = None,
142
+ required_permission: str | None = None,
162
143
  ) -> Response:
163
144
  """Render the Inertia error page for *status_code*.
164
145
 
146
+ ``required_permission`` names the permission a 403 lacked (from
147
+ :class:`PermissionDenied`); the page words its own sentence around it.
148
+
165
149
  ``headers`` carries an ``HTTPException``'s own headers through to the
166
150
  response. A rendered page is still the same status as the JSON body it
167
151
  replaces, so ``WWW-Authenticate`` on a 401 and ``Retry-After`` on a
@@ -187,7 +171,7 @@ async def render_error_page(
187
171
  inertia = Inertia(request, config)
188
172
  # This does not go through get_inertia, so the share step has to be
189
173
  # repeated here. Without it the error page renders raw translation keys
190
- # (host.error.not_found_title) and loses auth/menus, so a signed-in
174
+ # (ui.error_page.not_found_title) and loses auth/menus, so a signed-in
191
175
  # user's 404 has no layout.
192
176
  shared = getattr(request.state, "inertia_shared", None)
193
177
  if shared:
@@ -208,7 +192,7 @@ async def render_error_page(
208
192
  # Always present, null when no permission is involved: the page
209
193
  # branches on it, and an absent prop is indistinguishable there
210
194
  # from one that was never wired.
211
- "required_permission": _required_permission(message),
195
+ "required_permission": required_permission,
212
196
  "login_url": (_login_url(request) if status_code in _SIGN_IN_STATUSES else None),
213
197
  # Set by MaintenanceMiddleware. A planned outage reads very
214
198
  # differently from the same status code arriving unbidden.
@@ -249,7 +233,8 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> Respon
249
233
  headers = getattr(exc, "headers", None)
250
234
  if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
251
235
  detail = str(exc.detail) if exc.detail else ""
252
- return await render_error_page(request, exc.status_code, detail, headers)
236
+ required = exc.permission if isinstance(exc, PermissionDenied) else None
237
+ return await render_error_page(request, exc.status_code, detail, headers, required)
253
238
  return JSONResponse(
254
239
  status_code=exc.status_code,
255
240
  content={"detail": exc.detail},
@@ -0,0 +1,135 @@
1
+ """Honour ``X-Forwarded-Host`` from a trusted proxy, for allowlisted hosts only (#528).
2
+
3
+ uvicorn's ``ProxyHeadersMiddleware`` corrects scheme and client address but not
4
+ the host, so behind a proxy that rewrites ``Host`` to the upstream name,
5
+ ``request.url_for(...)`` and the tenants subdomain resolver see the internal
6
+ name. This middleware swaps the ``host`` header (and ``scope["server"]``) for the
7
+ forwarded one, but only when the peer is a trusted proxy *and* the value is on
8
+ the operator's allowlist: an unlisted host is ignored, so a forged header cannot
9
+ poison absolute URLs or pick a tenant.
10
+
11
+ It runs before ProxyHeaders (which replaces ``scope["client"]`` with the
12
+ forwarded client) so the peer it checks is still the proxy's TCP address.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ipaddress
18
+ import logging
19
+ import re
20
+ from collections.abc import Iterable
21
+
22
+ from starlette.types import ASGIApp, Receive, Scope, Send
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _WILDCARD = "*"
27
+ _SUBDOMAIN_PREFIX = "*."
28
+ _HOST_HEADER = b"host"
29
+ _FORWARDED_HOST_HEADER = b"x-forwarded-host"
30
+ _FORWARDED_PROTO_HEADER = b"x-forwarded-proto"
31
+ _DEFAULT_PORTS = {"https": 443, "wss": 443}
32
+ _FALLBACK_PORT = 80
33
+ _HOST_PATTERN = re.compile(r"^(?P<host>[a-z0-9]([a-z0-9._-]*[a-z0-9])?)(:(?P<port>\d{1,5}))?$")
34
+
35
+
36
+ def _split_host(value: str) -> tuple[str, str | None] | None:
37
+ match = _HOST_PATTERN.match(value.strip().lower())
38
+ return (match["host"], match["port"]) if match else None
39
+
40
+
41
+ def _forwarded_proto(scope: Scope) -> str | None:
42
+ """Rightmost ``X-Forwarded-Proto``: ProxyHeaders has not rewritten the scheme yet."""
43
+ raw = ",".join(v.decode("latin-1") for k, v in scope["headers"] if k == _FORWARDED_PROTO_HEADER)
44
+ return raw.split(",")[-1].strip().lower() or None
45
+
46
+
47
+ class TrustedPeers:
48
+ """Mirror of uvicorn's trusted-hosts rule: ``*``, IPs and CIDRs, comma-separated."""
49
+
50
+ def __init__(self, spec: str) -> None:
51
+ self._always = spec.strip() == _WILDCARD
52
+ self._literals: set[str] = set()
53
+ self._networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
54
+ for item in spec.split(","):
55
+ item = item.strip()
56
+ if not item or item == _WILDCARD:
57
+ continue
58
+ try:
59
+ self._networks.append(ipaddress.ip_network(item, strict=False))
60
+ except ValueError:
61
+ self._literals.add(item) # e.g. a unix-socket peer name
62
+
63
+ def __contains__(self, peer: str | None) -> bool:
64
+ if self._always:
65
+ return True
66
+ if not peer:
67
+ return False
68
+ if peer in self._literals:
69
+ return True
70
+ try:
71
+ address = ipaddress.ip_address(peer)
72
+ except ValueError:
73
+ return False
74
+ return any(address in network for network in self._networks)
75
+
76
+
77
+ class HostAllowlist:
78
+ """Exact hosts or ``*.example.com`` (any depth, never the apex), optional port."""
79
+
80
+ def __init__(self, entries: Iterable[str]) -> None:
81
+ self._exact: list[tuple[str, str | None]] = []
82
+ self._suffixes: list[tuple[str, str | None]] = []
83
+ for entry in entries:
84
+ wildcard = entry.strip().lower().startswith(_SUBDOMAIN_PREFIX)
85
+ parsed = _split_host(entry.strip()[len(_SUBDOMAIN_PREFIX) :] if wildcard else entry)
86
+ if parsed is None:
87
+ logger.warning("Ignoring invalid forwarded_host_allowlist entry %r", entry)
88
+ continue
89
+ host, port = parsed
90
+ (self._suffixes if wildcard else self._exact).append((host, port))
91
+
92
+ def allows(self, host: str, port: str | None) -> bool:
93
+ def port_ok(allowed: str | None) -> bool:
94
+ return allowed is None or allowed == port
95
+
96
+ if any(host == name and port_ok(p) for name, p in self._exact):
97
+ return True
98
+ return any(host.endswith("." + name) and port_ok(p) for name, p in self._suffixes)
99
+
100
+
101
+ class ForwardedHostMiddleware:
102
+ def __init__(self, app: ASGIApp, *, trusted_hosts: str, allowlist: Iterable[str]) -> None:
103
+ self.app = app
104
+ self._peers = TrustedPeers(trusted_hosts)
105
+ self._allowed = HostAllowlist(allowlist)
106
+
107
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
108
+ if scope["type"] in ("http", "websocket"):
109
+ client = scope.get("client")
110
+ if client and client[0] in self._peers:
111
+ self._rewrite(scope)
112
+ await self.app(scope, receive, send)
113
+
114
+ def _rewrite(self, scope: Scope) -> None:
115
+ raw = ",".join(
116
+ v.decode("latin-1") for k, v in scope["headers"] if k == _FORWARDED_HOST_HEADER
117
+ )
118
+ if not raw:
119
+ return
120
+ # Rightmost value: the one our trusted proxy added. Earlier values came
121
+ # from upstream hops or the client and cannot be trusted (an appending
122
+ # proxy keeps whatever the client sent at the front).
123
+ parsed = _split_host(raw.split(",")[-1])
124
+ if parsed is None or not self._allowed.allows(*parsed):
125
+ logger.debug("Ignoring X-Forwarded-Host %r: not on the allowlist", raw)
126
+ return
127
+ host, port = parsed
128
+ value = host if port is None else f"{host}:{port}"
129
+ scope["headers"] = [
130
+ *((k, v) for k, v in scope["headers"] if k != _HOST_HEADER),
131
+ (_HOST_HEADER, value.encode("latin-1")),
132
+ ]
133
+ proto = _forwarded_proto(scope) or scope.get("scheme", "")
134
+ default = _DEFAULT_PORTS.get(proto, _FALLBACK_PORT)
135
+ scope["server"] = (host, int(port) if port else default)