messagefoundry-webconsole 0.2.15__py3-none-any.whl

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 (40) hide show
  1. messagefoundry_webconsole/__init__.py +128 -0
  2. messagefoundry_webconsole/_auth.py +841 -0
  3. messagefoundry_webconsole/_html.py +539 -0
  4. messagefoundry_webconsole/_security.py +253 -0
  5. messagefoundry_webconsole/_service.py +22 -0
  6. messagefoundry_webconsole/_static.py +78 -0
  7. messagefoundry_webconsole/mount.py +103 -0
  8. messagefoundry_webconsole/pages/__init__.py +25 -0
  9. messagefoundry_webconsole/pages/_common.py +21 -0
  10. messagefoundry_webconsole/pages/account.py +771 -0
  11. messagefoundry_webconsole/pages/admin.py +480 -0
  12. messagefoundry_webconsole/pages/audit.py +66 -0
  13. messagefoundry_webconsole/pages/config.py +113 -0
  14. messagefoundry_webconsole/pages/connections.py +519 -0
  15. messagefoundry_webconsole/pages/messages.py +689 -0
  16. messagefoundry_webconsole/pages/monitoring.py +853 -0
  17. messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
  18. messagefoundry_webconsole/routes/__init__.py +6 -0
  19. messagefoundry_webconsole/routes/_common.py +45 -0
  20. messagefoundry_webconsole/routes/account.py +451 -0
  21. messagefoundry_webconsole/routes/admin.py +505 -0
  22. messagefoundry_webconsole/routes/audit.py +39 -0
  23. messagefoundry_webconsole/routes/config.py +67 -0
  24. messagefoundry_webconsole/routes/connection_writes.py +248 -0
  25. messagefoundry_webconsole/routes/core.py +1077 -0
  26. messagefoundry_webconsole/routes/monitoring.py +94 -0
  27. messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
  28. messagefoundry_webconsole/routes/oidc.py +172 -0
  29. messagefoundry_webconsole/routes/search.py +204 -0
  30. messagefoundry_webconsole/routes/sso.py +88 -0
  31. messagefoundry_webconsole/routes/status.py +178 -0
  32. messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
  33. messagefoundry_webconsole/static/app.css +345 -0
  34. messagefoundry_webconsole/static/app.js +1506 -0
  35. messagefoundry_webconsole/static/csp-probe.js +9 -0
  36. messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
  37. messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
  38. messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
  39. messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
  40. messagefoundry_webconsole-0.2.15.dist-info/licenses/NOTICE +31 -0
