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,539 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Zero-dependency, autoescape-by-default HTML rendering for the /ui ops dashboard (ADR 0065).
4
+
5
+ Security model (the reason this exists instead of a template engine): the **only** way to place a
6
+ dynamic value into the page is through :func:`el`/:func:`text`, which HTML-escape by default. Markup
7
+ that is already known safe must be wrapped explicitly in :class:`Markup`. There is **no**
8
+ template-syntax escape hatch (no ``|safe``), so an un-escaped injection of attacker-influenced HL7 is
9
+ not expressible in a page builder. Treat every message/HL7 value as hostile data.
10
+
11
+ This keeps the browser UI at **zero new runtime dependencies** (no jinja2, no npm) — a deliberate
12
+ trade recorded in ADR 0065; the module is small and localized so a later swap to a template engine is
13
+ contained.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextvars
19
+ from collections.abc import Iterable
20
+ from html import escape
21
+
22
+ __all__ = [
23
+ "CSP_PROBE_SRC",
24
+ "SCRIPTS_BLOCKED_BANNER_ID",
25
+ "SCRIPTS_OK_CLASS",
26
+ "Markup",
27
+ "attr",
28
+ "current_csp_nonce",
29
+ "el",
30
+ "minimal_nav",
31
+ "page",
32
+ "register_nav",
33
+ "reset_csp_nonce",
34
+ "rows_table",
35
+ "set_csp_nonce",
36
+ "text",
37
+ ]
38
+
39
+ #: Per-response CSP nonce (ADR 0065 §hardening / BACKLOG #192, ASVS 3.4.7/3.4.8). The /ui security
40
+ #: middleware mints one per SECURE-CONTEXT response (effective-https OR the loopback secure-context —
41
+ #: ``security_headers_context``, ADR 0143) and binds it here BEFORE the route renders; :func:`page`
42
+ #: reads it to stamp the ``<script>`` tag so it matches that response's ``script-src 'nonce-…'`` header.
43
+ #: A ContextVar (not a module global) so concurrent requests never share a nonce; ``None`` when the
44
+ #: middleware binds none (the org opt-out, or a cleartext NON-loopback context) means no nonce is emitted
45
+ #: (byte-identity with the pre-#192 tag).
46
+ _CSP_NONCE: contextvars.ContextVar[str | None] = contextvars.ContextVar(
47
+ "mf_ui_csp_nonce", default=None
48
+ )
49
+
50
+
51
+ def set_csp_nonce(nonce: str | None) -> contextvars.Token[str | None]:
52
+ """Bind ``nonce`` for the current context (the /ui security middleware, per secure-context response —
53
+ effective-https OR the loopback secure-context, ADR 0143). Returns the reset token the middleware
54
+ restores in its ``finally``."""
55
+ return _CSP_NONCE.set(nonce)
56
+
57
+
58
+ def reset_csp_nonce(token: contextvars.Token[str | None]) -> None:
59
+ """Undo a :func:`set_csp_nonce` binding (middleware teardown)."""
60
+ _CSP_NONCE.reset(token)
61
+
62
+
63
+ def current_csp_nonce() -> str | None:
64
+ """The CSP nonce bound for this response, or ``None`` (no nonce: the org opt-out, or a cleartext
65
+ NON-loopback context — the loopback secure-context binds one, ADR 0143)."""
66
+ return _CSP_NONCE.get()
67
+
68
+
69
+ # HTML void elements never get a closing tag or children.
70
+ _VOID = frozenset(
71
+ {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "wbr"}
72
+ )
73
+
74
+
75
+ class Markup(str):
76
+ """A string already known to be safe HTML — never re-escaped by :func:`el`/:func:`text`.
77
+
78
+ Only ever construct this from trusted, developer-authored markup (never from message/HL7 data).
79
+ Results of :func:`el`/:func:`page` are ``Markup`` so builders compose without double-escaping.
80
+ """
81
+
82
+ __slots__ = ()
83
+
84
+
85
+ def text(value: object) -> Markup:
86
+ """Escape any value to safe HTML text. ``Markup`` passes through; ``None`` renders empty."""
87
+ if isinstance(value, Markup):
88
+ return value
89
+ return Markup(escape("" if value is None else str(value), quote=True))
90
+
91
+
92
+ def _render_child(child: object) -> str:
93
+ if isinstance(child, Markup):
94
+ return child
95
+ if isinstance(child, (list, tuple)):
96
+ return "".join(_render_child(c) for c in child)
97
+ return escape("" if child is None else str(child), quote=True)
98
+
99
+
100
+ def attr(name: str, value: object) -> Markup:
101
+ """Render a single escaped ``name="value"`` attribute (both sides escaped)."""
102
+ return Markup(f'{escape(name)}="{escape(str(value), quote=True)}"')
103
+
104
+
105
+ def el(tag: str, *children: object, **attrs: object) -> Markup:
106
+ """Build an element with escaped attributes and escaped children.
107
+
108
+ Attribute keys map ``_`` → ``-`` and a trailing ``_`` is stripped (so ``class_`` → ``class``,
109
+ ``hx_get`` → ``hx-get``). A ``None``/``False`` attribute value is omitted; ``True`` renders a bare
110
+ attribute. Children that are :class:`Markup` pass through; any other value is HTML-escaped — so a
111
+ raw ``str`` (e.g. an HL7 field) can never inject markup.
112
+ """
113
+ parts: list[str] = [f"<{escape(tag)}"]
114
+ for key, value in attrs.items():
115
+ if value is None or value is False:
116
+ continue
117
+ name = key.rstrip("_").replace("_", "-")
118
+ if value is True:
119
+ parts.append(f" {escape(name)}")
120
+ else:
121
+ parts.append(f' {escape(name)}="{escape(str(value), quote=True)}"')
122
+ parts.append(">")
123
+ if tag in _VOID:
124
+ return Markup("".join(parts))
125
+ for child in children:
126
+ parts.append(_render_child(child))
127
+ parts.append(f"</{escape(tag)}>")
128
+ return Markup("".join(parts))
129
+
130
+
131
+ def page(
132
+ title: str,
133
+ *body: object,
134
+ nav: object = None,
135
+ active: str = "",
136
+ head_extra: object = None,
137
+ ) -> Markup:
138
+ """Wrap page ``body`` in the shared document chrome (doctype, head, nav, main).
139
+
140
+ ``title`` and all ``body`` content are escaped by the element builders. The head links the
141
+ same-origin ``/ui/static`` assets. There are no ``on*`` handlers anywhere.
142
+
143
+ **The 3.7.5 hardening detects, all emitted on the single ``nonce is not None`` gate** — a
144
+ per-response CSP nonce is bound, i.e. an effective-https response OR the loopback secure-context
145
+ (``security_headers_context``, ADR 0143). Four artifacts ship together, or none of them do:
146
+
147
+ * a NONCE'D inline mark script that stamps :data:`SCRIPTS_OK_CLASS` on ``<html>`` (and, as a belt,
148
+ hides the banner below directly once the body is parsed);
149
+ * an UN-NONCED external ``<script src=`` :data:`CSP_PROBE_SRC` ``>`` — the CSP-enforcement canary,
150
+ deliberately NOT nonced: an enforcing browser must refuse it, so a nonce would invert the signal;
151
+ * a NONCE'D inline detect that raises the insecure-context and CSP-degraded banners;
152
+ * the server-rendered :data:`SCRIPTS_BLOCKED_BANNER_ID` ``role="alert"`` in ``<body>``, which the
153
+ mark script above removes from view on a healthy client.
154
+
155
+ With no nonce bound (the org opt-out, or a cleartext NON-loopback context) NONE of the four is
156
+ emitted and the shell is byte-identical to the pre-hardening page: no inline script the static
157
+ ``script-src 'self'`` CSP would block, and no canary that same policy would ALLOW (which would read
158
+ as "CSP not enforced" on a perfectly conforming browser).
159
+
160
+ ``head_extra`` appends markup to the ``<head>`` and is ``None`` for every existing caller, so the
161
+ rendered document is byte-identical unless a caller opts in. It exists for the federated-login
162
+ landing hop (ADR 0142), which needs a ``<meta http-equiv="refresh">``: a pragma directive is only
163
+ conforming inside ``<head>``, and relying on browsers' tolerance of one in ``<body>`` would make a
164
+ security-critical redirect depend on undefined behaviour.
165
+ """
166
+ nonce = current_csp_nonce()
167
+ head_parts: list[Markup] = [
168
+ el("meta", charset="utf-8"),
169
+ el("meta", name="viewport", content="width=device-width, initial-scale=1"),
170
+ el("meta", name="referrer", content="no-referrer"),
171
+ el("title", f"{title} — MessageFoundry"),
172
+ el("link", rel="stylesheet", href="/ui/static/app.css"),
173
+ # First-party live-poll script (no third-party JS). On a SECURE-CONTEXT response (effective-https
174
+ # OR the loopback secure-context — ``security_headers_context``, ADR 0143) the /ui security
175
+ # middleware binds a per-response CSP nonce that stamps this tag + the matching
176
+ # ``script-src 'nonce-…' 'strict-dynamic'`` header (ADR 0065 §hardening / #192); when no nonce is
177
+ # bound (opt-out / cleartext non-loopback) the nonce is None and the tag is byte-identical (CSP:
178
+ # script-src 'self').
179
+ el("script", src="/ui/static/app.js", defer=True, nonce=nonce),
180
+ ]
181
+ # 3.7.5 client-side hardening detects: emitted ONLY when a per-response nonce is bound (a
182
+ # secure-context response — effective-https OR loopback, ADR 0143). With no nonce bound (opt-out /
183
+ # cleartext non-loopback) ``nonce is None`` -> the shell is byte-identical AND we never emit an
184
+ # inline <script> the ``script-src 'self'`` CSP would block (nor the canary, which that policy
185
+ # would ALLOW as 'self' and so would falsely read as "CSP not enforced").
186
+ if nonce is not None:
187
+ # (a0) The SCRIPTS-RUNNING MARK: a nonce'd inline script that flags the document element, so
188
+ # the server-rendered ``mf-scripts-blocked-banner`` below is hidden by app.css BEFORE first
189
+ # paint on a healthy client (no flash) and STANDS on a client that runs no script at all. It
190
+ # is first in document order so the mark is set as early as possible, and it is the inverse
191
+ # detect to the canary: the canary catches "CSP not enforced", this catches "our own nonce'd
192
+ # scripts did not run" — a browser that ENFORCES CSP but does not understand nonce sources
193
+ # blocks every script under ``script-src 'nonce-…' 'strict-dynamic'`` (app.js, the detect
194
+ # below, and the 14.3.1 session watchdog), so no script-raised banner could ever render.
195
+ head_parts.append(el("script", Markup(_SCRIPTS_RUNNING_MARK_JS), nonce=nonce))
196
+ # (a) The CSP-ENFORCEMENT CANARY: an UN-NONCED external script. Parser-blocking (no defer/
197
+ # async) so it resolves BEFORE the nonce'd detect below runs — classic scripts execute in
198
+ # document order. Under ``script-src 'nonce-…' 'strict-dynamic'`` an enforcing browser refuses
199
+ # it before the fetch and ``window.__mfCspProbe`` stays undefined; a browser that does not
200
+ # enforce CSP runs it. An EXTERNAL probe rather than an inline one is deliberate: its blocked
201
+ # URL is a unique discriminator, so the one expected violation report can be filtered out of
202
+ # the log by path WITHOUT a filter broad enough to also swallow the report a real inline XSS
203
+ # injection would produce (which is indistinguishable from a blocked inline canary), and
204
+ # without adding ``'report-sample'`` — which would put attacker-influenced script text into
205
+ # the general log.
206
+ head_parts.append(el("script", src=CSP_PROBE_SRC))
207
+ # (b) The nonce'd detect + banner script, which reads (a)'s outcome. Both banners
208
+ # degrade-never-block (see ``_INSECURE_CONTEXT_WARN_JS`` / ``_CSP_NOT_ENFORCED_WARN_JS``).
209
+ head_parts.append(
210
+ el("script", Markup(_INSECURE_CONTEXT_WARN_JS + _CSP_NOT_ENFORCED_WARN_JS), nonce=nonce)
211
+ )
212
+ if head_extra is not None:
213
+ head_parts.append(Markup(str(head_extra)))
214
+ head = Markup("".join(head_parts))
215
+ header = nav if nav is not None else _default_nav(active)
216
+ # The scripts-blocked banner is SERVER-rendered (plain HTML, no script needed to show it) and
217
+ # removed from view by app.css only once the nonce'd mark script has run — fail-VISIBLE. Emitted
218
+ # on the same gate as the head detects, so the no-nonce shell stays byte-identical.
219
+ body_parts: list[object] = []
220
+ if nonce is not None:
221
+ body_parts.append(_SCRIPTS_BLOCKED_BANNER)
222
+ body_parts.extend((header, el("main", *body)))
223
+ document = Markup(
224
+ "<!doctype html>"
225
+ + el(
226
+ "html",
227
+ el("head", head),
228
+ el("body", *body_parts),
229
+ lang="en",
230
+ )
231
+ )
232
+ return document
233
+
234
+
235
+ # The top-nav registry (key, href, label), in display order. Seeded with the core phase-0 items; a
236
+ # page lane appends ONE entry via register_nav() co-located with its builder, so parallel lanes never
237
+ # collide on a central literal (ADR 0065 §multi-session-build). Display order = registration order.
238
+ _NAV_ITEMS: list[tuple[str, str, str]] = [
239
+ ("dashboard", "/ui", "Connections"),
240
+ ("messages", "/ui/messages", "Messages"),
241
+ ("dead-letters", "/ui/dead-letters", "Dead letters"),
242
+ ]
243
+
244
+
245
+ def register_nav(key: str, href: str, label: str) -> None:
246
+ """Register a top-nav item (idempotent by ``key``; appended at the tail = displayed last).
247
+
248
+ A read/admin page lane calls this at import from its own page module to add itself to the nav
249
+ without editing this file — the append-only seam that keeps parallel lanes conflict-free.
250
+ """
251
+ if not any(existing == key for existing, _href, _label in _NAV_ITEMS):
252
+ _NAV_ITEMS.append((key, href, label))
253
+
254
+
255
+ def wordmark(*, tm: bool = False) -> Markup:
256
+ """The **MessageFoundry** wordmark, per the brand wordmark guidelines (June 2026): the single
257
+ camelCase word with ``Message`` in the base text color and ``Foundry`` in molten amber
258
+ (``#f59e0b``, the ``--foundry`` token). ``tm=True`` appends the superscript ™ — set it on the
259
+ primary lockup (the masthead) and the most-prominent appearance (the sign-in heading), and omit
260
+ it on repeated or running-text mentions. The amber stays confined to this mark and headings —
261
+ never body copy. ``Message`` and ``Foundry`` are adjacent with no separating space so the mark
262
+ renders as one word.
263
+ """
264
+ parts: list[object] = ["Message", el("span", "Foundry", class_="wm-foundry")]
265
+ if tm:
266
+ parts.append(el("sup", "™", class_="wm-tm"))
267
+ return el("span", *parts, class_="wordmark")
268
+
269
+
270
+ #: Top-nav groups, each rendered as a dropdown: (menu label, member keys). A registered key not listed
271
+ #: here falls into a trailing "More" menu, so a new page lane still appears without editing this.
272
+ _NAV_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
273
+ ("Traffic", ("dashboard", "messages", "dead-letters", "events")),
274
+ ("Monitoring", ("status", "alerts", "flow", "audit", "uploaded-logs")),
275
+ ("Admin", ("users", "config")),
276
+ ("Account", ("account", "security-events")),
277
+ )
278
+
279
+
280
+ # Two live status glyphs pinned to the right of the nav (left of Sign out): the alerts bell and the
281
+ # engine-health heart. They render NEUTRAL (gray) with data hooks; app.js polls GET /ui/nav-status (~15s,
282
+ # from every page) and recolors them — green/orange/blinking-red for engine health, severity-colored or gray
283
+ # for alerts. Monochrome inline SVG with fill=currentColor so a CSS `color` drives the tint (emoji can't be
284
+ # recolored). The SVGs are static, hand-authored Markup constants with NO data interpolation — no injection
285
+ # surface under the CSP. (Material-style glyph paths, 24×24 viewBox.)
286
+ _BELL_SVG = Markup(
287
+ '<svg class="statglyph" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" '
288
+ 'focusable="false"><path fill="currentColor" d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07'
289
+ "-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1"
290
+ 'l-2-2z"/></svg>'
291
+ )
292
+ _HEART_SVG = Markup(
293
+ '<svg class="statglyph" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" '
294
+ 'focusable="false"><path fill="currentColor" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 '
295
+ "4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 "
296
+ '6.86-8.55 11.54L12 21.35z"/></svg>'
297
+ )
298
+
299
+ # 3.7.5 (ASVS): a nonce'd, client-side insecure-context banner. window.isSecureContext is the
300
+ # transport precondition a page script can read directly; CSP-nonce ENFORCEMENT is detected
301
+ # separately and actively by the un-nonced canary (``_CSP_NOT_ENFORCED_WARN_JS`` below reads its
302
+ # outcome). COOP/CORP enforcement remains genuinely undetectable from inside the page — no browser API
303
+ # exposes it — and is documented as degrade-silent-with-rationale rather than warned. Both banners are
304
+ # a VISIBLE signal, never an active block (degrade-never-block): each only inserts a DOM node, wrapped
305
+ # in try/catch, textContent-only, NO inline on* handler.
306
+ # Static developer-authored JS with no data interpolation (Markup, like the nav SVGs) => not an
307
+ # injection surface. page() emits it whenever a per-response nonce is bound — a secure-context response
308
+ # (effective-https OR the loopback secure-context, ADR 0143). With no nonce bound (opt-out / cleartext
309
+ # non-loopback) the shell stays byte-identical AND the `script-src 'self'` CSP would otherwise block an
310
+ # un-nonced inline script. http://localhost / http://127.0.0.1 are themselves secure contexts, so even
311
+ # when the loopback shell now carries this nonce'd script, window.isSecureContext is true and it never
312
+ # trips (no banner shown).
313
+ _INSECURE_CONTEXT_WARN_JS = Markup(
314
+ "(function(){try{if(window.isSecureContext===false){"
315
+ "var show=function(){"
316
+ "if(!document.body||document.getElementById('mf-insecure-context-banner'))return;"
317
+ "var b=document.createElement('div');b.id='mf-insecure-context-banner';"
318
+ "b.className='mf-insecure-banner';b.setAttribute('role','alert');"
319
+ "b.textContent='Insecure connection: this console is not being served to your browser over "
320
+ "HTTPS. Browser hardening (secure cookies, COOP, CSP nonces) is degraded. Reach it over the "
321
+ "https:// origin behind the documented reverse proxy.';"
322
+ "document.body.insertBefore(b,document.body.firstChild);};"
323
+ "if(document.body){show();}else{document.addEventListener('DOMContentLoaded',show);}"
324
+ "}}catch(e){}})();"
325
+ )
326
+
327
+ #: The un-nonced CSP-enforcement canary the shell loads (see :func:`page`). A browser enforcing the
328
+ #: nonce CSP blocks it; the resulting violation report is the ONE expected report, filtered out of the
329
+ #: log by this exact path in ``routes.core`` (ASVS 3.7.5).
330
+ CSP_PROBE_SRC = "/ui/static/csp-probe.js"
331
+
332
+ #: The class the nonce'd mark script stamps on ``<html>`` and the id of the server-rendered banner it
333
+ #: thereby hides (``app.css`` owns the hiding rule). Named constants because THREE artifacts must
334
+ #: agree — the mark script, the banner markup, and the stylesheet.
335
+ SCRIPTS_OK_CLASS = "mf-scripts-ok"
336
+ SCRIPTS_BLOCKED_BANNER_ID = "mf-scripts-blocked-banner"
337
+
338
+ # 3.7.5: the nonce'd mark. Static developer-authored JS, no data interpolation (Markup, like the nav
339
+ # SVGs) => not an injection surface. ``className +=`` rather than classList for maximum compatibility
340
+ # with exactly the old/limited clients this detect exists for.
341
+ #
342
+ # TWO mechanisms, deliberately: the class stamp (hidden by app.css BEFORE first paint, no flash) is the
343
+ # LOAD-BEARING path — it is the only one that can work on a client that runs no script at all, which is
344
+ # the case the banner exists for. The direct ``style.display`` hide is a BELT against a warn control
345
+ # that cries wolf on a healthy client: the stylesheet link carries no cache-buster (adding one would
346
+ # break the byte-identity of the no-nonce shell, a deliberate invariant), and the static mount emits
347
+ # etag/last-modified but no Cache-Control, so a browser applying heuristic freshness can render
348
+ # post-upgrade HTML against a pre-upgrade app.css that has no hiding rule yet. Scripts run in that
349
+ # scenario, so the belt fires and the banner never appears on a conforming client. The banner lives in
350
+ # <body>, which the head-parse-time mark script has not reached yet, so the hide is deferred to
351
+ # DOMContentLoaded (and runs immediately if the document is already past parsing).
352
+ _SCRIPTS_RUNNING_MARK_JS = Markup(
353
+ "(function(){try{document.documentElement.className+=' "
354
+ + SCRIPTS_OK_CLASS
355
+ + "';var hide=function(){var b=document.getElementById('"
356
+ + SCRIPTS_BLOCKED_BANNER_ID
357
+ + "');if(b){b.style.display='none';}};"
358
+ "if(document.readyState!=='loading'){hide();}"
359
+ "else{document.addEventListener('DOMContentLoaded',hide);}"
360
+ "}catch(e){}})();"
361
+ )
362
+
363
+ #: 3.7.5: the FAIL-VISIBLE half of the detect pair. The canary catches a browser that does not enforce
364
+ #: CSP; this catches the inverse — a browser that ENFORCES CSP but does not understand ``'nonce-…'`` /
365
+ #: ``'strict-dynamic'`` sources (and, incidentally, a browser with JavaScript disabled). Under
366
+ #: ``script-src 'nonce-…' 'strict-dynamic'`` such a client has no valid script source at all, so it
367
+ #: blocks app.js, both detect scripts AND the 14.3.1 session watchdog — no script-raised banner could
368
+ #: ever render, and the console would silently lose its client-side PHI controls. So the warning is
369
+ #: SERVER-rendered and visible by default; ``app.css`` hides it under :data:`SCRIPTS_OK_CLASS`, which
370
+ #: only the nonce'd mark script above can set. A client that blocks scripts leaves it standing.
371
+ _SCRIPTS_BLOCKED_BANNER = el(
372
+ "div",
373
+ "This browser is not running the console's scripts: Content-Security-Policy nonce sources are "
374
+ "unsupported or JavaScript is disabled. Client-side protections — the automatic session "
375
+ "logoff that clears this page, the insecure-connection detect and live status — are NOT "
376
+ "active. Server-side session expiry, permissions and auditing still apply. Use a current "
377
+ "browser with JavaScript enabled.",
378
+ id=SCRIPTS_BLOCKED_BANNER_ID,
379
+ class_="mf-insecure-banner",
380
+ role="alert",
381
+ )
382
+
383
+ # 3.7.5 (ASVS): the ACTIVE CSP-enforcement detect, and the reason the 'JS cannot feature-detect nonce
384
+ # enforcement' limitation no longer holds for THIS header. ``window.__mfCspProbe`` can only be true if
385
+ # the un-nonced canary script executed, which the nonce CSP forbids — so a truthy flag is positive
386
+ # evidence that the browser is NOT enforcing the policy, on the default deployment, where the
387
+ # isSecureContext banner is correctly inert (http://127.0.0.1 IS a secure context). Same
388
+ # degrade-never-block shape as the banner above: a DOM node, try/catch, textContent only, no on*
389
+ # handler, no data interpolation (Markup, like the nav SVGs) => not an injection surface.
390
+ _CSP_NOT_ENFORCED_WARN_JS = Markup(
391
+ "(function(){try{if(window.__mfCspProbe===true){"
392
+ "var show=function(){"
393
+ "if(!document.body||document.getElementById('mf-csp-degraded-banner'))return;"
394
+ "var b=document.createElement('div');b.id='mf-csp-degraded-banner';"
395
+ "b.className='mf-insecure-banner';b.setAttribute('role','alert');"
396
+ "b.textContent='This browser does not enforce Content-Security-Policy: console hardening is "
397
+ "degraded and script-injection defenses are not being applied. Use a current browser to reach "
398
+ "this console.';"
399
+ "document.body.insertBefore(b,document.body.firstChild);};"
400
+ "if(document.body){show();}else{document.addEventListener('DOMContentLoaded',show);}"
401
+ "}}catch(e){}})();"
402
+ )
403
+
404
+
405
+ def _nav_status_icons() -> Markup:
406
+ """The alerts bell + engine-health heart, in that order (alerts left of the heart). Each is a LINK to
407
+ its detail page — the bell to /ui/alerts, the heart to /ui/status — so a colored glyph is a one-click
408
+ path to the related items. Neutral until the first ``/ui/nav-status`` poll recolors them + sets a live
409
+ aria-label (app.js). No ``role=status``: as a link the state rides the aria-label (announced on focus),
410
+ not a live region that would re-announce every 15s poll. The container carries the app.js hook."""
411
+ bell = el(
412
+ "a",
413
+ _BELL_SVG,
414
+ href="/ui/alerts",
415
+ class_="navstat alerts-unknown",
416
+ data_mf_nav_alerts=True,
417
+ title="Active alerts",
418
+ aria_label="Active alerts",
419
+ )
420
+ heart = el(
421
+ "a",
422
+ _HEART_SVG,
423
+ href="/ui/status",
424
+ class_="navstat health-unknown",
425
+ data_mf_nav_health=True,
426
+ title="Engine health",
427
+ aria_label="Engine health",
428
+ )
429
+ return el("div", bell, heart, class_="navstatus", data_mf_nav_status=True)
430
+
431
+
432
+ def _logout_form() -> Markup:
433
+ """The one-click Sign-out control: a tiny same-origin POST form (form-action 'self'), wired to the
434
+ real server-side revocation. THE single markup site — :func:`_default_nav` and
435
+ :func:`minimal_nav` both render this, so the affordance can never drift between the full chrome and
436
+ the confinement chrome (ASVS 7.4.4)."""
437
+ return el(
438
+ "form",
439
+ el("button", "Sign out", type="submit"),
440
+ method="post",
441
+ action="/ui/logout",
442
+ class_="logout",
443
+ )
444
+
445
+
446
+ def _default_nav(active: str) -> Markup:
447
+ by_key = {key: (key, href, label) for key, href, label in _NAV_ITEMS}
448
+ seen: set[str] = set()
449
+
450
+ def _link(item: tuple[str, str, str]) -> Markup:
451
+ key, href, label = item
452
+ return el("a", label, href=href, class_="active" if key == active else None)
453
+
454
+ def _dropdown(glabel: str, items: list[tuple[str, str, str]]) -> Markup:
455
+ # CSS-only dropdown: opens on :hover AND :focus-within, so it's keyboard-reachable with NO JS
456
+ # (stays within the script-src 'self' CSP). The toggle is a <button> (a menu opener, not a link)
457
+ # and shows active when the current page is one of its members; the items inside navigate.
458
+ active_group = any(item[0] == active for item in items)
459
+ top = el(
460
+ "button",
461
+ f"{glabel} ▾",
462
+ type="button",
463
+ # aria-haspopup marks it a menu opener; aria-expanded is intentionally omitted — a CSS-only
464
+ # menu can't truthfully toggle it without JS, so an honest static button beats a lying attr.
465
+ aria_haspopup="menu",
466
+ class_="navtop active" if active_group else "navtop",
467
+ )
468
+ menu = el("div", *[_link(i) for i in items], class_="navmenu")
469
+ return el("div", top, menu, class_="navgroup")
470
+
471
+ groups: list[object] = []
472
+ for glabel, keys in _NAV_GROUPS:
473
+ items = [by_key[k] for k in keys if k in by_key]
474
+ seen.update(k for k in keys if k in by_key)
475
+ if items:
476
+ groups.append(_dropdown(glabel, items))
477
+ extra = [item for item in _NAV_ITEMS if item[0] not in seen] # future lanes, ungrouped
478
+ if extra:
479
+ groups.append(_dropdown("More", extra))
480
+
481
+ brand = el("a", wordmark(tm=True), href="/ui", class_="brand")
482
+ # Right cluster: the live status glyphs then Sign out, grouped so nav's space-between keeps the links
483
+ # left and this block flush right (heart sits directly left of Sign out, alerts left of the heart).
484
+ right = el("div", _nav_status_icons(), _logout_form(), class_="navright")
485
+ # ``data-mf-session-watchdog`` (14.3.1) rides the <nav> element deliberately: a nav is rendered
486
+ # ONLY on an authenticated page (the two unauthenticated entry pages pass nav=Markup("")), so the
487
+ # hook is present exactly where a live session's rendered PHI needs discarding when that session
488
+ # ends — with no per-render authentication plumbing to keep in sync. minimal_nav carries it too.
489
+ return el(
490
+ "nav",
491
+ el("div", brand, *groups, class_="navlinks"),
492
+ right,
493
+ data_mf_session_watchdog=True,
494
+ )
495
+
496
+
497
+ def minimal_nav() -> Markup:
498
+ """The **confinement chrome**: the brand wordmark plus the one-click POST Sign-out form, and
499
+ nothing else (ASVS 7.4.4).
500
+
501
+ Authenticated pages that deliberately drop the full nav — step-up re-auth, the forced
502
+ must-change-password page, passkey enrolment, the federated landing hop — used to render
503
+ ``nav=Markup("")`` and therefore no logout affordance at all. The must-change page is the severe
504
+ case: :func:`._auth.require_ui` 303s such a session back to it from every other ``/ui`` route, so a
505
+ confined operator had **no reachable sign-out** even though ``POST /ui/logout`` would have accepted
506
+ them (that route carries no ``Depends`` gate precisely so it can).
507
+
508
+ It reuses the SAME logout form :func:`_default_nav` renders, so there is one markup site to change.
509
+ It deliberately omits the nav groups (preserving each page's focus/confinement intent) **and**
510
+ :func:`_nav_status_icons` — those glyphs carry the ``data-mf-nav-status`` hook that makes ``app.js``
511
+ poll ``GET /ui/nav-status`` every ~15s, and that route needs ``monitoring:read``; on a
512
+ reauth/must-change/passkey page the poll would 303-loop to the login or change-password page. The
513
+ hook these pages DO keep is the session watchdog (14.3.1), which lives on the ``<nav>`` element
514
+ itself — see :func:`_default_nav`.
515
+ """
516
+ brand = el("a", wordmark(tm=True), href="/ui", class_="brand")
517
+ right = el("div", _logout_form(), class_="navright")
518
+ return el("nav", el("div", brand, class_="navlinks"), right, data_mf_session_watchdog=True)
519
+
520
+
521
+ def rows_table(
522
+ headers: Iterable[str], rows: Iterable[Iterable[object]], *, adjustable: bool = True
523
+ ) -> Markup:
524
+ """A table whose header cells and every body cell are escaped (cells accept ``Markup`` for links).
525
+
526
+ ``adjustable`` (default) marks it ``data-mf-table`` so ``app.js`` enhances it in the browser with
527
+ click-to-sort + drag-to-resize columns (remembered per table). Use it for DATA GRIDS — the connections
528
+ dashboard, message/audit lists — where sorting and resizing earn their keep.
529
+
530
+ Pass ``adjustable=False`` for small **key/value readout** tables (status, connection detail, config
531
+ reload): they render as a plain full-width table (class ``info``) whose long values WRAP instead of the
532
+ fixed-layout grid's explicit width — so they never show a horizontal scrollbar, and they drop the
533
+ sort/resize UI a 2-column readout doesn't need. Purely presentational; with JS off both render plainly.
534
+ """
535
+ head = el("tr", *[el("th", h) for h in headers])
536
+ body = [el("tr", *[el("td", c) for c in row]) for row in rows]
537
+ if adjustable:
538
+ return el("table", el("thead", head), el("tbody", *body), class_="grid", data_mf_table=True)
539
+ return el("table", el("thead", head), el("tbody", *body), class_="grid info")