aboutsummaryrefslogtreecommitdiffstats
path: root/docs/refactor-node-ui.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/refactor-node-ui.md')
-rw-r--r--docs/refactor-node-ui.md413
1 files changed, 0 insertions, 413 deletions
diff --git a/docs/refactor-node-ui.md b/docs/refactor-node-ui.md
deleted file mode 100644
index 7dbf3da..0000000
--- a/docs/refactor-node-ui.md
+++ /dev/null
@@ -1,413 +0,0 @@
-# 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.