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.
- messagefoundry_webconsole/__init__.py +128 -0
- messagefoundry_webconsole/_auth.py +841 -0
- messagefoundry_webconsole/_html.py +539 -0
- messagefoundry_webconsole/_security.py +253 -0
- messagefoundry_webconsole/_service.py +22 -0
- messagefoundry_webconsole/_static.py +78 -0
- messagefoundry_webconsole/mount.py +103 -0
- messagefoundry_webconsole/pages/__init__.py +25 -0
- messagefoundry_webconsole/pages/_common.py +21 -0
- messagefoundry_webconsole/pages/account.py +771 -0
- messagefoundry_webconsole/pages/admin.py +480 -0
- messagefoundry_webconsole/pages/audit.py +66 -0
- messagefoundry_webconsole/pages/config.py +113 -0
- messagefoundry_webconsole/pages/connections.py +519 -0
- messagefoundry_webconsole/pages/messages.py +689 -0
- messagefoundry_webconsole/pages/monitoring.py +853 -0
- messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
- messagefoundry_webconsole/routes/__init__.py +6 -0
- messagefoundry_webconsole/routes/_common.py +45 -0
- messagefoundry_webconsole/routes/account.py +451 -0
- messagefoundry_webconsole/routes/admin.py +505 -0
- messagefoundry_webconsole/routes/audit.py +39 -0
- messagefoundry_webconsole/routes/config.py +67 -0
- messagefoundry_webconsole/routes/connection_writes.py +248 -0
- messagefoundry_webconsole/routes/core.py +1077 -0
- messagefoundry_webconsole/routes/monitoring.py +94 -0
- messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
- messagefoundry_webconsole/routes/oidc.py +172 -0
- messagefoundry_webconsole/routes/search.py +204 -0
- messagefoundry_webconsole/routes/sso.py +88 -0
- messagefoundry_webconsole/routes/status.py +178 -0
- messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
- messagefoundry_webconsole/static/app.css +345 -0
- messagefoundry_webconsole/static/app.js +1506 -0
- messagefoundry_webconsole/static/csp-probe.js +9 -0
- messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
- messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
- messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
- messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
- 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
|
+
})();
|