From cfc91e0a424163869c64d30e55d55a53f18a3dbf Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 14:08:32 +0200 Subject: refactor(node): JSON-only control API, Node page absorbs the admin dashboard Remove the node daemon's server-rendered admin UI (GET / and /audit, the _render_* helpers and inline templates) and the `meshbay-node ui` CLI verb. The loopback control API stays; it is now JSON only, ruff-clean, and 453 lines (was 1074). Also drop three never-wired endpoints (/api/config, /api/chat/history, /ws/chat, plus broadcast_chat) and the pointless 18000/tcp firewall profiles. The desktop client's Node page (static/node-page.js) takes over what the dashboard showed, reorganised into six tabs (Overview, Groups, Roster, Peers, Audit, Settings): - Overview: version, node id, QUIC port, hub, index-cache maintenance - Roster: node-wide view with unpin - Peers and Audit: auto-load on open, no Load button - Audit: real usernames and group names (resolved from the roster and node.toml), Previous/Next pagination newest-first, Export CSV of every matching row - Settings: node settings, STUN, ICE, denylist, then Unlink from hub Backend: audit.get_entries gains `offset`; /api/audit and /api/peers resolve ids to names via a new _display_names helper; CSP tightened to default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and 2.12 corrected -- the Node page uses the loopback API, not MNP. One capability is intentionally dropped: browser-based admin on a headless server. The CLI covers every operation there. See docs/refactor-node-ui.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5 --- docs/refactor-node-ui.md | 403 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 docs/refactor-node-ui.md (limited to 'docs/refactor-node-ui.md') diff --git a/docs/refactor-node-ui.md b/docs/refactor-node-ui.md new file mode 100644 index 0000000..7bab5d2 --- /dev/null +++ b/docs/refactor-node-ui.md @@ -0,0 +1,403 @@ +# Refactor: fold the node's local admin surface into the desktop Node page + +> 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 === '' && …}`; 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:`; `_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. -- cgit v1.2.3