simple-module-hosting 0.0.36__tar.gz → 0.0.37__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 (158) hide show
  1. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/.gitignore +1 -0
  2. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/PKG-INFO +4 -4
  3. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/pyproject.toml +4 -4
  4. simple_module_hosting-0.0.37/simple_module_hosting/_anonymous.py +27 -0
  5. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_error_handlers.py +1 -1
  6. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_hydrate_step.py +1 -1
  7. simple_module_hosting-0.0.37/simple_module_hosting/_i18n_target.py +40 -0
  8. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_module_routes.py +23 -5
  9. simple_module_hosting-0.0.37/simple_module_hosting/_openapi.py +98 -0
  10. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_tenant.py +38 -12
  11. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/app_builder.py +25 -5
  12. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/host_cli.py +41 -0
  13. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/host_settings.py +45 -0
  14. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/i18n_manifest.py +26 -18
  15. simple_module_hosting-0.0.37/simple_module_hosting/i18n_routes.py +52 -0
  16. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/inertia_deps.py +6 -0
  17. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/locales/en.json +3 -0
  18. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/locales/es.json +3 -0
  19. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/maintenance.py +2 -2
  20. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/middleware.py +29 -16
  21. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/permissions.py +90 -8
  22. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/setup_wizard/routes.py +2 -2
  23. simple_module_hosting-0.0.37/simple_module_hosting/token_guard.py +217 -0
  24. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/ui_locales/en.json +30 -0
  25. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/ui_locales/es.json +30 -0
  26. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_app.py +9 -10
  27. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_error_page_shared_props.py +2 -2
  28. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_error_status_coverage.py +10 -6
  29. simple_module_hosting-0.0.37/tests/test_hosting_openapi.py +158 -0
  30. simple_module_hosting-0.0.37/tests/test_i18n_manifest_published_target.py +74 -0
  31. simple_module_hosting-0.0.37/tests/test_i18n_routes.py +113 -0
  32. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_menu_providers_wiring.py +17 -1
  33. simple_module_hosting-0.0.37/tests/test_token_guard_effective_route.py +150 -0
  34. simple_module_hosting-0.0.37/tests/test_token_route_guard.py +205 -0
  35. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/LICENSE +0 -0
  36. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/README.md +0 -0
  37. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/__init__.py +0 -0
  38. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/__main__.py +0 -0
  39. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_body_limit.py +0 -0
  40. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_boot_checks.py +0 -0
  41. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_csp_dynamic.py +0 -0
  42. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_db_health.py +0 -0
  43. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_dev_boot.py +0 -0
  44. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_favicon.py +0 -0
  45. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_host_services.py +0 -0
  46. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_i18n_build.py +0 -0
  47. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_inertia_cache.py +0 -0
  48. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_inertia_setup.py +0 -0
  49. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_inertia_shared.py +0 -0
  50. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_lifespan.py +0 -0
  51. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_observability.py +0 -0
  52. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_phase_helpers.py +0 -0
  53. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_preapp_config.py +0 -0
  54. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_project_root.py +0 -0
  55. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_rate_limit.py +0 -0
  56. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_registrations.py +0 -0
  57. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_request_guard_responses.py +0 -0
  58. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_route_guard.py +0 -0
  59. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_secret_key.py +0 -0
  60. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/_settings_registration.py +0 -0
  61. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/assets.py +0 -0
  62. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/bootstrap_settings.py +0 -0
  63. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/csrf.py +0 -0
  64. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/health.py +0 -0
  65. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/i18n_deps.py +0 -0
  66. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/i18n_middleware.py +0 -0
  67. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/inertia_utils.py +0 -0
  68. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/logging.py +0 -0
  69. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/manifest.py +0 -0
  70. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/migrations.py +0 -0
  71. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/page_globs.py +0 -0
  72. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/py.typed +0 -0
  73. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/redirects.py +0 -0
  74. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/session.py +0 -0
  75. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/settings.py +0 -0
  76. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/setup_gate.py +0 -0
  77. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/setup_wizard/__init__.py +0 -0
  78. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +0 -0
  79. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/setup_wizard/components/StepForm.tsx +0 -0
  80. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/setup_wizard/migrate.py +0 -0
  81. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/setup_wizard/pages/Wizard.tsx +0 -0
  82. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/setup_wizard/payloads.py +0 -0
  83. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/shared_props.py +0 -0
  84. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/static_files.py +0 -0
  85. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/simple_module_hosting/tenancy.py +0 -0
  86. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_api_error_negotiation.py +0 -0
  87. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_auth_provider_setting.py +0 -0
  88. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_boot_drift_check.py +0 -0
  89. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_branding_head.py +0 -0
  90. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_check_migrations.py +0 -0
  91. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_check_migrations_expression_note.py +0 -0
  92. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_csp_module_sources.py +0 -0
  93. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_csp_providers.py +0 -0
  94. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_db_health_check.py +0 -0
  95. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_design_packs_wiring.py +0 -0
  96. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_diagnostics_state.py +0 -0
  97. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_favicon_default.py +0 -0
  98. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_grant_sources.py +0 -0
  99. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_health.py +0 -0
  100. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_home_url_default.py +0 -0
  101. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_host_cli.py +0 -0
  102. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_host_settings_fields.py +0 -0
  103. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_hosting_csrf.py +0 -0
  104. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_hosting_error_json_and_session_cookie.py +0 -0
  105. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_hosting_permissions.py +0 -0
  106. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_i18n_build_overrides.py +0 -0
  107. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_i18n_manifest.py +0 -0
  108. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_i18n_types_cover_installed_modules.py +0 -0
  109. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_i18n_ui_namespace.py +0 -0
  110. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_inertia_cache.py +0 -0
  111. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_inertia_i18n_shared_props.py +0 -0
  112. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_inertia_shared_providers.py +0 -0
  113. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_invalidation_wiring.py +0 -0
  114. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_lifespan_deferred_startup.py +0 -0
  115. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_lifespan_order.py +0 -0
  116. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_locale_middleware.py +0 -0
  117. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_logging.py +0 -0
  118. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_maintenance_mode.py +0 -0
  119. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_manifest.py +0 -0
  120. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_menu_urls_are_canonical.py +0 -0
  121. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_middleware_order.py +0 -0
  122. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_migration_listing.py +0 -0
  123. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_migration_multihead.py +0 -0
  124. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_preapp_config.py +0 -0
  125. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_precompressed_static.py +0 -0
  126. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_principal_permissions_claim.py +0 -0
  127. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_proxy_headers.py +0 -0
  128. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_redirects.py +0 -0
  129. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_request_guard_auth_parity.py +0 -0
  130. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_request_guard_body_limit.py +0 -0
  131. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_request_guard_body_limit_middleware_read.py +0 -0
  132. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_request_guard_body_limit_started.py +0 -0
  133. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_request_guard_rate_limit.py +0 -0
  134. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_response_compression.py +0 -0
  135. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_route_guard.py +0 -0
  136. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_secret_key_bootstrap.py +0 -0
  137. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_session_cookie_security.py +0 -0
  138. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_session_cookie_window.py +0 -0
  139. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_session_vary_dedupe.py +0 -0
  140. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_settings_i18n.py +0 -0
  141. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_settings_secrets.py +0 -0
  142. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_setup_gate.py +0 -0
  143. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_setup_gate_single_flight.py +0 -0
  144. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_setup_migrate_error_redaction.py +0 -0
  145. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_setup_public_routes.py +0 -0
  146. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_setup_registry.py +0 -0
  147. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_setup_routes.py +0 -0
  148. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_sqlite_path_anchor.py +0 -0
  149. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_static_caching.py +0 -0
  150. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_strict_discovery_wiring.py +0 -0
  151. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_tenancy_api.py +0 -0
  152. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_tenant_middleware.py +0 -0
  153. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_tenant_resolver_diagnostic.py +0 -0
  154. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_tenant_scope_helpers.py +0 -0
  155. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_tenant_source.py +0 -0
  156. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_translator_dep.py +0 -0
  157. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tests/test_view_prefixes.py +0 -0
  158. {simple_module_hosting-0.0.36 → simple_module_hosting-0.0.37}/tsconfig.json +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -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.37
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,9 +26,9 @@ 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.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.37
30
+ Requires-Dist: simple-module-db==0.0.37
31
+ Requires-Dist: simple-module-inertia==0.0.37
32
32
  Requires-Dist: starlette>=0.44
