simple-module-hosting 0.0.24__tar.gz → 0.0.26__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 (63) hide show
  1. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/PKG-INFO +6 -5
  2. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/README.md +3 -2
  3. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/pyproject.toml +3 -3
  4. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/_error_handlers.py +7 -0
  5. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/_phase_helpers.py +50 -24
  6. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/app_builder.py +15 -26
  7. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/bootstrap_settings.py +25 -1
  8. simple_module_hosting-0.0.26/simple_module_hosting/static_files.py +102 -0
  9. simple_module_hosting-0.0.26/tests/test_design_packs_wiring.py +52 -0
  10. simple_module_hosting-0.0.26/tests/test_error_page_shared_props.py +67 -0
  11. simple_module_hosting-0.0.26/tests/test_menu_urls_are_canonical.py +65 -0
  12. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_middleware_order.py +7 -2
  13. simple_module_hosting-0.0.26/tests/test_precompressed_static.py +142 -0
  14. simple_module_hosting-0.0.26/tests/test_proxy_headers.py +119 -0
  15. simple_module_hosting-0.0.26/tests/test_response_compression.py +88 -0
  16. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/.gitignore +0 -0
  17. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/LICENSE +0 -0
  18. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/__init__.py +0 -0
  19. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/__main__.py +0 -0
  20. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/_host_services.py +0 -0
  21. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/_hydrate_step.py +0 -0
  22. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/_inertia_setup.py +0 -0
  23. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/_inertia_shared.py +0 -0
  24. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/_observability.py +0 -0
  25. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/health.py +0 -0
  26. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/host_cli.py +0 -0
  27. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/host_settings.py +0 -0
  28. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/i18n_deps.py +0 -0
  29. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/i18n_manifest.py +0 -0
  30. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/i18n_middleware.py +0 -0
  31. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/inertia_deps.py +0 -0
  32. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/inertia_utils.py +0 -0
  33. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/logging.py +0 -0
  34. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/manifest.py +0 -0
  35. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/middleware.py +0 -0
  36. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/migrations.py +0 -0
  37. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/permissions.py +0 -0
  38. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/py.typed +0 -0
  39. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/redirects.py +0 -0
  40. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/settings.py +0 -0
  41. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/simple_module_hosting/shared_props.py +0 -0
  42. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_app.py +0 -0
  43. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_branding_head.py +0 -0
  44. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_check_migrations.py +0 -0
  45. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_health.py +0 -0
  46. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_host_cli.py +0 -0
  47. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_hosting_permissions.py +0 -0
  48. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_i18n_manifest.py +0 -0
  49. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_inertia_i18n_shared_props.py +0 -0
  50. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_inertia_manifest.py +0 -0
  51. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_inertia_shared_providers.py +0 -0
  52. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_lifespan_order.py +0 -0
  53. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_locale_middleware.py +0 -0
  54. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_logging.py +0 -0
  55. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_manifest.py +0 -0
  56. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_redirects.py +0 -0
  57. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_session_cookie_security.py +0 -0
  58. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_settings_i18n.py +0 -0
  59. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_settings_secrets.py +0 -0
  60. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_static_caching.py +0 -0
  61. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_strict_discovery_wiring.py +0 -0
  62. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_tenant_middleware.py +0 -0
  63. {simple_module_hosting-0.0.24 → simple_module_hosting-0.0.26}/tests/test_translator_dep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_hosting
3
- Version: 0.0.24
3
+ Version: 0.0.26
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,8 +26,8 @@ Requires-Dist: fastapi-inertia>=1.0
26
26
  Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
28
  Requires-Dist: jinja2>=3.1
29
- Requires-Dist: simple-module-core==0.0.24
30
- Requires-Dist: simple-module-db==0.0.24
29
+ Requires-Dist: simple-module-core==0.0.26
30
+ Requires-Dist: simple-module-db==0.0.26
31
31
  Requires-Dist: starlette>=0.44
32
32
  Requires-Dist: tomlkit>=0.13
33
33
  Requires-Dist: uvicorn[standard]>=0.34
@@ -64,11 +64,12 @@ Minimal `main.py`:
64
64
  from simple_module_hosting import create_app
65
65
  from simple_module_hosting.settings import Settings
66
66
 
67
- settings = Settings() # reads SM_* env vars
68
- app = create_app(settings) # discovers + registers every installed module
67
+ settings = Settings() # reads SM_* env vars
68
+ app = create_app(settings) # discovers + registers every installed module
69
69
 
70
70
  if __name__ == "__main__":
71
71
  import uvicorn
72
+
72
73
  uvicorn.run(app, host="0.0.0.0", port=8000)
73
74
  ```
74
75
 
@@ -29,11 +29,12 @@ Minimal `main.py`:
29
29
  from simple_module_hosting import create_app
30
30
  from simple_module_hosting.settings import Settings
31
31
 
32
- settings = Settings() # reads SM_* env vars
33
- app = create_app(settings) # discovers + registers every installed module
32
+ settings = Settings() # reads SM_* env vars
33
+ app = create_app(settings) # discovers + registers every installed module
34
34
 
35
35
  if __name__ == "__main__":
36
36
  import uvicorn
37
+
37
38
  uvicorn.run(app, host="0.0.0.0", port=8000)
38
39
  ```
