create-caspian-app 1.6.4 → 1.8.1

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.
package/dist/main.py CHANGED
@@ -41,7 +41,15 @@ from dotenv import load_dotenv
41
41
  import uvicorn
42
42
  from casp.state_manager import StateManager
43
43
  from casp.cache_handler import CacheHandler
44
- from casp.caspian_config import get_files_index, get_config
44
+ from casp.caspian_config import RouteEntry, get_files_index, get_config, route_priority
45
+ from casp.api_routes import (
46
+ csrf_required,
47
+ discover_handlers,
48
+ select_handler,
49
+ to_response,
50
+ verify_csrf,
51
+ )
52
+ from casp.errors import HttpError
45
53
  from casp.auth import (
46
54
  Auth,
47
55
  GoogleProvider,
@@ -49,6 +57,43 @@ from casp.auth import (
49
57
  configure_auth,
50
58
  )
51
59
  from casp.rpc import register_rpc_routes, rpc_limiter
60
+ from casp.errors import (
61
+ error_headers,
62
+ error_info,
63
+ error_response_headers,
64
+ fallback_html_response,
65
+ find_error_boundaries,
66
+ prefers_html,
67
+ problem_response,
68
+ rpc_error_response,
69
+ )
70
+ from casp.observability import (
71
+ PROBE_PATHS,
72
+ ObservabilitySettings,
73
+ RequestIdMiddleware,
74
+ RequestLogMiddleware,
75
+ add_readiness_check,
76
+ check_readiness,
77
+ record,
78
+ request_id_for,
79
+ )
80
+ from casp.observability import configure as configure_observability
81
+ from casp.maintenance import MaintenanceMiddleware, MaintenanceState
82
+ from casp.request_pipeline import RequestPipelineMiddleware, load_pipeline
83
+ from casp.jobs import jobs_lifespan, registered_jobs
84
+ from casp.schedule import schedule as app_schedule
85
+ from casp.schedule import scheduler_lifespan
86
+ from casp.i18n import LocaleMiddleware
87
+ from casp.i18n import current_locale as current_app_locale
88
+ from casp.i18n import enabled as i18n_enabled
89
+ from casp.seo import (
90
+ ROBOTS_PATH,
91
+ SITEMAP_PATH,
92
+ call_provider,
93
+ configured_base_url,
94
+ render_robots,
95
+ render_sitemap,
96
+ )
52
97
  from casp.layout import (
53
98
  render_with_nested_layouts,
54
99
  _finalize_page_region,
@@ -136,6 +181,48 @@ if cfg.prisma:
136
181
  # calendar is affected -- session expiry and cache TTLs stay on UTC by design.
137
182
  APP_TIMEZONE = get_app_timezone()
138
183
 
184
+ APP_ROOT = Path(__file__).resolve().parent
185
+ PUBLIC_FRAMEWORK_PATHS: tuple[str, ...] = (*PROBE_PATHS, SITEMAP_PATH, ROBOTS_PATH)
186
+
187
+ # Structured request/event logs and /ready policy. Production defaults: JSON
188
+ # request records on stderr. See casp.observability.ObservabilitySettings.
189
+ configure_observability(
190
+ ObservabilitySettings.from_env(is_production=IS_PRODUCTION, service_name=cfg.projectName)
191
+ )
192
+
193
+ if prisma is not None:
194
+ # The generated client connects lazily, so this also proves a connection
195
+ # can be opened. Cheap and side-effect free, as readiness checks must be.
196
+ async def _database_ready() -> None:
197
+ await prisma.query_raw("SELECT 1")
198
+
199
+ # `replace=True`: the dev server runs this file as __main__ and uvicorn then
200
+ # imports it again as `main`, so this module body executes twice.
201
+ add_readiness_check("database", _database_ready, replace=True)
202
+
203
+ # App-owned readiness checks (`@readiness_check("name")`) register on import.
204
+ if (APP_ROOT / "src" / "lib" / "readiness.py").is_file():
205
+ importlib.import_module("src.lib.readiness")
206
+
207
+ # Authorization policies are app-owned and register themselves on
208
+ # `casp.authorization.gate` when imported. The file is optional.
209
+ if (APP_ROOT / "src" / "lib" / "auth" / "policies.py").is_file():
210
+ importlib.import_module("src.lib.auth.policies")
211
+
212
+ # Background jobs (`@job`) and the task schedule register themselves on import.
213
+ # Both files are optional; the schedule usually imports the jobs it dispatches.
214
+ for _optional_module in ("jobs", "schedule"):
215
+ if (APP_ROOT / "src" / "lib" / f"{_optional_module}.py").is_file():
216
+ importlib.import_module(f"src.lib.{_optional_module}")
217
+
218
+
219
+ def load_app_middleware() -> list[Any]:
220
+ """Entries from the optional `src/middleware.py` (the application pipeline)."""
221
+ if not (APP_ROOT / "src" / "middleware.py").is_file():
222
+ return []
223
+ return load_pipeline(importlib.import_module("src.middleware"))
224
+
225
+
139
226
  # ====
140
227
  # CORS configuration (shared .env convention, mirrors casp.rpc origin checks)
141
228
  # ====
@@ -368,6 +455,14 @@ def get_app_lifespans() -> list[LifespanFactory]:
368
455
  if prisma is not None:
369
456
  lifespans.append(prisma_lifespan)
370
457
 
458
+ # Job workers start after the database lifespan and drain before it exits;
459
+ # the scheduler starts after the workers and stops before they drain, so a
460
+ # timer can never enqueue into a queue that is already shutting down.
461
+ if registered_jobs():
462
+ lifespans.append(jobs_lifespan)
463
+ if app_schedule.tasks:
464
+ lifespans.append(scheduler_lifespan)
465
+
371
466
  # MCP lifecycle
372
467
  # FastMCP needs its lifespan running so the MCP session manager starts.
373
468
  if mcp_app is not None:
@@ -409,7 +504,21 @@ app = FastAPI(
409
504
 
410
505
  @app.get("/health")
411
506
  async def healthcheck():
412
- return {"status": "ok"}
507
+ # Liveness: the process can serve HTTP. Deliberately checks no dependency --
508
+ # restarting a healthy process does not repair an unavailable database.
509
+ return JSONResponse({"status": "ok"}, headers={"Cache-Control": "no-store"})
510
+
511
+
512
+ @app.get("/ready")
513
+ async def readinesscheck():
514
+ # Readiness: every registered dependency check passes. Names and statuses
515
+ # only; failure details go to the log stream.
516
+ ready, checks = await check_readiness()
517
+ return JSONResponse(
518
+ {"status": "ok" if ready else "unavailable", "checks": checks},
519
+ status_code=200 if ready else 503,
520
+ headers={"Cache-Control": "no-store"},
521
+ )
413
522
 
414
523
 
415
524
  # ====
@@ -662,7 +771,9 @@ class AuthMiddleware:
662
771
  self.app = app
663
772
 
664
773
  async def __call__(self, scope: Scope, receive: Receive, send: Send):
665
- if scope["type"] != "http":
774
+ if scope["type"] != "http" or scope.get("path") in PUBLIC_FRAMEWORK_PATHS:
775
+ # Probes (for orchestrators) and sitemap/robots (for crawlers) are
776
+ # answered without a session, whatever the route-privacy policy says.
666
777
  await self.app(scope, receive, send)
667
778
  return
668
779
  request = Request(scope, receive, send)
@@ -783,7 +894,7 @@ class RateLimitMiddleware:
783
894
  return
784
895
 
785
896
  path = scope.get("path", "")
786
- if path == "/health":
897
+ if path in PROBE_PATHS:
787
898
  await self.app(scope, receive, send)
788
899
  return
789
900
 
@@ -1027,22 +1138,115 @@ def is_request_cacheable(request: Request) -> bool:
1027
1138
  return False
1028
1139
 
1029
1140
 
1030
- def register_routes():
1031
- idx = get_files_index()
1032
- for route in idx.routes:
1033
- base_path = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
1034
- full_path = f"{base_path}/index.py".replace("//", "/")
1035
- register_single_route(route.fastapi_rule, full_path)
1141
+ def _route_file_path(fs_dir: str, filename: str) -> str:
1142
+ base_path = f"src/app/{fs_dir}" if fs_dir else "src/app"
1143
+ return f"{base_path}/{filename}".replace("//", "/")
1036
1144
 
1037
1145
 
1038
- def register_single_route(url_pattern: str, file_path: str):
1039
- async def make_handler(request: Request):
1146
+ def register_route_entry(route: RouteEntry, *, api: bool = False) -> None:
1147
+ """Register a page (`index.py`) or API endpoint (`route.py`).
1148
+
1149
+ An optional catch-all (`[[...slug]]`) registers its base URL with the
1150
+ parameter set to None, then the path form.
1151
+ """
1152
+ for rule, default_params in route.fastapi_rules():
1153
+ if api:
1154
+ register_api_route(
1155
+ rule, _route_file_path(route.fs_dir, "route.py"), default_params=default_params
1156
+ )
1157
+ else:
1158
+ register_single_route(
1159
+ rule, _route_file_path(route.fs_dir, "index.py"), default_params=default_params
1160
+ )
1161
+
1162
+
1163
+ def register_routes():
1164
+ idx = get_files_index()
1165
+ # Pages and API routes share one URL space, so they are ordered together:
1166
+ # a catch-all page must never shadow a more specific API route.
1167
+ entries = [(route, False) for route in idx.routes]
1168
+ entries += [(route, True) for route in idx.api_routes]
1169
+ for route, api in sorted(entries, key=lambda entry: route_priority(entry[0])):
1170
+ register_route_entry(route, api=api)
1171
+
1172
+
1173
+ def _build_route_call(
1174
+ func: Callable[..., Any], request: Request, params: dict[str, Any]
1175
+ ) -> tuple[list[Any], dict[str, Any]]:
1176
+ """Arguments for a `route.py` handler, using the same contract as `page()`:
1177
+ path params as one positional dict, `request` by keyword, query params by name.
1178
+ """
1179
+ signature = inspect.signature(func)
1180
+ call_args: list[Any] = [params] if params else []
1181
+ call_kwargs: dict[str, Any] = {}
1182
+ if "request" in signature.parameters:
1183
+ call_kwargs["request"] = request
1184
+ for name, param in signature.parameters.items():
1185
+ if name in call_kwargs or name == "kwargs":
1186
+ continue
1187
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
1188
+ continue
1189
+ if name in request.query_params:
1190
+ call_kwargs[name] = _coerce_query_param(request, name, param)
1191
+ return call_args, call_kwargs
1192
+
1193
+
1194
+ def register_api_route(
1195
+ url_pattern: str,
1196
+ file_path: str,
1197
+ *,
1198
+ default_params: Optional[dict[str, Any]] = None,
1199
+ ):
1200
+ """Register a `route.py` API endpoint: one function per HTTP method, no layouts."""
1201
+ methods = list(discover_handlers(load_route_module(file_path)))
1202
+ # FastAPI's APIRoute does not add HEAD for GET the way a Starlette Route does.
1203
+ if "GET" in methods and "HEAD" not in methods:
1204
+ methods.append("HEAD")
1205
+
1206
+ async def api_handler(request: Request):
1207
+ try:
1208
+ module = load_route_module(file_path)
1209
+ handler = select_handler(discover_handlers(module), request.method)
1210
+ if handler is None:
1211
+ raise HttpError(405)
1212
+ if csrf_required(module):
1213
+ await verify_csrf(request, session_cookie=SESSION_COOKIE_NAME)
1214
+
1215
+ params = {**(default_params or {}), **request.path_params}
1216
+ call_args, call_kwargs = _build_route_call(handler, request, params)
1217
+ result = handler(*call_args, **call_kwargs)
1218
+ if inspect.isawaitable(result):
1219
+ result = await result
1220
+ return to_response(result)
1221
+ except Exception as exc:
1222
+ return await render_error_response(request, exc, APP_ROOT_DIR, is_api=True)
1223
+
1224
+ endpoint = re.sub(r"[^0-9A-Za-z_]", "_", file_path)
1225
+ app.add_api_route(url_pattern, api_handler, methods=methods, name=endpoint)
1226
+
1227
+
1228
+ def register_single_route(
1229
+ url_pattern: str,
1230
+ file_path: str,
1231
+ *,
1232
+ default_params: Optional[dict[str, Any]] = None,
1233
+ ):
1234
+ boundary_dir = os.path.dirname(file_path)
1235
+
1236
+ async def render_page(request: Request):
1040
1237
  _runtime_metadata.set(None)
1041
1238
  _runtime_injections.set({"head": [], "body": []})
1042
1239
 
1043
- kwargs = dict(request.path_params)
1044
- current_uri = request.url.path
1240
+ kwargs = {**(default_params or {}), **request.path_params}
1241
+ # The page cache keys on path plus query string, so `/products?page=2`
1242
+ # never serves the cached `/products` document.
1243
+ current_uri = request.url.path + (f"?{request.url.query}" if request.url.query else "")
1045
1244
  request_is_cacheable = is_request_cacheable(request)
1245
+ if i18n_enabled():
1246
+ # Each language is its own cached document; the marker sits in the
1247
+ # query part so revalidate_path("/x") still clears every language.
1248
+ separator = "&" if "?" in current_uri else "?"
1249
+ current_uri = f"{current_uri}{separator}__locale={current_app_locale()}"
1046
1250
 
1047
1251
  # 1. Cache Check (Fast Path)
1048
1252
  if CACHE_ENABLED and request_is_cacheable:
@@ -1057,6 +1261,7 @@ def register_single_route(url_pattern: str, file_path: str):
1057
1261
 
1058
1262
  req_should_cache = None
1059
1263
  req_cache_ttl = 0
1264
+ req_cache_tags: list[str] = []
1060
1265
 
1061
1266
  page_content_source = file_path
1062
1267
 
@@ -1098,6 +1303,7 @@ def register_single_route(url_pattern: str, file_path: str):
1098
1303
  if cache_settings:
1099
1304
  req_should_cache = cache_settings.enabled
1100
1305
  req_cache_ttl = cache_settings.ttl
1306
+ req_cache_tags = list(getattr(cache_settings, "tags", None) or [])
1101
1307
 
1102
1308
  if isinstance(result, tuple):
1103
1309
  page_content = result[0]
@@ -1113,16 +1319,7 @@ def register_single_route(url_pattern: str, file_path: str):
1113
1319
  static_meta = getattr(module, "metadata", None)
1114
1320
 
1115
1321
  def extract_meta(obj):
1116
- d = {}
1117
- if not obj:
1118
- return d
1119
- if obj.title:
1120
- d["title"] = obj.title
1121
- if obj.description:
1122
- d["description"] = obj.description
1123
- if obj.extra:
1124
- d.update(obj.extra)
1125
- return d
1322
+ return dict(obj.to_dict()) if obj else {}
1126
1323
 
1127
1324
  page_metadata.update(extract_meta(static_meta))
1128
1325
  page_metadata.update(extract_meta(dynamic_meta))
@@ -1158,10 +1355,18 @@ def register_single_route(url_pattern: str, file_path: str):
1158
1355
  # the shared, URI-keyed cache on disk.
1159
1356
  if should_cache and request_is_cacheable:
1160
1357
  ttl_to_save = req_cache_ttl if req_cache_ttl > 0 else DEFAULT_TTL
1161
- CacheHandler.save_cache(current_uri, html_output, ttl_to_save)
1358
+ CacheHandler.save_cache(current_uri, html_output, ttl_to_save, tags=req_cache_tags)
1162
1359
 
1163
1360
  return response
1164
1361
 
1362
+ async def make_handler(request: Request):
1363
+ # A failing page (or a layout below its nearest `error.py`) is rendered
1364
+ # by that boundary instead of escaping to the global 500 handler.
1365
+ try:
1366
+ return await render_page(request)
1367
+ except Exception as exc:
1368
+ return await render_error_response(request, exc, boundary_dir, is_page=True)
1369
+
1165
1370
  endpoint = (
1166
1371
  file_path.replace("/", "_")
1167
1372
  .replace("\\", "_")
@@ -1182,6 +1387,11 @@ def register_single_route(url_pattern: str, file_path: str):
1182
1387
  if normalized_methods:
1183
1388
  route_methods = list(dict.fromkeys(normalized_methods))
1184
1389
 
1390
+ # FastAPI's APIRoute does not answer HEAD for GET the way a Starlette Route
1391
+ # does; crawlers, uptime monitors, and BrowserSync all send HEAD.
1392
+ if "GET" in route_methods and "HEAD" not in route_methods:
1393
+ route_methods.append("HEAD")
1394
+
1185
1395
  app.add_api_route(url_pattern, make_handler, methods=route_methods, name=endpoint)
1186
1396
 
1187
1397
 
@@ -1511,6 +1721,57 @@ def finalize_html(html_output: str) -> str:
1511
1721
  register_routes()
1512
1722
  register_rpc_routes(app)
1513
1723
 
1724
+
1725
+ SEO_FILES: dict[str, tuple[str, str]] = {
1726
+ SITEMAP_PATH: ("sitemap.py", "sitemap"),
1727
+ ROBOTS_PATH: ("robots.py", "robots"),
1728
+ }
1729
+
1730
+
1731
+ def _make_seo_handler(url: str, filename: str, func_name: str):
1732
+ page_path = os.path.join("src", "app", filename)
1733
+
1734
+ async def seo_handler(request: Request):
1735
+ if not os.path.exists(page_path):
1736
+ raise HttpError(404)
1737
+ provider = getattr(load_route_module(page_path), func_name, None)
1738
+ if not callable(provider):
1739
+ raise HttpError.internal(f"{page_path} must define {func_name}()")
1740
+ value = await call_provider(provider, request)
1741
+ base_url = configured_base_url() or str(request.base_url).rstrip("/")
1742
+ if url == SITEMAP_PATH:
1743
+ return Response(render_sitemap(value or [], base_url), media_type="application/xml")
1744
+ return PlainTextResponse(render_robots(value, base_url))
1745
+
1746
+ return seo_handler
1747
+
1748
+
1749
+ def register_seo_routes() -> list[str]:
1750
+ """Serve `src/app/sitemap.py` at /sitemap.xml and `src/app/robots.py` at /robots.txt."""
1751
+ registered: list[str] = []
1752
+ for url, (filename, func_name) in SEO_FILES.items():
1753
+ if not os.path.exists(os.path.join("src", "app", filename)):
1754
+ continue
1755
+ if (APP_ROOT / "public" / url.lstrip("/")).is_file():
1756
+ print(
1757
+ f"[caspian] WARNING: public{url} exists and is served before src/app/{filename}; "
1758
+ "remove one of them.",
1759
+ file=sys.stderr,
1760
+ flush=True,
1761
+ )
1762
+ app.add_api_route(
1763
+ url,
1764
+ _make_seo_handler(url, filename, func_name),
1765
+ methods=["GET"],
1766
+ include_in_schema=False,
1767
+ name=f"seo_{func_name}",
1768
+ )
1769
+ registered.append(url)
1770
+ return registered
1771
+
1772
+
1773
+ SEO_ROUTES = register_seo_routes()
1774
+
1514
1775
  # Mount the FastMCP app at /mcp so the endpoint is exactly /mcp.
1515
1776
  if mcp_app is not None:
1516
1777
  # Starlette's automatic mount redirect builds an absolute URL from the
@@ -1573,14 +1834,8 @@ async def _render_special_page(
1573
1834
 
1574
1835
  page_metadata = default_metadata.copy()
1575
1836
  for metadata_obj in (getattr(module, "metadata", None), _runtime_metadata.get()):
1576
- if not metadata_obj:
1577
- continue
1578
- if metadata_obj.title:
1579
- page_metadata["title"] = metadata_obj.title
1580
- if metadata_obj.description:
1581
- page_metadata["description"] = metadata_obj.description
1582
- if metadata_obj.extra:
1583
- page_metadata.update(metadata_obj.extra)
1837
+ if metadata_obj:
1838
+ page_metadata.update(metadata_obj.to_dict())
1584
1839
 
1585
1840
  page_source = getattr(page_content, "source_path", page_path)
1586
1841
  html_output, root_layout_id = await render_with_nested_layouts(
@@ -1596,11 +1851,13 @@ async def _render_special_page(
1596
1851
  return finalize_html(html_output), root_layout_id
1597
1852
 
1598
1853
 
1599
- @app.exception_handler(StarletteHTTPException)
1600
- async def custom_404_handler(request: Request, exc: StarletteHTTPException):
1601
- if exc.status_code == 404:
1602
- not_found_path = os.path.join("src", "app", "not_found.py")
1603
- if os.path.exists(not_found_path):
1854
+ APP_ROOT_DIR = "src/app"
1855
+
1856
+
1857
+ async def _render_not_found_response(request: Request, headers: dict[str, str]) -> Response:
1858
+ not_found_path = os.path.join("src", "app", "not_found.py")
1859
+ if os.path.exists(not_found_path):
1860
+ try:
1604
1861
  html_output, root_layout_id = await _render_special_page(
1605
1862
  page_path=not_found_path,
1606
1863
  request=request,
@@ -1610,52 +1867,137 @@ async def custom_404_handler(request: Request, exc: StarletteHTTPException):
1610
1867
  },
1611
1868
  context_data={},
1612
1869
  )
1613
- resp = HTMLResponse(content=html_output, status_code=404)
1870
+ except Exception as render_exc:
1871
+ print(f"[error-boundary] {not_found_path} failed to render: {render_exc!r}", flush=True)
1872
+ else:
1873
+ resp = HTMLResponse(content=html_output, status_code=404, headers=headers)
1614
1874
  resp.headers["X-PP-Root-Layout"] = root_layout_id
1615
1875
  return resp
1616
- return HTMLResponse(content=f"<h1>{exc.detail}</h1>", status_code=exc.status_code)
1876
+ return HTMLResponse(content="<h1>404 - Not Found</h1>", status_code=404, headers=headers)
1617
1877
 
1618
1878
 
1619
- @app.exception_handler(Exception)
1620
- async def custom_general_exception_handler(request: Request, exc: Exception):
1621
- full_trace = traceback.format_exc()
1622
- print(full_trace)
1623
- error_message = _client_error_message(exc)
1624
- error_trace = full_trace if not IS_PRODUCTION else None
1625
-
1626
- error_page_path = os.path.join("src", "app", "error.py")
1627
- if os.path.exists(error_page_path):
1628
- context_data = {
1629
- "request": request,
1630
- "error_message": error_message,
1631
- "error_trace": error_trace,
1632
- }
1879
+ def _log_error(request: Request, exc: BaseException, status: int, trace: str) -> None:
1880
+ if status == 404:
1881
+ return
1882
+ summary = f"{status} {request.method} {request.url.path}"
1883
+ if status >= 500:
1884
+ # Server-side diagnostic; the client response is redacted separately.
1885
+ record("error", "http", summary, exception=type(exc).__name__, trace=trace)
1886
+ elif not IS_PRODUCTION:
1887
+ record("warn", "http", f"{summary}: {exc}")
1888
+
1889
+
1890
+ async def render_error_response(
1891
+ request: Request,
1892
+ exc: BaseException,
1893
+ route_dir: str,
1894
+ *,
1895
+ is_page: bool = False,
1896
+ is_api: bool = False,
1897
+ ) -> Response:
1898
+ """Turn a failure into the response shape its caller expects.
1899
+
1900
+ - RPC calls keep the PulsePoint wire (`{"error", "requestId"}`).
1901
+ - `route.py` endpoints (`is_api`) always get `application/problem+json`, as
1902
+ do API clients of other non-page endpoints.
1903
+ - Pages (and browsers) get the nearest `error.py` boundary above
1904
+ `route_dir`, wrapped by the layouts at and above that boundary. A
1905
+ boundary that itself fails yields to the next ancestor boundary.
1906
+ - A 404 renders the global `not_found.py`.
1907
+
1908
+ Client-visible text is redacted in production by `casp.errors`, and every
1909
+ error response is `no-store` with the request id attached.
1910
+ """
1911
+ trace = "".join(traceback.format_exception(exc))
1912
+ info = error_info(
1913
+ exc,
1914
+ is_production=IS_PRODUCTION,
1915
+ request_id=request_id_for(request),
1916
+ trace=trace,
1917
+ )
1918
+ _log_error(request, exc, info.status, trace)
1919
+ extra_headers = error_headers(exc)
1920
+
1921
+ if request.headers.get("X-PP-RPC") == "true":
1922
+ return rpc_error_response(info, headers=extra_headers)
1923
+ if is_api or (not is_page and not prefers_html(request)):
1924
+ return problem_response(info, headers=extra_headers)
1925
+
1926
+ headers = error_response_headers(info, extra_headers)
1927
+ if info.status == 404:
1928
+ return await _render_not_found_response(request, headers)
1929
+
1930
+ for boundary_path in find_error_boundaries(route_dir):
1633
1931
  try:
1634
1932
  html_output, root_layout_id = await _render_special_page(
1635
- page_path=error_page_path,
1933
+ page_path=boundary_path,
1636
1934
  request=request,
1637
1935
  default_metadata={
1638
- "title": "Application Error",
1639
- "description": "An unexpected error occurred.",
1936
+ "title": info.reason,
1937
+ "description": "An error occurred while loading this page.",
1938
+ },
1939
+ context_data={
1940
+ "error": info,
1941
+ # Legacy `page(error_message, error_trace)` signature.
1942
+ "error_message": info.message,
1943
+ "error_trace": info.trace,
1640
1944
  },
1641
- context_data=context_data,
1642
1945
  )
1643
- resp = HTMLResponse(content=html_output, status_code=500)
1644
- resp.headers["X-PP-Root-Layout"] = root_layout_id
1645
- return resp
1646
1946
  except Exception as render_exc:
1647
- print("Error rendering error.py:", render_exc)
1648
- return HTMLResponse(
1649
- content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>", status_code=500
1947
+ print(f"[error-boundary] {boundary_path} failed to render: {render_exc!r}", flush=True)
1948
+ continue
1949
+ response = HTMLResponse(content=html_output, status_code=info.status, headers=headers)
1950
+ response.headers["X-PP-Root-Layout"] = root_layout_id
1951
+ return response
1952
+
1953
+ return fallback_html_response(info, headers=extra_headers)
1954
+
1955
+
1956
+ @app.exception_handler(StarletteHTTPException)
1957
+ async def custom_404_handler(request: Request, exc: StarletteHTTPException):
1958
+ # 404 stays an HTML page for every client, as it always has.
1959
+ return await render_error_response(request, exc, APP_ROOT_DIR, is_page=exc.status_code == 404)
1960
+
1961
+
1962
+ @app.exception_handler(Exception)
1963
+ async def custom_general_exception_handler(request: Request, exc: Exception):
1964
+ return await render_error_response(request, exc, APP_ROOT_DIR)
1965
+
1966
+
1967
+ async def render_maintenance_page(request: Request, state: MaintenanceState) -> Optional[Response]:
1968
+ """Render `src/app/maintenance.py` for browsers while maintenance mode is on.
1969
+
1970
+ It runs outside the session and auth layers, so layouts that need a session
1971
+ fail here; the middleware then serves its built-in page instead.
1972
+ """
1973
+ page_path = os.path.join("src", "app", "maintenance.py")
1974
+ if not os.path.exists(page_path):
1975
+ return None
1976
+ html_output, root_layout_id = await _render_special_page(
1977
+ page_path=page_path,
1978
+ request=request,
1979
+ default_metadata={"title": "Maintenance", "description": state.message},
1980
+ context_data={"maintenance": state},
1650
1981
  )
1982
+ response = HTMLResponse(content=html_output)
1983
+ response.headers["X-PP-Root-Layout"] = root_layout_id
1984
+ return response
1651
1985
 
1652
1986
 
1653
1987
  # ====
1654
1988
  # Middleware Order (LAST added runs FIRST)
1655
1989
  # ====
1656
1990
  app.add_middleware(RPCMiddleware)
1991
+ # The application pipeline (`src/middleware.py`): after auth, so it can read the
1992
+ # session and is never reached by refused requests; around pages, `route.py`,
1993
+ # and RPC. It skips public files, WebSockets, /health, /ready, and /mcp.
1994
+ app.add_middleware(RequestPipelineMiddleware, entries=load_app_middleware())
1657
1995
  app.add_middleware(AuthMiddleware)
1658
1996
  app.add_middleware(CSRFMiddleware)
1997
+ # Locale resolution (no-op without src/locales or APP_LOCALES): inside the
1998
+ # session so a saved choice is readable, outside auth so route privacy and
1999
+ # routing both see the path with any `/es` prefix removed.
2000
+ app.add_middleware(LocaleMiddleware)
1659
2001
 
1660
2002
  app.add_middleware(
1661
2003
  SessionMiddleware,
@@ -1673,6 +2015,16 @@ app.add_middleware(MissingPublicAssetMiddleware)
1673
2015
  # Outermost of the security layers: reject flooding before any session
1674
2016
  # decryption, template rendering, or database work is spent on the request.
1675
2017
  app.add_middleware(RateLimitMiddleware)
2018
+ # Maintenance mode sits inside public-file serving (stylesheets and images keep
2019
+ # loading for the maintenance page) and outside everything that does real work.
2020
+ app.add_middleware(
2021
+ MaintenanceMiddleware,
2022
+ render=render_maintenance_page,
2023
+ secure_cookie=IS_PRODUCTION,
2024
+ )
2025
+ # Request records and counters: inside public-file serving (assets are not
2026
+ # application traffic), outside maintenance and rate limiting so refusals count.
2027
+ app.add_middleware(RequestLogMiddleware)
1676
2028
  # The public directory is itself the URL contract: any existing nested file is
1677
2029
  # served without adding a directory-specific route above. Keep upload content
1678
2030
  # in attachment mode unless its MIME type is explicitly safe to render inline.
@@ -1688,6 +2040,14 @@ app.add_middleware(SecurityHeadersMiddleware)
1688
2040
  if not IS_PRODUCTION:
1689
2041
  app.add_middleware(RequestDiagnosticsMiddleware)
1690
2042
 
2043
+ # Outermost: every response, including timeouts, rate-limit refusals, and error
2044
+ # pages, carries an `X-Request-Id`. A valid incoming id from a trusted gateway is
2045
+ # kept unless REQUEST_ID_TRUST_INCOMING=false.
2046
+ app.add_middleware(
2047
+ RequestIdMiddleware,
2048
+ accept_incoming=_bool_env("REQUEST_ID_TRUST_INCOMING", True),
2049
+ )
2050
+
1691
2051
 
1692
2052
  def _consume_dev_control_stream(
1693
2053
  server: uvicorn.Server,