33
33
  Requires-Dist: tomlkit>=0.13
34
34
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.36"
3
+ version = "0.0.37"
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,9 +29,9 @@ 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.36",
33
- "simple_module_db==0.0.36",
34
- "simple_module_inertia==0.0.36",
32
+ "simple_module_core==0.0.37",
33
+ "simple_module_db==0.0.37",
34
+ "simple_module_inertia==0.0.37",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
@@ -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"]
@@ -187,7 +187,7 @@ async def render_error_page(
187
187
  inertia = Inertia(request, config)
188
188
  # This does not go through get_inertia, so the share step has to be
189
189
  # 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
190
+ # (ui.error_page.not_found_title) and loses auth/menus, so a signed-in
191
191
  # user's 404 has no layout.
192
192
  shared = getattr(request.state, "inertia_shared", None)
193
193
  if shared:
@@ -28,7 +28,7 @@ async def hydrate_all(app: FastAPI, store: Any) -> None:
28
28
 
29
29
  for package, cls in settings_services.module_registry.items():
30
30
  try:
31
- hydrated = await hydrate_settings(cls, store, package)
31
+ hydrated = await hydrate_settings(cls, store, package, lenient=True)
32
32
  except Exception:
33
33
  logger.exception("Hydrating %s failed; falling back to defaults", package)
34
34
  continue
@@ -0,0 +1,40 @@
1
+ """Where generated i18n key files are written: monorepo checkout or published npm package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ RESOURCE_KEY_RE = re.compile(r"^\s+'([^']+)': '',$", re.MULTILINE)
9
+
10
+ _PUBLISHED_PKG = Path("node_modules") / "@simple-module-py" / "i18n" / "src"
11
+
12
+
13
+ def resolve_i18n_types_dir(project_root: Path) -> tuple[Path | None, bool]:
14
+ """Where the generated key files go, and whether that is a published package.
15
+
16
+ The monorepo's ``packages/i18n/src`` wins. A scaffolded app has no such
17
+ checkout — ``@simple-module-py/i18n`` is an npm dependency — so walk up from
18
+ ``project_root`` to the nearest ``node_modules`` holding it (npm workspaces
19
+ hoist it to the workspace root, above the host; a flat host installs into
20
+ ``client_app/``). ``(None, False)`` when
21
+ neither exists, the normal shape of a wheel-installed prod app.
22
+ """
23
+ local = project_root / "packages" / "i18n" / "src"
24
+ if local.is_dir():
25
+ return local, False
26
+ root = project_root.resolve()
27
+ # A flat host (``smpy new --flat``) keeps its npm install in client_app/.
28
+ for base in (root, root / "client_app", *root.parents):
29
+ published = base / _PUBLISHED_PKG
30
+ if published.is_dir():
31
+ return published, True
32
+ return None, False
33
+
34
+
35
+ def read_resource_keys(resources_path: Path) -> frozenset[str]:
36
+ """Keys listed in an existing ``generated-resources.ts`` (empty if unreadable)."""
37
+ try:
38
+ return frozenset(RESOURCE_KEY_RE.findall(resources_path.read_text(encoding="utf-8")))
39
+ except OSError:
40
+ return frozenset()
@@ -6,9 +6,12 @@ responsibility — and to keep both files under the 300-line cap.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from fastapi import APIRouter, FastAPI
9
+ from fastapi import APIRouter, Depends, FastAPI
10
10
  from fastapi.routing import APIRoute
11
11
 
12
+ from simple_module_hosting._openapi import make_unique_id_function
13
+ from simple_module_hosting.token_guard import page_route
14
+
12
15
  __all__ = ["wire_module_routes"]
13
16
 
14
17
 
@@ -23,13 +26,26 @@ def wire_module_routes(app: FastAPI, module) -> bool:
23
26
  in ``simple_module_test`` stay in lockstep if ``ModuleBase`` ever gains
24
27
  a new router type.
25
28
  """
26
- api_router = APIRouter(prefix=module.meta.route_prefix, tags=[module.meta.name])
27
- view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"])
29
+ api_router = APIRouter(
30
+ prefix=module.meta.route_prefix,
31
+ tags=[module.meta.name],
32
+ generate_unique_id_function=make_unique_id_function(),
33
+ )
34
+ # Views are Inertia pages, not part of the published JSON API (#456), and
35
+ # carry the page marker: an API token is refused on every page, gated or
36
+ # not (``token_guard``).
37
+ pages = [Depends(page_route)]
38
+ view_router = APIRouter(
39
+ prefix=module.meta.view_prefix,
40
+ tags=[f"{module.meta.name} Views"],
41
+ include_in_schema=False,
42
+ dependencies=pages,
43
+ )
28
44
  module.register_routes(api_router, view_router)
29
45
  _clone_bare_prefix_route(view_router, module.meta.view_prefix)
30
46
  has_views = bool(view_router.routes)
31
47
  app.include_router(api_router)
32
- app.include_router(view_router)
48
+ app.include_router(view_router, include_in_schema=False)
33
49
 
34
50
  # Second view router for modules that serve both public and admin pages
35
51
  # and therefore cannot express both under a single view_prefix — see
@@ -39,10 +55,12 @@ def wire_module_routes(app: FastAPI, module) -> bool:
39
55
  admin_router = APIRouter(
40
56
  prefix=admin_prefix,
41
57
  tags=[f"{module.meta.name} Admin Views"],
58
+ include_in_schema=False,
59
+ dependencies=pages,
42
60
  )
43
61
  module.register_admin_routes(admin_router)
44
62
  _clone_bare_prefix_route(admin_router, admin_prefix)
45
- app.include_router(admin_router)
63
+ app.include_router(admin_router, include_in_schema=False)
46
64
  return has_views
47
65
 
48
66
 
@@ -0,0 +1,98 @@
1
+ """OpenAPI export plumbing (#456).
2
+
3
+ Three small pieces, kept apart from ``create_app``:
4
+
5
+ * ``make_unique_id_function`` — stable ``<module>_<function>`` operationIds, so
6
+ generated clients get method names like ``users_list_users`` instead of
7
+ ``list_users_api_users__get``.
8
+ * ``install_openapi_filter`` — the published schema is the JSON API only. View
9
+ routes are already ``include_in_schema=False``; this also drops any other
10
+ non-``/api/`` path (host routes, health) so a client generator never sees them.
11
+ * ``API_OPENAPI_URL`` / ``openapi_served`` — where and when it is served.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ import warnings
19
+ from collections.abc import Callable
20
+
21
+ from fastapi import FastAPI
22
+ from fastapi.routing import APIRoute
23
+
24
+ __all__ = [
25
+ "API_OPENAPI_URL",
26
+ "dedupe_operation_ids",
27
+ "install_openapi_filter",
28
+ "make_unique_id_function",
29
+ "openapi_served",
30
+ ]
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ API_OPENAPI_URL = "/api/openapi.json"
35
+ _API_PREFIX = "/api/"
36
+
37
+
38
+ def openapi_served(*, is_development: bool, openapi_public: bool) -> bool:
39
+ """Always in development; elsewhere only when the operator opted in."""
40
+ return is_development or openapi_public
41
+
42
+
43
+ def _snake(name: str) -> str:
44
+ spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)
45
+ return re.sub(r"[^0-9a-zA-Z]+", "_", spaced).strip("_").lower()
46
+
47
+
48
+ def make_unique_id_function() -> Callable[[APIRoute], str]:
49
+ """Build an operationId generator: ``<module>_<endpoint function name>``.
50
+
51
+ The module is the route's first tag (``wire_module_routes`` tags each API
52
+ router with the module name). Stateless on purpose: FastAPI calls it again
53
+ when a router is included, so any bookkeeping here would see every route
54
+ twice. Clashes are resolved on the finished schema by ``dedupe_operation_ids``.
55
+ """
56
+
57
+ def unique_id(route: APIRoute) -> str:
58
+ module = _snake(route.tags[0]) if route.tags and isinstance(route.tags[0], str) else ""
59
+ return f"{module}_{route.name}" if module else route.name
60
+
61
+ return unique_id
62
+
63
+
64
+ def dedupe_operation_ids(schema: dict) -> None:
65
+ """Make every ``operationId`` unique, appending method and path on a clash."""
66
+ seen: set[str] = set()
67
+ for path, item in schema.get("paths", {}).items():
68
+ for method, op in item.items():
69
+ if not isinstance(op, dict) or "operationId" not in op:
70
+ continue
71
+ base = op["operationId"]
72
+ if base in seen:
73
+ op["operationId"] = f"{base}_{method}_{_snake(path)}"
74
+ logger.warning(
75
+ "Duplicate OpenAPI operationId %r; using %r", base, op["operationId"]
76
+ )
77
+ seen.add(op["operationId"])
78
+
79
+
80
+ def install_openapi_filter(app: FastAPI) -> None:
81
+ """Make ``app.openapi()`` list only ``/api/`` paths."""
82
+ original = app.openapi
83
+
84
+ def openapi() -> dict:
85
+ if app.openapi_schema is None:
86
+ # FastAPI warns about duplicate ids as it builds; they are resolved
87
+ # (and logged once) by dedupe_operation_ids below.
88
+ with warnings.catch_warnings():
89
+ warnings.filterwarnings("ignore", message="Duplicate Operation ID")
90
+ schema = original()
91
+ schema["paths"] = {
92
+ p: v for p, v in schema.get("paths", {}).items() if p.startswith(_API_PREFIX)
93
+ }
94
+ dedupe_operation_ids(schema)
95
+ assert app.openapi_schema is not None
96
+ return app.openapi_schema
97
+
98
+ app.openapi = openapi # type: ignore[method-assign]
@@ -17,6 +17,8 @@ TENANT_HEADER = "X-Tenant-ID"
17
17
 
18
18
  _CREDENTIAL_VARY = ("Cookie", "Authorization")
19
19
 
20
+ _STATE_MIDDLEWARE = "_sm_tenant_middleware"
21
+
20
22
 
21
23
  @dataclass(frozen=True, slots=True)
22
24
  class TenantResolution:
@@ -123,37 +125,41 @@ class TenantMiddleware:
123
125
  return
124
126
 
125
127
  request = Request(scope)
128
+ # Kept so a principal dropped later in the request can be re-resolved
129
+ # (``reresolve_tenant``) with this middleware's own configuration.
130
+ scope.setdefault("state", {})[_STATE_MIDDLEWARE] = self
126
131
  resolution = await self._resolve(request, scope)
127
132
  tenant_id = resolution.tenant_id
128
133
  request.state.tenant_id = tenant_id
129
134
  request.state.tenant_source = resolution.source
130
135
  request.state.tenant_vary = resolution.vary
131
136
 
132
- send = self._vary_sender(send, resolution.vary)
133
- if tenant_id is not None:
134
- token = current_tenant_id.set(tenant_id)
135
- try:
136
- await self.app(scope, receive, send)
137
- finally:
138
- current_tenant_id.reset(token)
139
- return
140
-
141
- await self.app(scope, receive, send)
137
+ send = self._vary_sender(scope, send, resolution.vary)
138
+ # Always bound and reset, even for ``None``: a principal demoted later in
139
+ # the request re-resolves the tenant (``reresolve_tenant``) and may set
140
+ # one this request did not start with, which must not outlive it.
141
+ token = current_tenant_id.set(tenant_id)
142
+ try:
143
+ await self.app(scope, receive, send)
144
+ finally:
145
+ current_tenant_id.reset(token)
142
146
 
143
147
  @staticmethod
144
- def _vary_sender(send: Send, vary: tuple[str, ...]) -> Send:
148
+ def _vary_sender(scope: Scope, send: Send, vary: tuple[str, ...]) -> Send:
145
149
  if not vary:
146
150
  return send
147
151
 
148
152
  async def wrapped(message: MutableMapping[str, Any]) -> None:
149
153
  if message["type"] == "http.response.start":
154
+ # Re-read: a re-resolution (``reresolve_tenant``) may add names.
155
+ names = (scope.get("state") or {}).get("tenant_vary") or vary
150
156
  headers = MutableHeaders(scope=message)
151
157
  lines = headers.getlist("vary")
152
158
  # Every line, not just the first (#423): MutableHeaders.get
153
159
  # reads one, and assigning drops the rest — including a
154
160
  # ``Vary: *`` on a later line.
155
161
  if not any("*" in _tokens(line) for line in lines):
156
- merged = merge_vary(", ".join(lines), vary)
162
+ merged = merge_vary(", ".join(lines), tuple(names))
157
163
  del headers["vary"]
158
164
  headers["vary"] = merged
159
165
  await send(message)
@@ -188,10 +194,30 @@ class TenantMiddleware:
188
194
  return TenantResolution(None)
189
195
 
190
196
 
197
+ async def reresolve_tenant(request: Request | Any) -> None:
198
+ """Resolve the tenant again for the request's *current* principal and rebind it.
199
+
200
+ For a principal dropped after ``TenantMiddleware`` ran: the tenant it
201
+ pinned must not outlive it. No-op without the middleware (single-tenant
202
+ hosts never bound one). The headers both answers depended on stay in
203
+ ``Vary``.
204
+ """
205
+ middleware = (request.scope.get("state") or {}).get(_STATE_MIDDLEWARE)
206
+ if not isinstance(middleware, TenantMiddleware):
207
+ return
208
+ resolution = await middleware._resolve(request, request.scope)
209
+ previous = tuple(getattr(request.state, "tenant_vary", None) or ())
210
+ request.state.tenant_id = resolution.tenant_id
211
+ request.state.tenant_source = resolution.source
212
+ request.state.tenant_vary = (*previous, *resolution.vary)
213
+ current_tenant_id.set(resolution.tenant_id)
214
+
215
+
191
216
  __all__ = [
192
217
  "TENANT_HEADER",
193
218
  "TenantMiddleware",
194
219
  "TenantResolution",
195
220
  "TenantResolver",
196
221
  "merge_vary",
222
+ "reresolve_tenant",
197
223
  ]
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
 
7
- from fastapi import FastAPI
7
+ from fastapi import Depends, FastAPI
8
8
  from simple_module_core import BodyLimitRegistry, CspSourceRegistry
9
9
  from simple_module_core.audit_links import AuditLinkRegistry
10
10
  from simple_module_core.design_packs import DesignPackRegistry
@@ -27,6 +27,12 @@ from simple_module_hosting._db_health import register_database_check
27
27
  from simple_module_hosting._dev_boot import run_dev_boot
28
28
  from simple_module_hosting._inertia_setup import setup_inertia
29
29
  from simple_module_hosting._lifespan import build_lifespan
30
+ from simple_module_hosting._openapi import (
31
+ API_OPENAPI_URL,
32
+ install_openapi_filter,
33
+ make_unique_id_function,
34
+ openapi_served,
35
+ )
30
36
  from simple_module_hosting._phase_helpers import (
31
37
  attach_public_routes,
32
38
  install_middleware,
@@ -44,16 +50,16 @@ from simple_module_hosting._settings_registration import (
44
50
  )
45
51
  from simple_module_hosting.health import router as health_router
46
52
  from simple_module_hosting.i18n_manifest import build_i18n_registry
53
+ from simple_module_hosting.i18n_routes import router as i18n_router
47
54
  from simple_module_hosting.settings import Settings
48
55
  from simple_module_hosting.setup_gate import register_migration_step
49
56
  from simple_module_hosting.setup_wizard import mount_setup_wizard
50
57
  from simple_module_hosting.static_files import PrecompressedStaticFiles
51
58
  from simple_module_hosting.tenancy import mode_for
59
+ from simple_module_hosting.token_guard import token_route_guard
52
60
 
53
61
  logger = logging.getLogger(__name__)
54
62
 
55
- _APP_TITLE = "SimpleModule"
56
- _APP_VERSION = "0.1.0"
57
63
  _DOCS_URL = "/api/docs"
58
64
  _REDOC_URL = "/api/redoc"
59
65
  _STATIC_MOUNT_PATH = "/static"
@@ -143,14 +149,27 @@ def create_app(settings: Settings | None = None) -> FastAPI:
143
149
 
144
150
  lifespan = build_lifespan(modules)
145
151
 
152
+ serve_openapi = openapi_served(
153
+ is_development=settings.is_development, openapi_public=settings.openapi_public
154
+ )
146
155
  app = FastAPI(
147
- title=_APP_TITLE,
148
- version=_APP_VERSION,
156
+ title=settings.api_title,
157
+ version=settings.api_version,
158
+ description=settings.api_description,
159
+ openapi_url=API_OPENAPI_URL if serve_openapi else None,
160
+ generate_unique_id_function=make_unique_id_function(),
149
161
  docs_url=_DOCS_URL if settings.is_development else None,
150
162
  redoc_url=_REDOC_URL if settings.is_development else None,
151
163
  lifespan=lifespan,
164
+ # API tokens reach only permission-gated, non-page routes (or ones
165
+ # marked ``TokenAllowed``); see ``token_guard``.
166
+ dependencies=[Depends(token_route_guard)],
152
167
  )
153
168
 
169
+ install_openapi_filter(app)
170
+ # Read by AuthMiddleware: the schema is anonymous exactly when it is served.
171
+ app.state.openapi_url = API_OPENAPI_URL if serve_openapi else None
172
+
154
173
  # ── Phase 4: Module settings ───────────────────────────
155
174
  for mod in modules:
156
175
  mod.register_settings(app)
@@ -240,6 +259,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
240
259
  view_prefixes[mod.meta.name] = mod.meta.view_prefix
241
260
 
242
261
  app.include_router(health_router)
262
+ app.include_router(i18n_router) # /i18n/set-locale — LocaleSwitcher posts here
243
263
  mount_setup_wizard(app, setup_registry) # /setup — what SetupMiddleware redirects to
244
264
 
245
265
  static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME
@@ -53,6 +53,47 @@ def gen_pages(
53
53
  )
54
54
 
55
55
 
56
+ @app.command("gen-i18n")
57
+ def gen_i18n(
58
+ allow_removals: Annotated[
59
+ bool,
60
+ typer.Option(
61
+ "--allow-removals",
62
+ help="Permit dropping key namespaces of modules not installed here.",
63
+ ),
64
+ ] = False,
65
+ ) -> None:
66
+ """Regenerate the typed i18n key files (keys.generated.ts) without booting the app.
67
+
68
+ Writes into packages/i18n/src in the monorepo, else into the installed
69
+ @simple-module-py/i18n package under node_modules. Fails loudly if neither
70
+ exists.
71
+ """
72
+ from simple_module_hosting._project_root import resolve_project_root
73
+ from simple_module_hosting.i18n_manifest import (
74
+ NamespaceRemovalError,
75
+ emit_frontend_types_for_modules,
76
+ )
77
+ from simple_module_hosting.settings import Settings
78
+
79
+ logging.basicConfig(level=logging.WARNING, format="%(message)s")
80
+ logging.getLogger("simple_module_hosting.i18n_manifest").setLevel(logging.INFO)
81
+ settings = Settings()
82
+ modules = discover_modules(enabled=settings.modules_enabled, strict=not settings.is_development)
83
+ try:
84
+ emit_frontend_types_for_modules(
85
+ settings,
86
+ modules,
87
+ resolve_project_root(),
88
+ strict=True,
89
+ allow_removals=allow_removals,
90
+ )
91
+ except (NamespaceRemovalError, FileNotFoundError) as exc:
92
+ typer.echo(f"error: {exc}", err=True)
93
+ raise typer.Exit(code=1) from exc
94
+ typer.echo("i18n key files up to date")
95
+
96
+
56
97
  @app.command("sync-js-deps")
57
98
  def sync_js_deps(
58
99
  host_client_app: Annotated[
@@ -26,6 +26,42 @@ _RESTART_DB = {"requires_restart": True, "group": "Database"}
26
26
 
27
27
  _RESTART_GUARD = {"requires_restart": True, "group": "Request guards"}
28
28
 
29
+ _RESTART_API = {"requires_restart": True, "group": "API"}
30
+
31
+
32
+ def _default_api_version() -> str:
33
+ """The host project's version, else ``0.0.0``.
34
+
35
+ This repo's host is the installed ``simple-module-host`` distribution. A
36
+ scaffolded host is ``package = false`` (never installed), so there is no
37
+ distribution to ask: read ``[project].version`` from its ``pyproject.toml``
38
+ under ``SM_PROJECT_ROOT`` (or the cwd) instead.
39
+ """
40
+ import tomllib
41
+ from importlib.metadata import PackageNotFoundError, version
42
+
43
+ from simple_module_core.dotenv import find_env_file
44
+
45
+ try:
46
+ return version("simple-module-host")
47
+ except PackageNotFoundError:
48
+ pass
49
+ # Same project-root convention as .env (SM_PROJECT_ROOT, else a bounded walk
50
+ # up from the cwd), so a CLI run from host/ finds the same project as the app.
51
+ root = find_env_file().parent
52
+ for candidate in (root / "host" / "pyproject.toml", root / "pyproject.toml"):
53
+ try:
54
+ project = tomllib.loads(candidate.read_text(encoding="utf-8")).get("project")
55
+ except (OSError, ValueError): # missing/unreadable, bad UTF-8, bad TOML
56
+ continue
57
+ if not isinstance(project, dict):
58
+ continue
59
+ # The first file declaring a [project] is the host's: a dynamic version
60
+ # must not fall through to an unrelated (workspace-root) project's.
61
+ found = project.get("version")
62
+ return found if isinstance(found, str) and found else "0.0.0"
63
+ return "0.0.0"
64
+
29
65
 
30
66
  class HostSettings(BaseSettings):
31
67
  """DB-backed host configuration — defaults live here, overrides in DB."""
@@ -113,6 +149,15 @@ class HostSettings(BaseSettings):
113
149
  """Opt-in per-IP limit for signed-in traffic, same syntax. Blank (default)
114
150
  leaves authenticated requests unlimited."""
115
151
 
152
+ api_title: str = Field(default="SimpleModule", json_schema_extra=_RESTART_API)
153
+ """``info.title`` of the OpenAPI document (built once at boot)."""
154
+ api_version: str = Field(default_factory=_default_api_version, json_schema_extra=_RESTART_API)
155
+ """``info.version`` of the OpenAPI document; defaults to the host distribution's version."""
156
+ api_description: str = Field(default="", json_schema_extra=_RESTART_API)
157
+ openapi_public: bool = Field(default=False, json_schema_extra=_RESTART_API)
158
+ """Serve ``/api/openapi.json`` outside development, anonymously. Off by default:
159
+ the schema lists every endpoint, so exposing it is an operator decision."""
160
+
116
161
  db_pool_size: int = Field(default=10, json_schema_extra=_RESTART_DB)
117
162
  db_max_overflow: int = Field(default=20, json_schema_extra=_RESTART_DB)
118
163
  db_pool_pre_ping: bool = Field(default=True, json_schema_extra=_RESTART_DB)