# 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.