@@ -0,0 +1,1077 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Core /ui pages: login/logout, dashboard, messages + parse-tree, dead-letters, replay, and the step-up re-auth flow (GET/POST /ui/reauth + the WebAuthn leg) — the sole consumer of the write-action registry (ADR 0065)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ from collections.abc import Callable
10
+ from datetime import UTC, datetime, timedelta
11
+ from typing import Any
12
+ from urllib.parse import parse_qsl, urlsplit
13
+ from uuid import uuid4
14
+
15
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
16
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
17
+ from pydantic import ValidationError
18
+
19
+ from messagefoundry.api._ui_seam import UiDeps
20
+ from messagefoundry.api.models import (
21
+ DeadLetterReplayRequest,
22
+ EditResendRequest,
23
+ PendingApprovalResponse,
24
+ )
25
+ from messagefoundry.api.security import get_auth
26
+ from messagefoundry.auth import Identity, Permission
27
+ from messagefoundry.auth.identity import AuthProvider
28
+ from messagefoundry.auth.service import AuthService, MfaStatus
29
+ from messagefoundry.auth.tokens import hash_token
30
+ from messagefoundry.parsing import HL7PeekError, parse_tree
31
+
32
+ from .. import pages
33
+ from .._auth import (
34
+ CLEAR_SITE_DATA_HEADER,
35
+ CLEAR_SITE_DATA_VALUE,
36
+ WEBAUTHN_EXTRA_MISSING_NOTICE,
37
+ WEBAUTHN_RP_CHANGED_NOTICE,
38
+ WEBAUTHN_RP_MISSING_NOTICE,
39
+ allow_reauth_attempt,
40
+ assert_not_cross_site,
41
+ assert_same_origin,
42
+ clear_session_cookie,
43
+ is_unlock_action,
44
+ login_redirect_response,
45
+ lookup_ui_action,
46
+ register_ui_action,
47
+ require_ui,
48
+ require_ui_step_up,
49
+ session_token,
50
+ set_session_cookie,
51
+ webauthn_rp,
52
+ )
53
+ from .._html import CSP_PROBE_SRC
54
+ from .._service import _service
55
+
56
+ _log = logging.getLogger(__name__)
57
+
58
+ #: Login-page outcome codes that mean "a session just ended" and therefore carry Clear-Site-Data.
59
+ #: The header itself (and every server-driven expiry redirect that carries it) lives in
60
+ #: :mod:`.._auth` — see :data:`.._auth.CLEAR_SITE_DATA_VALUE`. ``pwchanged`` belongs here for the same
61
+ #: reason as ``loggedout``: a password change revokes EVERY session for the user. It is belt — the
62
+ #: 303 that sends the browser here already carries the header via ``clear_session_cookie`` — but a
63
+ #: browser can reach this landing by another route (Back, a bookmark) with the cookie already gone,
64
+ #: which is exactly the case ``_has_stale_session_cookie`` below cannot detect.
65
+ _CLEAR_SITE_DATA_LOGIN_CODES = frozenset({"expired", "loggedout", "pwchanged"})
66
+
67
+ #: How many report bodies of one CSP violation BATCH the WARNING line summarises before it is
68
+ #: truncated to a count. The reports are attacker-influenceable, so the log line is bounded.
69
+ _CSP_REPORT_SUMMARY_MAX = 5
70
+
71
+ # Edit-and-resubmit (ADR 0090 §9, BACKLOG #153). The GET editor page is the step-up `unlock`
72
+ # continuation (a GET form the re-auth flow can 303-GET-redirect back to); the body-carrying POST
73
+ # `/edit-resend` is deliberately NOT a registered continuation — its `reauth_next` maps a stale-window
74
+ # step-up to this /edit page, so the operator re-submits inside a fresh window (mirrors /ui/users).
75
+ register_ui_action(
76
+ r"^/ui/messages/[^/?#]+/edit$", Permission.MESSAGES_EDIT, auto_retry=False, unlock=True
77
+ )
78
+
79
+
80
+ def _csp_report_bodies(doc: object) -> list[dict[str, object]] | None:
81
+ """Normalize either wired delivery shape to the LIST of report bodies it carries, or ``None`` when
82
+ the payload is not an object at all: the legacy ``report-uri`` body
83
+ (``{"csp-report": {"document-uri": ..., "violated-directive": ..., "blocked-uri": ...}}``, always
84
+ exactly one) and the modern Reporting-API ``report-to`` batch (a top-level ARRAY whose entries
85
+ carry a ``body`` dict keyed ``documentURL``/``effectiveDirective``/``blockedURL``,
86
+ ``application/reports+json``). Never raises on hostile input — the report is
87
+ attacker-influenceable DATA, never instructions.
88
+
89
+ Returning every entry rather than the first is LOAD-BEARING (ASVS 3.7.5). The Reporting API
90
+ batches per endpoint, and the enforcement canary provokes one report per page load on every
91
+ conforming browser, so a genuine violation raised on the same page load arrives in the SAME POST,
92
+ behind the canary. A first-entry-only view classified that batch by the canary and dropped the real
93
+ violation to DEBUG — the exact detection hole the external-canary design exists to avoid.
94
+ """
95
+ if isinstance(doc, list):
96
+ bodies: list[dict[str, object]] = []
97
+ for entry in doc:
98
+ if isinstance(entry, dict):
99
+ body = entry.get("body", entry)
100
+ if isinstance(body, dict):
101
+ bodies.append(body)
102
+ return bodies
103
+ if isinstance(doc, dict):
104
+ inner = doc.get("csp-report", doc)
105
+ return [inner] if isinstance(inner, dict) else []
106
+ return None
107
+
108
+
109
+ def _csp_report_summary(report: dict[str, object]) -> str:
110
+ """A bounded, PHI-free one-line summary of ONE CSP violation report body (either delivery shape).
111
+ Returns ``"empty"`` when nothing usable is present."""
112
+ # Accept both the hyphenated report-uri keys and the camelCase report-to keys, labelling the
113
+ # summary by the stable report-uri name in either case.
114
+ field_specs = (
115
+ ("document-uri", "documentURL"),
116
+ ("violated-directive", "effectiveDirective"),
117
+ ("blocked-uri", "blockedURL"),
118
+ )
119
+ fields: list[str] = []
120
+ for legacy_key, modern_key in field_specs:
121
+ value = report.get(legacy_key)
122
+ if value is None:
123
+ value = report.get(modern_key)
124
+ if value:
125
+ fields.append(f"{legacy_key}={str(value)[:256]}")
126
+ return "; ".join(fields) or "empty"
127
+
128
+
129
+ def _request_origin(request: Request) -> str | None:
130
+ """This deployment's own ORIGIN (``scheme://host[:port]``, lowercased) for a same-origin
131
+ comparison, or ``None`` when it cannot be established.
132
+
133
+ Follows the precedence of ``_auth._origin_matches`` — ``[api].public_origin`` is authoritative
134
+ when configured (the off-loopback case behind a proxy that may not preserve ``Host``), else the
135
+ request's own ``Host`` header — but, unlike that function's Host-only fallback, it also carries the
136
+ SCHEME. A violation report's blocked URL is absolute, so the scheme IS observable here, and
137
+ ``http://<our-host>/ui/static/csp-probe.js`` on an https deployment is NOT our canary. Behind a
138
+ TLS-terminating proxy that neither sets ``public_origin`` nor rewrites ``scope['scheme']`` the
139
+ comparison simply fails and the canary's own reports WARN instead of being filtered — noisier,
140
+ never quieter, which is the only safe direction for a filter on a security log.
141
+ """
142
+ public_origin: str | None = getattr(request.app.state, "public_origin", None)
143
+ if public_origin:
144
+ parts = urlsplit(public_origin)
145
+ if not parts.scheme or not parts.netloc:
146
+ return None
147
+ return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
148
+ host = request.headers.get("host")
149
+ return f"{request.url.scheme.lower()}://{host.lower()}" if host else None
150
+
151
+
152
+ def _is_expected_csp_probe_report(report: dict[str, object], origin: str | None) -> bool:
153
+ """Whether THIS report body is the one violation the 3.7.5 enforcement canary is designed to
154
+ provoke — a conforming browser refusing OUR OWN un-nonced ``/ui/static/csp-probe.js``.
155
+
156
+ The match is SAME-ORIGIN — scheme AND ``host[:port]`` — plus same-path, and nothing else. The path
157
+ alone is not sufficient: it is attacker-selectable on an attacker-hosted payload, so a host-blind
158
+ match would let ``https://evil.example/ui/static/csp-probe.js`` — an injected script load a real
159
+ CSP blocked — be filed as the expected canary and dropped to DEBUG. ``origin`` is this
160
+ deployment's own ``scheme://host[:port]`` (:func:`_request_origin`); a relative ``blocked-uri``
161
+ (some browsers report a bare path) is same-origin by construction, and an absolute URL must match
162
+ it case-insensitively. Unknown origin fails CLOSED — the report warns.
163
+
164
+ Being an external script rather than an inline one is what makes any filtering safe at all: a
165
+ blocked INLINE canary reports ``blocked-uri: "inline"`` with the same directive and document as a
166
+ blocked injected inline script, so any filter wide enough to drop the canary would also drop the
167
+ report a real XSS attempt produces. Directive naming is deliberately NOT part of the match
168
+ (browsers report ``script-src-elem`` or ``script-src`` depending on version), and neither is the
169
+ document URI (every /ui page emits the canary, so it discriminates nothing).
170
+ """
171
+ blocked = report.get("blocked-uri")
172
+ if blocked is None:
173
+ blocked = report.get("blockedURL")
174
+ if not isinstance(blocked, str):
175
+ return False
176
+ parts = urlsplit(blocked)
177
+ # Compare paths: browsers report the absolute URL, but tolerate a bare path too. Query/fragment are
178
+ # stripped so a cache-buster can never smuggle a non-probe URL past the match.
179
+ if parts.path != CSP_PROBE_SRC:
180
+ return False
181
+ if not parts.netloc:
182
+ return True
183
+ return origin is not None and f"{parts.scheme.lower()}://{parts.netloc.lower()}" == origin
184
+
185
+
186
+ async def _has_stale_session_cookie(request: Request, auth: AuthService | None) -> bool:
187
+ """Whether this request presents a session cookie that no longer authenticates (ASVS 14.3.1).
188
+
189
+ A first-time visitor carries no cookie and gets an ordinary login form; a browser arriving with a
190
+ dead ``mf_session`` is landing AFTER a termination — idle/absolute expiry, revoke, admin disable —
191
+ and its cached PHI pages must be dropped even when the redirect that sent it here was not ours
192
+ (a bookmark, a Back navigation, or a client that never ran the watchdog). Validated with
193
+ ``activity=False``: probing a dead session must not be treated as user activity.
194
+ """
195
+ token = session_token(request)
196
+ if not token:
197
+ return False
198
+ if auth is None or not auth.enabled:
199
+ return True # a cookie with no auth configured can never authenticate
200
+ return await auth.identity_for_token(token, activity=False) is None
201
+
202
+
203
+ def register(app: FastAPI, deps: UiDeps) -> None:
204
+ """Register the phase-0 /ui routes (login, dashboard, messages, dead-letters, replay,
205
+ reauth). Runs first in ``_UI_REGISTRARS``; a page lane adds its own
206
+ ``_register_<area>(app)`` + one tuple entry below, so parallel lanes never edit this
207
+ shared block (ADR 0065 §multi-session-build)."""
208
+ core = deps.core
209
+
210
+ @app.get("/ui/session-status")
211
+ async def ui_session_status(
212
+ request: Request,
213
+ service: AuthService = Depends(_service),
214
+ # activity=False (ASVS 14.3.1) is LOAD-BEARING: this probe is the client watchdog's heartbeat,
215
+ # so refreshing the idle clock from it would make the watchdog keep the very session alive it
216
+ # exists to notice the end of. No permission is required beyond a live session — every
217
+ # authenticated page needs it, including a must-change-confined one.
218
+ # allow_mfa_pending is MANDATORY, not a convenience: the confinement page at /ui/mfa carries
219
+ # the same watchdog, so gating this probe would 303 it to /ui/mfa on every heartbeat and the
220
+ # page would fight its own poll.
221
+ identity: Identity = Depends(
222
+ require_ui(allow_must_change=True, allow_mfa_pending=True, activity=False)
223
+ ),
224
+ ) -> JSONResponse:
225
+ """The session watchdog's heartbeat (ASVS 14.3.1).
226
+
227
+ Reaching this AT ALL is the liveness signal — ``require_ui`` 303s to the login page the moment
228
+ the session is idle-expired, absolute-expired or revoked, and the client treats that redirect
229
+ as termination. The body carries the ABSOLUTE deadline as **remaining seconds**, never a
230
+ wall-clock the client would have to trust against its own (possibly wrong, possibly
231
+ adversary-set) system time, and it is read from the session RECORD rather than computed from
232
+ ``[auth].session_absolute_hours`` — a federated session's deadline may be capped lower by the
233
+ IdP's ``id_token.exp``, so settings arithmetic would over-state it.
234
+
235
+ ``expires_secs`` is null when the record cannot be resolved; the client then relies on the
236
+ server verdict and its stale-contact bound alone rather than inventing a deadline.
237
+ """
238
+ remaining: int | None = None
239
+ token = session_token(request)
240
+ if token:
241
+ # The SAME indexed point-lookup ``identity_for_token`` just did (``require_ui`` above),
242
+ # not a per-user session listing: this probe runs every 30s in every open console tab, and
243
+ # ``list_sessions`` is an unindexed scan on all three backends.
244
+ record = await service.store.get_session(hash_token(token))
245
+ if record is not None:
246
+ remaining = max(0, int(record.expires_at - datetime.now(UTC).timestamp()))
247
+ return JSONResponse({"expires_secs": remaining})
248
+
249
+ @app.get("/ui/login", response_class=HTMLResponse)
250
+ async def ui_login_form(
251
+ request: Request, e: str | None = Query(None, max_length=32)
252
+ ) -> HTMLResponse:
253
+ auth = get_auth(request)
254
+ ad_enabled = auth is not None and auth.ad_enabled
255
+ sso_enabled = auth is not None and auth.kerberos_available
256
+ # getattr: an older engine (seam < 10) has no oidc_available property at all, and a bare
257
+ # attribute read would AttributeError rather than degrade (the exposure_protected
258
+ # precedent). oidc_available, not oidc_enabled -- the LINK should disappear while the IdP
259
+ # is known-down, even though the start leg still attempts per-request (AC-8).
260
+ oidc_enabled = bool(getattr(auth, "oidc_available", False))
261
+ resp = HTMLResponse(
262
+ pages.login(
263
+ e, ad_enabled=ad_enabled, sso_enabled=sso_enabled, oidc_enabled=oidc_enabled
264
+ )
265
+ )
266
+ # ASVS 14.3.1: this render IS the post-termination landing page when either the outcome code
267
+ # says so (the watchdog's ?e=expired, the redirect target of POST /ui/logout, or the
268
+ # server's own expiry redirect) OR the browser still presents a session cookie that no
269
+ # longer authenticates — a stale cookie IS a terminated session, whatever URL it landed on.
270
+ # Tell the browser to drop that session's cached representations so Back / bfcache cannot
271
+ # resurrect a PHI page whose session no longer exists.
272
+ if e in _CLEAR_SITE_DATA_LOGIN_CODES or await _has_stale_session_cookie(request, auth):
273
+ resp.headers[CLEAR_SITE_DATA_HEADER] = CLEAR_SITE_DATA_VALUE
274
+ return resp
275
+
276
+ @app.post("/ui/login")
277
+ async def ui_login(request: Request) -> Response:
278
+ # ASVS 3.5.1 — FIRST statement, ahead of the per-address login budget below: a cross-site
279
+ # credential POST is refused having spent no rate-limit token and run no password verify. The
280
+ # unauthenticated leg is exactly where SameSite=Strict supplies nothing (there is no session
281
+ # cookie yet), so this origin check is the only login-CSRF control available here.
282
+ assert_same_origin(request)
283
+ auth = get_auth(request)
284
+ if auth is None or not auth.enabled:
285
+ raise HTTPException(503, "authentication is not configured")
286
+ client = request.client.host if request.client else None
287
+ if not auth.allow_login_attempt(client):
288
+ raise HTTPException(429, "too many login attempts", headers={"Retry-After": "30"})
289
+ # Parse the urlencoded login form with stdlib — the engine has no python-multipart dep, so
290
+ # Form()/request.form() would fail; a same-origin login POST is always urlencoded here.
291
+ form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
292
+ # L5b (ADR 0068 §8): browser AD-password login rides the SAME auth.login seam as the
293
+ # JSON surface — allow-listed provider values only; absent stays LOCAL (regression-
294
+ # pinned). ONE session is minted per form POST, so the AD role-resync/revocation side
295
+ # effect fires once at login, never per navigation.
296
+ provider_value = form.get("provider", "local")
297
+ if provider_value not in ("local", "ad"):
298
+ return RedirectResponse("/ui/login?e=bad", status_code=303)
299
+ outcome = await auth.login(
300
+ form.get("username", ""),
301
+ form.get("password", ""),
302
+ provider=AuthProvider.AD if provider_value == "ad" else AuthProvider.LOCAL,
303
+ client=client,
304
+ )
305
+ if not outcome.ok or outcome.token is None:
306
+ return RedirectResponse("/ui/login?e=bad", status_code=303)
307
+ # A must-change account goes straight to the browser rotation page (L4b) — every other
308
+ # /ui route would bounce it there anyway (require_ui). An MFA-pending session lands on the
309
+ # second-factor page for the same reason (ASVS 6.3.3). Order matches the server-side gates:
310
+ # must_change first, since a fresh account is BOTH and can only rotate.
311
+ #
312
+ # UX only — require_ui is what actually enforces, and it covers the other two cookie-minting
313
+ # legs (Kerberos SSO, the OIDC callback) without either needing this branch.
314
+ if outcome.must_change_password:
315
+ target = "/ui/account/password"
316
+ elif outcome.mfa_required:
317
+ target = "/ui/mfa"
318
+ else:
319
+ target = "/ui"
320
+ resp = RedirectResponse(target, status_code=303)
321
+ set_session_cookie(resp, outcome.token, request=request)
322
+ return resp
323
+
324
+ @app.post("/ui/logout")
325
+ async def ui_logout(request: Request) -> Response:
326
+ # ASVS 3.5.1 — FIRST statement, before the session is revoked below. This route deliberately
327
+ # carries NO Depends gate (so a must-change-confined session can still sign itself out — the
328
+ # affordance ASVS 7.4.4 makes visible), which means the origin check is the only
329
+ # request-provenance control it will ever have: without it a cross-site POST the browser
330
+ # attaches the cookie to would forcibly terminate the operator's session.
331
+ assert_same_origin(request)
332
+ auth = get_auth(request)
333
+ token = session_token(request)
334
+ if auth is not None and token:
335
+ await auth.logout(token)
336
+ resp = RedirectResponse("/ui/login?e=loggedout", status_code=303)
337
+ # ASVS 14.3.1: revoking server-side and deleting the cookie leaves the browser's CACHED
338
+ # representations of the operator's PHI pages behind, which Back / bfcache can resurrect.
339
+ # ``clear_session_cookie`` emits Clear-Site-Data itself — cookie deletion IS the termination,
340
+ # so the two cannot be written apart (see its docstring for the scope rationale).
341
+ clear_session_cookie(resp, request)
342
+ return resp
343
+
344
+ @app.get("/ui", response_class=HTMLResponse)
345
+ async def ui_dashboard(
346
+ engine: Any = Depends(deps.get_engine),
347
+ identity: Identity = Depends(require_ui(Permission.MONITORING_READ)),
348
+ ) -> HTMLResponse:
349
+ rows = await core.list_connections(engine=engine, identity=identity)
350
+ return HTMLResponse(pages.dashboard(rows))
351
+
352
+ @app.get("/ui/connections", response_class=HTMLResponse)
353
+ async def ui_connections(
354
+ engine: Any = Depends(deps.get_engine),
355
+ # activity=False (ASVS 14.3.1): the dashboard's 5s live-table refresh is timer-driven, not
356
+ # user activity — it must not keep an abandoned tab's session alive.
357
+ identity: Identity = Depends(require_ui(Permission.MONITORING_READ, activity=False)),
358
+ ) -> HTMLResponse:
359
+ rows = await core.list_connections(engine=engine, identity=identity)
360
+ return HTMLResponse(pages.connections_fragment(rows))
361
+
362
+ @app.get("/ui/connection/{name}", response_class=HTMLResponse)
363
+ async def ui_connection_details(
364
+ name: str,
365
+ request: Request,
366
+ engine: Any = Depends(deps.get_engine),
367
+ identity: Identity = Depends(require_ui(Permission.MONITORING_READ)),
368
+ ) -> HTMLResponse:
369
+ # Compose the detail view from existing monitoring handlers (no new PHI surface): find the row in
370
+ # the (already channel-scoped) connection list, then its recent events. A singular /ui/connection/
371
+ # path avoids colliding with the /ui/connections/{purge-confirm,...} action routes.
372
+ rows = await core.list_connections(engine=engine, identity=identity)
373
+ row = next((r for r in rows if r.name == name), None)
374
+ if row is None:
375
+ raise HTTPException(404, "connection not found")
376
+ # Events are recorded + RBAC-scoped by the RAW connection name (channel_id for a source,
377
+ # destination for an outbound), NOT the composite display name — pass the raw name so the events
378
+ # actually match and a channel-scoped operator isn't spuriously denied (+ audited) on their own.
379
+ events_key = (
380
+ row.destination if (row.role == "destination" and row.destination) else row.channel_id
381
+ )
382
+ try:
383
+ # Pass EVERY param explicitly: called directly (not over HTTP), any FastAPI Query(...) default
384
+ # left unfilled arrives as a Query object (kind would reach the store un-iterable → 500).
385
+ events = await core.list_connection_events(
386
+ engine=engine,
387
+ identity=identity,
388
+ connection=events_key,
389
+ kind=None,
390
+ since=None,
391
+ limit=50,
392
+ request=request,
393
+ )
394
+ except HTTPException:
395
+ events = [] # still show the connection's info + stats if events are RBAC-scoped out
396
+ return HTMLResponse(pages.connection_details(row, events))
397
+
398
+ @app.get("/ui/messages", response_class=HTMLResponse)
399
+ async def ui_messages(
400
+ request: Request,
401
+ engine: Any = Depends(deps.get_engine),
402
+ identity: Identity = Depends(require_ui(Permission.MESSAGES_READ, phi=True)),
403
+ channel_id: str | None = Query(None, max_length=256),
404
+ status_filter: str | None = Query(None, alias="status", max_length=64),
405
+ message_type: str | None = Query(None, max_length=64),
406
+ control_id: str | None = Query(None, max_length=256),
407
+ received_from: str | None = Query(None, max_length=32), # datetime-local (UTC)
408
+ received_to: str | None = Query(None, max_length=32),
409
+ defer: bool = Query(False),
410
+ limit: int = Query(50, ge=1, le=500),
411
+ offset: int = Query(0, ge=0),
412
+ ) -> HTMLResponse:
413
+ # Arriving pre-filled from a connection name (defer=1) with no explicit dates → default a 1-day
414
+ # window (UTC). The operator adjusts and clicks Search (a plain submit, no defer) to run it.
415
+ if defer and not received_from and not received_to:
416
+ now = datetime.now(UTC)
417
+ received_from = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
418
+ received_to = now.strftime("%Y-%m-%dT%H:%M")
419
+
420
+ def _epoch(value: str | None) -> float | None:
421
+ if not value:
422
+ return None
423
+ try:
424
+ return datetime.fromisoformat(value).replace(tzinfo=UTC).timestamp()
425
+ except ValueError:
426
+ return None # a malformed datetime-local simply drops that bound
427
+
428
+ if defer:
429
+ # Form-only landing: pre-filled, NOT run until the operator submits (#4b).
430
+ return HTMLResponse(
431
+ pages.messages(
432
+ None,
433
+ deferred=True,
434
+ channel_id=channel_id or "",
435
+ status=status_filter or "",
436
+ message_type=message_type or "",
437
+ control_id=control_id or "",
438
+ received_from=received_from or "",
439
+ received_to=received_to or "",
440
+ )
441
+ )
442
+
443
+ data = await core.list_messages(
444
+ request,
445
+ engine=engine,
446
+ identity=identity,
447
+ channel_id=channel_id,
448
+ status=status_filter,
449
+ message_type=message_type,
450
+ control_id=control_id,
451
+ received_from=_epoch(received_from),
452
+ received_to=_epoch(received_to),
453
+ limit=limit,
454
+ offset=offset,
455
+ )
456
+ return HTMLResponse(
457
+ pages.messages(
458
+ data,
459
+ channel_id=channel_id or "",
460
+ status=status_filter or "",
461
+ message_type=message_type or "",
462
+ control_id=control_id or "",
463
+ received_from=received_from or "",
464
+ received_to=received_to or "",
465
+ )
466
+ )
467
+
468
+ @app.get("/ui/messages/{message_id}", response_class=HTMLResponse)
469
+ async def ui_message_detail(
470
+ message_id: str,
471
+ request: Request,
472
+ engine: Any = Depends(deps.get_engine),
473
+ identity: Identity = Depends(require_ui(Permission.MESSAGES_VIEW_RAW, phi=True)),
474
+ ) -> HTMLResponse:
475
+ detail = await core.get_message(message_id, request, engine=engine, identity=identity)
476
+ return HTMLResponse(pages.message_detail(detail))
477
+
478
+ @app.get("/ui/messages/{message_id}/parse-tree", response_class=HTMLResponse)
479
+ async def ui_message_parse_tree(
480
+ message_id: str,
481
+ request: Request,
482
+ engine: Any = Depends(deps.get_engine),
483
+ identity: Identity = Depends(require_ui(Permission.MESSAGES_VIEW_RAW, phi=True)),
484
+ ) -> HTMLResponse:
485
+ # Reuse the single audited PHI path (get_message → record_view + record_audit), then render
486
+ # the tree server-side via the pure parsing lib. Non-HL7 bodies (X12/DICOM/binary) have no
487
+ # HL7 tree — surface that rather than 500. No new PHI egress beyond the audited raw fetch.
488
+ detail = await core.get_message(message_id, request, engine=engine, identity=identity)
489
+ try:
490
+ nodes = parse_tree(detail.raw)
491
+ except HL7PeekError as exc:
492
+ return HTMLResponse(pages.parse_tree_unavailable(message_id, str(exc)))
493
+ return HTMLResponse(pages.parse_tree_page(message_id, nodes))
494
+
495
+ @app.get("/ui/messages/{message_id}/attachments/{attachment_id}")
496
+ async def ui_download_attachment(
497
+ message_id: str,
498
+ attachment_id: str,
499
+ request: Request,
500
+ engine: Any = Depends(deps.get_engine),
501
+ identity: Identity = Depends(require_ui(Permission.MESSAGES_VIEW_RAW, phi=True)),
502
+ ) -> Response:
503
+ # Reuse the engine's single audited download path (linkage + channel-scope 404 guard +
504
+ # record_view + attachment_download audit), then hand its Response straight to the browser. A
505
+ # top-level GET nav can't carry the bearer token, so the /ui gate re-asserts view_raw via the
506
+ # session cookie; the engine handler does the same PHI audit as the JSON API route.
507
+ result: Response = await core.download_attachment(
508
+ message_id, attachment_id, engine=engine, identity=identity, request=request
509
+ )
510
+ return result
511
+
512
+ @app.get("/ui/dead-letters", response_class=HTMLResponse)
513
+ async def ui_dead_letters(
514
+ request: Request,
515
+ engine: Any = Depends(deps.get_engine),
516
+ identity: Identity = Depends(require_ui(Permission.MESSAGES_READ, phi=True)),
517
+ channel_id: str | None = Query(None, max_length=256),
518
+ destination_name: str | None = Query(None, max_length=256),
519
+ limit: int = Query(50, ge=1, le=500),
520
+ offset: int = Query(0, ge=0),
521
+ ) -> HTMLResponse:
522
+ data = await core.list_dead_letters(
523
+ request,
524
+ engine=engine,
525
+ identity=identity,
526
+ channel_id=channel_id,
527
+ destination_name=destination_name,
528
+ limit=limit,
529
+ offset=offset,
530
+ )
531
+ return HTMLResponse(pages.dead_letters(data))
532
+
533
+ # Safe operator actions (M2): inbound connection start/stop/restart. These reuse the JSON
534
+ # control handlers (require CONNECTIONS_CONTROL + the per-channel _control_guard), and add
535
+ # assert_same_origin as CSRF defense-in-depth on top of the SameSite=Strict cookie (a
536
+ # cross-site POST carries no cookie, so require_ui already 303s). No step-up gate applies to
537
+ # start/stop/restart (unlike replay, which is require_step_up and lands with the browser MFA
538
+ # flow in a later milestone). Each redirects back to the dashboard.
539
+ async def _ui_control(
540
+ request: Request,
541
+ name: str,
542
+ engine: Any,
543
+ identity: Identity,
544
+ action: Callable[..., Any],
545
+ ) -> Response:
546
+ assert_same_origin(request)
547
+ # ADR 0150: the console mounts IN-PROCESS on the engine's own app, so this Request is the
548
+ # BROWSER's — forwarding it attributes the row to the operator's host, not the engine.
549
+ await action(name, engine=engine, identity=identity, request=request)
550
+ return RedirectResponse("/ui", status_code=303)
551
+
552
+ @app.post("/ui/connections/{name}/start")
553
+ async def ui_start_connection(
554
+ name: str,
555
+ request: Request,
556
+ engine: Any = Depends(deps.get_engine),
557
+ identity: Identity = Depends(require_ui(Permission.CONNECTIONS_CONTROL)),
558
+ ) -> Response:
559
+ return await _ui_control(request, name, engine, identity, core.start_connection)
560
+
561
+ @app.post("/ui/connections/{name}/stop")
562
+ async def ui_stop_connection(
563
+ name: str,
564
+ request: Request,
565
+ engine: Any = Depends(deps.get_engine),
566
+ identity: Identity = Depends(require_ui(Permission.CONNECTIONS_CONTROL)),
567
+ ) -> Response:
568
+ return await _ui_control(request, name, engine, identity, core.stop_connection)
569
+
570
+ @app.post("/ui/connections/{name}/restart")
571
+ async def ui_restart_connection(
572
+ name: str,
573
+ request: Request,
574
+ engine: Any = Depends(deps.get_engine),
575
+ identity: Identity = Depends(require_ui(Permission.CONNECTIONS_CONTROL)),
576
+ ) -> Response:
577
+ return await _ui_control(request, name, engine, identity, core.restart_connection)
578
+
579
+ # Sensitive action (M2b): single-message replay. It is require_step_up in the JSON API, so the
580
+ # /ui route uses require_ui_step_up — which, if the session hasn't recently stepped up, 303s the
581
+ # browser to /ui/reauth?next=<this action> instead of returning a 403 header the browser can't
582
+ # act on. After re-auth the browser auto-retries this POST (now inside the step-up window).
583
+ @app.post("/ui/messages/{message_id}/replay")
584
+ async def ui_replay_message(
585
+ message_id: str,
586
+ request: Request,
587
+ engine: Any = Depends(deps.get_engine),
588
+ identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_REPLAY)),
589
+ ) -> Response:
590
+ assert_same_origin(request)
591
+ await core.replay_message(message_id, engine=engine, identity=identity, request=request)
592
+ return RedirectResponse(f"/ui/messages/{message_id}", status_code=303)
593
+
594
+ # Edit-and-resubmit (ADR 0090 §9, BACKLOG #153). GET renders the editor (a COPY of the raw); the
595
+ # step-up gate opens it inside a fresh window (unlock continuation). The origin row is only READ
596
+ # here (the audited get_message path); nothing is written until the operator POSTs /edit-resend.
597
+ @app.get("/ui/messages/{message_id}/edit", response_class=HTMLResponse)
598
+ async def ui_message_edit(
599
+ message_id: str,
600
+ request: Request,
601
+ engine: Any = Depends(deps.get_engine),
602
+ identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_EDIT)),
603
+ ) -> HTMLResponse:
604
+ detail = await core.get_message(message_id, request, engine=engine, identity=identity)
605
+ # A fresh per-open idempotency token: a double-submit of THIS rendered form is an idempotent
606
+ # no-op; re-opening the editor mints a new token (a genuine second resubmit).
607
+ return HTMLResponse(pages.message_edit(detail, uuid4().hex))
608
+
609
+ @app.post("/ui/messages/{message_id}/edit-resend")
610
+ async def ui_message_edit_resend(
611
+ message_id: str,
612
+ request: Request,
613
+ engine: Any = Depends(deps.get_engine),
614
+ identity: Identity = Depends(
615
+ require_ui_step_up(
616
+ Permission.MESSAGES_EDIT,
617
+ # Stale-window step-up on this body-carrying POST → re-open the /edit form (fresh
618
+ # window), never the POST path (a re-POST would drop the edited body).
619
+ reauth_next=lambda r: r.url.path.removesuffix("/edit-resend") + "/edit",
620
+ )
621
+ ),
622
+ ) -> Response:
623
+ assert_same_origin(request)
624
+ # Parse the urlencoded resubmit form with stdlib — the engine has no python-multipart dep, so
625
+ # Form()/request.form() would fail (mirrors the /ui/login + /ui/reauth POST parsing above).
626
+ form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
627
+ raw = str(form.get("raw", ""))
628
+ idem = str(form.get("idempotency_key", "")).strip()
629
+ mode = str(form.get("mode", "reroute"))
630
+ to = str(form.get("to", "")).strip()
631
+
632
+ async def _reject(msg: str) -> HTMLResponse:
633
+ # Re-render the editor preserving the operator's edits (raw_value) AND their destination
634
+ # choice (mode + to) — so a rejected direct send doesn't silently reset to re-route and drop
635
+ # the typed outbound (review #153-4). The audited get_message re-read is the same PHI path
636
+ # the GET used. NEVER echo the edited body in the error text.
637
+ detail = await core.get_message(message_id, request, engine=engine, identity=identity)
638
+ return HTMLResponse(
639
+ pages.message_edit(
640
+ detail, idem or uuid4().hex, raw_value=raw, error=msg, mode=mode, to=to
641
+ ),
642
+ status_code=400,
643
+ )
644
+
645
+ if mode == "direct" and not to:
646
+ return await _reject(
647
+ "choose an outbound connection for a direct send, or re-route instead"
648
+ )
649
+ try:
650
+ body = EditResendRequest(
651
+ raw=raw,
652
+ idempotency_key=idem,
653
+ reroute=(mode != "direct"),
654
+ to=(to if mode == "direct" else None),
655
+ )
656
+ except ValidationError:
657
+ # PHI-safe: a bad edited body must never be echoed — a generic message only.
658
+ return await _reject("invalid input")
659
+ try:
660
+ result = await core.edit_resend_message(
661
+ message_id, body=body, engine=engine, identity=identity, request=request
662
+ )
663
+ except HTTPException as exc:
664
+ # str(exc.detail) carries ids only (the endpoint never interpolates the body).
665
+ return await _reject(str(exc.detail))
666
+ # Land on the NEW correlated child (re-route) so the operator sees the resubmit flow; the direct
667
+ # path lands back on the origin (which now carries the new outbound row). The ORIGINAL is intact.
668
+ target = result.new_message_id if (result.reroute and result.new_message_id) else message_id
669
+ return RedirectResponse(f"/ui/messages/{target}", status_code=303)
670
+
671
+ async def _reauth_webauthn_state(
672
+ request: Request,
673
+ auth: AuthService,
674
+ token: str | None,
675
+ mfa: MfaStatus,
676
+ satisfied: bool,
677
+ ) -> tuple[str | None, str | None]:
678
+ """(assertion-options JSON, fail-closed notice) for the reauth page's passkey leg.
679
+
680
+ Options are freshly staged per render (the prior challenge is single-use — ADR 0068
681
+ decision 1(e): the passkey button must survive a failed password/code attempt). The
682
+ notice is the legible dead-end copy when ceremonies can't run (extra absent /
683
+ rp unavailable) — never a redirect loop."""
684
+ if satisfied or not mfa.webauthn_enrolled:
685
+ return None, None
686
+ if not auth.webauthn_available():
687
+ return None, WEBAUTHN_EXTRA_MISSING_NOTICE
688
+ rp = webauthn_rp(request)
689
+ if rp is None:
690
+ return None, WEBAUTHN_RP_MISSING_NOTICE
691
+ options = await auth.begin_webauthn_assertion(token, rp_id=rp[0])
692
+ if options is None:
693
+ # Enrolled, but every credential was minted under a DIFFERENT rp_id (the
694
+ # origin-migration case, ADR 0068 §7) — a legible dead-end naming the
695
+ # admin-reset recovery, never a bare password form with a misleading
696
+ # "complete the passkey prompt" error (PR-A review finding).
697
+ return None, WEBAUTHN_RP_CHANGED_NOTICE
698
+ return options, None
699
+
700
+ @app.get("/ui/mfa", response_class=HTMLResponse)
701
+ async def ui_mfa_form(request: Request) -> Response:
702
+ """The ASVS 6.3.3 confinement page for an MFA-pending browser session.
703
+
704
+ Carries NO ``require_ui`` dependency on purpose — the gate 303s every pending session here, so
705
+ a gated version of this page would redirect to itself. Auth is done by hand instead, in the
706
+ SAME order the gate uses, so the two cannot disagree.
707
+
708
+ A separate route rather than a reuse of ``/ui/reauth``: that page 303s to /ui unless ``next``
709
+ names a registered write action, and its POST always demands the password — which an operator
710
+ proved seconds earlier at sign-in.
711
+ """
712
+ auth = get_auth(request)
713
+ token = session_token(request)
714
+ identity = await auth.identity_for_token(token) if auth is not None else None
715
+ if auth is None or identity is None:
716
+ return login_redirect_response()
717
+ if identity.must_change_password:
718
+ # must_change outranks MFA, mirroring require()/require_ui: a fresh account is both, and
719
+ # only rotation is reachable until it happens.
720
+ return RedirectResponse("/ui/account/password", status_code=303)
721
+ if await auth.mfa_satisfied(token):
722
+ return RedirectResponse("/ui", status_code=303) # idempotent: nothing owed
723
+ mfa = await auth.mfa_status(identity)
724
+ if not (mfa.enabled or mfa.webauthn_enrolled):
725
+ # Required but NOTHING enrolled: there is no factor to ask for. Reuse the existing
726
+ # enroll-first bounce rather than rendering a form that cannot be answered — this is
727
+ # what keeps a fresh account from bouncing between the gate and an empty page.
728
+ return RedirectResponse("/ui/account?m=enroll_first", status_code=303)
729
+ wa_options, wa_notice = await _reauth_webauthn_state(request, auth, token, mfa, False)
730
+ return HTMLResponse(
731
+ pages.mfa_gate(
732
+ totp_enrolled=mfa.enabled,
733
+ webauthn_options=wa_options,
734
+ webauthn_notice=wa_notice,
735
+ )
736
+ )
737
+
738
+ @app.post("/ui/mfa")
739
+ async def ui_mfa_submit(request: Request) -> Response:
740
+ assert_same_origin(request)
741
+ auth = get_auth(request)
742
+ token = session_token(request)
743
+ identity = await auth.identity_for_token(token) if auth is not None else None
744
+ if auth is None or not token or identity is None:
745
+ return login_redirect_response()
746
+ if identity.must_change_password:
747
+ return RedirectResponse("/ui/account/password", status_code=303)
748
+ client = request.client.host if request.client else None
749
+ if not allow_reauth_attempt(auth, identity, client):
750
+ # Same per-ACTOR ceremony budget the reauth/password flows draw on, so code-guessing
751
+ # here cannot outrun it either.
752
+ # Retry-After: 30 matches POST /ui/reauth, the sibling ceremony on the SAME per-actor
753
+ # budget — a different hint for the same limiter would just misreport when it clears.
754
+ raise HTTPException(429, "too many attempts", headers={"Retry-After": "30"})
755
+ form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
756
+ if await auth.verify_mfa(token, form.get("code", ""), client=client):
757
+ return RedirectResponse("/ui", status_code=303)
758
+ mfa = await auth.mfa_status(identity)
759
+ wa_options, wa_notice = await _reauth_webauthn_state(request, auth, token, mfa, False)
760
+ # The submitted code is NOT echoed back — it is a bearer credential, and verify_mfa has
761
+ # already audited the failure. Generic copy: the form cannot say whether the code was
762
+ # wrong or expired without narrowing a guess.
763
+ return HTMLResponse(
764
+ pages.mfa_gate(
765
+ totp_enrolled=mfa.enabled,
766
+ error="That code wasn't accepted. Try again.",
767
+ webauthn_options=wa_options,
768
+ webauthn_notice=wa_notice,
769
+ ),
770
+ status_code=400,
771
+ )
772
+
773
+ @app.get("/ui/reauth", response_class=HTMLResponse)
774
+ async def ui_reauth_form(
775
+ request: Request,
776
+ next_: str = Query("", alias="next", max_length=512),
777
+ ) -> Response:
778
+ # next MUST be a registered /ui action — a body-less POST the re-auth may auto-retry
779
+ # (is_safe_ui_action) OR a GET admin form page it may unlock (is_unlock_action). Never an
780
+ # arbitrary URL (anti open-redirect) — an unregistered next bounces to /ui.
781
+ action = lookup_ui_action(next_)
782
+ if action is None:
783
+ return RedirectResponse("/ui", status_code=303)
784
+ auth = get_auth(request)
785
+ token = session_token(request)
786
+ identity = await auth.identity_for_token(token) if auth is not None else None
787
+ if auth is None or identity is None:
788
+ # The session ended under the operator (expiry / revoke) — a post-termination landing
789
+ # like any other, so it carries Clear-Site-Data + the explanatory code (14.3.1).
790
+ return login_redirect_response()
791
+ if identity.must_change_password:
792
+ # Mirror require_ui's confinement: a must-change session can only rotate (L4b).
793
+ return RedirectResponse("/ui/account/password", status_code=303)
794
+ mfa = await auth.mfa_status(identity)
795
+ if mfa.required and not (mfa.enabled or mfa.webauthn_enrolled) and action.step_up:
796
+ # A full-step-up action a required-but-UNENROLLED session (no factor of EITHER
797
+ # kind — ADR 0068 decision 1(a)) can NEVER satisfy — send it to enroll instead
798
+ # of a password form that would loop straight back. Enrollment itself is
799
+ # step_up=False (below).
800
+ return RedirectResponse("/ui/account?m=enroll_first", status_code=303)
801
+ # The rendering splits BY FACTOR (decision 1(b)): the TOTP code field renders iff
802
+ # TOTP is enrolled (a required-but-unenrolled account can never produce a code —
803
+ # demanding one would deadlock, L4b); the passkey hook renders iff WebAuthn is
804
+ # enrolled — a WebAuthn-only user sees password + passkey, never an unanswerable
805
+ # code field; a both-enrolled user sees both, either satisfies.
806
+ satisfied = await auth.mfa_satisfied(token)
807
+ mfa_needed = not satisfied and mfa.enabled
808
+ wa_options, wa_notice = await _reauth_webauthn_state(request, auth, token, mfa, satisfied)
809
+ return HTMLResponse(
810
+ pages.reauth(
811
+ next_,
812
+ mfa_needed=mfa_needed,
813
+ webauthn_options=wa_options,
814
+ webauthn_notice=wa_notice,
815
+ )
816
+ )
817
+
818
+ @app.post("/ui/reauth")
819
+ async def ui_reauth(request: Request) -> Response:
820
+ assert_same_origin(request)
821
+ auth = get_auth(request)
822
+ token = session_token(request)
823
+ identity = await auth.identity_for_token(token) if auth is not None else None
824
+ if auth is None or not token or identity is None:
825
+ return login_redirect_response() # session ended mid-ceremony — see ui_reauth_form
826
+ if identity.must_change_password:
827
+ # Mirror require_ui's confinement: a must-change session can only rotate (L4b).
828
+ return RedirectResponse("/ui/account/password", status_code=303)
829
+ form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
830
+ next_ = form.get("next", "")
831
+ action = lookup_ui_action(next_)
832
+ if action is None:
833
+ return RedirectResponse("/ui", status_code=303)
834
+ mfa = await auth.mfa_status(identity)
835
+ if mfa.required and not (mfa.enabled or mfa.webauthn_enrolled) and action.step_up:
836
+ # See ui_reauth_form: a full-step-up action this session can never satisfy (no
837
+ # factor of EITHER kind — ADR 0068 decision 1(a)) — send it to enroll rather
838
+ # than loop. Checked BEFORE the rate limiter so a correct password isn't burned
839
+ # into a 429 (the review's silent-loop finding; the ordering pin covers the
840
+ # generalized condition too).
841
+ return RedirectResponse("/ui/account?m=enroll_first", status_code=303)
842
+ satisfied = await auth.mfa_satisfied(token)
843
+ if not satisfied and not mfa.enabled and mfa.webauthn_enrolled:
844
+ # ADR 0068 decision 1(d): a WebAuthn-ONLY user's password form can never satisfy
845
+ # MFA by itself — the passkey leg (POST /ui/reauth/webauthn) must run first.
846
+ # Checked BEFORE the rate limiter (parallel to the anti-loop check) so a
847
+ # password-first submission burns no limiter slot and no password verify runs
848
+ # before the ceremony. Never "Invalid code." — the user has no code to type.
849
+ wa_options, wa_notice = await _reauth_webauthn_state(
850
+ request, auth, token, mfa, satisfied
851
+ )
852
+ return HTMLResponse(
853
+ pages.reauth(
854
+ next_,
855
+ mfa_needed=False,
856
+ webauthn_options=wa_options,
857
+ webauthn_notice=wa_notice,
858
+ error=wa_notice
859
+ or "Complete the passkey prompt first, then re-enter your password.",
860
+ ),
861
+ status_code=400,
862
+ )
863
+ client = request.client.host if request.client else None
864
+ if not allow_reauth_attempt(auth, identity, client): # per-ACTOR, not the sign-in budget
865
+ raise HTTPException(429, "too many attempts", headers={"Retry-After": "30"})
866
+ # Satisfy whichever factor is pending — TOTP first (mirrors require_step_up), then
867
+ # password. The code is only demanded from a user with an ENROLLED authenticator
868
+ # (decision 1(c): the code branch keys on TOTP enrollment alone — a WebAuthn-only
869
+ # user is never asked for a code): a required-but-unenrolled account reaches this
870
+ # page on its way to enrolling (L4b) and has nothing to type — its enrollment routes
871
+ # gate on the password step-up alone (require_ui_reauth_only), exactly like the JSON
872
+ # require_reauth_only. Error re-renders re-stage FRESH assertion options (decision
873
+ # 1(e)): the prior challenge was single-use, and the passkey button must survive a
874
+ # failed password/code attempt.
875
+ mfa_enrolled = mfa.enabled
876
+ if mfa_enrolled and not satisfied:
877
+ code = form.get("code", "").strip()
878
+ if not code or not await auth.verify_mfa(token, code, client=client):
879
+ wa_options, wa_notice = await _reauth_webauthn_state(
880
+ request, auth, token, mfa, await auth.mfa_satisfied(token)
881
+ )
882
+ return HTMLResponse(
883
+ pages.reauth(
884
+ next_,
885
+ mfa_needed=True,
886
+ webauthn_options=wa_options,
887
+ webauthn_notice=wa_notice,
888
+ error="Invalid code.",
889
+ )
890
+ )
891
+ # 7.5.1 (ADR 0077): mint the single-use grant bound to this continuation's action. action.action
892
+ # is None for every non-factor continuation (replay/purge/config/create-user), so reauth mints
893
+ # nothing there and those flows stay byte-identical; the factor-binding lanes tag their action.
894
+ if not await auth.reauth(
895
+ identity, form.get("password", ""), token=token, client=client, purpose=action.action
896
+ ):
897
+ still_unsatisfied = not await auth.mfa_satisfied(token)
898
+ wa_options, wa_notice = await _reauth_webauthn_state(
899
+ request, auth, token, mfa, not still_unsatisfied
900
+ )
901
+ return HTMLResponse(
902
+ pages.reauth(
903
+ next_,
904
+ mfa_needed=mfa_enrolled and still_unsatisfied,
905
+ webauthn_options=wa_options,
906
+ webauthn_notice=wa_notice,
907
+ error="Incorrect password.",
908
+ )
909
+ )
910
+ # Fully stepped up. Hand control back per the action's continuation style:
911
+ # - an unlock target is a GET admin form → 303-GET-redirect so it re-opens inside the now
912
+ # fresh window; the operator then submits the body-carrying POST (incl. a create-user
913
+ # password) once, never crossing /ui/reauth (the stateless confirm-after-step-up path).
914
+ # - otherwise it is a body-less POST action → auto-retry it via the same-origin submit form.
915
+ if is_unlock_action(next_):
916
+ return RedirectResponse(next_, status_code=303)
917
+ return HTMLResponse(pages.reauth_continue(next_))
918
+
919
+ # ADR 0068 decision 6: the browser passkey leg of step-up. A cookie-authed JSON POST
920
+ # (the sanctioned /ui carve — the cookie stays confined to /ui deps; bearer_token()
921
+ # is untouched) that verifies an assertion and stamps the session's MFA leg ONLY —
922
+ # the operator still submits POST /ui/reauth (password) for reauth_at + the WP-L3-13
923
+ # client re-anchor. NOT registered as a continuation (body-carrying JSON — part of the
924
+ # step-up mechanism itself). MFA-pending sessions pass (the assertion IS the proof);
925
+ # must-change confinement is mirrored manually like both /ui/reauth handlers.
926
+ @app.post("/ui/reauth/webauthn")
927
+ async def ui_reauth_webauthn(request: Request) -> Response:
928
+ assert_same_origin(request)
929
+ auth = get_auth(request)
930
+ token = session_token(request)
931
+ identity = await auth.identity_for_token(token) if auth is not None else None
932
+ if auth is None or not token or identity is None:
933
+ return JSONResponse({"ok": False, "error": "session expired"}, status_code=401)
934
+ if identity.must_change_password:
935
+ # Mirror require_ui's confinement: a must-change session can only rotate (L4b).
936
+ return JSONResponse({"ok": False, "error": "password change required"}, status_code=403)
937
+ rp = webauthn_rp(request)
938
+ if rp is None:
939
+ return JSONResponse({"ok": False, "error": "rp_unavailable"}, status_code=409)
940
+ client = request.client.host if request.client else None
941
+ if not allow_reauth_attempt(auth, identity, client): # per-ACTOR, not the sign-in budget
942
+ return JSONResponse(
943
+ {"ok": False, "error": "too many attempts"},
944
+ status_code=429,
945
+ headers={"Retry-After": "30"},
946
+ )
947
+ try:
948
+ body = await request.json()
949
+ response_json = json.dumps(body["response"])
950
+ except (ValueError, KeyError, TypeError):
951
+ return JSONResponse({"ok": False, "error": "malformed request"}, status_code=400)
952
+ ok = await auth.finish_webauthn_assertion(
953
+ token, response_json, client=client, rp_id=rp[0], origin=rp[1]
954
+ )
955
+ if not ok:
956
+ return JSONResponse(
957
+ {"ok": False, "error": "passkey verification failed"}, status_code=400
958
+ )
959
+ return JSONResponse({"ok": True})
960
+
961
+ # Bulk dead-letter replay (M3): re-queue ALL dead deliveries for one channel. Like message
962
+ # replay it is require_step_up (→ require_ui_step_up, which 303s to /ui/reauth on a stale
963
+ # step-up; the channel is in the PATH so the auto-retry re-POST carries it — no lost body).
964
+ # Reuses the JSON replay_dead_letters handler, so the dual-control approval gate applies: when
965
+ # it holds the op for a second approver, surface that instead of redirecting.
966
+ async def _ui_dl_replay(
967
+ request: Request,
968
+ channel_id: str | None,
969
+ destination_name: str | None,
970
+ engine: Any,
971
+ identity: Identity,
972
+ gate: Any,
973
+ ) -> Response:
974
+ assert_same_origin(request)
975
+ # channel_id=None ⇒ every channel (the all-channels scope, L6b); the JSON handler
976
+ # pre-checks scope and refuses a channel-scoped user before mutating anything.
977
+ result = await core.replay_dead_letters(
978
+ DeadLetterReplayRequest(channel_id=channel_id, destination_name=destination_name),
979
+ Response(),
980
+ engine=engine,
981
+ identity=identity,
982
+ gate=gate,
983
+ request=request,
984
+ )
985
+ if isinstance(result, PendingApprovalResponse):
986
+ return HTMLResponse(pages.dead_letter_pending(result))
987
+ return RedirectResponse("/ui/dead-letters", status_code=303)
988
+
989
+ # L6b (#75 parity): replay ALL dead deliveries across every channel in one action (the
990
+ # desktop's null-scope "Replay all"). Declared before the {channel_id} routes; the
991
+ # literal `replay-all` can't be a channel id (it has no `/replay` suffix). Same
992
+ # step-up + dual-control gate; the JSON handler still denies channel-scoped users.
993
+ @app.post("/ui/dead-letters/replay-all")
994
+ async def ui_replay_all_dead_letters(
995
+ request: Request,
996
+ engine: Any = Depends(deps.get_engine),
997
+ identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_REPLAY)),
998
+ gate: Any = Depends(deps.get_gate),
999
+ ) -> Response:
1000
+ return await _ui_dl_replay(request, None, None, engine, identity, gate)
1001
+
1002
+ @app.post("/ui/dead-letters/{channel_id}/replay")
1003
+ async def ui_replay_dead_letters(
1004
+ channel_id: str,
1005
+ request: Request,
1006
+ engine: Any = Depends(deps.get_engine),
1007
+ identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_REPLAY)),
1008
+ gate: Any = Depends(deps.get_gate),
1009
+ ) -> Response:
1010
+ # All dead deliveries for the channel (every destination).
1011
+ return await _ui_dl_replay(request, channel_id, None, engine, identity, gate)
1012
+
1013
+ @app.post("/ui/dead-letters/{channel_id}/{destination_name}/replay")
1014
+ async def ui_replay_dead_letters_dest(
1015
+ channel_id: str,
1016
+ destination_name: str,
1017
+ request: Request,
1018
+ engine: Any = Depends(deps.get_engine),
1019
+ identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_REPLAY)),
1020
+ gate: Any = Depends(deps.get_gate),
1021
+ ) -> Response:
1022
+ # Just the dead deliveries for this (channel, destination).
1023
+ return await _ui_dl_replay(request, channel_id, destination_name, engine, identity, gate)
1024
+
1025
+ @app.post("/ui/csp-report")
1026
+ async def ui_csp_report(request: Request) -> Response:
1027
+ # ASVS 3.5.1 — FIRST statement. The THIRD unguarded /ui POST is disposed of here, with the
1028
+ # NARROW guard rather than the full same-origin check, because the two are not interchangeable
1029
+ # on a report sink: `report-uri` delivery is document-initiated (Sec-Fetch-Site: same-origin),
1030
+ # but Reporting-API (`report-to`) delivery is made OUT OF BAND by the user agent's reporting
1031
+ # agent — no Sec-Fetch-* headers, possibly `Origin: null` — so assert_same_origin's Origin
1032
+ # fallback would 403 every modern report and silently blind the 3.7.5 canary. The property that
1033
+ # actually matters here is preserved: a report a FOREIGN site's CSP aimed at this endpoint
1034
+ # (log amplification) is refused. The residual exposure of the header-less path is bounded by
1035
+ # construction — the sink is unauthenticated, non-state-changing and observation-only: it
1036
+ # parses defensively, never echoes or acts on the body, logs one bounded PHI-free summary and
1037
+ # 204s, under the engine's 1 MiB request-body cap.
1038
+ assert_not_cross_site(request)
1039
+ # Browser-delivered CSP violation report (ASVS 3.7.5). UNAUTHENTICATED and non-mutating: a
1040
+ # browser attaches no session credential to a report POST, and this only observes. The body is
1041
+ # attacker-influenceable DATA (never instructions) — parse it defensively, log a BOUNDED,
1042
+ # PHI-free summary at WARNING (the /ui surface carries no message bodies in its URLs), and 204.
1043
+ # Never echo or act on the report. Both the legacy report-uri body and the modern report-to
1044
+ # ARRAY (the wired Reporting-Endpoints header) are normalized by ``_csp_report_bodies``.
1045
+ client = request.client.host if request.client else "<unknown>"
1046
+ raw = await request.body()
1047
+ if not raw:
1048
+ _log.warning("CSP violation report from %s: %s", client, "empty")
1049
+ return Response(status_code=204)
1050
+ try:
1051
+ doc = json.loads(raw.decode("utf-8", "replace"))
1052
+ except ValueError:
1053
+ _log.warning("CSP violation report from %s: %s", client, "malformed-json")
1054
+ return Response(status_code=204)
1055
+ bodies = _csp_report_bodies(doc)
1056
+ if bodies is None:
1057
+ _log.warning("CSP violation report from %s: %s", client, "non-object")
1058
+ return Response(status_code=204)
1059
+ # Partition the BATCH, never classify it by one entry. The canary fires once per page load on
1060
+ # every conforming browser and the Reporting API batches per endpoint, so a real violation
1061
+ # raised on the same page load arrives in the same POST alongside the canary's report. Only
1062
+ # the canary's own entries are dropped to DEBUG (so it never floods the operational log); a
1063
+ # batch containing ANY other blocked URL — including "inline", the shape a real XSS attempt
1064
+ # produces — still WARNS, and the warning summarises the REAL entries, not the canary.
1065
+ origin = _request_origin(request)
1066
+ real = [b for b in bodies if not _is_expected_csp_probe_report(b, origin)]
1067
+ canary_count = len(bodies) - len(real)
1068
+ if canary_count:
1069
+ _log.debug("CSP enforcement canary blocked as designed (%d report(s))", canary_count)
1070
+ if real or not bodies:
1071
+ # Bounded on BOTH axes — per-field (256 chars, in the summariser), per-batch (the first
1072
+ # few entries) and overall (1024 chars) — so a hostile flood cannot inflate the log.
1073
+ shown = " | ".join(_csp_report_summary(b) for b in real[:_CSP_REPORT_SUMMARY_MAX])
1074
+ if len(real) > _CSP_REPORT_SUMMARY_MAX:
1075
+ shown += f" (+{len(real) - _CSP_REPORT_SUMMARY_MAX} more)"
1076
+ _log.warning("CSP violation report from %s: %s", client, (shown or "empty")[:1024])
1077
+ return Response(status_code=204)