aboutsummaryrefslogtreecommitdiffstats
path: root/docs/refactor-node-ui.md
blob: 7dbf3dac256fb029517e3b7a6f9bffb1c4362899 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# Refactor: fold the node's local admin surface into the desktop Node page

> **Superseded by `MESHBAY_DESIGN.md`.** This was the node admin surface refactor; its design
> content now lives in §6.7.
>
> It is kept because code comments, tests and other documents cite its
> sections and its labels, and because it records reasoning a synthesis
> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
> document is right; where either disagrees with the code, the code is.**
> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
> replacement, and §13 defines every label.

> Status: **complete** (2026-09-01) — all six phases landed; see §11 for what
> each did. This stays as the decision record.
> Scope: the node's *own* administration surface. Group-level settings
> (member-upload, apps, scan-settings) are not touched — they live in the group
> UI and stay there.
>
> **Net result:** `ui/app.py` is a JSON-only, ruff-clean loopback control API
> (1074 → 453 lines); the server-rendered dashboard, the `/audit` HTML page,
> the `meshbay-node ui` verb and three never-wired endpoints are gone; the
> desktop client's Node page (`static/node-page.js`) gained an Overview tab,
> node-wide Roster, Peers, a paginated Audit log with CSV export, index-cache
> Maintenance, and an Unlink action, organised into six tabs; the pointless
> `18000/tcp` firewall profiles were deleted. One capability is intentionally
> gone: browser-based admin on a headless server — the CLI covers every
> operation there.

---

## 1. The distinction that matters

"The local admin UI" is three things bundled in `packages/meshbay-node/src/meshbay_node/ui/`:

| Component | Consumers today | Redundant with `node-page.js`? |
|---|---|---|
| **Loopback JSON API** `/api/*` (~35 routes), `127.0.0.1:{ui_port}`, per-run token | the **entire CLI** (`_daemon_api` — every verb is an HTTP call), `node-page.js` (via `platform.node.call`), `platform.node.detect()`, the create-group wizard's index-progress poll | **No.** This is the node's control plane. |
| **Server-rendered HTML** — `GET /` (dashboard), `GET /audit` (`_DASHBOARD` / `_AUDIT_HTML` templates + `_render_*` helpers) | a human with a browser (`meshbay-node ui` prints a tokened URL; `ssh -L` from a remote host) | **Yes, entirely.** |
| uvicorn server + middleware (token gate, CSP headers) | — | Stays — the API needs it. |

**This refactor removes the HTML layer only.** Removing the module would break
the CLI, node detection, and the Node page itself.

---

## 2. Decision

1. Enrich `packages/meshbay-hub/src/meshbay_hub/static/node-page.js` so it covers
   everything the HTML dashboard showed (peers, audit, index-cache maintenance,
   node-wide roster, node identity/QUIC/hub display).
2. Add a **Unlink node** action (endpoint already exists, nothing surfaces it).
3. Fix the audit view to show real usernames.
4. Organise `node-page.js` into tabs.
5. Delete `GET /` and `GET /audit` from `ui/app.py`, plus the templates, the
   `render` helpers, the `meshbay-node ui` CLI verb, and every doc/packaging
   reference to a browser admin UI.
6. Keep the whole `/api/*` surface and the uvicorn server.

**Accepted cost:** on a headless server (the meshbay.org model) the only admin
path becomes the CLI. The CLI already covers every operation, so this is
acceptable — but it is a real capability reduction and is recorded here as a
conscious choice, not an oversight.

---

## 3. Inventory — nothing is lost

### 3a. HTML dashboard content → destination

| Dashboard element | Backing endpoint | Destination |
|---|---|---|
| Node status badge, `needs` hints | `GET /api/status` | Node page header (already partly there) |
| Stats: groups / files / peers / user | `GET /api/status` | Overview tab |
| **Connected Peers** table | `GET /api/peers` | **Peers tab (new wiring)** |
| Roster (node-wide) | `GET /api/roster?group_id=` | **Roster tab — extend the current per-group view to node-wide** |
| Groups list | `GET /api/groups` | Groups tab (already there) |
| Node Configuration: Hub URL, QUIC port, Node ID | `GET /api/status` (`endpoint_hint`, `quic_port`, `hub_url`) | Overview tab (new fields) |
| Node Configuration: 5 `[node]` settings | `GET /api/node-settings` | Settings tab (already there) |
| **Maintenance**: index-cache count + Prune | `GET /api/index-cache`, `POST /api/index-cache/prune` | **Settings/Maintenance tab (new wiring)** |
| `/audit` page | `GET /api/audit` | **Audit tab (new wiring)** |