39
40
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.24"
3
+ version = "0.0.26"
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"
@@ -26,8 +26,8 @@ dependencies = [
26
26
  "fastapi-inertia>=1.0",
27
27
  "httpx>=0.27",
28
28
  "jinja2>=3.1",
29
- "simple_module_core==0.0.24",
30
- "simple_module_db==0.0.24",
29
+ "simple_module_core==0.0.26",
30
+ "simple_module_db==0.0.26",
31
31
  "starlette>=0.44",
32
32
  "tomlkit>=0.13",
33
33
  "uvicorn[standard]>=0.34",
@@ -27,6 +27,13 @@ async def render_error_page(request: Request, status_code: int, message: str) ->
27
27
  config: InertiaConfig = request.app.state.sm.inertia_config
28
28
  try:
29
29
  inertia = Inertia(request, config)
30
+ # This builds its own Inertia instead of going through get_inertia, so
31
+ # the share step has to be repeated here. Without it the error page
32
+ # renders raw translation keys (host.error.not_found_title) and loses
33
+ # auth/menus, so a signed-in user's 404 has no layout.
34
+ shared = getattr(request.state, "inertia_shared", None)
35
+ if shared:
36
+ inertia.share(**shared)
30
37
  response = await inertia.render("Error", {"status": status_code, "message": message})
31
38
  response.status_code = status_code
32
39
  return response
@@ -22,9 +22,9 @@ from inertia import (
22
22
  from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
23
23
  from simple_module_core.exceptions import NotFoundError
24
24
  from starlette.exceptions import HTTPException
25
+ from starlette.middleware.gzip import GZipMiddleware
25
26
  from starlette.middleware.sessions import SessionMiddleware
26
- from starlette.responses import Response
27
- from starlette.types import Scope
27
+ from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
28
28
 
29
29
  from simple_module_hosting._error_handlers import (
30
30
  http_exception_handler,
@@ -32,6 +32,8 @@ from simple_module_hosting._error_handlers import (
32
32
  request_validation_error_handler,
33
33
  unhandled_exception_handler,
34
34
  )
35
+ from simple_module_hosting._host_services import _HostServices
36
+ from simple_module_hosting.host_settings import HostSettings
35
37
  from simple_module_hosting.i18n_middleware import LocaleMiddleware
36
38
  from simple_module_hosting.middleware import (
37
39
  CorrelationIdMiddleware,
@@ -41,6 +43,7 @@ from simple_module_hosting.middleware import (
41
43
  TenantMiddleware,
42
44
  )
43
45
  from simple_module_hosting.settings import Settings
46
+ from simple_module_hosting.static_files import PrecompressedStaticFiles
44
47
 
45
48
  if TYPE_CHECKING:
46
49
  from simple_module_core.menu import MenuRegistry
@@ -48,27 +51,11 @@ if TYPE_CHECKING:
48
51
 
49
52
  logger = logging.getLogger(__name__)
50
53
 
51
- _IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"
54
+ # Below this, gzip framing costs more than it saves. Starlette's own default.
55
+ COMPRESSION_MIN_BYTES = 500
52
56
 
53
-
54
- class ImmutableStaticFiles(StaticFiles):
55
- """StaticFiles that marks Vite's content-hashed build assets immutable.
56
-
57
- Vite emits files under ``dist/assets/`` with a content hash in the filename
58
- (e.g. ``main-3YbShAJ4.js``), so the bytes for a given URL never change —
59
- browsers can cache them indefinitely and skip even the revalidation
60
- round-trip. The default StaticFiles only sets ETag/Last-Modified, forcing a
61
- conditional GET per asset on every visit. Non-hashed paths (the manifest,
62
- etc.) keep the default behaviour.
63
- """
64
-
65
- async def get_response(self, path: str, scope: Scope) -> Response:
66
- response = await super().get_response(path, scope)
67
- # StaticFiles hands us an OS-separator path (backslashes on Windows), so
68
- # normalize before matching the forward-slash asset prefix.
69
- if response.status_code == 200 and path.replace("\\", "/").startswith("dist/assets/"):
70
- response.headers["Cache-Control"] = _IMMUTABLE_CACHE_CONTROL
71
- return response
57
+ # Re-exported for back-compat: static-file serving now lives in static_files.
58
+ ImmutableStaticFiles = PrecompressedStaticFiles
72
59
 
73
60
 
74
61
  def register_exception_handlers(app: FastAPI, modules: list) -> None:
@@ -95,8 +82,8 @@ def install_middleware(
95
82
  """Install the full middleware pipeline.
96
83
 
97
84
  Order matters: last added = first executed. Execution order:
98
- CorrelationId → RequestLogging → Security → Session
99
- → [module] → (Tenant, if multi_tenant) → Locale → Inertia.
85
+ (ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
86
+ → Security → Session → [module] → (Tenant, if multi_tenant) → Locale → Inertia.
100
87
  """
101
88
  app.add_middleware(
102
89
  InertiaLayoutDataMiddleware,
@@ -125,8 +112,21 @@ def install_middleware(
125
112
  )
126
113
  else:
127
114
  app.add_middleware(SecurityHeadersMiddleware)
115
+ # Compress response bodies. Added here so it sits inside CorrelationId and
116
+ # RequestLogging (which set headers and read request state) but outside
117
+ # everything that produces a body — including the /static mount, where it
118
+ # matters most: the built CSS is ~139 KB raw and ~21 KB gzipped, and the
119
+ # JS bundle compresses about 3x. Uncompressed assets dominated cold page
120
+ # load, several times larger than anything on the server request path.
121
+ app.add_middleware(GZipMiddleware, minimum_size=COMPRESSION_MIN_BYTES)
128
122
  app.add_middleware(RequestLoggingMiddleware)
129
123
  app.add_middleware(CorrelationIdMiddleware)
124
+ # Outermost: rewrite scheme/client from X-Forwarded-* before anything else
125
+ # reads them, so request logs see the real client IP and Inertia's absolute
126
+ # page url carries the proxy-terminated scheme (GH #223). Gated on an
127
+ # explicit trust setting — never trust forwarded headers by default.
128
+ if settings.trusted_proxy:
129
+ app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=settings.trusted_proxy)
130
130
 
131
131
 
132
132
  def attach_public_routes(app: FastAPI, settings: Settings, registry) -> None:
@@ -167,6 +167,32 @@ def mount_module_static_dirs(app: FastAPI, modules: list) -> None:
167
167
  )
168
168
 
169
169
 
170
+ def register_host_settings(app: FastAPI) -> None:
171
+ """Register host-level settings under ``package="host"`` (DB-backed).
172
+
173
+ The Settings module must already have run ``register_settings`` — topo
174
+ order puts it early, since its ``meta.depends_on`` is empty. When the
175
+ Settings module isn't enabled there's no registry to register against, so
176
+ this skips quietly.
177
+
178
+ ``settings.registration`` is resolved via importlib rather than a plain
179
+ ``from settings.registration import ...``: the SM009 coupling check is
180
+ AST-based and forbids any static import of a plugin package name from
181
+ within ``framework/*``. Dynamic resolution keeps the framework AST
182
+ plugin-free while still hitting the real helper at runtime.
183
+ """
184
+ if not hasattr(app.state, "settings"):
185
+ return
186
+
187
+ import importlib
188
+
189
+ register_module_settings = importlib.import_module(
190
+ "settings.registration"
191
+ ).register_module_settings
192
+
193
+ register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
194
+
195
+
170
196
  def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]:
171
197
  """SM012: warn if a module overrides register_settings but added nothing to app.state.
172
198
 
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
10
10
  from pathlib import Path
11
11
 
12
12
  from fastapi import FastAPI
13
+ from simple_module_core.design_packs import DesignPackRegistry
13
14
  from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
14
15
  from simple_module_core.discovery import discover_modules, topological_sort
15
16
  from simple_module_core.events import EventBus
@@ -22,22 +23,21 @@ from simple_module_core.services import Services
22
23
  from simple_module_db.listeners import register_listeners
23
24
  from simple_module_db.session import init_db
24
25
 
25
- from simple_module_hosting._host_services import _HostServices
26
26
  from simple_module_hosting._inertia_setup import setup_inertia
27
27
  from simple_module_hosting._phase_helpers import (
28
- ImmutableStaticFiles,
29
28
  attach_public_routes,
30
29
  check_settings_registration,
31
30
  install_middleware,
32
31
  mount_module_static_dirs,
33
32
  register_exception_handlers,
33
+ register_host_settings,
34
34
  wire_module_routes,
35
35
  )
36
36
  from simple_module_hosting.health import router as health_router
37
- from simple_module_hosting.host_settings import HostSettings
38
37
  from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
39
38
  from simple_module_hosting.migrations import check_migrations
40
39
  from simple_module_hosting.settings import Settings
40
+ from simple_module_hosting.static_files import PrecompressedStaticFiles
41
41
 
42
42
  logger = logging.getLogger(__name__)
43
43
 
@@ -165,6 +165,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
165
165
  event_bus = EventBus()
166
166
  health_registry = HealthRegistry()
167
167
  public_route_registry = PublicRouteRegistry()
168
+ design_pack_registry = DesignPackRegistry()
168
169
 
169
170
  @asynccontextmanager
170
171
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
@@ -203,25 +204,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
203
204
  for mod in modules:
204
205
  mod.register_settings(app)
205
206
 
206
- # Register host-level settings under package="host" (DB-backed). The
207
- # Settings module must already have run register_settings (topo order
208
- # puts it early; its meta.depends_on = [] so it's among the first).
209
- # When the Settings module isn't enabled, there's no registry to
210
- # register against — skip quietly.
211
- #
212
- # We resolve `settings.registration` via importlib rather than a plain
213
- # `from settings.registration import ...`: the SM009 coupling check is
214
- # AST-based and forbids any static import of a plugin package name
215
- # from within framework/* code. Dynamic resolution keeps the framework
216
- # AST plugin-free while still hitting the real helper at runtime.
217
- if hasattr(app.state, "settings"):
218
- import importlib
219
-
220
- _register_module_settings = importlib.import_module(
221
- "settings.registration"
222
- ).register_module_settings
223
-
224
- _register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
207
+ register_host_settings(app)
225
208
 
226
209
  if settings.is_development:
227
210
  settings_diagnostics = check_settings_registration(app, modules)
@@ -236,17 +219,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
236
219
  _register_event_handlers(mod, event_bus, app)
237
220
  mod.register_health_checks(health_registry)
238
221
  mod.register_public_routes(public_route_registry)
222
+ mod.register_design_packs(design_pack_registry)
239
223
 
240
224
  attach_public_routes(app, settings, public_route_registry)
241
225
 
226
+ # Branding reads the packs off app.state directly: its API validates a
227
+ # submitted slug before persisting it, and its view builds the dropdown.
228
+ app.state.design_packs = design_pack_registry
229
+
242
230
  logger.info(
243
231
  "Registered %d menu items, %d permissions, %d feature flags, "
244
- "%d health checks, %d public routes",
232
+ "%d health checks, %d public routes, %d design packs",
245
233
  len(menu_registry.all_items),
246
234
  len(perm_registry.all_permissions),
247
235
  len(ff_registry.all_flags),
248
236
  len(health_registry.all_checks),
249
237
  len(public_route_registry.routes),
238
+ len(design_pack_registry.all()),
250
239
  )
251
240
 
252
241
  # ── Phase 6: Initialize database ───────────────────────
@@ -277,9 +266,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
277
266
 
278
267
  static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME
279
268
  if static_dir.is_dir():
280
- app.mount(
281
- _STATIC_MOUNT_PATH, ImmutableStaticFiles(directory=static_dir), name=_STATIC_DIR_NAME
282
- )
269
+ static = PrecompressedStaticFiles(directory=static_dir)
270
+ app.mount(_STATIC_MOUNT_PATH, static, name=_STATIC_DIR_NAME)
283
271
 
284
272
  mount_module_static_dirs(app, modules)
285
273
 
@@ -292,6 +280,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
292
280
  feature_flags=ff_registry,
293
281
  health_registry=health_registry,
294
282
  public_routes=public_route_registry,
283
+ design_packs=design_pack_registry,
295
284
  i18n_registry=i18n_registry,
296
285
  inertia_config=inertia_config,
297
286
  modules=tuple(modules),
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Literal
11
11
 
12
- from pydantic import model_validator
12
+ from pydantic import field_validator, model_validator
13
13
  from pydantic_settings import BaseSettings, SettingsConfigDict
14
14
  from simple_module_core.environments import NON_PROD_ENVIRONMENTS
15
15
 
@@ -32,6 +32,15 @@ class BootstrapSettings(BaseSettings):
32
32
  vite_dev_url: str = "http://localhost:5050"
33
33
  debug: bool = False
34
34
 
35
+ # Trust the X-Forwarded-* headers from a fronting reverse proxy. Unset by
36
+ # default (no proxy trusted). Set to ``*`` to trust any peer (correct when
37
+ # the container is only reachable through a single proxy), or a comma-
38
+ # separated list of proxy IPs / CIDRs. Drives uvicorn's
39
+ # ProxyHeadersMiddleware so request.url.scheme reflects X-Forwarded-Proto
40
+ # behind a TLS-terminating proxy — without it Inertia's pushState throws a
41
+ # cross-scheme SecurityError and login breaks (GH #223).
42
+ trusted_proxy: str | None = None
43
+
35
44
  log_level: str = "INFO"
36
45
  log_format: Literal["json", "text"] = "json"
37
46
 
@@ -46,6 +55,21 @@ class BootstrapSettings(BaseSettings):
46
55
  ``register_public_routes`` hook, which is method-aware.
47
56
  """
48
57
 
58
+ @field_validator("trusted_proxy", mode="after")
59
+ @classmethod
60
+ def _normalize_trusted_proxy(cls, value: str | None) -> str | None:
61
+ """Strip surrounding whitespace; treat blank as unset.
62
+
63
+ Guards against a stray space silently defeating the feature: uvicorn
64
+ decides ``*``-trust by comparing the *raw* string to ``"*"`` before it
65
+ strips, so ``"* "`` would be parsed as a literal host that matches no
66
+ client — re-introducing GH #223 with no error. Blank → ``None`` so the
67
+ middleware isn't installed at all.
68
+ """
69
+ if value is None:
70
+ return None
71
+ return value.strip() or None
72
+
49
73
  @property
50
74
  def is_development(self) -> bool:
51
75
  return self.environment == "development"
@@ -0,0 +1,102 @@
1
+ """Static-file serving: immutable caching and pre-compressed variants.
2
+
3
+ Split out of ``_phase_helpers`` — static delivery is its own responsibility,
4
+ and that module was near the repo's 300-line cap.
5
+
6
+ Two optimizations live here, both aimed at cold page load:
7
+
8
+ * **Immutable caching.** Vite emits content-hashed filenames, so the bytes for
9
+ a given URL never change and the browser can skip even the revalidation
10
+ round-trip.
11
+ * **Pre-compressed variants.** ``GZipMiddleware`` would otherwise re-compress
12
+ the same immutable bundle on every request, using a fast (and therefore
13
+ worse) compression level. Files compressed once at build time can afford the
14
+ maximum level, and brotli is ~14% smaller than gzip on this bundle.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from mimetypes import guess_type
20
+
21
+ from fastapi.staticfiles import StaticFiles
22
+ from starlette.datastructures import Headers
23
+ from starlette.exceptions import HTTPException
24
+ from starlette.responses import Response
25
+ from starlette.types import Scope
26
+
27
+ __all__ = ["IMMUTABLE_CACHE_CONTROL", "PrecompressedStaticFiles"]
28
+
29
+ IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"
30
+
31
+ _HASHED_ASSET_PREFIX = "dist/assets/"
32
+ _ACCEPT_ENCODING = "accept-encoding"
33
+ _CONTENT_ENCODING = "Content-Encoding"
34
+ _CONTENT_TYPE = "content-type"
35
+ _VARY = "Vary"
36
+ _ACCEPT_ENCODING_HEADER = "Accept-Encoding"
37
+ _OK = 200
38
+
39
+ # (encoding token, filename suffix), best first. Brotli wins where offered:
40
+ # measured 248.5 KB vs 287.6 KB gzip-9 across this bundle.
41
+ _VARIANTS: tuple[tuple[str, str], ...] = (("br", ".br"), ("gzip", ".gz"))
42
+
43
+
44
+ def _accepted_encodings(scope: Scope) -> set[str]:
45
+ """Encoding tokens the client advertises.
46
+
47
+ Deliberately ignores q-values. Any client sending ``br;q=0`` to actively
48
+ refuse brotli is vanishingly rare, and getting it wrong only costs a few
49
+ KB — whereas parsing it wrong could serve a body the client cannot read.
50
+ Tokens are matched exactly, so a ``q=0`` entry still reads as "accepted";
51
+ that is the conservative direction only because every real client that
52
+ lists an encoding can decode it.
53
+ """
54
+ raw = Headers(scope=scope).get(_ACCEPT_ENCODING, "")
55
+ return {part.split(";")[0].strip().lower() for part in raw.split(",") if part.strip()}
56
+
57
+
58
+ class PrecompressedStaticFiles(StaticFiles):
59
+ """Serve ``.br``/``.gz`` siblings when present, and mark hashed assets immutable.
60
+
61
+ A request for ``app.js`` is answered with ``app.js.br`` when the client
62
+ accepts brotli and that file exists, falling back to ``.gz`` and then to the
63
+ original. The response keeps the *original* file's Content-Type — serving
64
+ ``app.js.br`` directly would have Starlette type it from the ``.br``
65
+ extension, and a browser will refuse to execute a script typed as
66
+ ``application/octet-stream``.
67
+
68
+ ``Vary: Accept-Encoding`` is always set on a negotiated response so shared
69
+ caches cannot hand a compressed body to a client that never asked for one.
70
+ """
71
+
72
+ async def get_response(self, path: str, scope: Scope) -> Response:
73
+ response = await self._get_negotiated_response(path, scope)
74
+ # StaticFiles hands us an OS-separator path (backslashes on Windows), so
75
+ # normalize before matching the forward-slash asset prefix.
76
+ if response.status_code == _OK and path.replace("\\", "/").startswith(_HASHED_ASSET_PREFIX):
77
+ response.headers["Cache-Control"] = IMMUTABLE_CACHE_CONTROL
78
+ return response
79
+
80
+ async def _get_negotiated_response(self, path: str, scope: Scope) -> Response:
81
+ accepted = _accepted_encodings(scope)
82
+ for encoding, suffix in _VARIANTS:
83
+ if encoding not in accepted:
84
+ continue
85
+ # StaticFiles signals "no such file" by *raising* HTTPException(404),
86
+ # not by returning a 404 response — so a missing variant has to be
87
+ # caught here, not detected from a status code.
88
+ try:
89
+ variant = await super().get_response(path + suffix, scope)
90
+ except HTTPException:
91
+ continue
92
+ if variant.status_code != _OK:
93
+ continue
94
+ # Type by the original filename, not the variant's extension: a
95
+ # browser refuses to execute a script typed application/octet-stream.
96
+ content_type = guess_type(path)[0]
97
+ if content_type:
98
+ variant.headers[_CONTENT_TYPE] = content_type
99
+ variant.headers[_CONTENT_ENCODING] = encoding
100
+ variant.headers[_VARY] = _ACCEPT_ENCODING_HEADER
101
+ return variant
102
+ return await super().get_response(path, scope)
@@ -0,0 +1,52 @@
1
+ """``create_app`` must collect module design packs onto ``app.state.design_packs``.
2
+
3
+ Branding reads this registry twice — the view builds its dropdown from it, and
4
+ the API validates a submitted slug against it. If the hook were never called,
5
+ or the populated registry never reached ``app.state``, branding would offer an
6
+ empty dropdown and reject every pack, with nothing in the logs to explain why.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import simple_module_hosting.app_builder as app_builder
12
+ from simple_module_core.design_packs import DesignPack, DesignPackRegistry
13
+ from simple_module_core.module import ModuleBase, ModuleMeta
14
+ from simple_module_hosting.app_builder import create_app
15
+
16
+ _PACK = DesignPack(value="pack-fixture", label="Pack Fixture")
17
+
18
+
19
+ class _PackModule(ModuleBase):
20
+ """A module whose only contribution is one design pack."""
21
+
22
+ meta = ModuleMeta(name="PackFixture")
23
+
24
+ def register_design_packs(self, registry: DesignPackRegistry) -> None:
25
+ registry.register(_PACK)
26
+
27
+
28
+ def test_registry_is_published_on_app_state(settings):
29
+ app = create_app(settings)
30
+ assert isinstance(app.state.design_packs, DesignPackRegistry)
31
+
32
+
33
+ def test_module_registered_pack_reaches_app_state(monkeypatch, settings):
34
+ real_discover = app_builder.discover_modules
35
+
36
+ def _with_pack_module(*args, **kwargs):
37
+ return [*real_discover(*args, **kwargs), _PackModule()]
38
+
39
+ monkeypatch.setattr(app_builder, "discover_modules", _with_pack_module)
40
+
41
+ app = create_app(settings)
42
+
43
+ assert app.state.design_packs.has("pack-fixture")
44
+ assert _PACK in app.state.design_packs.all()
45
+
46
+
47
+ def test_registry_is_also_reachable_through_services(settings):
48
+ # ``app.state.sm`` is the aggregate every other registry is published on;
49
+ # design packs should not be the one exception a module author has to
50
+ # remember a different lookup for.
51
+ app = create_app(settings)
52
+ assert app.state.sm.design_packs is app.state.design_packs
@@ -0,0 +1,67 @@
1
+ """The Inertia error page must carry the same shared props as any other page.
2
+
3
+ ``render_error_page`` builds its own ``Inertia`` instance instead of going
4
+ through ``get_inertia``, so it skipped the ``inertia.share(**shared)`` step.
5
+ The consequence was visible: a 404 rendered its copy as raw translation keys
6
+ — ``host.error.not_found_title``, ``host.error.go_home`` — because the page
7
+ received no ``i18n`` block at all. It also lost ``auth`` and ``menus``, so the
8
+ error page could not render the layout a signed-in user expects.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import html
14
+ import json
15
+ import re
16
+
17
+ import httpx
18
+ import pytest
19
+
20
+ _NOT_FOUND = 404
21
+ _MISSING_PATH = "/definitely/not/a/real/route"
22
+
23
+
24
+ def _inertia_page(body: str) -> dict:
25
+ """Pull the JSON blob Inertia embeds in the server-rendered document."""
26
+ match = re.search(r'data-page="([^"]+)"', body) or re.search(r"data-page='([^']+)'", body)
27
+ if match is None:
28
+ pytest.fail("no data-page attribute found — the error page did not render via Inertia")
29
+ return json.loads(html.unescape(match.group(1)))
30
+
31
+
32
+ class TestErrorPageSharedProps:
33
+ async def test_error_page_renders_via_inertia(
34
+ self, authenticated_client: httpx.AsyncClient
35
+ ) -> None:
36
+ resp = await authenticated_client.get(_MISSING_PATH)
37
+ assert resp.status_code == _NOT_FOUND
38
+ assert _inertia_page(resp.text)["component"] == "Error"
39
+
40
+ async def test_error_page_carries_i18n_messages(
41
+ self, authenticated_client: httpx.AsyncClient
42
+ ) -> None:
43
+ """Without this the page prints raw keys like host.error.not_found_title."""
44
+ resp = await authenticated_client.get(_MISSING_PATH)
45
+ props = _inertia_page(resp.text)["props"]
46
+ assert "i18n" in props, f"error page has no i18n block; props={sorted(props)}"
47
+ assert props["i18n"].get("messages"), "i18n block present but messages are empty"
48
+
49
+ async def test_error_page_carries_auth_and_menus(
50
+ self, authenticated_client: httpx.AsyncClient
51
+ ) -> None:
52
+ """The layout needs these to render for a signed-in user."""
53
+ props = _inertia_page((await authenticated_client.get(_MISSING_PATH)).text)["props"]
54
+ assert "auth" in props, f"error page has no auth block; props={sorted(props)}"
55
+ assert "menus" in props, f"error page has no menus block; props={sorted(props)}"
56
+
57
+ async def test_error_page_keeps_its_own_props(
58
+ self, authenticated_client: httpx.AsyncClient
59
+ ) -> None:
60
+ """Sharing must not clobber status/message."""
61
+ props = _inertia_page((await authenticated_client.get(_MISSING_PATH)).text)["props"]
62
+ assert props["status"] == _NOT_FOUND
63
+
64
+ async def test_anonymous_error_page_still_renders(self, client: httpx.AsyncClient) -> None:
65
+ """An unauthenticated 404 must not blow up on missing shared state."""
66
+ resp = await client.get("/health/definitely-not-real")
67
+ assert resp.status_code >= _NOT_FOUND
@@ -0,0 +1,65 @@
1
+ """Every sidebar link must hit its route directly, with no redirect.
2
+
3
+ A menu item pointing at ``/audit_log`` when the route is registered at
4
+ ``/audit_log/`` still works — Starlette 307s to the canonical path and the
5
+ client follows. But it costs a full extra round trip on *every* navigation to
6
+ that page. On localhost that's ~8 ms and invisible; on a 40 ms-latency link it
7
+ roughly doubles the navigation, and it is worse on mobile.
8
+
9
+ Five of the eight sidebar links had this mismatch when this test was written.
10
+ It is a whole class of bug that no functional test catches, because everything
11
+ still renders correctly — just slower.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import httpx
17
+ import pytest
18
+ from simple_module_hosting.middleware import InertiaLayoutDataMiddleware
19
+
20
+ _REDIRECT_CODES = {301, 302, 307, 308}
21
+ _NOT_FOUND = 404
22
+
23
+
24
+ def _menu_urls(app) -> list[str]:
25
+ """Every internal menu URL registered by the installed modules."""
26
+ for mw in app.user_middleware:
27
+ if mw.cls is InertiaLayoutDataMiddleware:
28
+ registry = mw.kwargs["menu_registry"]
29
+ break
30
+ else: # pragma: no cover - the middleware is always installed
31
+ pytest.fail("InertiaLayoutDataMiddleware not installed")
32
+
33
+ return sorted(
34
+ {
35
+ item.url
36
+ for item in registry.all_items
37
+ if item.url.startswith("/") and item.method == "get"
38
+ }
39
+ )
40
+
41
+
42
+ async def test_menu_urls_do_not_redirect(app, authenticated_client: httpx.AsyncClient) -> None:
43
+ """No sidebar link may cost a redirect hop."""
44
+ offenders: list[str] = []
45
+ for url in _menu_urls(app):
46
+ resp = await authenticated_client.get(url, headers={"X-Inertia": "true"})
47
+ if resp.status_code in _REDIRECT_CODES:
48
+ offenders.append(f"{url} -> {resp.status_code} {resp.headers.get('location', '')}")
49
+
50
+ assert not offenders, (
51
+ "These menu URLs redirect, costing an extra round trip on every "
52
+ "navigation. Point the menu item at the canonical path (usually adding "
53
+ "a trailing slash), or register the route without one:\n " + "\n ".join(offenders)
54
+ )
55
+
56
+
57
+ async def test_menu_urls_resolve(app, authenticated_client: httpx.AsyncClient) -> None:
58
+ """A menu item pointing at a route that doesn't exist is a dead link."""
59
+ dead: list[str] = []
60
+ for url in _menu_urls(app):
61
+ resp = await authenticated_client.get(url, headers={"X-Inertia": "true"})
62
+ if resp.status_code == _NOT_FOUND:
63
+ dead.append(url)
64
+
65
+ assert not dead, f"menu URLs resolve to 404: {dead}"
@@ -2,12 +2,15 @@
2
2
 
3
3
  CLAUDE.md spells out the pipeline:
4
4
 
5
- CorrelationId → RequestLogging → Security → Session → <module>
5
+ CorrelationId → RequestLogging → GZip → Security → Session → <module>
6
6
  → Tenant (opt-in) → Locale → InertiaLayoutData → app
7
7
 
8
8
  Tenant/Locale must see ``request.state.user`` set by AuthMiddleware so
9
9
  DB queries get filtered correctly; CorrelationId must wrap everything so
10
- every log line carries its id. Order matters and a swap is the kind of
10
+ every log line carries its id. GZip sits inside the observability pair so
11
+ those still see every request, but outside everything that produces a body
12
+ — including the /static mount, which is where compression pays off most.
13
+ Order matters and a swap is the kind of
11
14
  regression that breaks production without breaking any happy-path test.
12
15
  ``app.user_middleware`` lists middlewares in execution order (Starlette
13
16
  LIFOs ``add_middleware`` calls, FastAPI surfaces them already reversed).
@@ -22,6 +25,7 @@ from simple_module_hosting.settings import Settings
22
25
  _EXPECTED_MULTI_TENANT = (
23
26
  "CorrelationIdMiddleware",
24
27
  "RequestLoggingMiddleware",
28
+ "GZipMiddleware",
25
29
  "SecurityHeadersMiddleware",
26
30
  "SessionMiddleware",
27
31
  "AuthMiddleware",
@@ -33,6 +37,7 @@ _EXPECTED_MULTI_TENANT = (
33
37
  _EXPECTED_SINGLE_TENANT = (
34
38
  "CorrelationIdMiddleware",
35
39
  "RequestLoggingMiddleware",
40
+ "GZipMiddleware",
36
41
  "SecurityHeadersMiddleware",
37
42
  "SessionMiddleware",
38
43
  "AuthMiddleware",
@@ -0,0 +1,142 @@
1
+ """Serving pre-compressed asset variants.
2
+
3
+ Compressing the same immutable, content-hashed bundle on every request is
4
+ wasted CPU, and on-the-fly compression has to use a fast (worse) level. Files
5
+ compressed once at build time can use the maximum level, and brotli is ~14%
6
+ smaller than gzip on this bundle.
7
+
8
+ The subtle part is the Content-Type: naively serving ``main.js.br`` makes
9
+ Starlette guess the type from the ``.br`` extension and the browser refuses to
10
+ execute it. These tests pin that behaviour.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import gzip
16
+ from pathlib import Path
17
+
18
+ import brotli
19
+ import httpx
20
+ import pytest
21
+ from fastapi import FastAPI
22
+ from simple_module_hosting.static_files import PrecompressedStaticFiles
23
+
24
+ _JS = b"console.log('hello');" * 200
25
+ _OK = 200
26
+ _JS_TYPE = "text/javascript"
27
+
28
+
29
+ @pytest.fixture
30
+ def asset_dir(tmp_path: Path) -> Path:
31
+ (tmp_path / "app.js").write_bytes(_JS)
32
+ (tmp_path / "app.js.gz").write_bytes(gzip.compress(_JS, 9))
33
+ # A file with no pre-compressed sibling, to prove the fallback path.
34
+ (tmp_path / "plain.js").write_bytes(_JS)
35
+ return tmp_path
36
+
37
+
38
+ @pytest.fixture
39
+ def client(asset_dir: Path) -> httpx.AsyncClient:
40
+ app = FastAPI()
41
+ app.mount("/static", PrecompressedStaticFiles(directory=asset_dir), name="static")
42
+ return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")
43
+
44
+
45
+ class TestPrecompressedServing:
46
+ async def test_serves_gzip_sibling_when_accepted(self, client: httpx.AsyncClient) -> None:
47
+ resp = await client.get(
48
+ "/static/app.js", headers={"Accept-Encoding": "gzip"}, extensions={"trust_env": False}
49
+ )
50
+ assert resp.status_code == _OK
51
+ assert resp.headers["content-encoding"] == "gzip"
52
+
53
+ async def test_content_type_is_the_original_not_the_variant(
54
+ self, client: httpx.AsyncClient
55
+ ) -> None:
56
+ """The classic bug: .gz would otherwise be typed as octet-stream and
57
+ the browser would refuse to execute the script."""
58
+ resp = await client.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
59
+ assert _JS_TYPE in resp.headers["content-type"]
60
+
61
+ async def test_body_decodes_to_the_original_bytes(self, client: httpx.AsyncClient) -> None:
62
+ resp = await client.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
63
+ assert resp.content == _JS
64
+
65
+ async def test_vary_header_is_set(self, client: httpx.AsyncClient) -> None:
66
+ """Without Vary, a shared cache could serve a compressed body to a
67
+ client that never asked for one."""
68
+ resp = await client.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
69
+ assert "accept-encoding" in resp.headers.get("vary", "").lower()
70
+
71
+ async def test_identity_client_gets_uncompressed(self, client: httpx.AsyncClient) -> None:
72
+ resp = await client.get("/static/app.js", headers={"Accept-Encoding": "identity"})
73
+ assert resp.status_code == _OK
74
+ assert "content-encoding" not in resp.headers
75
+ assert resp.content == _JS
76
+
77
+ async def test_missing_sibling_falls_back_to_original(self, client: httpx.AsyncClient) -> None:
78
+ resp = await client.get("/static/plain.js", headers={"Accept-Encoding": "gzip, br"})
79
+ assert resp.status_code == _OK
80
+ assert "content-encoding" not in resp.headers
81
+ assert resp.content == _JS
82
+
83
+ async def test_unknown_file_still_404s(self, client: httpx.AsyncClient) -> None:
84
+ resp = await client.get("/static/nope.js", headers={"Accept-Encoding": "gzip"})
85
+ assert resp.status_code == 404
86
+
87
+
88
+ class TestBrotliPreference:
89
+ async def test_brotli_preferred_over_gzip(self, tmp_path: Path) -> None:
90
+ """Brotli is ~14% smaller on this bundle, so it wins when both exist."""
91
+ (tmp_path / "app.js").write_bytes(_JS)
92
+ (tmp_path / "app.js.gz").write_bytes(gzip.compress(_JS, 9))
93
+ (tmp_path / "app.js.br").write_bytes(brotli.compress(_JS))
94
+
95
+ app = FastAPI()
96
+ app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
97
+ async with httpx.AsyncClient(
98
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
99
+ ) as c:
100
+ resp = await c.get("/static/app.js", headers={"Accept-Encoding": "gzip, deflate, br"})
101
+ assert resp.headers["content-encoding"] == "br"
102
+
103
+ async def test_gzip_used_when_brotli_not_accepted(self, tmp_path: Path) -> None:
104
+ (tmp_path / "app.js").write_bytes(_JS)
105
+ (tmp_path / "app.js.gz").write_bytes(gzip.compress(_JS, 9))
106
+ (tmp_path / "app.js.br").write_bytes(brotli.compress(_JS))
107
+
108
+ app = FastAPI()
109
+ app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
110
+ async with httpx.AsyncClient(
111
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
112
+ ) as c:
113
+ resp = await c.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
114
+ assert resp.headers["content-encoding"] == "gzip"
115
+
116
+
117
+ class TestImmutableCachingPreserved:
118
+ """The existing immutable-caching behaviour must survive the refactor."""
119
+
120
+ async def test_hashed_assets_keep_immutable_cache_control(self, tmp_path: Path) -> None:
121
+ assets = tmp_path / "dist" / "assets"
122
+ assets.mkdir(parents=True)
123
+ (assets / "main-abc123.js").write_bytes(_JS)
124
+
125
+ app = FastAPI()
126
+ app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
127
+ async with httpx.AsyncClient(
128
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
129
+ ) as c:
130
+ resp = await c.get("/static/dist/assets/main-abc123.js")
131
+ assert "immutable" in resp.headers.get("cache-control", "")
132
+
133
+ async def test_non_hashed_paths_do_not_get_immutable(self, tmp_path: Path) -> None:
134
+ (tmp_path / "favicon.ico").write_bytes(b"\x00" * 100)
135
+
136
+ app = FastAPI()
137
+ app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
138
+ async with httpx.AsyncClient(
139
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
140
+ ) as c:
141
+ resp = await c.get("/static/favicon.ico")
142
+ assert "immutable" not in resp.headers.get("cache-control", "")
@@ -0,0 +1,119 @@
1
+ """Proxy-header handling (X-Forwarded-Proto / X-Forwarded-For).
2
+
3
+ Behind a TLS-terminating reverse proxy the app sees plain ``http`` on the
4
+ container socket, so ``request.url.scheme`` is wrong. inertia-python copies
5
+ that scheme into the page object's absolute ``url``; the browser then refuses
6
+ the cross-scheme ``history.pushState`` and login breaks (GH #223).
7
+
8
+ When ``SM_TRUSTED_PROXY`` is set, the host registers uvicorn's
9
+ ``ProxyHeadersMiddleware`` as the outermost middleware so the corrected scheme
10
+ and client IP are visible to every downstream middleware (logging included).
11
+ Left unset (the default) nothing changes — the header is ignored.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import httpx
17
+ import pytest
18
+ from simple_module_hosting.app_builder import create_app
19
+ from simple_module_hosting.settings import Settings
20
+ from simple_module_test.fixtures import _create_all_tables
21
+
22
+ _LOGIN_PATH = "/users/login"
23
+
24
+
25
+ def _names(app) -> tuple[str, ...]:
26
+ return tuple(m.cls.__name__ for m in app.user_middleware)
27
+
28
+
29
+ def _settings(**overrides) -> Settings:
30
+ base = {
31
+ "database_url": "sqlite+aiosqlite:///:memory:",
32
+ "environment": "testing",
33
+ "secret_key": "x" * 32,
34
+ "multi_tenant": False,
35
+ }
36
+ base.update(overrides)
37
+ return Settings(**base)
38
+
39
+
40
+ class TestMiddlewareWiring:
41
+ def test_absent_by_default(self) -> None:
42
+ """No SM_TRUSTED_PROXY → ProxyHeadersMiddleware must not be installed."""
43
+ app = create_app(_settings())
44
+ assert "ProxyHeadersMiddleware" not in _names(app)
45
+
46
+ def test_present_and_outermost_when_configured(self) -> None:
47
+ """ProxyHeaders must run before every other middleware.
48
+
49
+ ``app.user_middleware`` is in execution order (FastAPI reverses the
50
+ LIFO add_middleware stack), so the proxy middleware must be first —
51
+ otherwise RequestLogging would log the proxy's address, and Inertia
52
+ would already have read the wrong scheme.
53
+ """
54
+ app = create_app(_settings(trusted_proxy="*"))
55
+ names = _names(app)
56
+ assert names[0] == "ProxyHeadersMiddleware"
57
+
58
+ def test_trusted_hosts_passed_through(self) -> None:
59
+ """The configured value reaches uvicorn's middleware as trusted_hosts."""
60
+ app = create_app(_settings(trusted_proxy="10.0.0.0/8,127.0.0.1"))
61
+ proxy = next(m for m in app.user_middleware if m.cls.__name__ == "ProxyHeadersMiddleware")
62
+ assert proxy.kwargs["trusted_hosts"] == "10.0.0.0/8,127.0.0.1"
63
+
64
+ def test_surrounding_whitespace_stripped(self) -> None:
65
+ """A stray space must not silently defeat ``*`` trust.
66
+
67
+ uvicorn computes always-trust as ``raw in ("*", ["*"])`` *before*
68
+ stripping, so ``"* "`` would be parsed as a literal host that never
69
+ matches any client — silently re-introducing GH #223 with no error.
70
+ The setting must normalize the value first.
71
+ """
72
+ app = create_app(_settings(trusted_proxy="* "))
73
+ proxy = next(m for m in app.user_middleware if m.cls.__name__ == "ProxyHeadersMiddleware")
74
+ assert proxy.kwargs["trusted_hosts"] == "*"
75
+
76
+ def test_blank_value_disables(self) -> None:
77
+ """Whitespace-only SM_TRUSTED_PROXY is treated as unset (no middleware)."""
78
+ app = create_app(_settings(trusted_proxy=" "))
79
+ assert "ProxyHeadersMiddleware" not in _names(app)
80
+
81
+
82
+ async def _client_for(settings: Settings):
83
+ """Build an app + lifespan-started client mirroring the shared fixtures."""
84
+ app = create_app(settings)
85
+ await _create_all_tables(app.state.sm.db.engine)
86
+ ctx = app.router.lifespan_context(app)
87
+ await ctx.__aenter__()
88
+ transport = httpx.ASGITransport(app=app)
89
+ client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
90
+ return client, ctx
91
+
92
+
93
+ class TestSchemeCorrection:
94
+ @pytest.mark.parametrize(
95
+ ("trusted_proxy", "expected_scheme"),
96
+ [
97
+ ("*", "https"), # trusted proxy → X-Forwarded-Proto honored
98
+ (None, "http"), # no trust configured → header ignored
99
+ ],
100
+ )
101
+ async def test_inertia_page_url_scheme(
102
+ self, trusted_proxy: str | None, expected_scheme: str
103
+ ) -> None:
104
+ """The Inertia page object's absolute url reflects X-Forwarded-Proto.
105
+
106
+ This is the exact thing the browser chokes on: inertia-python sets
107
+ ``"url": str(request.url)``, scheme included, and embeds it in the
108
+ ``data-page`` blob of the rendered HTML.
109
+ """
110
+ client, ctx = await _client_for(_settings(trusted_proxy=trusted_proxy))
111
+ try:
112
+ resp = await client.get(_LOGIN_PATH, headers={"X-Forwarded-Proto": "https"})
113
+ assert resp.status_code == 200
114
+ assert f"{expected_scheme}://testserver{_LOGIN_PATH}" in resp.text
115
+ wrong = "https" if expected_scheme == "http" else "http"
116
+ assert f"{wrong}://testserver{_LOGIN_PATH}" not in resp.text
117
+ finally:
118
+ await client.aclose()
119
+ await ctx.__aexit__(None, None, None)
@@ -0,0 +1,88 @@
1
+ """Responses must be compressed when the client advertises support.
2
+
3
+ The built frontend ships ~139 KB of CSS and ~490 KB of JS. Uncompressed that
4
+ is the dominant cost of a cold page load — far larger than anything on the
5
+ server request path. Compression cuts the CSS alone by ~85%.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ _OK = 200
14
+ _GZIP = "gzip"
15
+ # Below Starlette's default minimum_size, compression is skipped because the
16
+ # gzip framing would cost more than it saves.
17
+ _TINY_BODY_BYTES = 100
18
+
19
+
20
+ class TestResponseCompression:
21
+ async def test_large_response_is_gzipped_when_accepted(
22
+ self, authenticated_client: httpx.AsyncClient
23
+ ) -> None:
24
+ resp = await authenticated_client.get(
25
+ "/api/permissions/", headers={"Accept-Encoding": "gzip"}
26
+ )
27
+ assert resp.status_code == _OK
28
+ # httpx transparently decodes, so assert on the header the server set.
29
+ assert resp.headers.get("content-encoding") == _GZIP
30
+
31
+ async def test_response_is_not_gzipped_when_not_accepted(
32
+ self, authenticated_client: httpx.AsyncClient
33
+ ) -> None:
34
+ """A client that doesn't advertise gzip must still get a usable body."""
35
+ resp = await authenticated_client.get(
36
+ "/api/permissions/", headers={"Accept-Encoding": "identity"}
37
+ )
38
+ assert resp.status_code == _OK
39
+ assert resp.headers.get("content-encoding") != _GZIP
40
+ assert resp.json() is not None
41
+
42
+ async def test_compression_does_not_corrupt_the_payload(
43
+ self, authenticated_client: httpx.AsyncClient
44
+ ) -> None:
45
+ """The decoded body must match what the uncompressed route returns."""
46
+ compressed = await authenticated_client.get(
47
+ "/api/permissions/", headers={"Accept-Encoding": "gzip"}
48
+ )
49
+ plain = await authenticated_client.get(
50
+ "/api/permissions/", headers={"Accept-Encoding": "identity"}
51
+ )
52
+ assert compressed.json() == plain.json()
53
+
54
+ async def test_health_endpoint_still_works(self, client: httpx.AsyncClient) -> None:
55
+ """Small responses fall under the size threshold and pass through."""
56
+ resp = await client.get("/health", headers={"Accept-Encoding": "gzip"})
57
+ assert resp.status_code == _OK
58
+ assert resp.json() is not None
59
+
60
+
61
+ class TestCompressionMiddlewareOrder:
62
+ def test_gzip_runs_inside_correlation_id(self, app) -> None:
63
+ """GZip must sit inside CorrelationId/RequestLogging.
64
+
65
+ Those two set response headers and read request state; compression is
66
+ a transport concern that should wrap only the response body beneath
67
+ them. Starlette's stack is LIFO, so a middleware added *later* is
68
+ outermost — GZip must therefore appear earlier in user_middleware
69
+ than CorrelationId.
70
+ """
71
+ from simple_module_hosting._observability import CorrelationIdMiddleware
72
+ from starlette.middleware.gzip import GZipMiddleware
73
+
74
+ classes = [m.cls for m in app.user_middleware]
75
+ assert GZipMiddleware in classes, "GZipMiddleware is not installed"
76
+ assert classes.index(GZipMiddleware) > classes.index(CorrelationIdMiddleware)
77
+
78
+
79
+ @pytest.mark.parametrize("encoding", ["gzip", "gzip, deflate", "gzip, deflate, br"])
80
+ async def test_common_browser_accept_encodings(
81
+ authenticated_client: httpx.AsyncClient, encoding: str
82
+ ) -> None:
83
+ """Real browsers send several forms; all must yield a valid response."""
84
+ resp = await authenticated_client.get(
85
+ "/api/permissions/", headers={"Accept-Encoding": encoding}
86
+ )
87
+ assert resp.status_code == _OK
88
+ assert resp.json() is not None