aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ui
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py419
1 files changed, 341 insertions, 78 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index b4885af..28654df 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -9,13 +9,14 @@ FastAPI app providing:
- API endpoints for all data (JSON)
Served only on 127.0.0.1 — not exposed to the network.
-No authentication required (localhost only).
+Gated by a per-run session token (11.5.3) — printed at daemon startup.
"""
import base64
import json
import logging
import time
+from html import escape
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
@@ -23,6 +24,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
from meshbay_node import __version__
from meshbay_common.crypto import generate_gek, wrap_gek_aes
+from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
log = logging.getLogger(__name__)
@@ -35,6 +37,55 @@ def create_ui_app(state: dict) -> FastAPI:
redoc_url=None,
)
+ @app.middleware("http")
+ async def _require_session_token(request, call_next):
+ """
+ Gate the admin UI behind a per-run token (11.5.3).
+
+ "localhost only" is weaker than it sounds: any process on the machine can
+ reach it, and a page in the operator's browser can reach it too via DNS
+ rebinding. Since this API can re-initialise a group's GEK and read the
+ audit log, an unauthenticated loopback service is a privilege boundary
+ waiting to be crossed. The token is printed at startup and accepted as
+ ?t= or the X-MeshBay-Token header.
+ """
+ from fastapi.responses import PlainTextResponse
+
+ token = state.get("ui_token")
+ if token:
+ supplied = (request.query_params.get("t")
+ or request.headers.get("X-MeshBay-Token"))
+ if supplied != token:
+ return PlainTextResponse("Forbidden", status_code=403)
+ return await call_next(request)
+
+ @app.middleware("http")
+ async def _security_headers(request, call_next):
+ """
+ Defence in depth behind the escaping fixes for H2. This UI is unauthenticated
+ on loopback, so script execution here equals full control of the node admin API.
+
+ Note what this does and does not do: the page relies on inline <script>, so
+ script-src must allow 'unsafe-inline' and CSP therefore does NOT prevent an
+ injected script from running. Escaping is the actual fix. What CSP buys is
+ containment — connect-src/img-src/form-action 'self'|'none' stop an injected
+ script from exfiltrating the audit log or config to an external host.
+ """
+ response = await call_next(request)
+ response.headers["Content-Security-Policy"] = (
+ "default-src 'none'; "
+ "style-src 'unsafe-inline'; "
+ "script-src 'unsafe-inline'; "
+ "connect-src 'self'; "
+ "img-src 'self' data:; "
+ "form-action 'none'; "
+ "frame-ancestors 'none'; "
+ "base-uri 'none'"
+ )
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["Referrer-Policy"] = "no-referrer"
+ return response
+
# ── JSON API ─────────────────────────────────────────────────────────────
@app.get("/api/status")
@@ -48,7 +99,6 @@ def create_ui_app(state: dict) -> FastAPI:
"status": state.get("status", "starting"),
"hub_url": state.get("hub_url", ""),
"username": state.get("username", ""),
- "node_port": state.get("node_port", 0),
"quic_port": state.get("quic_port", 0),
"endpoint_hint": state.get("endpoint_hint"),
"group_count": len(groups_ctx),
@@ -154,9 +204,7 @@ def create_ui_app(state: dict) -> FastAPI:
return {
"hub_url": config.hub.url,
"username": config.hub.username,
- "node_port": config.node.port,
"quic_port": config.node.quic_port,
- "http_port": config.node.http_port,
"ui_port": config.node.ui_port,
"data_dir": str(config.data_dir),
"groups": [
@@ -170,11 +218,159 @@ def create_ui_app(state: dict) -> FastAPI:
],
}
+ # ── Operator pairing (localhost only) ──────────────────────────────────
+
+ @app.post("/api/operator/pair")
+ async def operator_pair():
+ """
+ Issue a one-time code that pairs a browser as this node's operator.
+
+ The code is the whole point: it binds the operator's browser identity key
+ to their account without asking the hub, which is what stops a hub from
+ naming itself node administrator (M3, and the same substitution as H3).
+ It is returned once and stored only as a hash.
+ """
+ roster = state.get("roster")
+ user_id = state.get("node_user_id")
+ if not roster or not user_id:
+ return JSONResponse({"error": "Node not connected to hub yet"}, 503)
+
+ config = state.get("config")
+ ttl = (config.node.pair_ttl_hours if config else 24) * 3600
+ code = await roster.create_invite(
+ group_id="", # operator authority is node-wide
+ user_id=user_id,
+ role=ROLE_OPERATOR,
+ created_by="local-cli",
+ ttl=ttl,
+ username=(config.hub.username if config else ""),
+ )
+ invites = await roster.list_invites()
+ expires = next((i["expires_at"] for i in invites
+ if i["user_id"] == user_id and i["role"] == ROLE_OPERATOR), "")
+ return {"code": code, "expires_at": expires, "user_id": user_id}
+
+ @app.get("/api/roster")
+ async def api_roster(group_id: str = ""):
+ roster = state.get("roster")
+ if not roster:
+ return {"identities": [], "members": [], "invites": []}
+ return {
+ "identities": await roster.list_identities(),
+ "members": await roster.list_members(group_id or None),
+ "invites": await roster.list_invites(),
+ }
+
+ @app.post("/api/groups/{group_id}/invites")
+ async def create_invite(group_id: str, username: str):
+ """
+ Issue an invitation code from the CLI, without a browser.
+
+ The hub is asked for the account id and nothing else — never for a key.
+ A hub that answered with the wrong account would produce an invite whose
+ code it never learns, since the code goes to a human out of band.
+ """
+ roster = state.get("roster")
+ groups_ctx = state.get("groups_ctx", {})
+ if not roster:
+ return JSONResponse({"error": "Roster not available"}, 503)
+ if group_id not in groups_ctx:
+ return JSONResponse({"error": "Group not hosted on this node"}, 404)
+
+ hub = state.get("hub")
+ if not hub or not hub._session:
+ return JSONResponse({"error": "Hub not connected"}, 503)
+ try:
+ account = await hub.get_user_pubkeys(username)
+ except Exception as e:
+ return JSONResponse({"error": f"Unknown user {username!r}: {e}"}, 404)
+
+ config = state.get("config")
+ ttl = (config.node.invite_ttl_hours if config else 168) * 3600
+ code = await roster.create_invite(
+ group_id=group_id,
+ user_id=account["user_id"],
+ role=ROLE_MEMBER,
+ created_by="local-cli",
+ ttl=ttl,
+ username=username,
+ )
+ invites = await roster.list_invites()
+ expires = next((i["expires_at"] for i in invites
+ if i["user_id"] == account["user_id"]
+ and i["group_id"] == group_id), "")
+ return {"code": code, "expires_at": expires,
+ "username": username, "user_id": account["user_id"]}
+
+ @app.get("/api/resolve")
+ async def resolve_user(username: str):
+ """
+ Map a username to an account id for the CLI.
+
+ The roster answers first — it is the node's own record. The hub is the
+ fallback for identities pinned before invitations carried a name, and for
+ people admitted through an open-join group. Only an account id comes back;
+ no key is ever taken from here.
+ """
+ roster = state.get("roster")
+ if roster:
+ for ident in await roster.list_identities():
+ if ident["username"] == username:
+ return {"user_id": ident["user_id"], "source": "roster"}
+ hub = state.get("hub")
+ if hub and hub._session:
+ try:
+ account = await hub.get_user_pubkeys(username)
+ return {"user_id": account["user_id"], "source": "hub"}
+ except Exception:
+ pass
+ return JSONResponse({"error": f"Unknown user {username!r}"}, 404)
+
+ @app.post("/api/members/{user_id}/revoke")
+ async def revoke_member(user_id: str, group_id: str):
+ """
+ Stop serving the group key to someone.
+
+ Takes effect on their next connection: the key is wrapped on demand, so
+ there is no stored bundle left behind that would outlive this. Rotating
+ the group key is still required — they hold the current one.
+ """
+ roster = state.get("roster")
+ if not roster:
+ return JSONResponse({"error": "Roster not available"}, 503)
+ if not await roster.set_status(group_id, user_id, "revoked"):
+ return JSONResponse({"error": "No such member in that group"}, 404)
+ log.info("Member revoked: user=%s group=%s", user_id[:8], group_id[:8])
+ return {"status": "revoked", "user_id": user_id, "group_id": group_id,
+ "reminder": "rotate the group key: meshbay-node gek-init"}
+
+ @app.post("/api/members/{user_id}/unpin")
+ async def unpin_member(user_id: str):
+ """Forget a pinned identity, so the person can pair again with a new key."""
+ roster = state.get("roster")
+ if not roster:
+ return JSONResponse({"error": "Roster not available"}, 503)
+ if not await roster.unpin(user_id):
+ return JSONResponse({"error": "No such pinned identity"}, 404)
+ log.info("Identity unpinned: user=%s", user_id[:8])
+ return {"status": "unpinned", "user_id": user_id}
+
# ── GEK initialization (operator only, localhost) ──────────────────────
@app.post("/api/groups/{group_id}/gek")
async def init_gek(group_id: str):
- """Generate GEK, wrap for all group members, store, and activate."""
+ """
+ Generate the group key and activate it.
+
+ It used to be wrapped here for every member, using public keys fetched from
+ the hub — which is H3 with the node as the victim instead of the inviter: a
+ hub answering with its own key was handed the group key by the node itself.
+
+ Nothing is pre-wrapped for members now. Each member's copy is produced when
+ they connect, for a key they proved they hold (`join_request`). Only the
+ node's own copy is stored, so the daemon can reload the key across restarts
+ without the operator's browser.
+ """
groups_ctx = state.get("groups_ctx", {})
if group_id not in groups_ctx:
return JSONResponse({"error": "Group not hosted on this node"}, 404)
@@ -187,48 +383,15 @@ def create_ui_app(state: dict) -> FastAPI:
if not bundle_store:
return JSONResponse({"error": "Bundle store not available"}, 503)
- await hub.ensure_fresh_token()
- session = hub._session
- members_resp = await hub._http.get(
- f"/v1/groups/{group_id}/members",
- headers=session.auth_headers,
- )
- if not members_resp.is_success:
- return JSONResponse(
- {"error": f"Failed to fetch members: {members_resp.status_code}"}, 502)
- members = members_resp.json().get("members", [])
- if not members:
- return JSONResponse({"error": "No members in group"}, 400)
-
existing_gek = groups_ctx[group_id].get("gek")
gek = existing_gek or generate_gek()
+ errors: list[str] = []
- wrapped_count = 0
- errors = []
- for member in members:
- username = member["username"]
- user_id = member["user_id"]
- try:
- pk_data = await hub.get_user_pubkeys(username)
- pk_x_raw = base64.b64decode(pk_data["pk_x25519"])
- bundle = wrap_gek_aes(gek, pk_x_raw)
- await bundle_store.store(
- group_id, user_id,
- bundle["pk_eph_b64"], bundle["nonce_b64"], bundle["wrapped_b64"],
- )
- wrapped_count += 1
- log.info("GEK wrapped for %s (%s)", username, user_id[:8])
- except Exception as e:
- errors.append(f"{username}: {e}")
- log.warning("Failed to wrap GEK for %s: %s", username, e)
+ roster = state.get("roster")
+ authorized = len(await roster.list_members(group_id)) if roster else 0
- if wrapped_count == 0:
- return JSONResponse(
- {"error": "Failed to wrap GEK for any member", "details": errors}, 500)
-
- # Also store a copy wrapped for the node keystore X25519 key
- # so the daemon can reload GEK on restart without the operator's browser keys
- config = state.get("config")
+ # Store a copy wrapped for the node keystore X25519 key so the daemon can
+ # reload the GEK on restart without the operator's browser keys.
node_user_id = hub._session.user_id if hub._session else None
pk_x_node_raw = state.get("pk_x25519_raw")
if pk_x_node_raw and node_user_id:
@@ -239,13 +402,14 @@ def create_ui_app(state: dict) -> FastAPI:
node_bundle["pk_eph_b64"], node_bundle["nonce_b64"],
node_bundle["wrapped_b64"],
)
- log.info("GEK also wrapped for node keystore (daemon reload)")
+ log.info("GEK wrapped for node keystore (daemon reload)")
except Exception as e:
+ errors.append(f"node keystore: {e}")
log.warning("Failed to wrap GEK for node keystore: %s", e)
groups_ctx[group_id]["gek"] = gek
- log.info("GEK initialized for group %s — wrapped for %d/%d members",
- group_id[:8], wrapped_count, len(members))
+ log.info("GEK initialized for group %s — %d authorized member(s) will "
+ "receive it on connect", group_id[:8], authorized)
webrtc = state.get("webrtc")
if webrtc and "groups" in webrtc._ctx and group_id in webrtc._ctx["groups"]:
@@ -254,8 +418,7 @@ def create_ui_app(state: dict) -> FastAPI:
return {
"status": "ok",
"group_id": group_id,
- "wrapped_count": wrapped_count,
- "total_members": len(members),
+ "authorized_members": authorized,
"errors": errors,
}
@@ -311,11 +474,21 @@ def create_ui_app(state: dict) -> FastAPI:
@app.get("/", response_class=HTMLResponse)
async def root():
- return _render_page(state)
+ # Roster reads are async and the page renderer is not, so gather here.
+ roster = state.get("roster")
+ roster_view = None
+ if roster:
+ identities = {i["user_id"]: i for i in await roster.list_identities()}
+ roster_view = {
+ "identities": identities,
+ "members": await roster.list_members(),
+ "invites": await roster.list_invites(),
+ }
+ return _render_page(state, roster_view)
@app.get("/audit", response_class=HTMLResponse)
async def audit_page():
- return _render_audit_page()
+ return _render_audit_page(state.get("ui_token", ""))
return app
@@ -330,7 +503,71 @@ def _fmt_size(n: int) -> str:
return f"{n / (1024 * 1024 * 1024):.2f} GB"
-def _render_page(state: dict) -> str:
+def _render_roster(roster_view: dict | None) -> str:
+ """
+ Who this node recognises, and which keys are theirs.
+
+ Every value here is escaped: usernames come from the hub and pass through the
+ roster, so they are attacker-influenced text on the operator's own admin page
+ (the H2 rule applies to them exactly as it does to filenames).
+ """
+ if roster_view is None:
+ return '<p class="muted">Roster unavailable</p>'
+
+ identities = roster_view["identities"]
+ rows = ""
+ for m in roster_view["members"]:
+ ident = identities.get(m["user_id"], {})
+ scope = escape(m["group_id"][:8]) if m["group_id"] else "node-wide"
+ status_color = "#22c55e" if m["status"] == "active" else "#ef4444"
+ rows += (
+ f"<tr><td>{escape(str(ident.get('username') or m['user_id']))}</td>"
+ f"<td>{escape(str(m['role']))}</td>"
+ f"<td><span class='badge' style='background:{status_color}'>"
+ f"{escape(str(m['status']))}</span></td>"
+ f"<td>{scope}</td>"
+ f"<td><code>{escape(str(ident.get('pk_ed25519', ''))[:16])}…</code></td>"
+ f"<td>{escape(str(ident.get('pinned_at', '?')))} "
+ f"({escape(str(ident.get('pinned_via', '?')))})</td></tr>"
+ )
+ if not rows:
+ rows = ('<tr><td colspan="6" class="muted">Nobody admitted yet — '
+ 'run <code>meshbay-node member invite &lt;username&gt;</code></td></tr>')
+
+ invite_rows = ""
+ for i in roster_view["invites"]:
+ invite_rows += (
+ f"<tr><td><code>{escape(str(i['user_id'])[:16])}</code></td>"
+ f"<td>{escape(str(i['group_id'][:8] or 'node-wide'))}</td>"
+ f"<td>{escape(str(i['role']))}</td>"
+ f"<td>{escape(str(i['expires_at']))}</td></tr>"
+ )
+ invites_html = ""
+ if invite_rows:
+ invites_html = f"""
+ <details style="margin-top:10px"><summary>Pending invitations</summary>
+ <table>
+ <thead><tr><th>Account</th><th>Group</th><th>Role</th><th>Expires</th></tr></thead>
+ <tbody>{invite_rows}</tbody>
+ </table>
+ </details>"""
+
+ return f"""
+ <table>
+ <thead><tr><th>User</th><th>Role</th><th>Status</th><th>Scope</th>
+ <th>Identity key</th><th>Pinned</th></tr></thead>
+ <tbody>{rows}</tbody>
+ </table>
+ {invites_html}
+ <p class="muted" style="margin-top:8px">
+ Codes are issued from the CLI: <code>meshbay-node operator pair</code>,
+ <code>meshbay-node member invite &lt;username&gt;</code>. They never pass
+ through the hub.
+ </p>"""
+
+
+def _render_page(state: dict, roster_view: dict | None = None) -> str:
+ token_js = json.dumps(state.get("ui_token", ""))
status = state.get("status", "starting")
indexes = state.get("indexes", {})
groups_ctx = state.get("groups_ctx", {})
@@ -356,12 +593,16 @@ def _render_page(state: dict) -> str:
fcount = idx.count if idx else 0
total_size = sum(e.size for e in idx.entries) if idx else 0
+ # Everything interpolated below is attacker-controlled: filenames come from
+ # uploads by any group member. Rendering them raw was a stored XSS into the
+ # unauthenticated localhost admin UI, i.e. full control of the node admin API
+ # from the operator's browser (finding H2).
file_rows = ""
if idx:
for e in sorted(idx.entries, key=lambda x: x.name):
file_rows += (
- f"<tr><td>{e.name}</td><td>{e.type}</td>"
- f"<td>{_fmt_size(e.size)}</td><td>{e.path or '/'}</td></tr>"
+ f"<tr><td>{escape(e.name)}</td><td>{escape(e.type)}</td>"
+ f"<td>{_fmt_size(e.size)}</td><td>{escape(e.path or '/')}</td></tr>"
)
has_gek = bool(ctx.get("gek"))
@@ -385,14 +626,14 @@ def _render_page(state: dict) -> str:
groups_html += f"""
<div class="card">
- <h3>{name}
- <span class="badge" style="background:#6366f1">{vis}</span>
+ <h3>{escape(str(name))}
+ <span class="badge" style="background:#6366f1">{escape(str(vis))}</span>
{gek_badge}
</h3>
- <p><b>Directory:</b> <code>{shared}</code></p>
+ <p><b>Directory:</b> <code>{escape(str(shared))}</code></p>
<p><b>Files:</b> {fcount} &mdash; <b>Total:</b> {_fmt_size(total_size)}</p>
{gek_action}
- <p class="muted">ID: {gid}</p>
+ <p class="muted">ID: {escape(gid)}</p>
<details><summary>File list</summary>
<table>
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead>
@@ -408,10 +649,10 @@ def _render_page(state: dict) -> str:
from meshbay_node.transport.webrtc_server import _get_remote_ip
ip = session._remote_ip or _get_remote_ip(session._pc)
peers_html += (
- f"<tr><td>{session._username or session._user_id or '—'}</td>"
- f"<td>{ip or '—'}</td>"
- f"<td>{session._group_id[:8] if session._group_id else '—'}</td>"
- f"<td>{session._pc.connectionState}</td></tr>"
+ f"<tr><td>{escape(session._username or session._user_id or '—')}</td>"
+ f"<td>{escape(ip or '—')}</td>"
+ f"<td>{escape(session._group_id[:8] if session._group_id else '—')}</td>"
+ f"<td>{escape(session._pc.connectionState)}</td></tr>"
)
if not peers_html:
peers_html = '<tr><td colspan="4" class="muted">No connected peers</td></tr>'
@@ -487,14 +728,16 @@ def _render_page(state: dict) -> str:
<tbody>{peers_html}</tbody>
</table>
+ <h2>Roster</h2>
+ {_render_roster(roster_view)}
+
<h2>Groups</h2>
{groups_html or '<p class="muted">No groups configured</p>'}
<h2>Node Configuration</h2>
<div class="card">
<p><b>Hub:</b> {state.get("hub_url", "—")}</p>
- <p><b>QUIC port:</b> {state.get("quic_port", "—")} &mdash;
- <b>TCP port:</b> {state.get("node_port", "—")}</p>
+ <p><b>QUIC port:</b> {state.get("quic_port", "—")}</p>
<p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p>
</div>
@@ -524,17 +767,18 @@ def _render_page(state: dict) -> str:
</div>
</div>
<script>
+const TOKEN = {token_js};
async function initGEK(groupId) {{
const btn = document.getElementById('gek-btn-' + groupId.slice(0,8));
const status = document.getElementById('gek-status-' + groupId.slice(0,8));
if (btn) btn.disabled = true;
if (status) status.textContent = 'Initializing...';
try {{
- const resp = await fetch('/api/groups/' + groupId + '/gek', {{ method: 'POST' }});
+ const resp = await fetch('/api/groups/' + groupId + '/gek?t=' + TOKEN, {{ method: 'POST' }});
const data = await resp.json();
if (resp.ok) {{
- if (status) status.textContent = 'GEK initialized — wrapped for '
- + data.wrapped_count + '/' + data.total_members + ' members';
+ if (status) status.textContent = 'GEK initialized — '
+ + data.authorized_members + ' authorized member(s) get it on connect';
if (status) status.style.color = '#22c55e';
setTimeout(() => location.reload(), 2000);
}} else {{
@@ -554,8 +798,11 @@ setTimeout(()=>location.reload(), 10000);
</html>"""
-def _render_audit_page() -> str:
- return """<!DOCTYPE html>
+def _render_audit_page(token: str = "") -> str:
+ return _AUDIT_HTML.replace("__TOKEN__", json.dumps(token))
+
+
+_AUDIT_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@@ -591,7 +838,7 @@ def _render_audit_page() -> str:
<body>
<div class="container">
<h1>Audit Log</h1>
- <nav><a href="/">Dashboard</a><a href="/audit">Audit Log</a></nav>
+ <nav><a id="navHome" href="/">Dashboard</a><a id="navAudit" href="/audit">Audit Log</a></nav>
<div class="filters">
<select id="eventFilter">
@@ -620,23 +867,39 @@ def _render_audit_page() -> str:
</table>
</div>
<script>
+const TOKEN = __TOKEN__;
async function load() {
const ev = document.getElementById('eventFilter').value;
const limit = document.getElementById('limitSelect').value;
- let url = '/api/audit?limit=' + limit;
+ let url = '/api/audit?limit=' + limit + (TOKEN ? '&t=' + TOKEN : '');
if (ev) url += '&event=' + ev;
const r = await fetch(url);
const data = await r.json();
const tbody = document.getElementById('tbody');
document.getElementById('count').textContent = data.entries.length + ' entries';
- tbody.innerHTML = data.entries.map(e => {
- const t = new Date(e.timestamp * 1000).toLocaleString();
- return '<tr><td>' + t + '</td><td>' + e.event + '</td><td>'
- + (e.username || e.user_id.slice(0,8)) + '</td><td>'
- + (e.ip || '—') + '</td><td>'
- + (e.group_id ? e.group_id.slice(0,8) : '—') + '</td><td>'
- + (e.detail || '') + '</td></tr>';
- }).join('');
+ // textContent, not innerHTML: e.detail carries filenames chosen by group members
+ // (finding H2). Building this row with string concatenation was a stored XSS.
+ tbody.replaceChildren(...data.entries.map(e => {
+ const tr = document.createElement('tr');
+ const cells = [
+ new Date(e.timestamp * 1000).toLocaleString(),
+ e.event,
+ e.username || (e.user_id || '').slice(0, 8),
+ e.ip || '—',
+ e.group_id ? e.group_id.slice(0, 8) : '—',
+ e.detail || '',
+ ];
+ for (const value of cells) {
+ const td = document.createElement('td');
+ td.textContent = value;
+ tr.appendChild(td);
+ }
+ return tr;
+ }));
+}
+for (const [id, href] of [['navHome','/'],['navAudit','/audit']]) {
+ const el = document.getElementById(id);
+ if (el && TOKEN) el.href = href + '?t=' + TOKEN;
}
document.getElementById('eventFilter').onchange = load;
document.getElementById('limitSelect').onchange = load;