### 3b. `/api/*` endpoints — consumer and disposition

| Endpoint | Consumed by | Disposition |
|---|---|---|
| `GET /api/status` | Electron `node:detect`, CLI `status`, node-page | keep |
| `DELETE /api/unlink` | *nothing* | keep + **surface in node-page** |
| `GET /api/groups` | CLI, node-page | keep |
| `POST /api/groups/attach` · `POST /api/groups/detach` | CLI, node-page (detach) | keep |
| `DELETE /api/groups/{g}/files/{f}` | CLI `file rm`, group Files UI | keep |
| `GET /api/groups/{g}/files` | CLI `file list`, HTML dashboard | keep (drop dashboard caller) |
| `GET /api/denylist` · `POST /api/denylist/clear` | CLI, node-page | keep |
| `GET /api/index-cache` · `POST /api/index-cache/prune` | **HTML dashboard only** | keep + **wire into node-page** |
| `POST /api/groups/{g}/video/rematch` | CLI, group Videos UI | keep |
| `GET /api/peers` | **HTML dashboard only** | keep + **wire into node-page** |
| `GET /api/audit` | **HTML `/audit` only** | keep + **wire into node-page** + username fix (§5) |
| `GET /api/config` | *nothing* | **triage — likely dead (§6b)** |
| `POST /api/operator/pair` | CLI, node-page | keep |
| `GET /api/roster` | CLI, node-page | keep (node-page uses node-wide mode too) |
| `POST /api/groups/{g}/invites` · `GET /api/resolve` | CLI | keep |
| `POST /api/members/{u}/revoke` | CLI | keep + optionally surface in node-page |
| `POST /api/members/{u}/unpin` | CLI, node-page | keep |
| `POST /api/groups/{g}/gek` | CLI `gek init/rotate`, node-page (rotate) | keep |
| `POST /api/groups/{g}/roots` · `DELETE .../roots/{n}` | node-page | keep |
| `GET /api/groups/{g}/index-status` | create-group wizard | keep |
| `PUT /api/groups/{g}/member-upload` · `.../apps` · `.../scan-settings` | group Settings UI | keep (out of scope) |
| `POST /api/reload` | CLI, node-page | keep |
| `GET /api/node-settings` · `PUT /api/node-settings` | CLI `stun`, node-page | keep |
| `GET /api/chat/history` · `WS /ws/chat` | *nothing* | **triage — likely dead (§6b)** |
| `GET /` · `GET /audit` | browser only | **DELETE** |

### 3c. CLI verbs

`init reset status ui gek-init gek operator member group file video denylist stun
reload restart-daemon calibrate-argon2`.

Only **`ui`** goes away. `status` output drops its `admin UI  meshbay-node ui`
line. Nothing else changes — the CLI keeps talking to the same `/api/*`.

---

## 4. Gap fills in `node-page.js`

New sections/tabs, each a thin `nodeCall`:

- **Peers** — `GET /api/peers` → table (user, IP, group, state). Poll ~5 s while visible.
- **Audit** — `GET /api/audit` with the event/user/limit filters the HTML page had.
  Render every field as **text** (htm/preact escapes by default — never
  `dangerouslySetInnerHTML` on `detail`/`username`; `detail` carries
  member-chosen filenames, H2).
- **Maintenance** — `GET /api/index-cache` (count) + `POST /api/index-cache/prune`.
- **Roster (node-wide)** — call `GET /api/roster` with no `group_id`. Show every
  identity and its devices; keep the existing `unpin`, add `revoke_member` per
  member. (`revoke_device` for a single device is a later item — the endpoint
  does not exist yet.)
