summaryrefslogtreecommitdiffstats
path: root/docs/devel-phases-next.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 16:05:16 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 16:05:16 +0200
commit8a6294b0412a86f378c6e2e937c28de64a903c91 (patch)
tree20d1977d9148a2c86c62925c5be7e571815c9057 /docs/devel-phases-next.md
parent854a9441ccb734c7fbb1e0ff8570b9ef659c09b0 (diff)
downloadmeshbay-8a6294b0412a86f378c6e2e937c28de64a903c91.tar.gz
docs: move root docs into docs/ and archive superseded drafts
Move the remaining root-level .md files (except CLAUDE.md) into docs/: devel-phases.md, devel-phases-next.md, first-review.md, second-review.md, tmp-decisions.md. Update all inbound references in CLAUDE.md (now docs/-prefixed) and strip the now-redundant docs/ prefix from links inside the moved files. Consolidate the superseded material into docs/old-draft.md: architecture drafts v1-v4, POC v1, and the Phase 1-12 development log, each under an ARCHIVED banner with a preamble pointing at the current specs. Delete the merged originals plus the unreferenced French translations (v1-fr, v2-fr, poc-v1-fr). Repoint the surviving file-links in first-review.md, second-review.md and meshbay-draft-v5.md at old-draft.md; prose "draft-v3 §x" mentions are left as-is since the content now lives in the archive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J74kj44q6REczub8XR3DRy
Diffstat (limited to 'docs/devel-phases-next.md')
-rw-r--r--docs/devel-phases-next.md1343
1 files changed, 1343 insertions, 0 deletions
diff --git a/docs/devel-phases-next.md b/docs/devel-phases-next.md
new file mode 100644
index 0000000..6730ca0
--- /dev/null
+++ b/docs/devel-phases-next.md
@@ -0,0 +1,1343 @@
+# MeshBay — Next Implementation Phases
+
+> Base: Phases 1–12 complete (except 10.9 → Phase 18). Web SPA + admin panel + self-service UI + MSE video streaming live on meshbay.org. Node daemon is production-ready (WebRTC, WS, chat, HTTP, index push, swarm all wired).
+> Architecture reference: **meshbay-draft-v6.md** (2026-08-17; v5 remains
+> authoritative for everything v6 does not restate, v4 superseded 2026-08-13).
+> Desktop client, roots, device linking: **desktop-client-v1.md**
+> First security review: first-review.md (2026-08-10)
+> **Second security review: second-review.md (2026-08-13) — 6 critical, 7 high findings.**
+>
+> ⛔ **Phase 11.5 is BLOCKING.** No feature phase starts until C1–C6 and H1–H7 are closed.
+> The current build must not host real private data: the node's HTTP API serves private
+> group content unauthenticated (C1), any user can hijack a node's signaling identity (C2),
+> and an active hub can obtain any group key through the key directory it controls (H3).
+>
+> **Phases renumbered 2026-08-13** (old → new): 12→14, 13→15, 14→16, 15→17, 16→18, 17→19.
+> New: 11.5 (security remediation), 12 (client key verification — reworked 2026-08-13,
+> hub minimization deferred by operator decision), 13 (native desktop client).
+
+---
+
+## Phase 7 — Node v2 : production, streaming, chat ✅ DONE
+
+Commit: fc56585 — 26 files, +2155/−159 lines, 109 tests.
+
+| # | Component | Status |
+|---|---|---|
+| 7.0 | JWT group claims + node authz check | ✅ |
+| 7.1 | QUIC 0-RTT session resumption | ✅ |
+| 7.2 | Signaling `client_incoming`/`punch_ready` + jti denylist push | ✅ |
+| 7.3 | Multi-group daemon (1-port multiplexing) | ✅ |
+| 7.4 | HLS streaming via QUIC | ✅ |
+| 7.5 | Chat: Sender Keys protocol + storage + MNP wire | ✅ |
+| 7.6 | Chat: local web UI + WS push to members | ✅ |
+
+---
+
+## Phase 8 — Hub v2: admin, federation, security ✅ DONE
+
+Commit: 46918ec — 20 files, +508/−90 lines, 117 tests.
+Deployed to meshbay.org. Existing emails encrypted. DB schema migrated.
+
+| # | Component | Status |
+|---|---|---|
+| 8.1 | Admin roles — config-based `require_admin` | ✅ S1 resolved |
+| 8.2 | Email encrypted at rest — AES-256-GCM, HKDF | ✅ S2 resolved |
+| 8.3 | Refresh token rotation — family-based reuse detection | ✅ S5 resolved |
+| 8.4 | Federation DB persistence (HubPeer model) | ✅ |
+| 8.5 | Federation token verification async (DB-backed) | ✅ |
+| 8.6 | CSAM hash check in swarm registration | ✅ |
+| 8.7 | Rate limiting on auth endpoints (5/10/20 per min) | ✅ |
+| 8.8 | Healthcheck endpoint (GET /v1/health) | ✅ |
+| 8.9 | IP log cleanup background task (365-day retention) | ✅ |
+| 8.10 | Argon2id bumped to 256 MB (pw_version=2, rehash on login) | ✅ |
+
+---
+
+## Phase 9 — Web client: WebRTC transport + core SPA ✅ DONE
+
+Commit: ab4d389 — 27 files, +3053/−330 lines, 132 tests.
+Deployed to meshbay.org + Orange node. Tested browser → node P2P through two ISP NATs.
+
+**Objective:** a web browser can connect P2P to a node behind residential NAT,
+browse files, download, stream video, and chat — with zero data through the hub.
+
+**Architecture decisions (settled 2026-08-10):**
+
+### Transport: WebRTC DataChannel for browsers
+
+Native clients (desktop, Android) use QUIC with `punch_nat()` — already validated
+in demo-v2 on SFR residential (Port-Restricted Cone NAT).
+
+Browsers cannot use QUIC for NAT traversal because WebTransport does not allow
+the browser to choose its UDP source port. Port-Restricted Cone NAT requires the
+client to connect from the exact port the node probed — impossible for browsers.
+
+**Solution:** WebRTC DataChannel with ICE/STUN. The browser's built-in WebRTC
+stack handles NAT traversal automatically. The node uses `aiortc` (same author as
+`aioquic`, already referenced in draft-v3 as [future]).
+
+ICE is strictly superior to our custom `punch_nat()` for this use case:
+- Both sides send STUN binding requests simultaneously → mutual hole-punching
+- No need for the client to pre-announce its port
+- Handles both sides behind NAT
+- Battle-tested by billions of users (Google Meet, Discord, etc.)
+
+The MNP protocol (handshake, file_request, file_chunk, chat_message, etc.) runs
+identically over WebRTC DataChannel as over QUIC streams. Same E2E encryption.
+
+**Node dual transport:**
+- QUIC (port 19000) — native clients, already in place
+- WebRTC DataChannel — browsers, using `aiortc`
+
+### Signaling: hub WebSocket relay
+
+The hub relays WebRTC signaling (SDP offer/answer, ICE candidates) between
+browser and node. This is the same role described in draft-v3 section 4.1.3:
+"NAT traversal coordination [...] stateless [...] <1 KB per message."
+
+```
+Browser → Hub (HTTPS) : POST /v1/nodes/{id}/webrtc/offer {sdp, ice_candidates}
+Hub → Node (WS) : {type: "webrtc_offer", sdp, ice_candidates, peer_id}
+Node → Hub (WS) : {type: "webrtc_answer", sdp, ice_candidates, peer_id}
+Hub → Browser (SSE) : {sdp, ice_candidates}
+```
+
+After signaling, the DataChannel is P2P. Hub is no longer involved.
+
+### UI: Preact SPA
+
+- **Framework:** Preact (~3 KB gzipped) + preact-router
+- **Build:** esbuild (single binary, no node_modules bloat) for minification
+- **Theming:** CSS `prefers-color-scheme` + localStorage toggle (dark/light)
+- **i18n:** JSON translation files loaded client-side, English default
+- **Responsive:** sidebar collapses to hamburger on mobile viewports
+- **Crypto:** existing `crypto.js` (SubtleCrypto AES-GCM) for E2E decryption
+
+### Hub role (reminder — fundamental constraint)
+
+The hub is a registrar and signaling facilitator. It stores ONLY:
+- User accounts (login, encrypted email, public keys, keypair bundle)
+- Group metadata (name, admin, members, GEK bundles — no file indexes)
+- Node registrations (endpoint hints, public keys)
+
+All data (files, streams, chat messages, directory indexes) lives on mesh nodes.
+Clients (web or native) transfer data E2E with nodes. The hub never touches
+content. This is non-negotiable.
+
+### Chat/forum storage
+
+Chat messages are stored on the node(s) hosting the group, not on the hub.
+The browser retrieves chat history from the node via DataChannel, same as files.
+If no node in the group is online, the group (including chat) is unavailable.
+This is inherent to the P2P model and acceptable.
+
+### File search
+
+Content is not indexed on the hub. Search works client-side:
+- Node provides a Mesh Group Index (file metadata: names, paths, sizes, hashes)
+- For private groups, the index is GEK-encrypted — hub stores it opaque, client decrypts
+- Browser caches decrypted indexes in IndexedDB (~50–100 MB quota, extensible)
+- Search runs locally on cached indexes — instant, no network call, no hub involvement
+
+### Milestones
+
+| # | Component | Files | Priority |
+|---|---|---|---|
+| 9.1 | **Spike: WebRTC DataChannel on node** | `aiortc` integration, 4 tests (handshake, file transfer, auth, guard) | ✅ |
+| 9.2 | WebRTC signaling endpoints on hub | `hub/api/signaling.py` — relay SDP/ICE, 2 tests | ✅ |
+| 9.3 | WebRTC→MNP transport adapter on node | `node/transport/webrtc_server.py` + hub_client WebRTC handler | ✅ |
+| 9.4 | `transport.js` — browser WebRTC client | `static/transport.js` — connect, handshake, fetch, msgpack | ✅ |
+| 9.5 | **Spike: E2E browser→NAT→node file transfer** | Mobile 4G → SFR NAT → node, IPv4 STUN + IPv6 validated | ✅ |
+| 9.6 | Preact SPA shell (login, routing, theme) | `static/app.js`, `static/style.css`, `static/vendor/htm-preact.js` | ✅ |
+| 9.7 | Group list + file explorer UI | `app.js` GroupPage, `groups.py` nodes endpoint, `revocation.py` group tracking | ✅ |
+| 9.8 | File download via DataChannel | AES-GCM chunks, GEK delivery, progress bar, browser download | ✅ |
+| 9.9 | Video streaming via DataChannel | Chunk download → Blob URL, video overlay with native controls | ✅ |
+| 9.10 | Chat/forum UI via DataChannel | ChatPanel component, chat history MNP, peer broadcast, tabs UI | ✅ |
+| 9.11 | i18n framework + English strings | `static/i18n.js` — t() lookup, ESM, localStorage lang, all strings extracted | ✅ |
+| 9.12 | Settings UI (profile, theme, language) | SettingsPage component, system theme support, sidebar link | ✅ |
+| 9.13 | Tests: unit + integration | WebRTC transport, MNP over DataChannel | ✅ |
+| 9.14 | Performance: pipelined download | sliding window (8 concurrent chunks) | ✅ |
+| 9.15 | Performance: binary wire format | raw bytes via msgpack, no base64 (+33%) | ✅ |
+| 9.16 | Performance: avoid redundant I/O | file_hash from index, not re-read per chunk | ✅ |
+| 9.17 | Large file download to disk | File System Access API (`showSaveFilePicker`) | ✅ |
+
+**Critical path validated (2026-08-10):** 9.1 → 9.5 all pass. WebRTC DataChannel
+works browser → node through two different ISP residential NATs:
+
+**SFR residential NAT** (mobile 4G → node behind SFR Port-Restricted Cone + CGNAT):
+
+| Test | ICE path | Result |
+|---|---|---|
+| WiFi LAN (same network) | IPv6 direct | OK, ~100ms |
+| Mobile 4G SFR + IPv6 | IPv6 inter-network | OK, ~600ms |
+| Mobile 4G SFR + IPv4 only | STUN hole-punch IPv4 | OK, ~650ms |
+
+**Orange Livebox NAT** (laptop browser → node behind Orange residential NAT, cross-site):
+
+| Test | ICE path | Result |
+|---|---|---|
+| Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms |
+| Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms |
+| Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms |
+
+Two ISPs validated, both Chrome and Firefox. No TURN relay needed.
+ICE/STUN handles all tested NAT types automatically.
+
+**Performance optimizations (2026-08-11):**
+- Initial transfer speed: ~2 MB/s (sequential, base64, redundant I/O)
+- After file_hash fix (9.16): ~3 MB/s (eliminated 78 GB redundant reads on 279 MB file)
+- After pipelining (9.14): ~5 MB/s (8-chunk sliding window, concurrent requests)
+- After binary wire format (9.15): eliminated 33% base64 inflation + removed
+ redundant per-chunk fields (sig, hashes, pk_node) — AES-GCM tag already
+ authenticates ciphertext, DTLS authenticates transport
+- Large file support (9.17): `showSaveFilePicker` (Chrome/Edge) streams decrypted
+ chunks directly to disk — flat ~8 MB RAM regardless of file size. Firefox/Safari
+ fall back to Blob-in-RAM approach.
+
+**Indexer debounce (2026-08-11):**
+- File copy triggers multiple watchdog events at different file sizes → duplicate
+ index entries with different blake3 hashes. Fixed with 2-second debounce +
+ path-based dedup (remove old entry before adding new).
+
+**Known remaining items for future phases:**
+- ~~True video streaming (MSE or Service Worker)~~ → Phase 10c (2026-08-11)
+- Multiple shared directories per node (UI + config)
+- Multi-node per user support
+
+**Dependencies added:**
+- `aiortc>=1.9` in `meshbay-node/pyproject.toml` ✅
+- `esbuild` as a dev tool (single binary, not npm) — needed for 9.6+
+- `preact` + `preact-router` (ESM imports, no npm needed — CDN or vendored)
+
+---
+
+## Phase 10 — meshbay.org site + admin/moderation UI
+
+Commit: 8fa298e (10.1–10.4), 022da76 (10.5–10.10) — 155 tests.
+
+**Objective:** meshbay.org becomes both a production hub and the project's public
+website, with admin/moderation interfaces and user-facing features.
+
+### Site architecture
+
+Two layers, cleanly separated:
+- **Generic hub** (API + web app) — reusable by any hub operator
+- **Site overlay** — meshbay.org-specific pages (landing, /downloads, /about)
+
+The site overlay is served by Caddy (static files) with priority over the hub.
+The hub serves the SPA for authenticated users at `/app/`.
+
+```
+site/ # meshbay.org-specific (not in generic hub package)
+├── index.html # Landing page — project promotion
+├── downloads.html # Package repos (placeholder, Phase 13)
+├── about.html # Project info, GitHub link, contact
+└── assets/
+ └── site.css # Landing page styles (dark/light aware)
+```
+
+### User roles
+
+| Role | Capabilities |
+|---|---|
+| `user` | Standard user — browse, download, chat, manage own profile |
+| `moderator` | Review reports, suspend content/groups/users |
+| `admin` | All moderator rights + hub management (same as moderator for now, distinction reserved for future federation/mirror) |
+
+Role stored as `role` column on User model (`user` | `moderator` | `admin`).
+`require_moderator` dependency (checks role ≥ moderator OR config allowlist).
+`require_admin` checks role = admin OR config allowlist (backward compat).
+Config-listed admin usernames are synced to `role = "admin"` in DB at startup.
+
+### Milestones
+
+| # | Component | Status |
+|---|---|---|
+| 10.1 | Landing page + /downloads + /about | ✅ |
+| 10.2 | Moderator role + `require_moderator` dependency + admin API | ✅ |
+| 10.3 | Moderation UI (user/group suspend, blocklist management) | ✅ |
+| 10.4 | Admin UI (stats, user list, group list, audit logs viewer, blocklist) | ✅ |
+| 10.5 | Notification system (invitations, role changes, account status) | ✅ |
+| 10.6 | User settings (profile, role display, per-group notification mute) | ✅ |
+| 10.7 | Public group search (name keyword filtering) | ✅ |
+| 10.8 | Front page (notification feed with unread badge) | ✅ |
+| 10.9 | Package repositories (APT/DNF) | Deferred to Phase 13 |
+| 10.10 | Auto-update check endpoint (`GET /v1/hub/version`) | ✅ |
+
+### API endpoints (10.2, 10.5, 10.7, 10.10)
+
+| Method | Path | Auth | Description |
+|---|---|---|---|
+| GET | `/v1/users/me` | Access token | Current user info (id, username, role, status) |
+| GET | `/v1/admin/stats` | Moderator+ | Hub stats (user/group/node counts, online nodes) |
+| GET | `/v1/admin/users` | Moderator+ | List users (paginated, searchable) |
+| GET | `/v1/admin/users/{id}` | Moderator+ | User detail (email decrypted, group count) |
+| PATCH | `/v1/admin/users/{id}` | Moderator+ | Update role or status (triggers notification) |
+| GET | `/v1/admin/groups` | Moderator+ | List groups (with member count) |
+| PATCH | `/v1/admin/groups/{id}` | Moderator+ | Update group status |
+| GET | `/v1/admin/logs` | Moderator+ | IP audit logs (filterable by event, user) |
+| GET | `/v1/notifications` | Access token | List notifications (unread_only, paginated) |
+| POST | `/v1/notifications/{id}/read` | Access token | Mark single notification read |
+| POST | `/v1/notifications/read-all` | Access token | Mark all notifications read |
+| GET | `/v1/groups?q=` | None | Search public groups by name |
+| GET | `/v1/hub/version` | None | Version check (hub, MNP, MHP versions) |
+
+### Admin UI (10.3–10.4)
+
+Admin page at `#/admin` in SPA, accessible to moderators and admins.
+Five tabs: Stats, Users, Groups, Logs, Blocklist.
+
+- **Stats:** card grid (users, groups, nodes, online nodes)
+- **Users:** searchable table, inline role dropdown, suspend/unsuspend buttons, detail overlay
+- **Groups:** table with member count, suspend/unsuspend
+- **Logs:** filterable IP audit log table, paginated (50/page, load more)
+- **Blocklist:** existing `/v1/admin/blocklist` endpoints, add/remove hashes
+
+### SPA route change
+
+SPA now also served at `/app/` and `/app/{path}` (in addition to `/`).
+With Caddy site overlay, Caddy serves `site/index.html` at `/`,
+and requests to `/app/` fall through to the hub.
+
+### Caddy integration
+
+**The real configuration lives at `packaging/caddy/meshbay.org.Caddyfile`** (added
+2026-08-17). Use it, not the snippet this section used to carry.
+
+The snippet that was here served `site/` from the root with `try_files` and proxied
+`/v1/*`, `/app*`, `/style.css` and `/*.js` to the hub. It predates asset versioning and
+**would have broken the SPA**: the module graph is served under `/a/<hash>/`, which
+`handle /*.js` does not match, and neither does `/locales/*.js`. Worse, `/sw.js` would
+have 404ed — the service worker has to stay at the root or its scope stops covering the
+pages it intercepts downloads for, which breaks streamed downloads on Firefox and Safari
+without any visible error.
+
+The rule is inverted: an **allowlist** of site paths served statically, everything else
+proxied to the hub. The hub mounts its whole static directory at `/` (`app.py`), so it
+owns the root namespace by default and the site takes only what it names.
+
+**Consequence to be aware of:** with the site overlay in front, `/` is the landing page,
+so `webapp.py`'s `GET /` (which returns the SPA shell) is unreachable on meshbay.org. That
+route stays — a **generic** hub with no site overlay should serve the application at its
+root. The overlay is meshbay.org-specific by design.
+
+`site/` is **not** pushed by the hub deploy procedure; it syncs separately to
+`/srv/meshbay/site`.
+
+### Hub mirror (design only — implementation deferred)
+
+A mirror hub is a complete replica of the primary hub (same user DB, same groups,
+same GEK bundles, same storage). Purpose: load distribution via DNS round-robin.
+
+**Design constraints:**
+- Shared Ed25519 private key (transferred once at setup, securely)
+- PostgreSQL logical replication for active-active read/write on both mirrors
+- Both mirrors can issue JWTs (same signing key)
+- DNS round-robin (2+ A records on meshbay.org)
+- If one mirror goes down, the other continues serving
+
+**Not implemented now.** The design must not prevent future implementation:
+- Hub config and private key paths must be externalizable
+- No hub-specific state that can't be replicated
+- JWT verification must not depend on hub-local state
+
+---
+
+## Phase 10b — Self-service UI + client-side features
+
+Pending commit — 166 tests.
+
+**Objective:** make the web SPA fully self-service — users can create groups,
+manage members, join open groups, upload files, and search across all cached
+group file indexes. No admin intervention needed for basic operations.
+
+### Self-service features
+
+| # | Component | Status |
+|---|---|---|
+| 10b.1 | Group creation UI (CreateGroupPage) | ✅ |
+| 10b.2 | Member management + invite (MembersPanel) | ✅ |
+| 10b.3 | Group join flow (open groups self-join) | ✅ |
+| 10b.4 | File upload (client → node via MNP FILE_UPLOAD) | ✅ |
+| 10b.5 | IndexedDB caching (group file indexes cached locally) | ✅ |
+| 10b.6 | Cross-group file search (SearchPage — client-side, no hub) | ✅ |
+
+### New API endpoints (10b.1–10b.3)
+
+| Method | Path | Auth | Description |
+|---|---|---|---|
+| POST | `/v1/groups` | Access token | Create a new group (name, visibility, join_policy) |
+| GET | `/v1/groups/{id}/members` | Access token | List group members (requires membership) |
+| POST | `/v1/groups/{id}/join` | Access token | Self-join open group (checks join_policy) |
+| POST | `/v1/groups/{id}/members/{username}/gek` | Access token | Store GEK bundle for invitee |
+| GET | `/v1/groups/{id}/gek` | Access token | Get own GEK bundle (for wrapping) |
+
+### New MNP message types (10b.4)
+
+| Type | Direction | Description |
+|---|---|---|
+| `file_upload` | client → node | Push encrypted file chunk (filename, chunk_index, total_chunks, data) |
+| `file_upload_ack` | node → client | Acknowledge chunk receipt |
+
+Node stores uploads in `shared_root/.uploads/` as `.part` files during transfer,
+renames to final location on last chunk. Filename sanitized (no path traversal).
+
+### Browser crypto additions (10b.2)
+
+AES-256-GCM ECIES variant for GEK wrapping in browsers. WebCrypto does not
+support ChaCha20-Poly1305, so a parallel ECIES scheme uses AES-256-GCM with
+a distinct HKDF info string (`meshbay:gek_wrap:v1:aes` vs `meshbay:gek_wrap:v1`).
+Both Python and browser implement the AES variant for interop.
+
+Functions added to `crypto.js`: `generateGEK()`, `wrapGEK()`, `unwrapGEK()`,
+`encryptChunk()`, `b64encode()`.
+
+Functions added to `crypto.py`: `wrap_gek_aes()`, `unwrap_gek_aes()`.
+
+### IndexedDB caching (10b.5)
+
+When a group's file index is fetched from a node, it is cached in IndexedDB
+(`meshbay` database, `group_indexes` store). On subsequent visits, cached
+entries are shown immediately while the live connection is established. This
+gives instant file list display even before WebRTC connects.
+
+Cache key: `groupId`. Stored: `{ groupId, groupName, entries[], cachedAt }`.
+Best-effort — failures are silently ignored.
+
+### Cross-group file search (10b.6)
+
+SearchPage component at `#/search`. Searches file names and paths across ALL
+cached group indexes in IndexedDB. Pure client-side — no hub involvement.
+Results link back to the group page. Accessible from sidebar.
+
+### Tests added
+
+- 8 tests: group self-service (create, join open, join invite rejected, join already member, members list, non-member denied, search, join triggers notification)
+- 3 tests: AES GEK wrap/unwrap (round-trip, wrong key rejected, differs from ChaCha20 wrap)
+
+---
+
+## Phase 10c — MSE video streaming (real-time playback)
+
+Pending commit — 167 tests.
+
+**Objective:** replace the download-then-play video player with real-time
+MSE (MediaSource Extensions) streaming. Playback starts within seconds
+instead of waiting for the full file download.
+
+### Architecture
+
+```
+Browser Node
+ │ │
+ ├── stream_req {file_id} ──────►│
+ │ ├── ffprobe → codec info
+ │◄──── stream_init {codec,dur} ──┤
+ │ ├── ffmpeg -c copy → fMP4 pipe
+ │◄──── stream_data {seg 0, ct} ──┤ (256 KB encrypted segments)
+ │◄──── stream_data {seg 1, ct} ──┤
+ │ ... │
+ │◄──── stream_end ───────────────┤
+ │ │
+ MediaSource → SourceBuffer │
+ ├── appendBuffer(decrypted) │
+ ├── video.play() after ~2-3s │
+```
+
+**Key design decisions:**
+
+1. **Node-side remux via ffmpeg** — `ffmpeg -c copy -movflags frag_keyframe+empty_moov+default_base_moof -f mp4 pipe:1` remuxes any video format (MP4, MKV, AVI, WebM, MOV) into fragmented MP4 (fMP4) that MSE can consume. No transcoding — just remuxing. Near-zero CPU overhead.
+
+2. **Codec detection via ffprobe** — the node probes the video to determine the exact codec string for MSE SourceBuffer creation (e.g., `avc1.640028,mp4a.40.2` for H.264 High@4.0 + AAC-LC). This ensures the browser creates the correct decoder.
+
+3. **Same encryption model** — each 256 KB fMP4 segment is encrypted with AES-256-GCM using the same key derivation as file downloads (GEK + file_hash + segment_index → HKDF → chunk_key). E2E encryption is maintained.
+
+4. **Progressive SourceBuffer append** — the browser creates a MediaSource, opens a SourceBuffer with the probed codec, and appends decrypted segments as they arrive. SourceBuffer handles partial MP4 boxes internally. Playback starts after ~2-3 segments (~512 KB buffered).
+
+### Supported codecs
+
+| Codec | MSE string | Browser support |
+|---|---|---|
+| H.264 (AVC) | `avc1.PPCCLL` | Chrome, Firefox, Safari, Edge |
+| H.265 (HEVC) | `hev1.1.6.L93.B0` | Safari, Chrome (partial) |
+| VP9 | `vp09.00.10.08` | Chrome, Firefox |
+| AV1 | `av01.0.01M.08` | Chrome, Firefox |
+| AAC | `mp4a.40.2` | All |
+| MP3 | `mp4a.6b` | All |
+| Opus | `opus` | Chrome, Firefox |
+| AC-3 | `ac-3` | Safari, Chrome |
+
+### New MNP message types
+
+| Type | Direction | Description |
+|---|---|---|
+| `stream_req` | client → node | Request MSE video stream for file_id |
+| `stream_init` | node → client | Codec string + duration (probed via ffprobe) |
+| `stream_data` | node → client | Encrypted fMP4 segment (256 KB, AES-GCM) |
+| `stream_end` | node → client | End of stream signal |
+
+### Milestones
+
+| # | Component | Status |
+|---|---|---|
+| 10c.1 | MNP protocol: STREAM_REQUEST/INIT/DATA/END message types | ✅ |
+| 10c.2 | Node: ffprobe codec detection + MSE codec string derivation | ✅ |
+| 10c.3 | Node: ffmpeg fMP4 remux + encrypted segment streaming | ✅ |
+| 10c.4 | Transport: event-based stream message dispatch | ✅ |
+| 10c.5 | Browser: MSE VideoPlayer (MediaSource + SourceBuffer) | ✅ |
+| 10c.6 | Tests: stream_request error handling | ✅ |
+
+### File changes
+
+**Modified:**
+- `packages/meshbay-common/src/meshbay_common/protocol.py` — STREAM_REQUEST/INIT/DATA/END
+- `packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py` — `_probe_video()`, `_stream_video()` handler
+- `packages/meshbay-hub/src/meshbay_hub/static/transport.js` — `requestStream()`, stream event handlers
+- `packages/meshbay-hub/src/meshbay_hub/static/app.js` — MSE-based VideoPlayer component
+- `packages/meshbay-hub/src/meshbay_hub/static/style.css` — streaming progress bar
+- `packages/meshbay-hub/src/meshbay_hub/static/i18n.js` — buffering/MSE error strings
+- `packages/meshbay-node/tests/test_webrtc_transport.py` — stream_request error test
+
+### Known limitations (future work)
+
+- No seeking beyond buffered range (user must wait for data to arrive)
+- No adaptive bitrate (single quality stream)
+- Requires ffmpeg/ffprobe on the node (already a dependency for the old STREAM_SEGMENT handler)
+
+---
+
+## Phase 11 — Node daemon: production-ready ✅ DONE
+
+Pending commit — 171 tests.
+
+**Objective:** the node daemon (`meshbay-node`) runs as a complete, self-contained
+service. Previously the daemon only started QUIC/TCP servers and the local web UI;
+everything browser-facing (WebRTC, hub WS, chat store, HTTP API) was only wired
+in QE demo scripts. This phase moved all that logic into the daemon.
+
+### What changed
+
+**`daemon.py` — complete rewrite.** The daemon now starts all transports and
+services in a single process:
+
+1. Keystore + hub login (unchanged)
+2. Per-group directory indexers (unchanged)
+3. **ChatStore** per group (new) — SQLite DB in `~/.local/share/meshbay/{group_id}/chat.db`
+4. **WebRTC transport** (new) — browser clients via DataChannel, wired as
+ `on_webrtc_offer` callback on the hub WS
+5. QUIC + TCP servers (unchanged)
+6. **Hub WebSocket** (new) — `maintain_ws()` as asyncio task, receives signaling,
+ revocation tokens, WebRTC offers. Auto-reconnect on disconnect.
+7. **HTTP file API** (new) — one `create_http_app()` per group on configured port
+8. Local web UI (unchanged)
+9. **Graceful shutdown** (enhanced) — cancels WS task, closes WebRTC peers, closes
+ chat stores, stops HTTP/QUIC/TCP servers, stops indexers
+
+**`hub_client.py`** — added `_ws` tracking, `send_ws()` for chat notifications,
+and `register_swarm()` for file hash registration with the hub.
+
+**`config.py`** — added `data_dir` field (default `~/.local/share/meshbay/`)
+for chat DBs and other persistent state.
+
+**`meshbay-node.service`** — updated systemd unit with `StateDirectory=meshbay`,
+`ProtectSystem=strict`, `ReadWritePaths` for config and data directories.
+
+**Index push (11.5):** when watchdog detects file changes, the debounced
+`on_change` callback fires `_on_index_change` on the daemon, which pushes a
+full `INDEX_SYNC` to all WebRTC peers in that group. Only peers whose
+`_group_id` matches receive the push.
+
+**Swarm registration (11.9):** on startup and on each index change, the daemon
+registers all file hashes with the hub's `/v1/swarm/register` endpoint. This
+allows other nodes/clients to discover which nodes host which content.
+
+### Milestones
+
+| # | Component | Status |
+|---|---|---|
+| 11.1 | Daemon: hub WS integration | ✅ |
+| 11.2 | Daemon: WebRTC transport | ✅ |
+| 11.3 | Daemon: chat store | ✅ |
+| 11.4 | Daemon: HTTP file API | ✅ |
+| 11.5 | Daemon: index push on change | ✅ |
+| 11.6 | Daemon: node_user_id + hub_ws context | ✅ |
+| 11.7 | Daemon: graceful shutdown | ✅ |
+| 11.8 | Systemd unit file | ✅ |
+| 11.9 | Swarm registration | ✅ |
+| 11.10 | Integration test | ✅ (4 tests: lifecycle, no-groups, index push, group filtering) |
+
+### File changes
+
+**Modified:**
+- `packages/meshbay-node/src/meshbay_node/daemon.py` — complete rewrite
+- `packages/meshbay-node/src/meshbay_node/hub_client.py` — `_ws` tracking, `send_ws()`
+- `packages/meshbay-node/src/meshbay_node/config.py` — `data_dir` field
+- `packaging/systemd/meshbay-node.service` — hardening, StateDirectory
+
+**Added:**
+- `packages/meshbay-node/tests/test_daemon.py` — 2 integration tests
+
+---
+
+## Phase 11.5 — Security remediation ⛔ BLOCKING
+
+> Source: `second-review.md` (2026-08-13). Finding IDs in brackets.
+> **No other phase starts until section J acceptance criteria pass.**
+
+**Objective:** close the gap between what the documents describe and what the code
+enforces. The Phase 12 sovereignty work (GEK-HMAC proof, DTLS channel binding, Ed25519
+admin challenge) is sound but was implemented on one of four paths into the node. This
+phase reduces the node to two paths and brings both to the same standard.
+
+### Transport decision (settled 2026-08-13)
+
+| Listener | Fate | Reason |
+|---|---|---|
+| WebRTC DataChannel (aiortc) | **Primary** — browser + native | ICE/STUN is the only NAT traversal validated here (2 ISPs, 2 browsers, IPv4 STUN + IPv6, 4G CGNAT) |
+| QUIC 19000 | **Kept, brought to parity** | LAN, port-forwarded, and hub-less `group://` direct access |
+| TCP+TLS 18001 | **Removed** | Superseded; no GEK proof; nothing uses it |
+| HTTP 19001 | **Removed** | Source of C1; duplicates MNP without any of its controls |
+
+> `punch_nat()` is a single UDP probe (`quic_server.py:446`) with no STUN client, no
+> candidate gathering and no dual-stack fallback — `aioice` is pulled in by `aiortc` only.
+> It is a direct-connection helper, **not** a traversal stack. ICE remains the primary path.
+
+### A — Reduce the node's exposed surface
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.1 | Delete `transport/http_server.py` + daemon wiring (`daemon.py:341-366`) | **C1** | No listener on `0.0.0.0` other than QUIC; no endpoint serves file bytes or an index without a completed handshake |
+| 11.5.2 | Delete `transport/server.py` + `transport/client.py` (TCP+TLS) | C6 scope | `ChunkServer` gone from `daemon.py`; port 18001 unbound |
+| 11.5.3 | Node admin UI stays loopback + gains a session token in the URL | H2 | UI unreachable without the token printed at daemon startup |
+
+### B — One handshake, two transports
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.4 | Extract `meshbay_common/handshake.py`: JWT verify → `scope == "user"` → denylist → **mandatory** `group_id` in claims → group hosted → GEK challenge → proof verify → ack | **C6**, M1, M9 | Single implementation; `webrtc_server.py` and `quic_server.py` contain no JWT logic of their own |
+| 11.5.5 | Both transports call it; test parametrized over `[webrtc, quic]` | C6 | A test that adds a step to the handshake fails for any transport that skips it |
+| 11.5.6 | **Spike DONE 2026-08-13 — see findings below.** Channel binding for QUIC | C6/NS5 | QUIC handshake proof is bound to the connection, not replayable across connections |
+
+#### 11.5.6 spike results (aioquic 1.3.0)
+
+**No RFC 5705 exporter.** `aioquic.tls.Context` has no `export_keying_material`, so the
+preferred anchor is unavailable.
+
+**Certificate access is asymmetric and partly private:**
+
+| Side | Path to the server certificate | API status |
+|---|---|---|
+| Server | `tls.certificate` | public attribute |
+| Client | `tls._peer_certificate` | **private** — set by `_set_peer_certificate()` |
+
+`QuicConnection` exposes no `tls`/`cert` attribute either, so the client's route is
+`protocol._quic.tls._peer_certificate` — two levels of private API.
+
+**The risk this creates.** Binding a security check to a private attribute means an
+aioquic upgrade can remove it silently. A channel binding that silently becomes
+unavailable is the worst failure mode: 11.5.21 already established that the handshake
+must *refuse* rather than degrade, so a rename would turn every QUIC connection into a
+hard failure — noisy, but only if the refusal path is right. If it were ever made
+tolerant, it would turn into a silent loss of MitM detection.
+
+**Options for the implementer, in order of preference:**
+
+1. **Certificate hash via the private attribute, guarded.** Pin `aioquic` in
+ `pyproject.toml`, and add a test that asserts `_peer_certificate` is reachable and
+ non-None on a live connection — so an upgrade fails CI rather than production. Keep
+ `make_proof()` refusing an empty binding.
+2. **Bind to `pk_node` instead of the channel.** For QUIC the MitM story differs from
+ WebRTC: signaling is not hub-relayed, and the client already learns `pk_node` from the
+ hub. The C3 mutual proof (node signs the transcript with `sk_node`) may be sufficient
+ connection authentication on its own — but note the QUIC client currently does **not**
+ verify the TLS certificate (`verify_mode` disabled, identity checked at the MNP layer),
+ so this option must be paired with pinning, or the TLS layer authenticates nobody.
+3. **Upstream an exporter.** Correct long-term answer, wrong timescale for 11.5.
+
+Recommendation: option 1 with the guard test, and open option 3 upstream.
+
+### C — Mutual authentication
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.7 | Node proves GEK possession over a client nonce **and** signs the transcript with `sk_node`: `Ed25519(sk_node, "meshbay:node_proof:v1" ‖ nonce_c ‖ binding)` | **C3** | Client rejects a peer that cannot produce both |
+| 11.5.8 | Client pins `pk_node` (TOFU on first connect, persisted); key change raises a blocking warning | C3 | Swapping the node's key surfaces to the user instead of silently succeeding |
+| 11.5.9 | Node WS registration: require `scope == "node"`, verify `Node.user_id == payload["sub"]`, derive `group_ids` **from the DB**, refuse to overwrite a live registration | **C2** | A user-scoped token, or a mismatched `node_id`, is rejected at `/v1/nodes/ws` |
+| 11.5.10 | `POST /v1/nodes/announce` requires proof of possession of `sk_node`; one active record per user | M8 | Announcing someone else's `pk_node` fails |
+
+### D — MNP authorization
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.11 | `gek_bundle_store` requires an Ed25519 admin challenge; **delete `_try_activate_gek`** — GEK activation is local-UI/CLI only | **C5b** | A member cannot change the group's active GEK |
+| 11.5.12 | Upload: per-user quarantine `.uploads/{user_id}/`, refuse to overwrite an existing index entry, size cap + per-user quota, filename allowlist (`[A-Za-z0-9._-]`) | **C5a**, H2 | A member cannot replace another member's file, and cannot inject markup via a filename |
+| 11.5.13 | Admin challenge becomes a structured transcript: `"meshbay:file_delete:v1" ‖ node_pk ‖ group_id ‖ file_id ‖ nonce ‖ ts`; client displays what it signs | **H5** | No path exists where a peer obtains a signature over bytes it fully chose |
+| 11.5.14 | `gek_bundle_fetch` / `keypair_bundle_fetch` move **after** proof verification; interim rate-limit + audit on the pre-proof window | C4 (partial) | Pre-proof window serves nothing; full fix lands in 13.3 |
+
+### E — Isolation
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.15 | `chat_store` and `_peers` resolve from `_group_ctx()`, one peer registry per group (`daemon.py:249`, `webrtc_server.py:602,617,650`) | **H1** | Two-group / two-user test proves neither history nor broadcast crosses groups |
+
+### F — Node admin UI
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.16 | `html.escape()` on every interpolated value (`ui/app.py:363`), `textContent` in the audit page (`:632`), CSP header | **H2** | A file named `<img src=x onerror=...>` renders as text |
+
+### G — Revocation
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.17 | Handle `target == "group"` on the node; persist the denylist to `data_dir`; check group status in `webrtc_offer` | **H4** | Revoking a group drops live sessions and blocks new signaling |
+
+### H — Privacy
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.18 | Swarm registers hashes for `visibility == "public"` groups only; fix the mis-mounted route (`/v1/groups/v1/swarm/...`); authenticate the lookup | **H7** | No private-group content hash ever reaches the hub |
+
+### I — Resource limits
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.19 | Pre-handshake buffer cap (a few KB, not 64 MB); `asyncio.Semaphore` around ffmpeg; delete the synchronous `subprocess.run` in `_do_stream_segment`; per-user signaling rate limit + membership check before relaying an offer; validate `peer_ip` against the request source | **H6** | One client cannot stall the daemon's event loop or exhaust its memory/CPU |
+
+### J — Crypto hygiene, hub fixes, acceptance
+
+| # | Component | Finding | Done when |
+|---|---|---|---|
+| 11.5.20 | Keystore Argon2id → 256 MB, parameters stored per-node in `node.toml` (not a `meshbay_common` constant); raise the password minimum | M2 | `calibrate-argon2` writes usable config; `crypto.py:173` no longer hardcodes 64 MB |
+| 11.5.21 | Length-prefix every field in the HMAC transcript; **reject** empty DTLS fingerprints instead of proceeding | L4 | A missing fingerprint fails the handshake rather than degrading it to nonce-only |
+| 11.5.22 | Hub: fix IPLog backfill (`users.py:118-122`), trusted-proxy XFF, scrub `str(e)` from peer-visible errors, drop `GEK_REQUEST`/`GEK_RESPONSE` constants, validate email | M6, M7, L3, L1, L6 | Compliance log attributes each row to the right account |
+| 11.5.23 | Regression suite | all | See below |
+
+**Required regression tests (all must exist and fail on reintroduction):**
+
+```
+test_no_unauthenticated_content — every node listener refuses index/chunks pre-handshake
+test_handshake_parity[webrtc,quic] — identical checks on both transports
+test_group_isolation — 2 groups × 2 users: chat + peers never cross
+test_upload_cannot_overwrite — member B cannot replace member A's file
+test_gek_store_requires_admin — member cannot store/activate a GEK
+test_ws_node_identity — user token / foreign node_id rejected
+test_node_proof_required — client aborts when the node cannot prove GEK + sk_node
+test_ui_escapes_filenames — markup in a filename renders inert
+test_swarm_public_only — private hashes never registered
+```
+
+**Acceptance criteria for the phase:** with a hub whose signing key is in the attacker's
+hands, an attacker who is not a group member obtains **no** index entry, **no** file byte,
+**no** chat message, and cannot write to any node. A member who is not the node operator
+cannot delete or overwrite another member's file, and cannot change the group key.
+
+---
+
+## Phase 12 — Client key verification + served-SPA integrity
+
+> **Reworked 2026-08-13 by operator decision.** This phase was "Hub minimization:
+> registrar and nothing more". That work is **deferred and may be dropped** — see
+> decisions D1/D2 in `tmp-decisions.md`. The hub will keep serving the web UI, and a
+> native client will be offered *in addition to* it, not as a replacement.
+>
+> Two items are kept here because the decision makes them *more* relevant, not less:
+> the hub stays in the trusted path, so what it can substitute and what code it serves
+> both still matter. Everything else from the old Phase 12 (route blindness test,
+> opaque private-group metadata, chat_notify minimization, schema cleanup) is dropped
+> from the plan; the swarm item already shipped in 11.5.18.
+
+**Objective:** make the hub's remaining power over confidentiality *detectable*, given
+that it stays in the trusted path by choice.
+
+### 12.1 is DONE — and not as it was written
+
+**H3 is closed (2026-08-14), by removing the lookup rather than by verifying it.** The
+plan here was key transparency and safety numbers: keep fetching the invitee's key from
+the hub, and give humans a way to notice a substitution. What shipped instead is the
+invite redesign in `invite-pairing-v1.md` — the node holds the GEK and wraps it
+itself, for a key the recipient proves possession of, and identities are bound to
+accounts by one-time codes the hub never sees.
+
+Why that is better than what was planned: safety numbers make a substitution *detectable
+by a human who bothers to check*, at the single worst moment (first contact, when there
+is no previous key to compare against). Removing the directory read from the invite path
+makes the substitution impossible instead, and costs the user one code to pass along
+rather than a fingerprint comparison ritual.
+
+It also closed **M3** as a side effect, and absorbed **14.3/14.4** (CLI invite, member
+management), which had to exist for a headless operator to admit anyone.
+
+Safety numbers may still return later as defence in depth for *identity* verification —
+"is this really Bob's account" — which is a different question from "which key gets the
+group key". They are no longer load-bearing.
+
+### Milestones
+
+| # | Component | Description |
+|---|---|---|
+| 12.1 | ~~Key transparency + safety numbers~~ [H3] | ✅ **DONE 2026-08-14**, by a different design — see above and `invite-pairing-v1.md` |
+| 12.2 | Served-SPA integrity | Strict CSP, Subresource Integrity on the bundle, and a signed digest of the served bundle published by the hub so a native client or extension can verify what the browser was given. **Now the highest-value item here**: T3 is the only remaining way an active hub reads content, and it can also lift a pairing code out of the page it served. **The CSP must keep `wasm-unsafe-eval` in `script-src`** — the bundle KDF is Argon2id in WebAssembly since 2026-08-14, and a policy that forbids it locks every user out of their keys |
+| 12.3 | Honest labelling | `/app/` states plainly that the hub serves this code and what that implies. Docs stop claiming end-to-end integrity for the hub-served path — the claim that holds is "the hub cannot read your content unless it ships you malicious client code" |
+| 12.4 | Written threat model | One page: passive hub, active hub, malicious node operator, malicious member, network attacker, local attacker — and for each claim, which adversary it holds against. This is what stops the overclaiming pattern the second review kept finding |
+
+**Dropped from the old Phase 12** (recorded so the intent is not lost if it returns):
+route-inventory blindness test, opaque private-group name/description, chat_notify
+metadata minimization, residual schema cleanup.
+
+---
+
+## Phase 13 — Native desktop client (Electron + optional Python sidecar)
+
+> **Reworked 2026-08-17 by operator decision. `desktop-client-v1.md` is
+> authoritative for this phase** — shell, device linking, account creation, node
+> management, packaging and the open items. The milestone table below is the summary;
+> the design and its rationale are there.
+>
+> **The shell changed: pywebview → Electron.** Structural decision 18 is reversed. The
+> reason is measured, not aesthetic: the SPA depends on Chromium-class APIs (WebRTC,
+> WebCrypto X25519/Ed25519, MSE, Service Workers, File System Access), so keeping
+> Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js` and `sw.js`
+> unchanged, while a system webview meant reimplementing ~2500–3000 lines plus a loopback
+> media server and native dialogs. The old "69 % reused" figure was measured against an
+> `app.js` of ~2600 lines; it is **4586** as of 2026-08-17, and `app.js` now holds 2 direct
+> `crypto.subtle` calls — the three-globals seam had already leaked.
+>
+> **One blocking addition: device linking (13.3b).** Identity keys are per node, so a
+> native client holding its own keys is refused by `webrtc_server.py:886-894` where a
+> browser is not. Without linking, an account created natively could never be opened in a
+> browser without an operator code per node — the "native must not prevent web use"
+> objective fails outright. See `desktop-client-v1.md` §4.
+
+**Objective:** ship a desktop application whose UI is not served by the hub, with durable
+local key storage, at feature parity with the web for a standard user, that does not
+prevent web use, and that can manage locally installed nodes.
+
+> ⚠️ **Do not justify this phase as "the fix for T3".** An earlier draft of
+> `second-review.md` claimed a native client makes code integrity independent of the hub.
+> That was wrong: a binary downloaded from `meshbay.org` and signed with a key the hub
+> operator holds relocates the trust rather than removing it. What native actually changes is
+> **detectability** — an attack must ship as an artifact that can be hashed and compared
+> instead of a one-off HTTP response — and that value is realised only by **18.7 reproducible
+> builds** plus published hashes.
+>
+> **Cost line revised 2026-08-17.** Two of the three costs recorded here were pywebview's,
+> not native's. Electron with `sandbox` and `contextIsolation` **keeps** the Chromium
+> renderer sandbox — the strongest available — so "native costs the browser sandbox" is
+> false for this shell, and the D2 table in `tmp-decisions.md` is wrong on that row. The
+> loopback media server is deleted (13.6). What remains, and is real: **we own Chromium's
+> patch cadence**, the renderer parses attacker-controlled content from nodes, npm enters
+> the supply chain, and the updater is new surface.
+>
+> The security-per-effort ranking is: **11.5 ≫ 12 ≫ 14 (CLI) ≫ 13.** This phase is justified
+> on product grounds. It closes **C4** for native devices, but **not for accounts that also
+> use a browser** — see `desktop-client-v1.md` §5.1. Reproducible builds are unusually
+> tractable here: the UI has no bundler or minifier, and Electron's own binaries have
+> published upstream hashes.
+
+### Why this is cheap (re-measured 2026-08-17)
+
+| Asset | Lines | Under Electron | Under a system webview |
+|---|---|---|---|
+| `style.css`, `i18n.js`, `vendor/`, `zipstream.js`, `transfers.js` | ~4200 | reuse | reuse |
+| `app.js` | 4586 | reuse, minus the hub base URL | reuse, minus storage glue + MSE player |
+| `transport.js`, `crypto.js`, `keyderive.js` | 1907 | **reuse** | delete and reimplement |
+| `downloads.js`, `sw.js` | 363 | **reuse** | delete |
+
+`HUB = ''` (`app.js:12`) becoming a configurable absolute base URL is the one structural
+change. That is what 13.1 exists for, and under Electron it is nearly all of it.
+
+The old plan also budgeted a loopback media server (WebKitGTK MSE is unreliable), a SQLite
+index cache (IndexedDB is restricted under `file://`) and native file dialogs. Chromium
+provides all three, so those milestones are deleted rather than rewritten.
+
+### Non-negotiable
+
+**UI assets ship inside the package and load from disk.** A shell that points its WebView at
+`https://meshbay.org/app/` is a browser with a different icon and fixes nothing. The hub is
+used for the API only, and the bundle is covered by 13.9 signing.
+
+Shell hardening is part of that and is not optional: `contextIsolation` and `sandbox` on,
+`nodeIntegration` off, a custom `app://` protocol rather than `file://`, navigation to
+non-local targets refused, and a strict in-package CSP that **keeps `wasm-unsafe-eval`** —
+the bundle KDF is Argon2id in WebAssembly, and a policy forbidding it locks every user out
+of their keys.
+
+### Milestones
+
+| # | Component | Prio | Description |
+|---|---|---|---|
+| 13.1 | Hub base URL + storage/save-file adapter | 1 | `HUB = ''` becomes configurable; a thin adapter for storage and saving. **Acceptance: the browser SPA behaves identically.** Much smaller than the old platform split — Chromium stays Chromium |
+| 13.2 | Electron shell | 1 | `app://` via `protocol.handle`, CSP, preload with an enumerated API, sandbox, single instance, tray, window state |
+| 13.3 | Local key storage + device auth | 1 | `safeStorage` (OS keychain) with an explicit fallback where no keyring exists; device Ed25519 auth on the `POST /v1/nodes/auth` pattern. **`POST /v1/users/auth` does not exist yet and must be written** |
+| **13.3b** | **Device linking** | 1 | **Blocking.** One-time code generated by the new device, countersigned by an already-pinned key. `identities` gains a device dimension; `pin_identity`'s `INSERT OR REPLACE` must go. `desktop-client-v1.md` §4 |
+| 13.3c | Hybrid registration | 1 | Register with a passphrase-derived `auth_key` (existing endpoint, browser-compatible at once, and the only account recovery path), device key thereafter |
+| ~~13.4~~ | aiortc client transport | — | **Deleted** — Chromium provides WebRTC. `transport.js` is kept as the client |
+| ~~13.5~~ | SQLite index cache | — | **Deleted** — IndexedDB works under `app://` |
+| ~~13.6~~ | Loopback media server | — | **Deleted** — Chromium MSE works. Removes the C1-shaped surface this milestone would have added |
+| 13.7 | Native save dialog | 2 | `dialog.showSaveDialog` + streamed write from the main process; the service-worker path already works, so this is an improvement, not a prerequisite |
+| 13.8 | Safety-number UI | 3 | Consumes 12.2. **No longer load-bearing for device linking** — the code binding replaced digit comparison |
+| 13.9 | Release key + verified updates | 3 | Prefer the signed apt/dnf repo (18.5) over a bespoke updater. **The key must exist before the first public package**; without it the update channel is the new T3 |
+| 13.10 | Packaging | 1 / 3 | `.deb` (Ubuntu 24+) and `.rpm` (Fedora 44+) first; MSI per-user (Windows) later; AppImage/Flatpak optional |
+| ~~13.11~~ | "Retire the SPA?" | — | **Settled**: the web stays. A native client must not prevent web use |
+| 13.11b | Per-root `unavailable` index state | 2 | Freeze the subtree instead of emptying it when a volume goes away. **Prerequisite for root selection** — otherwise unplugging a USB drive propagates deletions for a whole library |
+| 13.11c | Named roots per group | 2 | `shared_dir` (one string) → a list of `{name, path, kind}`; the name is the directory's basename, derived once and stored. Unique names (case-insensitive), no nesting, per-root availability, operator-designated upload target |
+| 13.11d | Filesystem portability | 1 | Case folding, NFC normalization, Windows reserved names, `\\?\` paths, reconciliation scans. Case folding is for index identity, collision reporting and root names. **Not** the no-overwrite check: `Path.exists()` is already case-insensitive on NTFS/exFAT, so C5a is not reachable that way (verified 2026-08-18) |
+| 13.12 | Node management over signed MNP ops | 2 | Invitations, revoke, unpin, devices, file/dir delete, roster, audit, peers, root selection, **`gek_rotate`**. The loopback admin API is never exposed to the network |
+| 13.13 | First-run wizard | 2 | Detect a local node, `systemctl --user enable --now` (no privilege), link to the hub account, create/attach a group, `gek-init`, `operator pair` — all on loopback, no terminal |
+| 13.14 | Python sidecar — `group://` over QUIC | 2 | Reuses `quic_client.py`. Also the only local management path on a LAN with no internet, since MNP setup needs the hub's signaling |
+
+### Deletions enabled once native is the recommended client
+
+`webcrypto.py` + the `:aes` HKDF variant · `deriveAuthKey`/`deriveEncryptionKey` +
+`pw_version` 3 + legacy migration · keypair bundle MNP messages + `keypair_bundles` table ·
+`_bundleKey` in IndexedDB + `_sessionKeys` in sessionStorage + `_pkFromSk`.
+
+**Revised 2026-08-17:** `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js`, `sw.js`
+and the MSE path are **no longer on this list** — under Electron they are the client, not
+browser workarounds. The keypair-bundle deletions still apply, but only for accounts that
+opt out of browser use (`desktop-client-v1.md` §5.1); the browser path needs them.
+
+**Kept regardless:** WebRTC transport, hub signaling relay, DTLS channel binding.
+These carry NAT traversal and are not browser workarounds.
+
+---
+
+## Phase 14 — Node CLI + management
+
+> Was Phase 12 before the 2026-08-13 renumbering.
+
+**Objective:** `meshbay-node` CLI becomes a full management tool, not just a
+daemon launcher.
+
+**Partially delivered early (2026-08-13), forced by the first real deployment.**
+Every operator action lived behind a web UI on the node's own loopback interface,
+so a node on a server reached over SSH could not be operated at all without
+port-forwarding a browser session — and 11.5.3 added a token that had to be
+copied out of a log to get in. `status`, `ui` and `gek-init` shipped to unblock
+that.
+
+**Member management landed 2026-08-14** with the invite redesign, for the same
+reason: a node admits people from its own roster, and a headless operator had no
+way to put anyone on it. `operator pair`, `member list|invite|revoke|unpin` all
+work over SSH. **Deleting a file is now the only operator action that still needs
+a browser.**
+
+### Milestones
+
+| # | Component | Description |
+|---|---|---|
+| 14.1 | `meshbay-node status` | ✅ DONE — hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is stopped |
+| 14.1b | ~~`meshbay-node ui`~~ | **Removed 2026-09-01** (`refactor-node-ui.md` phase 5). The server-rendered admin page it opened is gone; the CLI and the desktop client's Node page use the loopback control API directly |
+| 14.1c | `meshbay-node gek-init` | ✅ DONE — initialises a group key via the daemon's loopback API. Was previously only possible by clicking a button in a browser on the node's own machine |
+| 14.2 | `meshbay-node group list` | ✅ **DONE 2026-08-18** — groups with roots, key state, file and peer counts |
+| 14.3 | `meshbay-node group create` | Create group on hub, add to config, generate GEK |
+| 14.4 | `meshbay-node group join` | Join existing group, fetch GEK from local BundleStore, add to config |
+| 14.5 | `meshbay-node member invite` | ✅ **DONE 2026-08-14** — issues a one-time code; the node wraps the GEK itself when the invitee connects. The original description ("wrap GEK for new member, store bundle") describes the design the invite redesign replaced |
+| 14.6 | `meshbay-node member revoke` | ✅ **DONE** — stops the node serving the key, and tells the operator to rotate it, since the ex-member still holds the current one |
+| 14.6b | `meshbay-node member unpin` | ✅ **DONE** — forget a pinned identity so someone can pair again after a key reset |
+| 14.7 | `meshbay-node member list` | ✅ **DONE** — roster: who is admitted, with what role, pinned when and how. Online status still to add |
+| 14.8 | Config reload (SIGHUP) | ✅ **DONE 2026-08-18** — `meshbay-node reload`. Deliberately narrow: it re-roots **groups already hosted**, which is what an operator adjusts day to day, and reports a changed group *set* as needing a restart. Adding a group live means new indexers, chat stores, GEK loads and transport contexts, and that is how a half-built group ends up serving content. No connection is dropped |
+| 14.9 | ~~`meshbay-node admin-key`~~ | ✅ **Superseded by `operator pair`** — pairing binds the operator's browser key with a one-time code instead of pasting a base64 key, and the auto-pin that made M3 possible is deleted |
+| 14.10 | `meshbay-node denylist` | ✅ **DONE 2026-08-18** — `denylist show|clear [identifier]`. Clearing asks for confirmation and reports the count, because it re-admits whoever it was keeping out |
+| 14.11 | `meshbay-node file rm` | ✅ **DONE 2026-08-18** — `file list|rm <id>`. **No operator action now requires a browser.** Refuses a file whose root is unavailable: it is frozen, not gone |
+
+### Sequencing and factoring (added 2026-08-17)
+
+**Phase 14 finishes before 13.12** (node management from the desktop client), and the
+remaining commands are written against a single internal module rather than beside one.
+
+- The CLI is the only interface that works with the daemon stopped, with no GEK, or with
+ no operator paired — exactly the states the desktop client cannot reach, and the ones
+ decision E5 sends back to the local machine. Holes here have no fallback.
+- 13.12 would add MNP handlers for operations the CLI already performs through the
+ loopback API. **Two paths to one operation with different authorization is the shape of
+ C1 and C6.** Factor each operation into `meshbay_node/ops.py`, with the CLI, the
+ loopback API and the MNP handler as three thin adapters. Parity becomes structural,
+ authorization lives in one place, and 13.12 is adapter code.
+
+The refactor is cheaper now, with six commands left, than after 13.12 exists.
+See `desktop-client-v1.md` §6.6.
+
+**Done 2026-08-18.** `meshbay_node/ops.py` holds each operation once; the loopback API is
+a one-line adapter per endpoint (`_op()` translates `OpError` into a JSON response) and the
+MNP handlers call the same functions through `_run_op`. `test_ops.py` asserts the shape
+rather than trusting it: every operation takes `state` first, `ops` imports nothing
+web-shaped, and no loopback handler performs an operation itself.
+
+**Signed MNP ops shipped with it:** `gek_rotate` and `member_unpin`, both operator-signed
+over a structured transcript like every other destructive operation. Rotation is the half
+of revocation that revocation cannot do — the ex-member holds the current key — and the
+node generates the replacement with its own CSPRNG, so no key material crosses the wire.
+
+### Architecture
+
+CLI commands talk to the running daemon over its **loopback admin API**, authenticated
+with the per-run session token (11.5.3) — `_daemon_api()` in `daemon.py`. The Unix-socket
+design below was the original plan; the loopback API already existed for the admin UI,
+carries the same authorization, and avoided a second control plane. A socket would still
+be an improvement (no port, file permissions instead of a token file) if the admin UI
+ever goes away.
+
+`status` deliberately does *not* use it: it reads the keystore, the config and the roster
+directly, so it works while the daemon is stopped — which is when an operator most needs
+to know why.
+
+---
+
+## Phase 15 — Chat encryption (Sender Keys) + retention
+
+> Was Phase 13 before the 2026-08-13 renumbering.
+
+**Objective:** implement spec section 6.6 — group chat messages are encrypted
+with the Sender Keys protocol. Currently, chat messages are stored and
+transmitted as plaintext payloads (relying on transport encryption only).
+
+### Background
+
+`meshbay_common.senderkeys` (Phase 7.5) implements the Sender Keys protocol, but nothing
+in production imports it — `grep` finds it only in its own tests. The node chat flow
+(`_do_chat_message`) stores raw payloads. The module provides per-sender chain key
+derivation, symmetric message encryption, and a distribution format.
+
+### 15.0 — Decide the distribution channel FIRST (blocking sub-milestone)
+
+`draft-v4` §6.6 says sender keys are distributed "via pairwise channels (GEK-wrapped or
+direct)". **GEK-wrapped is the wrong choice** and must not be implemented: it makes every
+sender key a function of the GEK, so anyone who holds the GEK — including an attacker who
+obtained it via H3 key substitution, or a former member who kept it — recovers every sender
+key. The encryption would then be decorative.
+
+Distribution must be **pairwise to identity keys**: wrap each sender key with ECIES to the
+recipient's `pk_x25519` (the existing `wrap_gek_aes` primitive), or run the existing
+`ratchet.py` Double Ratchet per member pair. Decide and record before writing 15.1.
+
+### 15.0b — A sender key is per DEVICE, never per person (added 2026-08-17)
+
+**This phase predates device linking (`desktop-client-v1.md` §4) and is wrong as
+written.** One person now holds several identity keys on one node — a browser and a
+desktop client, up to the device cap. Two consequences, and the first is the whole
+decision:
+
+**A shared per-person chain reintroduces C1, one level down.** If Alice's two devices share
+one sending chain, both advance it, and concurrent sends produce **key and nonce reuse** —
+which is precisely why `first-review.md` C1 rejected a shared Double Ratchet for groups.
+Per-device chains have no shared mutable state and no reuse. There is no third option worth
+weighing.
+
+**The code already fails this, silently.** `senderkeys.py` keys everything by
+`sender_id: str`, and `GroupSenderKeyStore.add_sender` does
+`self._states[dist.sender_id] = ...` — so a second device registering under the same
+`sender_id` **overwrites the first, dropping its chain**. Same shape as `pin_identity`'s
+`INSERT OR REPLACE`, same fix: `sender_id` becomes a **device** identifier (account plus
+device key fingerprint), not a `user_id`. The module needs its identifier redefined, not
+restructuring — and the class docstring, which says "one chain per member", needs to say
+per device.
+
+**What follows from per-device chains:**
+
+- **Fan-out is O(devices), not O(members)** — bounded by the per-user device cap (5 by
+ default), so up to 5× the distribution messages. Acceptable, but size the distribution
+ path for it rather than discovering it.
+- **A new device cannot read history until every sender redistributes.** Nobody but the
+ senders holds their chain keys — that is the point — so a freshly linked device sees an
+ unreadable backlog until each sender is next online. Either accept and surface it
+ ("history before this device was added is unavailable"), or have the **linking device
+ hand over its own accumulated state as a blob sealed to the new device's key**, relayed
+ by the node, which cannot read it. Decide in 15.0.
+- **Revoking a device must rotate**, exactly like revoking a member: a lost laptop holds
+ every sender key it ever received. 15.4 only knows about members today and must cover
+ `device revoke` and `member unpin`.
+
+### Honest threat delta (state this in the docs, not just here)
+
+Sender Keys protects chat against **someone who holds the node's disk but is not a group
+member** — a seized machine, a hosting provider, a compromised node. It does **not** protect
+chat from the node operator, because on this platform the operator is a group member and
+therefore a legitimate sender-key recipient. Claiming more than that would repeat the
+overstatement pattern `second-review.md` §7 flags.
+
+Three additions once devices exist, all of which belong in the user-facing docs:
+
+- **It does not protect against anyone holding any one device of any member.** With
+ several devices per person, that surface is larger than it was.
+- **C4's blast radius reaches chat history.** A browser recovers its identity key from the
+ keypair bundle on the node; cracking that bundle yields every sender key ever wrapped to
+ it, because the distribution channel has no forward secrecy. Not a regression — chat is
+ plaintext at rest today — but it means Sender Keys is worth measurably less to a
+ browser-using account than to a native one, which is the same asymmetry as everywhere
+ else in `desktop-client-v1.md` §5.1.
+- **Sender authentication is now a requirement, not an accepted limitation**
+ (operator decision, 2026-08-17). A sender key proves *a device*; it does not prove which
+ account that device belongs to, and NS6's enforcement of `sender_id` from the session is
+ the node's word. The design is in `desktop-client-v1.md` §4.8: **sign every message
+ with the sender's device key** (independent of encryption, so it can land before this
+ phase), **pin `account → device keys` client-side** using the device-add
+ countersignatures as evidence, and optionally have the **operator sign a roster
+ attestation** to close first contact. What survives: an operator who turns malicious
+ *later* cannot forge an account a member has already seen — forgery is limited to
+ accounts the victim has never encountered.
+
+### Milestones
+
+| # | Component | Description |
+|---|---|---|
+| 15.0 | **Distribution decision** | Pairwise-to-identity-key, never GEK-derived. Blocking |
+| 15.0b | **Per-device chains** | `sender_id` becomes a device identifier; fix `GroupSenderKeyStore`'s silent overwrite; decide the history-handover question. **Blocking, and depends on device linking (Stage C) landing first** |
+| 15.1 | Node: sender key init | Generate a sender key **per device** on group join, distribute to **every device of every member** |
+| 15.2 | Node: encrypt chat on send | Encrypt payload with that device's chain key before broadcast |
+| 15.3 | Node: decrypt chat on receive | Decrypt incoming chat messages, handle out-of-order |
+| 15.4 | Key rotation on removal | Member removed **or device revoked or unpinned** → all remaining devices rotate |
+| 15.5 | Chat retention config | Per-group `max_age_days` setting, periodic cleanup in ChatStore |
+| 15.6 | MNP version negotiation | Handshake declares a supported version range, not a single `v` field (L2 — today `v` is sent by everyone and checked by no one). **Move this earlier**: it pairs with the minimum-client-version check, and version skew begins the day the desktop client ships (`desktop-client-v1.md` §2.6), not when chat is encrypted |
+| 15.7 | Chat attachments | Attachments are ordinary files on the node and remain plaintext at rest. Either encrypt them under the sender key, or document the asymmetry explicitly. Note they now land in the **operator-designated upload root** (§6.7 of the desktop-client doc) |
+
+---
+
+## Phase 16 — Android client MVP
+
+> Was Phase 14 before the 2026-08-13 renumbering.
+>
+> **Rewritten 2026-08-17.** The previous text described an architecture that no longer
+> exists and, in two places, one that was deliberately dismantled. Corrections are listed
+> below rather than silently applied, because the same mistakes are easy to make twice.
+>
+> **Shares the desktop design** (`desktop-client-v1.md`): keys generated and kept
+> locally, device Ed25519 authentication, no keypair bundles, and **an Android client is
+> simply another device** under device linking. Do not re-derive a second crypto, auth or
+> admission model here.
+
+**Objective:** Android app for account creation, group browsing, file download, streaming
+and chat. **Client only — no node functionality on mobile**, and that is structural: an
+app cannot freely read the phone's folders (the user grants access to one tree at a time,
+revocably), background processes are killed, and a long-lived listening socket is not
+guaranteed. All three are things a node must have.
+
+### What the previous text got wrong
+
+| It said | Reality |
+|---|---|
+| "The `keypair_bundle` (encrypted, stored on hub) enables cross-device" | **The hub has stored no keypair bundle since 2026-08-12**, and since 2026-08-14 identity keys are **per node** — there is no single identity to carry between platforms. Cross-device is **device linking**, not a shared bundle |
+| "Notification state and read markers sync via hub (small encrypted blob per user)" | Violates the rule that **group-related server state lives on the node** (draft-v6 §2.5). Even encrypted, a per-user blob the hub stores gives it update timing and frequency — who reads which group, when. Node-side or not synced |
+| "Hub client (auth, groups, **GEK**)" | The hub does not serve GEKs. `GET /gek` and the `gek_bundles` table were removed in the T3 work; the node wraps the key on every connection |
+| "NAT traversal (`punch_nat` + QUIC)" | `punch_nat()` is **not** a traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated (structural decision 17). **ICE/STUN is the traversal path**, and Android has a native WebRTC stack |
+| "MNP extended with an `upload` message type" | Already shipped — `FILE_UPLOAD`, Phase 10b.4 |
+| "Account creation … + keypair bundle" | Hybrid registration (draft-v6 §1 item 7): passphrase-derived `auth_key`, then a device Ed25519 key. No bundle anywhere |
+| Milestones numbered 14.x inside Phase 16 | Leftover from the renumbering; they are 16.x below |
+
+**Stack:** Kotlin + Jetpack Compose. **WebRTC via Android's native stack** — the traversal
+path, same as every other client. Crypto via Bouncy Castle JVM.
+
+**QUIC is deferred.** It exists for LAN and hub-less `group://`, which is marginal on a
+phone, and it would drag a Rust JNI dependency (`quiche`) into an MVP. Add it if a real
+use case appears.
+
+### Milestones
+
+| # | Component | Priority |
+|---|---|---|
+| 16.1 | Hub client — auth, groups, notifications (Retrofit) | High |
+| 16.2 | Crypto — Ed25519, X25519, ChaCha20, and **`auth_key` derivation byte-identical to `keyderive.js`/`keyderive.py`** | High |
+| 16.3 | WebRTC DataChannel transport + the unified handshake (11.5.4) | High |
+| 16.4 | **Device linking** — the app generates its own keys and is approved by an already-paired device (§4 of the desktop-client doc) | High |
+| 16.5 | Hybrid registration from the app | High |
+| 16.6 | File browser + download, **root-aware paths**, per-root "unavailable" state | High |
+| 16.7 | Upload from mobile via the existing `FILE_UPLOAD` handler; photo picker, no broad storage permission | Medium |
+| 16.8 | Chat UI | Medium |
+| 16.9 | Video streaming (native player, MSE not required) | Medium |
+| 16.10 | Contact list integration (permission-gated) | Low |
+
+### Consequences carried from the other phases
+
+- **Device linking is a prerequisite** (Stage C), exactly as for the desktop client. Without
+ it, installing the app on a phone would need an operator code per node.
+- **A fourth consumer of the KDF parity test.** `auth_key` is PBKDF2-SHA512 600 000 in
+ `keyderive.js`, `keyderive.py`, the QE harness and now Kotlin. The standing warning
+ applies and matters more each time: **never change those parameters in one place** — a
+ mismatch does not look like an error, it looks like an account nobody can open.
+- **Sender Keys**: a phone is a device, so it gets its own chain (§15.0b). The "no history
+ until every sender redistributes" property is **most visible here** — people install an
+ app and expect their backlog — which argues for the sealed state handover rather than
+ the accept-and-explain option.
+- **Version skew is worse than on desktop.** An installed client meets a newer hub
+ (`desktop-client-v1.md` §2.6), and store review latency means a fix cannot be
+ pushed quickly. The minimum-client-version check is not optional here.
+- **Multi-root** falls out for free if the app is built after Stage A; it must not assume a
+ group is one directory.
+
+### Open, and worth deciding before 16.8
+
+**Chat delivery on a phone has no answer today.** Android will not let an app hold a
+WebRTC DataChannel open in the background, so a message arriving while the app is closed
+reaches nobody. The obvious mechanism is a push service, and the obvious push service is
+FCM — which would mean **Google learning the timing of your group activity, and the hub
+sending it**, against the whole metadata posture (H7, draft-v6 §2.5). Alternatives
+(a self-hosted UnifiedPush distributor, a foreground service the user opts into, polling
+on open) each cost something different. **Decide it explicitly; do not let FCM arrive as
+an implementation detail.**
+
+**Out of scope:** node functionality on mobile, Mac and iPhone support.
+
+---
+
+## Phase 17 — Network resilience (optional, low priority)
+
+> Was Phase 15 before the 2026-08-13 renumbering.
+
+**Objective:** handle edge cases — symmetric NAT (CGNAT mobile), TURN relay,
+0-RTT reconnection. Not needed for typical residential users.
+
+| # | Component | Priority |
+|---|---|---|
+| 15.1 | Mesh Relay TURN server | Low |
+| 15.2 | Relay registration via MHP | Low |
+| 15.3 | Node fallback to relay after ICE failure | Low |
+| 15.4 | QUIC 0-RTT (session tickets) | Medium |
+| 15.5 | Connection pool (1 QUIC conn = N requests) | Medium |
+| 15.6 | Test CGNAT mobile 4G | Low |
+
+**Note:** enterprise users behind restrictive firewalls can configure port
+forwarding themselves. This phase targets the ~15% of residential connections
+where even ICE/STUN fails (symmetric NAT behind CGNAT). Not a priority —
+the user explicitly deprioritized this.
+
+---
+
+## Phase 18 — Packaging, repositories, CI, supply chain
+
+> Was Phase 16 before the 2026-08-13 renumbering.
+> Release **signing** is not here — it moved into 13.9, because a desktop application
+> cannot ship without a verified update channel. This phase covers distro packaging and CI.
+
+| # | Component |
+|---|---|
+| 18.1 | RPM build pipeline (Fedora, RHEL) |
+| 18.2 | DEB build pipeline (Ubuntu, Debian) |
+| 18.3 | GitHub Actions CI (pytest + ruff on PR) |
+| 18.4 | **Security CI**: the 11.5.23 regression suite + the 12.1 hub-blindness test run on every PR; dependency audit (`pip-audit`); static analysis (`bandit`/`semgrep`) |
+| 18.5 | Repo apt/dnf on meshbay.org/packages/, signed with the 13.9 key |
+| 18.6 | Android APK distribution on meshbay.org/downloads/ |
+| 18.7 | Reproducible builds for the desktop client (stretch) — lets third parties verify the shipped bundle matches the source, the last piece of the T3 answer |
+
+---
+
+## Phase 19 — Extension module sandbox (future)
+
+> Was Phase 17 before the 2026-08-13 renumbering.
+> Adds a large new attack surface (arbitrary code near group data). Requires its own
+> security review before any code is written. Must stay last.
+
+**Objective:** implement spec section 12 — Python extension modules that can
+react to group events, access the file index, and send messages, running in
+a sandboxed subprocess with limited permissions.
+
+| # | Component | Description |
+|---|---|---|
+| 17.1 | Module manifest loader | Parse `module.toml`, validate permissions |
+| 17.2 | Sandboxed subprocess | `read_index()`, `send_message()`, `receive_events()` API |
+| 17.3 | Permission enforcement | No filesystem/network beyond group context |
+| 17.4 | Module marketplace on hub | List/install/rate extension modules |
+
+**Low priority.** This is an extensibility feature for power users and
+community developers. Core functionality must be complete and stable first.
+
+---
+
+## Recommended order
+
+```
+Phase 11.5 (Security remediation) ⛔ BLOCKING — nothing else starts
+Phase 13.1 (Hub base URL + adapters)← free refactor, prerequisite for the desktop client
+Phase 12 (Key verification) ← H3 safety numbers + served-SPA integrity
+Phase 14 (Node CLI) ← best security-per-effort answer to T3
+Phase 15 (Sender Keys) ← chat encryption; 15.0 decision first
+Phase 13.2+ (Desktop client) ← Electron; offered alongside the browser SPA
+Phase 16 (Android) ← reuses the Phase 13 design
+Phase 17 (Resilience) ← optional, edge cases only
+Phase 18 (Packaging + CI) ← distro repos; 18.7 gates 13's security argument
+Phase 19 (Extensions) ← last, needs its own security review
+```
+
+**Reordered 2026-08-13.** The desktop client was originally placed third on the strength of
+"it removes T3". That claim was corrected (see the Phase 13 banner), so the client is now
+sequenced after the work that closes actual findings, and behind decision D2 in
+`tmp-decisions.md`. Security-per-effort: **11.5 ≫ 12 ≫ 14 ≫ 13**.
+
+Phase 14 (CLI) moved ahead of the client work for a specific reason: the node operator holds
+the GEK and is the content authority, yet today must use hub-served JS to initialize GEKs and
+invite members. The CLI removes that dependency for the highest-value target at a fraction of
+any client's cost.
+
+**Phase 11.5 is blocking and not negotiable.** The current build serves private group
+content over an unauthenticated HTTP port (C1), lets any user hijack a node's signaling
+identity (C2), and lets any member seize the group key (C5b). No feature work lands on top
+of that.
+
+**One task can run in parallel:** 13.1 (hub base URL + storage/save-file adapter) is pure
+refactoring with the acceptance criterion "the browser SPA is unchanged in behaviour". It
+de-risks Phase 13 and touches none of the security surface.
+
+**One task must not be deferred inside Phase 13:** 13.3b (device linking). It is a protocol
+and schema change, it gates the "native must not prevent web use" objective, and the
+roster's `pin_identity` currently does `INSERT OR REPLACE` on a `user_id` primary key — a
+silent overwrite that becomes a hole the moment more than one key per person is legitimate.
+
+**Renumbering map (2026-08-13):**
+
+| Old | New | Phase |
+|---|---|---|
+| — | 11.5 | Security remediation (new) |
+| — | 12 | Hub minimization (new) |
+| — | 13 | Native desktop client (new) |
+| 12 | 14 | Node CLI + management |
+| 13 | 15 | Chat encryption (Sender Keys) |
+| 14 | 16 | Android client |
+| 15 | 17 | Network resilience |
+| 16 | 18 | Packaging, repos, CI |
+| 17 | 19 | Extension module sandbox |
+
+---
+
+## Structural decisions (all resolved)
+
+1. Multi-group on a single QUIC port ✅ (Phase 7)
+2. Signaling punch/connect via hub WS ✅ (Phase 7)
+3. Chat is a core feature, not a module ✅ (draft v3)
+4. Chat encryption: Sender Keys ✅ (security review)
+5. JWT group claims required ✅ (security review)
+6. Admin model: config-based ✅ (Phase 8)
+7. Refresh token rotation: family-based ✅ (Phase 8)
+8. Email encrypted at rest: AES-256-GCM ✅ (Phase 8)
+9. Argon2id params: 256 MB, pw_version for migration ✅ (Phase 8)
+10. **Browser transport: WebRTC DataChannel + ICE/STUN** ✅ (decided 2026-08-10)
+11. **Hub role: registrar + signaling ONLY, never in data path** ✅ (reinforced 2026-08-10)
+12. **Chat stored on nodes, not hub** ✅ (decided 2026-08-10)
+13. **Web UI: Preact SPA, dark/light, responsive, i18n** ✅ (decided 2026-08-10)
+14. **Site overlay: meshbay.org-specific pages separate from generic hub** ✅ (decided 2026-08-10)
+15. **MSE streaming: ffmpeg fMP4 remux on node, SourceBuffer on browser** ✅ (Phase 10c)
+16. **Transport: aiortc/ICE is primary for browser AND native. QUIC kept at parity for LAN,
+ port-forwarded and hub-less `group://` access. TCP+TLS and the node HTTP API are
+ removed.** ✅ (decided 2026-08-13, second review)
+17. **`punch_nat()` is a direct-connection helper, not a NAT traversal stack** — no STUN, no
+ candidate gathering, no dual-stack fallback, validated on one ISP. ICE/STUN (validated on
+ two ISPs, two browsers, IPv4 + IPv6 + 4G CGNAT) is the traversal path. ✅ (2026-08-13)
+18. ~~**Native desktop shell: pywebview**~~ → **Electron**, with an optional Python sidecar
+ for hub-less `group://` over QUIC. **Reversed 2026-08-17** — the SPA depends on
+ Chromium-class APIs, so a system webview meant reimplementing ~2500–3000 lines and
+ losing the renderer sandbox. What is unchanged and non-negotiable: **UI assets ship
+ inside the package and load from disk**, never fetched from the hub, or T3 is not
+ fixed. See `desktop-client-v1.md` §2.
+18b. **A second device is admitted by device linking, not by an operator code.** The
+ already-pinned key countersigns; the binding is a one-time code the new device
+ generates and displays, never a human comparing digits. The hub cannot produce that
+ countersignature. ✅ (2026-08-17)
+19. **Private keys never leave the device on native clients.** Keypair bundles are retired
+ rather than relocated; Phase 12's move of bundles from hub to node was the wrong
+ destination (C4). ✅ (2026-08-13). **Qualified 2026-08-17:** this holds for native
+ devices. A browser has no durable storage of its own and still needs a bundle on each
+ node, so C4 closes for an account only when it opts out of browser use.
+20. **Sender keys are distributed pairwise to identity keys, never derived from or wrapped
+ under the GEK.** ✅ (2026-08-13)
+21. **Hub minimization is enforced by an acceptance test (12.1), not by policy.** The hub
+ must be *unable* to see keys, content, or file listings. ✅ (2026-08-13)