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,1506 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // First-party JS for the /ui ops dashboard (ADR 0065). No third-party JS — CSP script-src 'self'.
3
+ //
4
+ // Dispatch registry: each page feature registers a (hook-selector, init) pair via feature(); on load
5
+ // (the script is deferred, so the DOM is parsed) each init runs only if its namespaced data-* hook is
6
+ // present on this page. A page lane adds ONE feature() block — parallel lanes never collide on a shared
7
+ // entry point. Every fragment swapped in is SERVER-RENDERED and server-escaped (see
8
+ // messagefoundry/api/webui/_html.py el()), so no un-escaped message/HL7 data is ever handled here.
9
+ (function () {
10
+ "use strict";
11
+
12
+ var inits = [];
13
+ function feature(hookSelector, fn) {
14
+ inits.push([hookSelector, fn]);
15
+ }
16
+
17
+ // SECURITY controls register here instead, and the runner starts them FIRST (see the bottom of
18
+ // this file). A security control must not share a failure domain with — or be ordered behind —
19
+ // cosmetic page enhancers: the registry is replayed in registration order, so a control sitting
20
+ // ninth of fourteen depended on eight unrelated inits not throwing, on exactly the PHI pages
21
+ // (Messages, Dead letters) it exists to protect. Each init is also isolated in its own try/catch
22
+ // by the runner, so one broken feature can no longer disable the rest.
23
+ var securityInits = [];
24
+ function securityFeature(hookSelector, fn) {
25
+ securityInits.push([hookSelector, fn]);
26
+ }
27
+
28
+ // [security].allowed_client_networks: the engine's source-network gate refuses OUTSIDE the app, so a
29
+ // denial is a 403 that matches none of the auth branches below. Without this the pollers would keep
30
+ // painting the last-good table forever and the health box would hide itself as if it were an RBAC
31
+ // outcome — a console that LOOKS alive but is frozen, which is exactly how an operator whose
32
+ // workstation was renumbered mid-session experiences it. The engine marks its network denials with a
33
+ // dedicated header so we can tell them apart from a genuine permission 403 and navigate instead: a
34
+ // full page load lands on the engine's denial page, which names the setting and shows the address the
35
+ // engine actually observed.
36
+ function mfNetworkDenied(resp) {
37
+ return resp.status === 403 && resp.headers.get("X-MessageFoundry-Denied") === "client-network";
38
+ }
39
+
40
+ // ASVS 14.3.1 — "the server has ended this session", for a fetch. EVERY fetch below sets
41
+ // redirect:"manual", which is load-bearing and was the bug this replaces: a same-origin 303 is
42
+ // followed TRANSPARENTLY under the default redirect:"follow", so a status test for the redirect
43
+ // could never be observed in a real browser — the expiry branch was dead code and the poller
44
+ // instead assigned a 200 LOGIN PAGE into the container it was refreshing. With redirect:"manual"
45
+ // the auth redirect surfaces as an opaque response (type "opaqueredirect", status 0) that this
46
+ // recognises, so the page actually reloads and the server can send the operator to the login page.
47
+ // The explicit status tests stay for a non-browser client and for the engine answering 401.
48
+ function mfSessionEnded(resp) {
49
+ return (
50
+ resp.type === "opaqueredirect" ||
51
+ resp.status === 0 ||
52
+ resp.status === 303 ||
53
+ resp.status === 401
54
+ );
55
+ }
56
+
57
+ // Auto-retry after a successful step-up re-auth: submit the pending action form (server-rendered,
58
+ // same-origin, action already validated as a replay path). Graceful: if this doesn't run, the user
59
+ // clicks the visible "Continue" button instead.
60
+ feature("form[data-autosubmit]", function (pending) {
61
+ pending.submit();
62
+ });
63
+
64
+ // Live connections view: poll the same-origin fragment named in data-poll and, when available, take
65
+ // updates over the /ws/stats WebSocket (stopping the poll while the socket is open).
66
+ feature("[data-poll]", function (container) {
67
+ var url = container.getAttribute("data-poll");
68
+ var intervalMs = parseInt(container.getAttribute("data-poll-ms") || "5000", 10);
69
+ if (!url || !(intervalMs > 0)) return;
70
+
71
+ async function tick() {
72
+ // Pause the live swap while an operator is dragging a column resize: the swap REPLACES the whole
73
+ // table element, which detaches the <th> being dragged — the column snaps back to its old width and
74
+ // the drag loses its target. The next tick after the drag ends resumes the live view. (The resize
75
+ // feature sets body.mf-col-resizing for the duration of the drag.)
76
+ if (document.body.classList.contains("mf-col-resizing")) return;
77
+ try {
78
+ var resp = await fetch(url, {
79
+ credentials: "same-origin",
80
+ redirect: "manual", // an auth 303 must SURFACE, not be followed into a 200 login page
81
+ headers: { "X-Requested-With": "fetch" },
82
+ cache: "no-store",
83
+ });
84
+ if (resp.ok) {
85
+ // Server-rendered + server-escaped fragment (same-origin); safe to assign as markup.
86
+ var htmlText = await resp.text();
87
+ // Re-check the resize guard AFTER the awaits: a column drag may have started mid-fetch (the
88
+ // top-of-tick check is TOCTOU on its own). Swapping now would detach the <th> being dragged;
89
+ // skip this swap — the next tick resumes the live view.
90
+ if (document.body.classList.contains("mf-col-resizing")) return;
91
+ container.innerHTML = htmlText;
92
+ } else if (mfSessionEnded(resp) || mfNetworkDenied(resp)) {
93
+ // Session expired, or the source-network gate refused us — reload so the server can redirect
94
+ // to the login page / serve its network-denial page instead of freezing on stale data.
95
+ window.location.reload();
96
+ }
97
+ } catch (e) {
98
+ // Transient network/engine hiccup — leave the last good view in place and retry next tick.
99
+ }
100
+ }
101
+
102
+ var pollTimer = setInterval(tick, intervalMs);
103
+
104
+ // Live updates over the /ws/stats WebSocket (M-ws + enrichment). The server pushes the rendered
105
+ // connections fragment (drives the table) plus the queue-by-status counts. While the socket is open
106
+ // we STOP polling; if it drops we resume the poll (fallback). The same-origin handshake carries the
107
+ // mf_session cookie automatically (browsers can't set the WS Authorization header) — the server
108
+ // authorizes it via that cookie, CSWSH-guarded by a same-origin Origin check + SameSite=Strict.
109
+ // Every update is a server-rendered, already-escaped fragment / store counts — no client-side
110
+ // markup building.
111
+ var livestats = document.getElementById("livestats");
112
+ try {
113
+ var proto = location.protocol === "https:" ? "wss:" : "ws:";
114
+ var ws = new WebSocket(proto + "//" + location.host + "/ws/stats");
115
+ ws.onopen = function () {
116
+ if (pollTimer) {
117
+ clearInterval(pollTimer);
118
+ pollTimer = null;
119
+ }
120
+ };
121
+ ws.onmessage = function (e) {
122
+ try {
123
+ var d = JSON.parse(e.data) || {};
124
+ // Same resize guard as the poll: don't swap the table out from under an in-progress column drag
125
+ // (the livestats counts below are unaffected — they don't touch the table).
126
+ if (
127
+ typeof d.connections_html === "string" &&
128
+ !document.body.classList.contains("mf-col-resizing")
129
+ ) {
130
+ container.innerHTML = d.connections_html; // server-rendered + server-escaped fragment
131
+ }
132
+ if (livestats && d.outbox_by_status) {
133
+ var by = d.outbox_by_status;
134
+ var parts = Object.keys(by).map(function (k) {
135
+ return k + ": " + by[k];
136
+ });
137
+ livestats.textContent = parts.length ? "Queue — " + parts.join(" ") : "";
138
+ }
139
+ } catch (_) {
140
+ /* ignore a malformed frame */
141
+ }
142
+ };
143
+ var resumePoll = function () {
144
+ if (!pollTimer) {
145
+ pollTimer = setInterval(tick, intervalMs);
146
+ }
147
+ };
148
+ ws.onclose = resumePoll;
149
+ ws.onerror = resumePoll;
150
+ } catch (_) {
151
+ /* WebSocket unavailable — the polled table remains the live view */
152
+ }
153
+ });
154
+
155
+ // --- Connection controls: bulk-action toolbar over a checkbox selection ----------------------
156
+ // The toolbar (action <select> + Apply + feedback) lives in the STABLE dashboard shell OUTSIDE
157
+ // [data-poll], so its init runs once and the 5s poll / ~1s /ws/stats swaps that replace #conns never
158
+ // wipe it. The per-row checkboxes DO live inside #conns and are destroyed on every swap — a JS Set
159
+ // keyed by the stable server-minted _row_key (checkbox .value) survives it, the change listener is
160
+ // DELEGATED to the persistent [data-poll] node, and a MutationObserver re-hydrates cb.checked after
161
+ // each swap. Purge eligibility keys on data-paused (paused AND quiesced), NOT the collapsed display
162
+ // status, so a failed/filtered-but-paused outbound stays purgeable. JS filtering is UX-only — the
163
+ // engine is the sole authority for the role/state matrix. CSP script-src 'self': DOM APIs +
164
+ // programmatic form submit / window.location only, NEVER innerHTML from connection data.
165
+ feature("[data-mf-conns-toolbar]", function (toolbar) {
166
+ var poll = document.querySelector("[data-poll]");
167
+ if (!poll) return; // no live table on this page → nothing to drive
168
+ var select = toolbar.querySelector("[data-mf-conns-action]");
169
+ var applyBtn = toolbar.querySelector("[data-mf-conns-apply]");
170
+ var feedback = toolbar.querySelector("[data-mf-conns-feedback]");
171
+ var selected = new Set(); // keyed by checkbox .value (the stable _row_key)
172
+
173
+ function rowCheckboxes() {
174
+ return poll.querySelectorAll("[data-mf-conns-cb]");
175
+ }
176
+ function selectAllBox() {
177
+ return poll.querySelector("[data-mf-conns-all]");
178
+ }
179
+ function actionLabel(a) {
180
+ switch (a) {
181
+ case "start":
182
+ return "Start";
183
+ case "stop":
184
+ return "Stop";
185
+ case "restart":
186
+ return "Restart";
187
+ case "reset":
188
+ return "Reset stats";
189
+ case "purge-top":
190
+ return "Purge top";
191
+ case "purge-all":
192
+ return "Purge all";
193
+ default:
194
+ return a;
195
+ }
196
+ }
197
+
198
+ // Partition the live selection for the chosen action. Purge keeps only a stopped-AND-quiesced
199
+ // outbound (data-role destination && data-paused==='1'), deduped by destination; every other action
200
+ // accepts both roles. Reads data-* LIVE from the DOM so a status that flipped under the poll is
201
+ // honored. Returns the eligible targets + skip counts (UX only; the server re-derives eligibility).
202
+ function plan() {
203
+ var action = select ? select.value : "";
204
+ var isPurge = action === "purge-top" || action === "purge-all";
205
+ var keys = []; // row keys for bulk-control / reset-many (both roles)
206
+ var seenDest = {};
207
+ var dests = []; // deduped destinations for purge
208
+ var skippedInbound = 0;
209
+ var skippedNotStopped = 0;
210
+ var cbs = rowCheckboxes();
211
+ for (var i = 0; i < cbs.length; i++) {
212
+ var cb = cbs[i];
213
+ if (!selected.has(cb.value)) continue;
214
+ if (isPurge) {
215
+ if (cb.getAttribute("data-role") !== "destination") {
216
+ skippedInbound++;
217
+ continue;
218
+ }
219
+ if (cb.getAttribute("data-paused") !== "1") {
220
+ skippedNotStopped++;
221
+ continue;
222
+ }
223
+ var dest = cb.getAttribute("data-dest") || "";
224
+ if (!Object.prototype.hasOwnProperty.call(seenDest, dest)) {
225
+ seenDest[dest] = true;
226
+ dests.push(dest);
227
+ }
228
+ } else {
229
+ keys.push(cb.value);
230
+ }
231
+ }
232
+ return {
233
+ action: action,
234
+ isPurge: isPurge,
235
+ keys: keys,
236
+ dests: dests,
237
+ skippedInbound: skippedInbound,
238
+ skippedNotStopped: skippedNotStopped,
239
+ };
240
+ }
241
+
242
+ // Recompute the feedback string + enable Apply only when ≥1 eligible target.
243
+ function refresh() {
244
+ var p = plan();
245
+ var label = actionLabel(p.action);
246
+ var eligible = p.isPurge ? p.dests.length : p.keys.length;
247
+ var msg;
248
+ if (p.isPurge) {
249
+ msg = label + " → " + p.dests.length + " outbound" + (p.dests.length === 1 ? "" : "s");
250
+ var skips = p.skippedInbound + p.skippedNotStopped;
251
+ if (skips > 0) {
252
+ var reasons = [];
253
+ if (p.skippedInbound) reasons.push(p.skippedInbound + " inbound");
254
+ if (p.skippedNotStopped) reasons.push(p.skippedNotStopped + " not-stopped");
255
+ msg += "; skipped " + skips + ": " + reasons.join(", ");
256
+ }
257
+ } else if (eligible === 0) {
258
+ msg = ""; // idle: no clutter text — feedback only appears once an action targets a selection
259
+ } else {
260
+ msg = label + " → " + eligible + " selected";
261
+ }
262
+ if (feedback) feedback.textContent = msg;
263
+ if (applyBtn) applyBtn.disabled = eligible < 1;
264
+ }
265
+
266
+ // Recompute the select-all header checkbox's checked/indeterminate from the live Set.
267
+ function syncSelectAll() {
268
+ var box = selectAllBox();
269
+ if (!box) return;
270
+ var cbs = rowCheckboxes();
271
+ var checked = 0;
272
+ for (var i = 0; i < cbs.length; i++) {
273
+ if (selected.has(cbs[i].value)) checked++;
274
+ }
275
+ box.checked = cbs.length > 0 && checked === cbs.length;
276
+ box.indeterminate = checked > 0 && checked < cbs.length;
277
+ }
278
+
279
+ // After every swap (poll tick AND ws push): re-apply cb.checked from the Set, recompute select-all,
280
+ // and PRUNE any key whose input has vanished (a removed/reconfigured connection). Strictly
281
+ // read-value/set-checked — never innerHTML, and it never suppresses the swap (the operator must keep
282
+ // seeing status flip running→stopping→stopped, which is what unlocks Purge).
283
+ function rehydrate() {
284
+ var cbs = rowCheckboxes();
285
+ var present = {};
286
+ for (var i = 0; i < cbs.length; i++) {
287
+ present[cbs[i].value] = true;
288
+ cbs[i].checked = selected.has(cbs[i].value);
289
+ }
290
+ selected.forEach(function (key) {
291
+ if (!present[key]) selected.delete(key);
292
+ });
293
+ syncSelectAll();
294
+ refresh();
295
+ }
296
+
297
+ // Build + submit a transient same-origin POST form (never innerHTML). actionValue is the
298
+ // bulk-control action (start/stop/restart); null for reset-many (which takes only repeated sel).
299
+ function submitBulk(url, actionValue, keys) {
300
+ var form = document.createElement("form");
301
+ form.method = "post";
302
+ form.action = url;
303
+ if (actionValue != null) {
304
+ var a = document.createElement("input");
305
+ a.type = "hidden";
306
+ a.name = "action";
307
+ a.value = actionValue;
308
+ form.appendChild(a);
309
+ }
310
+ for (var i = 0; i < keys.length; i++) {
311
+ var inp = document.createElement("input");
312
+ inp.type = "hidden";
313
+ inp.name = "sel";
314
+ inp.value = keys[i];
315
+ form.appendChild(inp);
316
+ }
317
+ document.body.appendChild(form);
318
+ form.submit();
319
+ }
320
+
321
+ // Delegated to the persistent [data-poll] node (only its children are swapped): a row checkbox
322
+ // toggles its .value in the Set; the select-all box checks/unchecks every rendered row + syncs.
323
+ poll.addEventListener("change", function (ev) {
324
+ var t = ev.target;
325
+ if (!t || !t.hasAttribute) return;
326
+ if (t.hasAttribute("data-mf-conns-cb")) {
327
+ if (t.checked) selected.add(t.value);
328
+ else selected.delete(t.value);
329
+ syncSelectAll();
330
+ refresh();
331
+ } else if (t.hasAttribute("data-mf-conns-all")) {
332
+ var cbs = rowCheckboxes();
333
+ for (var i = 0; i < cbs.length; i++) {
334
+ // Select-all applies to the VISIBLE rows only: when checking, skip a row the filter has hidden
335
+ // (offsetParent is null for a display:none row) so the operator never sweeps in — and then
336
+ // Stop/Restart/Reset — connections they can't see. Unchecking still clears every row.
337
+ if (t.checked && cbs[i].offsetParent === null) continue;
338
+ cbs[i].checked = t.checked;
339
+ if (t.checked) selected.add(cbs[i].value);
340
+ else selected.delete(cbs[i].value);
341
+ }
342
+ refresh();
343
+ }
344
+ });
345
+
346
+ if (select) select.addEventListener("change", refresh);
347
+
348
+ if (applyBtn) {
349
+ applyBtn.addEventListener("click", function () {
350
+ var p = plan();
351
+ if (p.isPurge) {
352
+ if (p.dests.length < 1) return;
353
+ var scope = p.action === "purge-all" ? "all" : "top";
354
+ // A step-up + dual-control destructive op: NAVIGATE (GET) into the server's unlock/confirm
355
+ // flow — deliberately NOT a fetch, so the opaqueredirect success-vs-step-up ambiguity that
356
+ // would sink a fetch-loop never arises. The confirm page carries its own POST to purge-bulk.
357
+ var q = "/ui/connections/purge-confirm?scope=" + encodeURIComponent(scope);
358
+ for (var i = 0; i < p.dests.length; i++) {
359
+ q += "&dest=" + encodeURIComponent(p.dests[i]);
360
+ }
361
+ window.location.assign(q);
362
+ return;
363
+ }
364
+ if (p.keys.length < 1) return;
365
+ if (p.action === "reset") {
366
+ submitBulk("/ui/statistics/reset-many", null, p.keys);
367
+ } else {
368
+ submitBulk("/ui/connections/bulk-control", p.action, p.keys);
369
+ }
370
+ });
371
+ }
372
+
373
+ // Re-hydrate on every #conns swap (poll + ws), and once now (the fragment is already rendered).
374
+ new MutationObserver(rehydrate).observe(poll, { childList: true, subtree: true });
375
+ rehydrate();
376
+ });
377
+
378
+ // --- L5a: WebAuthn passkey ceremonies (ADR 0068 §6) -----------------------------------------
379
+ // The ceremony OPTIONS ride server-rendered data-* attributes (never an inline script — CSP is
380
+ // 'self'-only); only the verify legs are fetch POSTs. Progressive enhancement: without
381
+ // window.PublicKeyCredential the buttons explain themselves and the TOTP/password path is
382
+ // untouched. Buttons are disabled for the ceremony's duration (a double-click would race the
383
+ // sign-count compare-and-set into a false clone signal). fetch uses redirect:"manual" — a stale
384
+ // step-up 303 surfaces as an opaqueredirect we can act on (following it would innerHTML a login
385
+ // page — the dead branch lesson from the poll feature).
386
+
387
+ function b64urlToBytes(s) {
388
+ var pad = "===".slice(0, (4 - (s.length % 4)) % 4);
389
+ var raw = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
390
+ var out = new Uint8Array(raw.length);
391
+ for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
392
+ return out.buffer;
393
+ }
394
+ function bytesToB64url(buf) {
395
+ var bytes = new Uint8Array(buf);
396
+ var s = "";
397
+ for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
398
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
399
+ }
400
+ function statusNode(button) {
401
+ var node = document.querySelector("[data-mf-webauthn-status]");
402
+ return {
403
+ set: function (text) {
404
+ if (node) node.textContent = text;
405
+ },
406
+ };
407
+ }
408
+
409
+ // Registration: parse the creation options from the data attribute, run the browser ceremony,
410
+ // POST the attestation + label to the verify endpoint, then navigate per the server's answer.
411
+ feature("[data-mf-webauthn-create]", function (button) {
412
+ var status = statusNode(button);
413
+ if (!window.PublicKeyCredential) {
414
+ button.disabled = true;
415
+ status.set("This browser does not support passkeys.");
416
+ return;
417
+ }
418
+ button.addEventListener("click", async function () {
419
+ var labelInput = document.querySelector("input[name=label]");
420
+ var label = labelInput ? labelInput.value.trim() : "";
421
+ if (!label) {
422
+ status.set("Name this passkey first.");
423
+ return;
424
+ }
425
+ button.disabled = true;
426
+ status.set("Follow your browser's prompt…");
427
+ try {
428
+ var options = JSON.parse(button.getAttribute("data-mf-webauthn-create"));
429
+ options.challenge = b64urlToBytes(options.challenge);
430
+ options.user.id = b64urlToBytes(options.user.id);
431
+ (options.excludeCredentials || []).forEach(function (c) {
432
+ c.id = b64urlToBytes(c.id);
433
+ });
434
+ var cred = await navigator.credentials.create({ publicKey: options });
435
+ var resp = await fetch("/ui/account/webauthn/verify", {
436
+ method: "POST",
437
+ credentials: "same-origin",
438
+ redirect: "manual",
439
+ headers: { "Content-Type": "application/json", "X-Requested-With": "fetch" },
440
+ cache: "no-store",
441
+ body: JSON.stringify({
442
+ label: label,
443
+ response: {
444
+ id: cred.id,
445
+ rawId: bytesToB64url(cred.rawId),
446
+ type: cred.type,
447
+ clientExtensionResults: cred.getClientExtensionResults(),
448
+ response: {
449
+ clientDataJSON: bytesToB64url(cred.response.clientDataJSON),
450
+ attestationObject: bytesToB64url(cred.response.attestationObject),
451
+ transports:
452
+ cred.response.getTransports && cred.response.getTransports(),
453
+ },
454
+ },
455
+ }),
456
+ });
457
+ if (resp.type === "opaqueredirect") {
458
+ // Stale step-up window mid-ceremony: walk the registered continuation explicitly.
459
+ window.location.assign(
460
+ "/ui/reauth?next=" + encodeURIComponent("/ui/account/webauthn/enroll")
461
+ );
462
+ return;
463
+ }
464
+ var data = await resp.json();
465
+ if (data.ok && data.redirect) {
466
+ window.location.assign(data.redirect);
467
+ return;
468
+ }
469
+ status.set(data.error || "Passkey enrollment failed.");
470
+ } catch (e) {
471
+ status.set("Passkey enrollment was cancelled or failed.");
472
+ }
473
+ button.disabled = false;
474
+ });
475
+ });
476
+
477
+ // Step-up assertion: run the get() ceremony against the staged options and POST the assertion
478
+ // to the reauth passkey leg. On success the MFA leg is satisfied — the operator still enters
479
+ // the password below (the mandatory leg that stamps step-up freshness + re-anchors, ADR 0068).
480
+ feature("[data-mf-webauthn-get]", function (button) {
481
+ var status = statusNode(button);
482
+ if (!window.PublicKeyCredential) {
483
+ button.disabled = true;
484
+ status.set("This browser does not support passkeys — use your password/code.");
485
+ return;
486
+ }
487
+ button.addEventListener("click", async function () {
488
+ button.disabled = true;
489
+ status.set("Follow your browser's prompt…");
490
+ try {
491
+ var options = JSON.parse(button.getAttribute("data-mf-webauthn-get"));
492
+ options.challenge = b64urlToBytes(options.challenge);
493
+ (options.allowCredentials || []).forEach(function (c) {
494
+ c.id = b64urlToBytes(c.id);
495
+ });
496
+ var cred = await navigator.credentials.get({ publicKey: options });
497
+ var resp = await fetch("/ui/reauth/webauthn", {
498
+ method: "POST",
499
+ credentials: "same-origin",
500
+ redirect: "manual",
501
+ headers: { "Content-Type": "application/json", "X-Requested-With": "fetch" },
502
+ cache: "no-store",
503
+ body: JSON.stringify({
504
+ response: {
505
+ id: cred.id,
506
+ rawId: bytesToB64url(cred.rawId),
507
+ type: cred.type,
508
+ clientExtensionResults: cred.getClientExtensionResults(),
509
+ response: {
510
+ clientDataJSON: bytesToB64url(cred.response.clientDataJSON),
511
+ authenticatorData: bytesToB64url(cred.response.authenticatorData),
512
+ signature: bytesToB64url(cred.response.signature),
513
+ userHandle:
514
+ cred.response.userHandle && bytesToB64url(cred.response.userHandle),
515
+ },
516
+ },
517
+ }),
518
+ });
519
+ if (resp.type === "opaqueredirect") {
520
+ window.location.reload(); // session expired — let the server redirect to login
521
+ return;
522
+ }
523
+ var data = await resp.json();
524
+ if (data.ok) {
525
+ // ASVS 6.3.3: on the /ui/mfa sign-in gate the assertion COMPLETES the ceremony — there is
526
+ // no password still owed, so navigate. Without this a passkey-only user (no TOTP, so no
527
+ // code field and no submit button on that page) verifies successfully and is stranded on
528
+ // a dead page, which is the deadlock ADR 0068 decision 1(d) exists to prevent.
529
+ var done = button.getAttribute("data-mf-webauthn-done");
530
+ if (done) {
531
+ status.set("Passkey verified — continuing…");
532
+ window.location.assign(done);
533
+ return;
534
+ }
535
+ // On the step-up reauth page the password is still owed, so stay put and unlock it.
536
+ status.set("Passkey verified — enter your password to continue.");
537
+ var codeInput = document.querySelector("input[name=code]");
538
+ if (codeInput) codeInput.disabled = true; // either factor satisfies; this one just did
539
+ var pw = document.querySelector("input[name=password]");
540
+ if (pw) pw.focus();
541
+ return; // leave the button disabled: the ceremony is done for this challenge
542
+ }
543
+ status.set(data.error || "Passkey verification failed.");
544
+ } catch (e) {
545
+ status.set("Passkey prompt was cancelled or failed.");
546
+ }
547
+ button.disabled = false;
548
+ });
549
+ });
550
+
551
+ // --- Adjustable tables: click-to-sort + drag-to-resize columns, remembered per table -----------
552
+ // Enhances every server-rendered grid table (data-mf-table, emitted by _html.rows_table). Pure DOM
553
+ // APIs only — never innerHTML from cell data (CSP script-src 'self'; cells are already server-escaped;
554
+ // sort/compare read textContent). Sort is CLIENT-SIDE: it reorders the rows currently in the tbody —
555
+ // exact for full-data tables; on the server-paginated Messages/Dead-letters it sorts the shown page
556
+ // only (by design, owner-chosen: zero engine changes). Column widths + the last sort persist in
557
+ // localStorage keyed by pathname + the table's ordinal on the page, so they survive reloads AND the
558
+ // Connections poll/ws swaps (which REPLACE the table element — a MutationObserver on [data-poll]
559
+ // re-enhances the fresh table and re-applies both). Coexists with the connections checkbox selection,
560
+ // which is keyed by row value, so reordering rows never disturbs it. localStorage failures (private
561
+ // mode / quota) degrade to session-only, never throw.
562
+ feature("[data-mf-table]", function () {
563
+ var PREFIX = "mfcols:v2:"; // v2: reset stale widths once so the fill-to-container default applies
564
+ var MINW = 50; // minimum column width (px)
565
+ var reorderFrom = null; // ORIGINAL index of the column being drag-reordered (one drag at a time)
566
+ function readState(key) {
567
+ try {
568
+ return JSON.parse(localStorage.getItem(PREFIX + key)) || {};
569
+ } catch (_) {
570
+ return {};
571
+ }
572
+ }
573
+ function writeState(key, st) {
574
+ try {
575
+ localStorage.setItem(PREFIX + key, JSON.stringify(st));
576
+ } catch (_) {
577
+ /* private mode / quota — this session's adjustments simply don't persist */
578
+ }
579
+ }
580
+ // Ordinal among all enhanced tables on the page — stable across reload AND a live swap (the
581
+ // connections fragment always renders one table at the same position).
582
+ function tableKey(table) {
583
+ var all = document.querySelectorAll("[data-mf-table]");
584
+ return location.pathname + "#" + Array.prototype.indexOf.call(all, table);
585
+ }
586
+
587
+ // Detect a column's sort type by sampling its body cells: numeric (counts) / date / text.
588
+ function colType(tbody, ci) {
589
+ var numeric = true,
590
+ dateish = true,
591
+ seen = 0;
592
+ for (var i = 0; i < tbody.rows.length && seen < 16; i++) {
593
+ var cell = tbody.rows[i].cells[ci];
594
+ if (!cell) continue;
595
+ var t = cell.textContent.trim();
596
+ if (!t || t === "—") continue; // skip blanks / em-dash placeholders
597
+ seen++;
598
+ // Numeric only when the WHOLE token is a number (+ optional unit like "5s"/"12%"/"1,234") — a
599
+ // bare parseFloat would accept the leading year of "2026-07-07 ..." and mis-tag timestamps as num.
600
+ if (numeric && !/^-?[\d,]+(\.\d+)?\s*[a-z%]*$/i.test(t)) numeric = false;
601
+ if (dateish && isNaN(Date.parse(t))) dateish = false;
602
+ }
603
+ if (seen === 0) return "text";
604
+ // Numeric wins over date (a bare "2026" is a count, not a year); a year-first timestamp fails the
605
+ // strict numeric test above and falls through to date.
606
+ return numeric ? "num" : dateish ? "date" : "text";
607
+ }
608
+ function cellVal(tr, ci, type) {
609
+ var cell = tr.cells[ci];
610
+ var t = cell ? cell.textContent.trim() : "";
611
+ if (type === "num") {
612
+ var n = parseFloat(t.replace(/[, ]/g, ""));
613
+ return isNaN(n) ? -Infinity : n;
614
+ }
615
+ if (type === "date") {
616
+ var d = Date.parse(t);
617
+ return isNaN(d) ? -Infinity : d;
618
+ }
619
+ return t.toLowerCase();
620
+ }
621
+ // Reorder the tbody. dir "none" restores the server/original ROW order captured in data-mf-ord. The
622
+ // column is identified by its stable ORIGINAL index; we sort by its CURRENT display position so a
623
+ // reordered column still sorts the right cells.
624
+ function applySort(table, orig, dir) {
625
+ var tbody = table.tBodies[0];
626
+ if (!tbody) return;
627
+ var ci = displayIdx(table, orig);
628
+ var rows = Array.prototype.slice.call(tbody.rows);
629
+ if (dir === "none") {
630
+ rows.sort(function (a, b) {
631
+ return (+a.getAttribute("data-mf-ord") || 0) - (+b.getAttribute("data-mf-ord") || 0);
632
+ });
633
+ } else {
634
+ var type = colType(tbody, ci);
635
+ var sign = dir === "asc" ? 1 : -1;
636
+ rows.sort(function (a, b) {
637
+ var va = cellVal(a, ci, type),
638
+ vb = cellVal(b, ci, type);
639
+ return va < vb ? -sign : va > vb ? sign : 0;
640
+ });
641
+ }
642
+ for (var i = 0; i < rows.length; i++) tbody.appendChild(rows[i]);
643
+ }
644
+ // Map a stable ORIGINAL column index to its CURRENT display position (cellIndex) — columns reorder,
645
+ // so state keys by the original index and looks up the live position on demand.
646
+ function displayIdx(table, orig) {
647
+ var cells = table.tHead ? table.tHead.rows[0].cells : [];
648
+ for (var i = 0; i < cells.length; i++) {
649
+ if (+cells[i].getAttribute("data-mf-orig") === orig) return i;
650
+ }
651
+ return orig;
652
+ }
653
+ // The current column order = the sequence of ORIGINAL indices in display order.
654
+ function currentOrder(table) {
655
+ var cells = table.tHead ? table.tHead.rows[0].cells : [],
656
+ o = [];
657
+ for (var i = 0; i < cells.length; i++) o.push(+cells[i].getAttribute("data-mf-orig"));
658
+ return o;
659
+ }
660
+ // Reorder <col>s, header <th>s, and every body <td> to the given display order (array of ORIGINAL
661
+ // indices). Uniform: every element carries data-mf-orig, so it works from any current arrangement —
662
+ // used to apply a saved order onto a fresh server render AND to commit a drag.
663
+ function applyOrder(table, order) {
664
+ if (!order || !order.length) return;
665
+ var parents = [];
666
+ var cg = table.querySelector("colgroup.mf-cols");
667
+ if (cg) parents.push(cg);
668
+ if (table.tHead && table.tHead.rows[0]) parents.push(table.tHead.rows[0]);
669
+ var tb = table.tBodies[0];
670
+ if (tb) for (var r = 0; r < tb.rows.length; r++) parents.push(tb.rows[r]);
671
+ for (var p = 0; p < parents.length; p++) {
672
+ var byOrig = {},
673
+ kids = parents[p].children;
674
+ for (var k = 0; k < kids.length; k++) byOrig[kids[k].getAttribute("data-mf-orig")] = kids[k];
675
+ for (var j = 0; j < order.length; j++) {
676
+ var c = byOrig[order[j]];
677
+ if (c) parents[p].appendChild(c); // appendChild moves it — builds display order left→right
678
+ }
679
+ }
680
+ }
681
+ // Widths live on an injected <colgroup class="mf-cols">; the table width is their SUM, so there is no
682
+ // leftover space to redistribute (the "snaps back wider" fix). Store the intended number, never read
683
+ // back offsetWidth (which would drift on each refresh).
684
+ function colsOf(table) {
685
+ var cg = table.querySelector("colgroup.mf-cols");
686
+ return cg ? cg.children : [];
687
+ }
688
+ function applyWidths(table, widths) {
689
+ var cols = colsOf(table),
690
+ sum = 0;
691
+ for (var i = 0; i < cols.length; i++) {
692
+ var o = cols[i].getAttribute("data-mf-orig");
693
+ var w = widths[o == null ? i : o]; // widths keyed by ORIGINAL index (stable across reorder)
694
+ if (w) {
695
+ cols[i].style.width = w + "px";
696
+ sum += w;
697
+ }
698
+ }
699
+ if (sum) table.style.width = sum + "px";
700
+ }
701
+ // aria-sort + arrow on exactly the active column (by ORIGINAL index); "none" (orig -1) clears all.
702
+ function markHeaders(table, orig, dir) {
703
+ var cells = table.tHead ? table.tHead.rows[0].cells : [];
704
+ for (var i = 0; i < cells.length; i++) {
705
+ var active = +cells[i].getAttribute("data-mf-orig") === orig && dir !== "none";
706
+ var arrow = cells[i].querySelector(".mf-sort-arrow");
707
+ if (arrow) arrow.textContent = active ? (dir === "asc" ? " ▲" : " ▼") : "";
708
+ if (active) cells[i].setAttribute("aria-sort", dir === "asc" ? "ascending" : "descending");
709
+ else cells[i].removeAttribute("aria-sort");
710
+ }
711
+ }
712
+
713
+ function enhance(table) {
714
+ if (table.__mfEnhanced) return; // this element is already wired
715
+ table.__mfEnhanced = true;
716
+ var head = table.tHead && table.tHead.rows[0];
717
+ if (!head) return;
718
+ var st = readState(tableKey(table));
719
+ var ncols = head.cells.length;
720
+
721
+ // Contain a widened table's horizontal overflow to the table, not the page.
722
+ if (!table.parentElement || !table.parentElement.classList.contains("mf-table-scroll")) {
723
+ var wrap = document.createElement("div");
724
+ wrap.className = "mf-table-scroll";
725
+ table.parentNode.insertBefore(wrap, table);
726
+ wrap.appendChild(table);
727
+ }
728
+
729
+ // Tag each row with its server/original ROW order (for tri-state "none") and each cell with its
730
+ // original COLUMN index (so applyOrder can permute the <td>s by data-mf-orig).
731
+ var tb = table.tBodies[0];
732
+ if (tb)
733
+ Array.prototype.forEach.call(tb.rows, function (r, i) {
734
+ r.setAttribute("data-mf-ord", i);
735
+ for (var c = 0; c < r.cells.length; c++) r.cells[c].setAttribute("data-mf-orig", c);
736
+ });
737
+
738
+ // Capture each column's natural width BEFORE fixed layout so the initial look is unchanged; a
739
+ // stored width (the intended number) wins when present.
740
+ var natural = [];
741
+ Array.prototype.forEach.call(head.cells, function (th) {
742
+ natural.push(th.offsetWidth);
743
+ });
744
+ var widths = [];
745
+ for (var wi = 0; wi < ncols; wi++) widths[wi] = (st.widths && st.widths[wi]) || natural[wi];
746
+
747
+ // Fill to the container's right edge. The table uses an explicit width = sum(col widths) so a resized
748
+ // column keeps its exact width (no width:100% "snap back wider" redistribution). But when that sum
749
+ // falls SHORT of the space available — few columns, or remembered narrow widths — the table stops
750
+ // before the right edge. Grow the LAST resizable column to absorb the slack so the table stays flush
751
+ // with the container's right edge (a real data-grid look); the other columns keep their exact widths.
752
+ // When columns already overflow (sum >= space) it's left alone → horizontal scroll, unchanged. Uses the
753
+ // scroll-wrap's clientWidth (padding-box, minus any scrollbar); it's re-run on each poll re-enhance, so
754
+ // the live table re-fills after a window resize.
755
+ var availW = table.parentElement ? table.parentElement.clientWidth : 0;
756
+ var sumW = 0;
757
+ for (var sw = 0; sw < widths.length; sw++) sumW += widths[sw];
758
+ var noStored = !(st.widths && st.widths.length);
759
+ if (availW && sumW) {
760
+ var lastIdx = ncols - 1;
761
+ while (lastIdx > 0 && head.cells[lastIdx].querySelector("input")) lastIdx--; // skip control cols
762
+ var delta = availW - sumW;
763
+ if (delta > 0) {
764
+ widths[lastIdx] += delta; // grow the last column to reach the right edge
765
+ } else if (noStored && -delta <= 32 && widths[lastIdx] + delta >= MINW) {
766
+ // Trim a SMALL rounding overshoot on a fresh table: the natural widths are summed integer
767
+ // offsetWidths (border-box), which can total a few px OVER the container and trip overflow-x:auto —
768
+ // a spurious horizontal scrollbar on a table that visually fits (e.g. the narrow key/value info
769
+ // tables on the connection-detail + status pages). A large (content-driven) overflow is left to
770
+ // scroll, and a user-resized column (stored widths) is never trimmed.
771
+ widths[lastIdx] += delta;
772
+ }
773
+ }
774
+ table.__mfWidths = widths.slice(); // seed for a first resize (numeric intent, never offsetWidth)
775
+
776
+ // Inject <colgroup> + switch to fixed layout with an explicit table width = sum(widths). Each
777
+ // <col> carries data-mf-orig so widths stay keyed to their column through a reorder.
778
+ var cg = document.createElement("colgroup");
779
+ cg.className = "mf-cols";
780
+ for (var ci0 = 0; ci0 < ncols; ci0++) {
781
+ var col0 = document.createElement("col");
782
+ col0.setAttribute("data-mf-orig", ci0);
783
+ cg.appendChild(col0);
784
+ }
785
+ table.insertBefore(cg, table.firstChild);
786
+ table.classList.add("mf-adjustable");
787
+ applyWidths(table, widths);
788
+
789
+ Array.prototype.forEach.call(head.cells, function (th, ci) {
790
+ th.setAttribute("data-mf-orig", ci); // stable column identity (state keys + reorder)
791
+ // Skip control columns (the connections select-all checkbox): width only, no sort/resize/reorder.
792
+ if (th.querySelector("input")) return;
793
+
794
+ // Sort: wrap the header label in a native <button> (Enter/Space + focus for free — W3C APG).
795
+ if (!th.querySelector(".mf-sort")) {
796
+ var btn = document.createElement("button");
797
+ btn.type = "button";
798
+ btn.className = "mf-sort";
799
+ while (th.firstChild) btn.appendChild(th.firstChild); // move the (server-escaped) label in
800
+ var arrow = document.createElement("span");
801
+ arrow.className = "mf-sort-arrow";
802
+ btn.appendChild(arrow);
803
+ th.appendChild(btn);
804
+ btn.addEventListener("click", function () {
805
+ var cur = readState(tableKey(table));
806
+ var dir; // tri-state cycle: unsorted/other → asc → desc → none
807
+ if (cur.sortCol !== ci) dir = "asc";
808
+ else if (cur.sortDir === "asc") dir = "desc";
809
+ else if (cur.sortDir === "desc") dir = "none";
810
+ else dir = "asc";
811
+ if (dir === "none") {
812
+ delete cur.sortCol;
813
+ delete cur.sortDir;
814
+ } else {
815
+ cur.sortCol = ci;
816
+ cur.sortDir = dir;
817
+ }
818
+ writeState(tableKey(table), cur);
819
+ applySort(table, ci, dir);
820
+ markHeaders(table, dir === "none" ? -1 : ci, dir);
821
+ });
822
+ }
823
+
824
+ // Resize: a handle that is a SIBLING of the sort button, so a drag never lands on the button.
825
+ if (!th.querySelector(".mf-resize")) {
826
+ var handle = document.createElement("span");
827
+ handle.className = "mf-resize";
828
+ th.appendChild(handle);
829
+ // Pointer Events + setPointerCapture: the drag keeps receiving pointermove/pointerup even off
830
+ // the handle/window, so it never gets stuck; pointerup/pointercancel always fire, clearing the
831
+ // mf-col-resizing flag (which pauses the live swap so a refresh cannot detach the dragged column).
832
+ handle.addEventListener("pointerdown", function (ev) {
833
+ ev.preventDefault();
834
+ ev.stopPropagation();
835
+ handle.setPointerCapture(ev.pointerId);
836
+ var w = (readState(tableKey(table)).widths || table.__mfWidths).slice();
837
+ var startX = ev.clientX,
838
+ startW = w[ci] || natural[ci];
839
+ document.body.classList.add("mf-col-resizing");
840
+ function move(e) {
841
+ w[ci] = Math.max(MINW, startW + (e.clientX - startX));
842
+ applyWidths(table, w);
843
+ }
844
+ function end() {
845
+ handle.removeEventListener("pointermove", move);
846
+ handle.removeEventListener("pointerup", end);
847
+ handle.removeEventListener("pointercancel", end);
848
+ handle.removeEventListener("lostpointercapture", end);
849
+ document.body.classList.remove("mf-col-resizing");
850
+ var cur = readState(tableKey(table));
851
+ cur.widths = w; // persist the INTENDED numbers, not a measured offsetWidth
852
+ writeState(tableKey(table), cur);
853
+ table.__mfWidths = w.slice();
854
+ }
855
+ handle.addEventListener("pointermove", move);
856
+ handle.addEventListener("pointerup", end);
857
+ handle.addEventListener("pointercancel", end);
858
+ // Defense-in-depth: if the captured handle is ever removed from the DOM mid-drag (a full
859
+ // re-render), the browser fires lostpointercapture — run end() so the flag can never wedge.
860
+ handle.addEventListener("lostpointercapture", end);
861
+ });
862
+ // Double-click a handle: reset that column to its natural width.
863
+ handle.addEventListener("dblclick", function (ev) {
864
+ ev.preventDefault();
865
+ ev.stopPropagation();
866
+ var cur = readState(tableKey(table));
867
+ var w2 = (cur.widths || table.__mfWidths).slice();
868
+ w2[ci] = natural[ci];
869
+ applyWidths(table, w2);
870
+ cur.widths = w2;
871
+ writeState(tableKey(table), cur);
872
+ table.__mfWidths = w2.slice();
873
+ });
874
+ }
875
+
876
+ // Reorder: native drag-and-drop on the header. Dragging the header body reorders the column; the
877
+ // sort button (click) and the resize handle (its pointerdown preventDefault suppresses the native
878
+ // drag) stay separate gestures. The checkbox column returned above, so it is never draggable.
879
+ th.draggable = true;
880
+ th.addEventListener("dragstart", function (ev) {
881
+ reorderFrom = +th.getAttribute("data-mf-orig");
882
+ try {
883
+ ev.dataTransfer.effectAllowed = "move";
884
+ ev.dataTransfer.setData("text/plain", String(reorderFrom));
885
+ } catch (_) {}
886
+ });
887
+ th.addEventListener("dragover", function (ev) {
888
+ if (reorderFrom == null) return;
889
+ ev.preventDefault(); // permit the drop
890
+ th.classList.add("mf-drop");
891
+ });
892
+ th.addEventListener("dragleave", function () {
893
+ th.classList.remove("mf-drop");
894
+ });
895
+ th.addEventListener("drop", function (ev) {
896
+ ev.preventDefault();
897
+ th.classList.remove("mf-drop");
898
+ var toOrig = +th.getAttribute("data-mf-orig");
899
+ if (reorderFrom == null || reorderFrom === toOrig) return;
900
+ var order = currentOrder(table);
901
+ var fp = order.indexOf(reorderFrom),
902
+ tp = order.indexOf(toOrig);
903
+ if (fp < 0 || tp < 0) return;
904
+ order.splice(fp, 1);
905
+ order.splice(tp, 0, reorderFrom); // move the dragged column to the drop target's slot
906
+ applyOrder(table, order);
907
+ var cur = readState(tableKey(table));
908
+ cur.order = order;
909
+ writeState(tableKey(table), cur);
910
+ });
911
+ th.addEventListener("dragend", function () {
912
+ reorderFrom = null;
913
+ var hcells = table.tHead ? table.tHead.rows[0].cells : [];
914
+ for (var i = 0; i < hcells.length; i++) hcells[i].classList.remove("mf-drop");
915
+ });
916
+ });
917
+
918
+ // Apply a saved column order onto this (freshly server-rendered, original-order) table.
919
+ if (st.order && st.order.length === ncols) applyOrder(table, st.order);
920
+
921
+ // Re-apply a remembered sort to this render (sortCol is the stable ORIGINAL column index).
922
+ if (typeof st.sortCol === "number" && st.sortCol < ncols) {
923
+ applySort(table, st.sortCol, st.sortDir || "asc");
924
+ markHeaders(table, st.sortCol, st.sortDir || "asc");
925
+ }
926
+ }
927
+
928
+ function enhanceAll() {
929
+ var tables = document.querySelectorAll("[data-mf-table]");
930
+ for (var i = 0; i < tables.length; i++) enhance(tables[i]);
931
+ }
932
+ enhanceAll();
933
+ // The connections table is the only live one: its [data-poll] container swaps in a fresh table each
934
+ // tick — re-enhance it (idempotent; enhanced elements are skipped). Static pages need no observer.
935
+ var poll = document.querySelector("[data-poll]");
936
+ if (poll) new MutationObserver(enhanceAll).observe(poll, { childList: true, subtree: true });
937
+ });
938
+
939
+ // --- Connections filter box: hide rows that don't match the typed query + the Flagged-only toggle ---
940
+ // Both controls live in the un-polled shell, so their state survives the ~1s live swap; app.js filters
941
+ // the table's rows and re-applies after each poll/ws swap (MutationObserver on [data-poll]). A row is
942
+ // visible iff (text query matches) AND (Flagged-only off OR the row's selection checkbox is
943
+ // data-flagged="1", #131). display:none only — the server still sends every row and a hidden row's
944
+ // checkbox stays in the selection Set (the filter is view-only). Coexists with sort + column resize.
945
+ feature("[data-mf-conns-filter]", function (input) {
946
+ var poll = document.querySelector("[data-poll]");
947
+ if (!poll) return; // no live table on this page
948
+ var flaggedOnly = document.querySelector("[data-mf-conns-flagged]");
949
+ function apply() {
950
+ var q = input.value.trim().toLowerCase();
951
+ var onlyFlagged = !!(flaggedOnly && flaggedOnly.checked);
952
+ var rows = poll.querySelectorAll("tbody tr");
953
+ for (var i = 0; i < rows.length; i++) {
954
+ var textHit = !q || rows[i].textContent.toLowerCase().indexOf(q) !== -1;
955
+ var cb = rows[i].querySelector("[data-mf-conns-cb]");
956
+ var flagHit = !onlyFlagged || (cb && cb.getAttribute("data-flagged") === "1");
957
+ rows[i].style.display = textHit && flagHit ? "" : "none";
958
+ }
959
+ }
960
+ input.addEventListener("input", apply);
961
+ if (flaggedOnly) flaggedOnly.addEventListener("change", apply);
962
+ // Re-apply after each swap (fresh rows) and after a sort re-append. Style-only changes don't feed
963
+ // back into this observer (it watches childList), so no loop.
964
+ new MutationObserver(apply).observe(poll, { childList: true, subtree: true });
965
+ apply();
966
+ });
967
+
968
+ // --- Generic fragment poller (BACKLOG #76) -------------------------------------------------------
969
+ // Fetch a server-rendered, server-escaped fragment named in data-fragment-url on an interval and swap
970
+ // it into this container's innerHTML. Used by the Flow & trends page (/ui/monitoring/live) so the
971
+ // status-colored inline-SVG data-flow graph + the queue-trend chart refresh without a WebSocket. A
972
+ // DISTINCT hook from [data-poll] (no ws, no connections-table entanglement); same server-render
973
+ // security model — the fragment is already escaped, never client-built markup (CSP script-src 'self').
974
+ // The initial content is server-rendered into the container, so the first swap is one interval later.
975
+ feature("[data-mf-fragment]", function (container) {
976
+ var url = container.getAttribute("data-fragment-url");
977
+ var ms = parseInt(container.getAttribute("data-fragment-ms") || "5000", 10);
978
+ if (!url || !(ms > 0)) return;
979
+ async function tick() {
980
+ try {
981
+ var resp = await fetch(url, {
982
+ credentials: "same-origin",
983
+ redirect: "manual", // an auth 303 must SURFACE, not be followed into a 200 login page
984
+ headers: { "X-Requested-With": "fetch" },
985
+ cache: "no-store",
986
+ });
987
+ if (resp.ok) {
988
+ container.innerHTML = await resp.text(); // server-rendered + server-escaped fragment
989
+ } else if (mfSessionEnded(resp) || mfNetworkDenied(resp)) {
990
+ // Session expired, or the source-network gate refused us — never freeze on stale data.
991
+ window.location.reload();
992
+ }
993
+ } catch (e) {
994
+ // Transient network/engine hiccup — leave the last good view in place and retry next tick.
995
+ }
996
+ }
997
+ setInterval(tick, ms);
998
+ });
999
+
1000
+ // --- Live engine-health + alerts nav glyphs ------------------------------------------------------
1001
+ // ---------------------------------------------------------------------------------------------
1002
+ // SESSION WATCHDOG (ASVS 14.3.1). A rendered console page can hold PHI in the DOM long after the
1003
+ // server has ended the session — the tab keeps showing it because nothing asks. This discards it.
1004
+ //
1005
+ // Anchored to the SERVER, never to local DOM activity. That distinction is the whole control: the
1006
+ // server's idle clock only advances on real requests, so a watchdog reset by mouse/keyboard would
1007
+ // sit happily on a poll-less PHI page keeping the rendered chart alive for hours after the session
1008
+ // it belongs to was terminated — precisely the exposure this requirement is about. Three legs, all
1009
+ // server-derived:
1010
+ //
1011
+ // 1. VERDICT — GET /ui/session-status with redirect:"manual". The server 303s to the login page
1012
+ // the moment the session is idle-expired, absolute-expired or revoked (including "sign out
1013
+ // everywhere else" and an admin disable), and that surfaces here as an opaque redirect. It is
1014
+ // probed with activity=false server-side, so probing never keeps the session alive.
1015
+ // 2. ABSOLUTE — the probe returns the deadline as REMAINING SECONDS, converted to a local
1016
+ // monotonic-ish deadline on arrival. Never a wall-clock the page would have to trust against
1017
+ // its own system time, and read from the session record so a federated session capped by the
1018
+ // IdP's id_token.exp is respected rather than over-stated from settings.
1019
+ // 3. STALE CONTACT — if no probe has SUCCEEDED for STALE_LIMIT_MS the page can no longer confirm
1020
+ // the session at all (engine down, network gone, laptop offline). It terminates anyway: an
1021
+ // unconfirmable session is not a licence to keep PHI on screen. This bound is deliberately far
1022
+ // tighter than any sane server idle timeout, so it never pre-empts a healthy session.
1023
+ //
1024
+ // Terminating BLANKS THE DOCUMENT FIRST and only then navigates. Ordering matters: location.replace
1025
+ // can fail or hang when the engine is unreachable — exactly leg 3's scenario — and a failed
1026
+ // navigation would otherwise leave the PHI on screen indefinitely. Blanking is unconditional and
1027
+ // synchronous, so the offline case is covered whether or not the navigation ever completes.
1028
+ // Registered as a SECURITY control (securityFeature, not feature): started before every page
1029
+ // enhancer and isolated from their failures — see the registry comment at the top of this file.
1030
+ securityFeature("[data-mf-session-watchdog]", function () {
1031
+ var PROBE_MS = 30000; // heartbeat; activity=false server-side, so it never slides the idle clock
1032
+ var TICK_MS = 1000; // deadline check — cheap, and keeps the fire granularity tight
1033
+ var STALE_LIMIT_MS = 300000; // no CONFIRMED-live server response for 5 min => discard the DOM
1034
+ var EXPIRED_URL = "/ui/login?e=expired";
1035
+
1036
+ var lastServerContact = Date.now(); // the page render itself was a server interaction
1037
+ var absoluteDeadline = null; // ms epoch, from the probe's remaining-seconds
1038
+ var terminated = false;
1039
+
1040
+ function terminate() {
1041
+ if (terminated) return;
1042
+ terminated = true;
1043
+ try {
1044
+ // Blank BEFORE navigating (see above). replaceChildren() drops every rendered node; the
1045
+ // title goes too so a tab preview cannot leak the message/patient context either.
1046
+ if (document.body) document.body.replaceChildren();
1047
+ document.title = "Session ended — MessageFoundry";
1048
+ } catch (e) {
1049
+ // Never let a DOM edge case skip the navigation below.
1050
+ }
1051
+ try {
1052
+ window.location.replace(EXPIRED_URL);
1053
+ } catch (e) {
1054
+ window.location.href = EXPIRED_URL;
1055
+ }
1056
+ }
1057
+
1058
+ async function probe() {
1059
+ if (terminated) return;
1060
+ try {
1061
+ var resp = await fetch("/ui/session-status", {
1062
+ credentials: "same-origin",
1063
+ redirect: "manual", // an auth 303 must SURFACE, not be followed into a 200 login page
1064
+ headers: { "X-Requested-With": "fetch" },
1065
+ cache: "no-store",
1066
+ });
1067
+ if (resp.type === "opaqueredirect" || resp.status === 0 || resp.status === 303) {
1068
+ terminate(); // the server has ended this session
1069
+ return;
1070
+ }
1071
+ if (resp.status === 401 || resp.status === 403) {
1072
+ terminate();
1073
+ return;
1074
+ }
1075
+ if (!resp.ok) return; // a 5xx is an engine problem, not a session verdict — let leg 3 decide
1076
+ var data = JSON.parse(await resp.text()) || {};
1077
+ lastServerContact = Date.now();
1078
+ var secs = data.expires_secs;
1079
+ absoluteDeadline = typeof secs === "number" ? Date.now() + secs * 1000 : null;
1080
+ } catch (e) {
1081
+ // Unreachable engine / offline: do NOT refresh lastServerContact — leg 3 is the answer.
1082
+ }
1083
+ }
1084
+
1085
+ function tick() {
1086
+ if (terminated) return;
1087
+ var now = Date.now();
1088
+ if (absoluteDeadline !== null && now >= absoluteDeadline) {
1089
+ terminate();
1090
+ return;
1091
+ }
1092
+ if (now - lastServerContact >= STALE_LIMIT_MS) terminate();
1093
+ }
1094
+
1095
+ probe();
1096
+ setInterval(probe, PROBE_MS);
1097
+ setInterval(tick, TICK_MS);
1098
+ });
1099
+
1100
+ // The nav renders on EVERY page, so this is the one always-on poller. It fetches the metadata-only
1101
+ // GET /ui/nav-status (~15s) and recolors two server-rendered <span> glyphs via classList + title/
1102
+ // aria-label only — never innerHTML (CSP script-src 'self'; the count is a number, set as text/aria, not
1103
+ // markup). Engine health: ok=green, warn=orange, down=blinking-red; a fetch/network failure means the
1104
+ // ENGINE itself is unreachable → down. Alerts: gray at zero, else colored by worst severity; a null
1105
+ // payload (viewer lacks monitoring:diagnose) HIDES the bell rather than showing a gray "no alerts" it
1106
+ // isn't cleared to trust. A 403 (no monitoring:read at all) hides the whole cluster.
1107
+ feature("[data-mf-nav-status]", function (box) {
1108
+ var heart = box.querySelector("[data-mf-nav-health]");
1109
+ var bell = box.querySelector("[data-mf-nav-alerts]");
1110
+ var HEALTH_CLS = ["health-unknown", "health-ok", "health-warn", "health-down"];
1111
+ var ALERT_CLS = ["alerts-unknown", "alerts-none", "alerts-info", "alerts-warning", "alerts-critical"];
1112
+
1113
+ function setClass(node, all, one) {
1114
+ if (!node) return;
1115
+ for (var i = 0; i < all.length; i++) node.classList.remove(all[i]);
1116
+ node.classList.add(one);
1117
+ }
1118
+ function label(node, text) {
1119
+ if (!node) return;
1120
+ node.setAttribute("title", text);
1121
+ node.setAttribute("aria-label", text);
1122
+ }
1123
+ function setHealth(state, reason) {
1124
+ var cls =
1125
+ state === "ok"
1126
+ ? "health-ok"
1127
+ : state === "warn"
1128
+ ? "health-warn"
1129
+ : state === "down"
1130
+ ? "health-down"
1131
+ : "health-unknown";
1132
+ setClass(heart, HEALTH_CLS, cls);
1133
+ label(heart, "Engine health: " + (reason || state || "unknown"));
1134
+ }
1135
+ function setAlerts(info) {
1136
+ if (!bell) return;
1137
+ if (!info) {
1138
+ bell.style.display = "none"; // no monitoring:diagnose → don't imply "no alerts"
1139
+ return;
1140
+ }
1141
+ bell.style.display = "";
1142
+ var n = info.count || 0;
1143
+ var sev = info.severity;
1144
+ var cls =
1145
+ n === 0
1146
+ ? "alerts-none"
1147
+ : sev === "critical"
1148
+ ? "alerts-critical"
1149
+ : sev === "warning"
1150
+ ? "alerts-warning"
1151
+ : "alerts-info";
1152
+ setClass(bell, ALERT_CLS, cls);
1153
+ label(
1154
+ bell,
1155
+ n === 0
1156
+ ? "No active alerts"
1157
+ : n + " active alert" + (n === 1 ? "" : "s") + (sev ? " (worst: " + sev + ")" : "")
1158
+ );
1159
+ }
1160
+ async function poll() {
1161
+ try {
1162
+ var resp = await fetch("/ui/nav-status", {
1163
+ credentials: "same-origin",
1164
+ redirect: "manual", // don't FOLLOW an auth 303 into the login page (see below)
1165
+ headers: { "X-Requested-With": "fetch" },
1166
+ cache: "no-store",
1167
+ });
1168
+ // Session expired: require_ui answers 303 → /ui/login. With redirect:"manual" that surfaces as an
1169
+ // opaque redirect (type "opaqueredirect", status 0) instead of being transparently followed into a
1170
+ // 200 login PAGE — which we'd then JSON.parse and, on the throw, falsely paint the heart blinking-red
1171
+ // "engine unreachable". Treat it as a no-op: the next real navigation takes the user to login. (Same
1172
+ // idiom as the step-up fetches above.)
1173
+ if (resp.type === "opaqueredirect" || resp.status === 0) return;
1174
+ if (mfNetworkDenied(resp)) {
1175
+ // NOT an RBAC outcome — the source-network gate refused us. Navigating surfaces the engine's
1176
+ // denial page (which names the setting and the observed address) instead of silently hiding
1177
+ // the box, which would read as "you lack monitoring:read".
1178
+ window.location.reload();
1179
+ return;
1180
+ }
1181
+ if (resp.status === 403) {
1182
+ box.style.display = "none"; // not permitted to see engine status at all
1183
+ return;
1184
+ }
1185
+ if (resp.status === 401) return; // auth edge — don't alarm; a navigation redirects to login
1186
+ if (!resp.ok) {
1187
+ setHealth("down", "status unavailable");
1188
+ return;
1189
+ }
1190
+ var d = JSON.parse(await resp.text()) || {};
1191
+ setHealth(d.health, d.reason);
1192
+ setAlerts(Object.prototype.hasOwnProperty.call(d, "alerts") ? d.alerts : null);
1193
+ } catch (e) {
1194
+ setHealth("down", "engine unreachable"); // network error → the engine/API is down
1195
+ }
1196
+ }
1197
+ poll();
1198
+ setInterval(poll, 15000);
1199
+ });
1200
+
1201
+ // Edit-and-resubmit editor (ADR 0090 §9, BACKLOG #153). Reveal a "Modified" badge as soon as the
1202
+ // edited COPY differs from the pristine original, enable a Revert button that restores the original
1203
+ // copy, and show the direct-outbound field only when "Send directly" is chosen. Pure client-side
1204
+ // progressive enhancement — the Resubmit itself is an ordinary server-validated, same-origin form POST
1205
+ // (no fetch), so the step-up/unlock re-auth flow applies unchanged and the feature is optional. The
1206
+ // ORIGINAL message is never touched: this edits a copy the server rendered from the audited raw view.
1207
+ feature("[data-mf-edit]", function (form) {
1208
+ var ta = form.querySelector("#edit-raw");
1209
+ var badge = form.querySelector("#edit-modified");
1210
+ var revert = form.querySelector("#edit-revert");
1211
+ var toRow = form.querySelector("#edit-to-row");
1212
+ var toInput = form.querySelector("#edit-to");
1213
+ if (!ta) return;
1214
+ // Normalize CR/CRLF → LF on BOTH sides: a browser textarea normalizes the DOM value's newlines, so
1215
+ // comparing against the raw (\r-delimited HL7) attribute would flag "modified" on open with no edit.
1216
+ function norm(s) {
1217
+ return (s || "").replace(/\r\n?/g, "\n");
1218
+ }
1219
+ var original = norm(ta.getAttribute("data-original"));
1220
+ function sync() {
1221
+ var changed = norm(ta.value) !== original;
1222
+ if (badge) badge.hidden = !changed;
1223
+ if (revert) revert.disabled = !changed;
1224
+ }
1225
+ ta.addEventListener("input", sync);
1226
+ if (revert)
1227
+ revert.addEventListener("click", function () {
1228
+ ta.value = ta.getAttribute("data-original") || "";
1229
+ sync();
1230
+ ta.focus();
1231
+ });
1232
+ function modeSync() {
1233
+ var checked = form.querySelector("input[name=mode]:checked");
1234
+ var isDirect = checked && checked.value === "direct";
1235
+ if (toRow) toRow.hidden = !isDirect;
1236
+ if (toInput) toInput.required = !!isDirect;
1237
+ }
1238
+ Array.prototype.forEach.call(
1239
+ form.querySelectorAll("input[name=mode]"),
1240
+ function (r) {
1241
+ r.addEventListener("change", modeSync);
1242
+ }
1243
+ );
1244
+ sync();
1245
+ modeSync();
1246
+ });
1247
+
1248
+ // Runtime log-verbosity control (BACKLOG #171, ADR 0130). A server-rendered <select> whose current
1249
+ // value is the live level; changing it PATCHes the same-origin /ui proxy of PATCH /logging/level. The
1250
+ // override is EPHEMERAL — it resets on a process restart (and NOT on /config/reload) — so the status
1251
+ // line says so. On failure the select reverts to the last-applied value (no silent divergence).
1252
+ feature("[data-mf-log-level]", function (select) {
1253
+ var status = document.querySelector("[data-mf-log-level-status]");
1254
+ var current = select.value;
1255
+ select.addEventListener("change", async function () {
1256
+ var next = select.value;
1257
+ if (status) status.textContent = "Applying…";
1258
+ try {
1259
+ var resp = await fetch("/ui/logging/level", {
1260
+ method: "PATCH",
1261
+ credentials: "same-origin",
1262
+ redirect: "manual", // an auth 303 must SURFACE, not be followed into a 200 login page
1263
+ headers: { "Content-Type": "application/json", "X-Requested-With": "fetch" },
1264
+ body: JSON.stringify({ level: next }),
1265
+ cache: "no-store",
1266
+ });
1267
+ if (mfSessionEnded(resp) || mfNetworkDenied(resp)) {
1268
+ window.location.reload(); // session expired, or refused by the source-network gate
1269
+ return;
1270
+ }
1271
+ if (resp.ok) {
1272
+ var data = await resp.json();
1273
+ current = data.level || next;
1274
+ select.value = current;
1275
+ if (status)
1276
+ status.textContent =
1277
+ "Log level set to " + current + " (ephemeral — resets on restart).";
1278
+ } else {
1279
+ select.value = current; // revert on refusal (e.g. missing monitoring:diagnose / step-up)
1280
+ if (status) status.textContent = "Could not change log level (" + resp.status + ").";
1281
+ }
1282
+ } catch (e) {
1283
+ select.value = current;
1284
+ if (status) status.textContent = "Network error changing log level.";
1285
+ }
1286
+ });
1287
+ });
1288
+
1289
+ // Redacted application-log viewer (BACKLOG #171, ADR 0130). Pages the same-origin /ui proxy of GET
1290
+ // /logs/tail, which returns ALREADY-REDACTED lines (best-effort PHI/secret scrub) counted back from the
1291
+ // end of the newest log file. offset counts lines from the END (0 = newest page). Lines are appended as
1292
+ // TEXT nodes, never innerHTML — the redacted log is still untrusted content and must never be parsed as
1293
+ // markup. Degrades to a message when no log file is configured (available=false).
1294
+ feature("[data-mf-log-viewer]", function (root) {
1295
+ var out = root.querySelector("[data-mf-log-lines]");
1296
+ var olderBtn = root.querySelector("[data-mf-log-older]");
1297
+ var newerBtn = root.querySelector("[data-mf-log-newer]");
1298
+ var refreshBtn = root.querySelector("[data-mf-log-refresh]");
1299
+ var status = root.querySelector("[data-mf-log-status]");
1300
+ var limit = parseInt(root.getAttribute("data-mf-log-limit") || "200", 10);
1301
+ if (!(limit > 0)) limit = 200;
1302
+ var offset = 0;
1303
+ var total = 0;
1304
+
1305
+ async function load() {
1306
+ if (status) status.textContent = "Loading…";
1307
+ try {
1308
+ var resp = await fetch("/ui/logs/tail?limit=" + limit + "&offset=" + offset, {
1309
+ credentials: "same-origin",
1310
+ redirect: "manual", // an auth 303 must SURFACE, not be followed into a 200 login page
1311
+ headers: { "X-Requested-With": "fetch" },
1312
+ cache: "no-store",
1313
+ });
1314
+ if (mfSessionEnded(resp) || mfNetworkDenied(resp)) {
1315
+ window.location.reload(); // session expired, or refused by the source-network gate
1316
+ return;
1317
+ }
1318
+ if (!resp.ok) {
1319
+ if (status) status.textContent = "Could not load log (" + resp.status + ").";
1320
+ return;
1321
+ }
1322
+ var data = await resp.json();
1323
+ total = data.total_lines || 0;
1324
+ if (out) {
1325
+ out.textContent = "";
1326
+ if (!data.available) {
1327
+ out.textContent = "No application-log file is configured (logging to stdout).";
1328
+ } else if (!data.lines.length) {
1329
+ out.textContent = "(no log lines)";
1330
+ } else {
1331
+ data.lines.forEach(function (line) {
1332
+ out.appendChild(document.createTextNode(line + "\n"));
1333
+ });
1334
+ }
1335
+ }
1336
+ if (newerBtn) newerBtn.disabled = offset <= 0;
1337
+ if (olderBtn) olderBtn.disabled = offset + limit >= total;
1338
+ if (status) {
1339
+ var shown = Math.min(limit, Math.max(0, total - offset));
1340
+ status.textContent = data.available
1341
+ ? "Lines " +
1342
+ (total - offset - shown + 1) +
1343
+ "–" +
1344
+ (total - offset) +
1345
+ " of " +
1346
+ total
1347
+ : "";
1348
+ }
1349
+ } catch (e) {
1350
+ if (status) status.textContent = "Network error loading log.";
1351
+ }
1352
+ }
1353
+
1354
+ if (olderBtn)
1355
+ olderBtn.addEventListener("click", function () {
1356
+ if (offset + limit < total) {
1357
+ offset += limit;
1358
+ load();
1359
+ }
1360
+ });
1361
+ if (newerBtn)
1362
+ newerBtn.addEventListener("click", function () {
1363
+ offset = Math.max(0, offset - limit);
1364
+ load();
1365
+ });
1366
+ if (refreshBtn)
1367
+ refreshBtn.addEventListener("click", function () {
1368
+ offset = 0;
1369
+ load();
1370
+ });
1371
+ load();
1372
+ });
1373
+
1374
+ // Bulk message-body export (BACKLOG #124, ADR 0131). Save-all downloads every match of the current
1375
+ // search (the server-rendered data-mf-export-url carries the basic filters); save-selected downloads
1376
+ // the checked rows (?ids=…). The download STREAMS from the same-origin /ui proxy of GET
1377
+ // /messages/export with a live progress readout (messages + bytes) and a Stop control (AbortController).
1378
+ // The browser's own download picker chooses the file location — a PHI-safe destination is the
1379
+ // operator's responsibility (ADR 0131). One export at a time.
1380
+ feature("[data-mf-msg-export]", function (root) {
1381
+ var allBtn = root.querySelector("[data-mf-export-all]");
1382
+ var selBtn = root.querySelector("[data-mf-export-selected]");
1383
+ var stopBtn = root.querySelector("[data-mf-export-stop]");
1384
+ var progress = root.querySelector("[data-mf-export-progress]");
1385
+ var allUrl = root.getAttribute("data-mf-export-url") || "/ui/messages/export";
1386
+ var base = root.getAttribute("data-mf-export-base") || "/ui/messages/export";
1387
+ var controller = null;
1388
+
1389
+ function setBusy(busy) {
1390
+ if (allBtn) allBtn.disabled = busy;
1391
+ if (selBtn) selBtn.disabled = busy;
1392
+ if (stopBtn) stopBtn.hidden = !busy;
1393
+ }
1394
+
1395
+ function selectedIds() {
1396
+ var out = [];
1397
+ Array.prototype.forEach.call(
1398
+ root.querySelectorAll("[data-mf-export-cb]"),
1399
+ function (cb) {
1400
+ if (cb.checked && cb.value) out.push(cb.value);
1401
+ }
1402
+ );
1403
+ return out;
1404
+ }
1405
+
1406
+ async function run(url, filename) {
1407
+ if (controller) return; // one export at a time
1408
+ controller = new AbortController();
1409
+ setBusy(true);
1410
+ if (progress) progress.textContent = "Starting…";
1411
+ try {
1412
+ var resp = await fetch(url, {
1413
+ credentials: "same-origin",
1414
+ redirect: "manual", // an auth 303 must SURFACE, not be followed into a 200 login page
1415
+ headers: { "X-Requested-With": "fetch" },
1416
+ cache: "no-store",
1417
+ signal: controller.signal,
1418
+ });
1419
+ if (mfSessionEnded(resp) || mfNetworkDenied(resp)) {
1420
+ window.location.reload(); // session expired, or refused by the source-network gate
1421
+ return;
1422
+ }
1423
+ if (!resp.ok) {
1424
+ if (progress) progress.textContent = "Export failed (" + resp.status + ").";
1425
+ return;
1426
+ }
1427
+ var reader = resp.body.getReader();
1428
+ var chunks = [];
1429
+ var bytes = 0;
1430
+ var lines = 0;
1431
+ for (;;) {
1432
+ var step = await reader.read();
1433
+ if (step.done) break;
1434
+ chunks.push(step.value);
1435
+ bytes += step.value.length;
1436
+ // Each NDJSON record ends in a newline (0x0A) — count them for a human progress readout.
1437
+ for (var i = 0; i < step.value.length; i++) if (step.value[i] === 10) lines++;
1438
+ if (progress)
1439
+ progress.textContent =
1440
+ "Exported " + lines + " messages (" + bytes + " bytes)…";
1441
+ }
1442
+ var blob = new Blob(chunks, { type: "application/x-ndjson" });
1443
+ var href = URL.createObjectURL(blob);
1444
+ var a = document.createElement("a");
1445
+ a.href = href;
1446
+ a.download = filename;
1447
+ document.body.appendChild(a);
1448
+ a.click();
1449
+ document.body.removeChild(a);
1450
+ URL.revokeObjectURL(href);
1451
+ if (progress) progress.textContent = "Saved " + lines + " messages.";
1452
+ } catch (e) {
1453
+ if (e && e.name === "AbortError") {
1454
+ if (progress) progress.textContent = "Export stopped.";
1455
+ } else if (progress) {
1456
+ progress.textContent = "Network error during export.";
1457
+ }
1458
+ } finally {
1459
+ controller = null;
1460
+ setBusy(false);
1461
+ }
1462
+ }
1463
+
1464
+ if (allBtn)
1465
+ allBtn.addEventListener("click", function () {
1466
+ run(allUrl, "messages-export.ndjson");
1467
+ });
1468
+ if (selBtn)
1469
+ selBtn.addEventListener("click", function () {
1470
+ var ids = selectedIds();
1471
+ if (!ids.length) {
1472
+ if (progress) progress.textContent = "Select at least one message.";
1473
+ return;
1474
+ }
1475
+ var q = ids
1476
+ .map(function (id) {
1477
+ return "ids=" + encodeURIComponent(id);
1478
+ })
1479
+ .join("&");
1480
+ run(base + "?" + q, "messages-export.ndjson");
1481
+ });
1482
+ if (stopBtn)
1483
+ stopBtn.addEventListener("click", function () {
1484
+ if (controller) controller.abort();
1485
+ });
1486
+ setBusy(false);
1487
+ });
1488
+
1489
+ // Deferred script → DOM already parsed. Run each feature whose hook is present on this page.
1490
+ // ISOLATED: a throw inside one init must never abort the loop and silently disable everything
1491
+ // after it — least of all the session watchdog (ASVS 14.3.1), whose job is discarding rendered
1492
+ // PHI. Security controls run FIRST, so they are never behind a page enhancer in either order or
1493
+ // failure domain.
1494
+ function runInit(pair) {
1495
+ var el = document.querySelector(pair[0]);
1496
+ if (!el) return;
1497
+ try {
1498
+ pair[1](el);
1499
+ } catch (e) {
1500
+ // One broken feature must not disable the rest. Nothing is logged: the console log is a
1501
+ // browser-side surface and these inits touch rendered PHI.
1502
+ }
1503
+ }
1504
+ securityInits.forEach(runInit);
1505
+ inits.forEach(runInit);
1506
+ })();