- **Overview** — Node ID (`endpoint_hint`), QUIC port, hub URL, version, from
  `GET /api/status`.

Tabs: **Overview · Groups · Roster · Peers · Audit · Settings**. Pure UI
refactor of the current 749-line single scroll; no protocol change. Split into
per-tab files if it grows (same move as `docs/apps.md` did for the group UI) —
optional.

---

## 5. Audit usernames

**Symptom:** rows show `8f7e206e` (that is `user_id[:8]`, the fallback in the
HTML template's `e.username || e.user_id.slice(0,8)`).

**Cause:** the `username` column exists and `_audit()` already passes
`username=self._username`, but it is blank for (a) legacy rows written before the
column was populated and (b) events where the handshake payload carried no
username (pre-handshake `auth_failed`, some token shapes).

**Fix — resolve at read time, no migration:** in the audit endpoint (or a new
`ops.get_audit`), build `{user_id: username}` from the roster
(`roster.list_members()` + identities already expose `username` node-wide, same
source `_render_roster` uses) and fill blanks:
`row.username or roster_name.get(row.user_id) or row.user_id[:8]`.

**Security:** this is **not** the stored-XSS concern. That concern is rendering
attacker-influenced strings (`username` from the hub, `detail` filenames) as
**markup**. Showing the real name is fine *as text*. The rule for the Preact
Audit tab: interpolate, never inject HTML.

---

## 6. Cleanup

### 6a. Dead code / config once the HTML is gone

- `ui/app.py`: `@app.get("/")`, `@app.get("/audit")`, `_DASHBOARD_*`/`_AUDIT_HTML`
  templates, `_render_dashboard`, `_render_node_settings`, `_render_roster` (HTML
  variant), `_render_audit_page`, and any `escape`-only helpers left unused.
- `daemon.py`: the `ui` CLI verb + its `argparse` choice + help text + `quiet`
  list entry; the `_ui_url`/`ui` handler block; `log.info("Admin UI ready — open
  it with: meshbay-node ui")`; the `admin UI  meshbay-node ui` line in
  `_print_status`.
- `packaging/firewall/ufw/meshbay` and
  `packaging/firewall/firewalld/meshbay-node.xml`: drop the `18000/tcp` service.
  The UI binds `127.0.0.1` only (`daemon.py` `host="127.0.0.1"`), so these rules
  never did anything useful and become a footgun if the bind address ever
  changes. Remove regardless of the rest of this refactor.
- Docs/man: `docs/QUICKSTART.md:110`, `QE/deploy/README.md:99`,
  `man/meshbay-node.1` (the `meshbay-node ui` entry), `packaging/deb/.../control`
  and `packaging/rpm/meshbay-node.spec` ("a local admin UI on localhost:18000").

**Keep:** the uvicorn server, the token middleware, the CSP middleware (harmless
and good hygiene on JSON responses), the `ui-token` file, `ui_port` in
`node.toml`. Renaming `ui_*` → `localapi_*` is churn for no user value — deferred,
noted only so the naming mismatch is a known thing.

### 6b. Orphan endpoints — ✅ removed (phase 6)

`GET /api/config`, `GET /api/chat/history`, `WS /ws/chat` (plus
`broadcast_chat_to_ui` / `app.broadcast_chat` / `_chat_subscribers`) were a
never-wired local operator chat view plus a superseded config dump. Confirmed
dead against git history and removed. See phase 6 above.

---

## 7. Security review

The loopback API's model is unchanged and adequate: bind `127.0.0.1`; per-run
token (`os.urandom(18)`, `ui-token` mode 0600, required as `?t=` or
`X-MeshBay-Token`); CSP `default-src 'none'; connect-src 'self'`. Threats
modelled: DNS-rebinding from the operator's browser, and any local process. The
token closes both.

- **Folding functions into `node-page.js` adds no surface** — every call goes
  through the same token-gated API; the Electron main process holds the token,
  the renderer never sees it (same model as `hub:fetch`).
- **Do not shell out to the CLI from the Node page.** The CLI is itself an HTTP
  client of this API; spawning it would be a strictly worse `nodeCall` (process
  spawn, arg-injection, PATH assumptions, unstructured errors). If a function is
  missing from the API, add the endpoint. `main.js` shelling out to
  `systemctl --user` is a legitimate exception — OS service control has no API
  equivalent; node *operations* stay on the API.
- **Audit tab:** render as text (§5).
- **Unlink copy:** unlink clears only the hub's stored linking key
  (`DELETE /v1/users/me/node_key` → `user.pk_node_ed25519 = None`). It does **not**
  touch the roster, GEKs, pins, or on-disk group data — the node keeps serving
  anyone who reaches it directly over MNP. The UI must say "the hub stops
  brokering connections to this node", not imply a wipe. `reset` stays the
  destroy-everything path. After unlink, `POST /v1/nodes/auth` fails and the hub
  WebSocket drops — expected; show it as "offline from hub", not an error.

---

## 8. Node-key linking — confirmed automatic (native)

`create-group-page.js` `linkNodeKey(pk)` is called from `detectNode()` after
`platform.node.detect()` returns the node's `pk_node_ed25519`; it
`PUT /v1/users/me/node_key`. So in the desktop wizard the key links
automatically on node detection. The manual paste box in `profile-page.js` is the
fallback for the browser-only case (no bridge to read the node's key) and for
re-linking. No change needed; the **unlink** counterpart is the only missing half
(§2.2, §7).

---

## 9. Draft correction

`docs/meshbay-draft-v6.md` §2.11 and §2.12 describe the Node page as connecting
**over MNP** (`node_status_ack`, `node_settings_set`). The code went
loopback-HTTP instead (`node-page.js` header comment says so explicitly), which is
better for a native client — no signaling round-trip, no GEK/session, works
before any group is joined. The MNP handlers
(`webrtc_server.py:_do_node_status`, `_do_node_settings_set`) are dormant.

Update §2.11/§2.12 to state: the Node page uses the node's loopback API; the MNP
`node_*` message types are reserved for a possible future browser-side (no
Electron) path and are not the mechanism today.

---

## 10. Tests

- `test_cli_dispatch.py` — drop the `["ui", ...]` row; assert `ui` is no longer a
  parser choice.
- New `ops`/endpoint test for audit username backfill from the roster.
- Node-page: extend whatever source-reading test pins its structure (per the SPA
  testing posture) to cover the new tabs' `nodeCall` paths.
- `test_packaging_units.py` / packaging tests — assert the firewall service files
  no longer open 18000.
- Grep guard (or manual): no `meshbay-node ui` string left in `man/`, `docs/`,
  `packaging/`.

---

## 11. Phases

1. **Draft + firewall** — ✅ done 2026-09-01. Corrected draft-v6 §2.11/§2.12
   (loopback API, not MNP) with a 2026-09-01 amendment line. Deleted
   `packaging/firewall/firewalld/meshbay-node.xml`; stripped the `[MeshBay Node]`
   stanza from `packaging/firewall/ufw/meshbay` (the `[MeshBay Cast]` profile
   stays). Follow-on edits so the build still passes:
   `packaging/build/build-node.sh` (drop the firewalld copy),
   `packaging/rpm/meshbay-node.spec` `%files` (drop the `.xml` line),
   `docs/PACKAGING-GUIDE.md` (drop the "Node admin UI / TCP 18000" section). No
   behaviour change. The deb/spec "local admin UI on localhost:18000" description
   lines are left for phase 5.
2. **node-page.js gap fills** — ✅ done 2026-09-01. Added five sections to the
   existing single-scroll page (tabs come in phase 4): **Overview** (version,
   node id, QUIC port, hub — from `GET /api/status`, fetched in
   `fetchStatus`/`refresh`), **Node roster** (`GET /api/roster` with no
   `group_id`, node-wide, with `unpin`), **Connected peers** (`GET /api/peers`),
   **Audit log** (`GET /api/audit` with event + limit filters, every field
   rendered as text), **Maintenance** (`GET /api/index-cache` +
   `POST /api/index-cache/prune`). Backend: `GET /api/audit` now backfills a
   blank `username` from `roster.list_identities()` at read time (no migration).
   ~31 `node.*` i18n keys added to all ten locale catalogues (English
   placeholders, matching the existing node-page convention). `test_locales.py`
   green; `node --check` green; node ops/audit/status/roster tests green.
   Per-member `revoke` in the node-wide view is deferred — revoke is
   group-scoped and the node-wide list has no group context.
3. **Unlink** — ✅ done 2026-09-01. Added a danger-styled "Unlink from hub"
   section at the bottom of `node-page.js` (`unlinkNode` → `DELETE /api/unlink`,
   confirm + consequence copy per §7, hidden while `status ===
   'waiting_for_node_key'`). Backend endpoint was already present and unchanged.
   5 `node.unlink_*` i18n keys added to all ten locales; `.node-group-danger`
   added to `style.css`. `node --check` + `test_locales.py` green. Note: the
   endpoint 503s when the node has no hub session — the catch surfaces the
   bridge message, which is acceptable for that edge case.
4. **Tabs** — ✅ done 2026-09-01. `node-page.js` now has a `tab` state and an
   `.admin-tabs` bar (reusing the existing admin-page tab styles) with six tabs:
   **Overview** (node info + Maintenance), **Groups** (per-group cards),
   **Roster** (node-wide roster), **Peers**, **Audit**, **Settings** (denylist +
   node settings + STUN + ICE + Unlink). Header, service panel, action message
   and the operator-pair banner stay above the bar, visible on every tab. Each
   section is gated `${tab === '<x>' && …}`; the groups IIFE is
   `${tab === 'groups' && (() => {…})()}`. 6 `node.tab_*` i18n keys added to all
   ten locales. Verified with a per-tab render probe (htm parses and the
   component executes cleanly for all six tabs) plus `node --check` and
   `test_locales.py`.
5. **Remove HTML** — ✅ done 2026-09-01.
   - `ui/app.py`: deleted `GET /`, `GET /audit`, and everything below the
     `# ── HTML UI ──` marker (`_render_page` + inline dashboard template,
     `_render_roster`, `_render_node_settings`, `_render_audit_page`,
     `_AUDIT_HTML`, `_fmt_size`). ~500 lines gone. Dropped now-unused imports
     (`base64`, `time`, `html.escape`, `HTMLResponse`). Module docstring rewritten
     — it is a JSON control API, not a web UI. The uvicorn server, the token
     middleware and the CSP middleware stay.
   - `daemon.py`: removed the `ui` argparse choice + its help text + `quiet`
     entry + the `if args.command == "ui":` handler. Startup log line is now
     `Control API on 127.0.0.1:<port>`; `_print_status` drops the `admin UI`
     line; the "waiting_for_node_key" hint now points at `meshbay-node status`
     and the desktop client. `_daemon_api` docstring reworded. (Net effect on
     ruff: one F541 fewer; the file's pre-existing lint debt is untouched.)
   - Tests: `test_cli_dispatch.py` drops the `["ui"]` case and asserts `ui` is
     no longer a parser choice. The two `_render_page` H2 tests
     (`test_admin_ui_escapes_filenames`, `_roster_usernames`) are replaced by
     `test_node_control_api_serves_no_html`, which pins the routes at 404 and
     the render helpers as absent. Full node suite: 749 passed, same 2
     pre-existing failures (`test_packaging_units` bad split logic;
     `test_webrtc_transport` aiortc flake).
   - Docs/packaging: `docs/QUICKSTART.md`, `man/meshbay-node.1` (the `ui` verb
     entry deleted, "admin UI" → "control API" throughout),
     `packaging/deb/.../control`, `packaging/rpm/meshbay-node.spec`,
     `devel-phases-next.md` (14.1b struck through), `docs/invite-pairing-v1.md`
     (`_render_roster` pointer → `node-page.js`), `CLAUDE.md` key-modules row,
     `config.py` example-config comment. `QE/deploy/README.md` also touched but
     it is gitignored.
6. **Orphan-endpoint triage** — ✅ done 2026-09-01. All three confirmed dead and
   removed from `ui/app.py`:
   - `GET /api/config` — no consumer (the CLI, SPA and Electron never call it);
     its payload is a strict subset of `/api/status` + `/api/groups` +
     `/api/node-settings`. `test_security_regressions.py` used it only as a
     sample path for the token-gate check → switched to `/api/groups`.
   - `GET /api/chat/history` — reads `state["chat_store"]`, a **global** store
     that the daemon deliberately never sets (per-group stores since finding
     H1), so it always returned `{"messages": []}`; the message shape was stale
     too. The real chat-history path is the MNP `chat_history` handler in
     `webrtc_server.py` (unchanged, `test_transport_contracts.py` still green).
   - `WS /ws/chat` + `_chat_subscribers` + `broadcast_chat_to_ui` +
     `app.broadcast_chat` — added in the Phase-7 "Node v2" commit and never
     wired: `git grep broadcast_chat` finds only the definition and the
     assignment, nothing invokes it, so the socket's subscribers received
     nothing.
   - Fallout: dropped now-unused imports (`json`, `WebSocket`,
     `WebSocketDisconnect`) and ran `ruff --fix` for the file's remaining
     pre-existing import debt (`Path`, `DEFAULT_CONFIG_PATH`, `generate_gek`,
     `wrap_gek_aes`, `ROLE_MEMBER`, `ROLE_OPERATOR`, the function-local
     `Roster`). **`ui/app.py` now passes `ruff check` with no errors** (1074 →
     453 lines across phases 5–6).
   - Tightened the `_security_headers` CSP to `default-src 'none'; frame-ancestors
     'none'; base-uri 'none'` — the JSON-only API has no HTML, inline script or
     stylesheet to allow, so the old `'unsafe-inline'` / `connect-src 'self'`
     grants were dead weight. No test asserted the string. Middleware docstrings
     refreshed ("admin UI" → "control API").

4b. **Node page UI polish** — ✅ done 2026-09-01 (operator feedback on phase 4):
    - Overview/Maintenance, Roster, Peers and Audit tabs **auto-load on open** —
      the per-tab "Load" buttons are gone (`useEffect` on `[tab, status]`, audit
      also on filter/page). Per-group roster and denylist keep their buttons.
    - **Names, not ids**: `ui/app.py` gained `_display_names(state)` →
      `(user_id→username from roster, group_id→name from node.toml)`. `/api/peers`
      resolves both (`username`, new `group_name`); `/api/audit` adds
      `group_name` and keeps the username backfill. The SPA renders the name and
      falls back to a shortened id.
    - **Audit pagination**: `get_entries` gained `offset`; `/api/audit` takes
      `offset`, clamps `limit` to 1000, and fetches `limit+1` to report
      `has_more` without a count. The tab has Previous/Next + "Page N", newest
      first, page size 50/100/200/500. `test_audit.py::test_pagination_newest_first`.
    - **Audit overflow**: table wrapped in `.node-table-scroll` (`overflow-x:auto`);
      `.node-table-audit` keeps time/event/user/ip/group on one line (IP never
      clipped) and wraps only the detail column.
    - **CSV export**: `Export CSV` button → `exportAuditCsv` walks the whole log
      for the current filter in 1000-row pages (de-duped by id so an event
      written mid-export cannot duplicate a row; 1M-row hard stop) and
      `saveCsv()` writes every matching entry — native Save As on the desktop
      (`platform.nativeSave`), blob download link otherwise. RFC-4180 quoting.
    - Settings tab section order: node settings → STUN → ICE → **denylist** →
      **Unlink** (denylist sits directly above the danger zone).
    - 6 new i18n keys (`node.loading`, `node.audit_export/_exporting/_prev/_next/_page`),
      4 dead `*_load` keys removed, across all ten locales. Per-tab render probe +
      `node --check` + `test_locales.py` + node test subset (193) green.

Phases 1–4 lose nothing and can land independently. Phase 5 is the one that
removes a capability (browser admin on a headless box) and should be its own
reviewed commit.

---

## 12. Out of scope

- Group-level settings UI (member-upload, apps, scan-settings) — stays in the
  group UI.
- Renaming `ui_port` / `ui-token` / `create_ui_app`.
- `revoke_device` (single-device revocation) — needs a new endpoint; track
  separately.
- Any MNP browser-side node admin path.