summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md99
-rw-r--r--devel-phases-next.md406
-rw-r--r--docs/desktop-client-v1.md1087
-rw-r--r--docs/meshbay-draft-v5.md46
-rw-r--r--docs/meshbay-draft-v6.md270
-rw-r--r--packaging/caddy/meshbay.org.Caddyfile64
-rw-r--r--second-review.md2
-rw-r--r--site/about.html2
-rw-r--r--site/downloads.html83
-rw-r--r--site/index.html2
-rw-r--r--tmp-decisions.md24
11 files changed, 1942 insertions, 143 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 76a7aa6..fcdb28a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3,7 +3,9 @@
## What this project is
MeshBay is a decentralized peer-to-peer platform for file sharing, video streaming, and group messaging.
-See `docs/meshbay-draft-v5.md` for the architecture specification (v3/v4 superseded).
+See `docs/meshbay-draft-v6.md` for the architecture specification. v6 restates only
+what changed on 2026-08-17; `docs/meshbay-draft-v5.md` remains authoritative for
+everything v6 does not touch (v3/v4 superseded).
## Repository structure
@@ -127,7 +129,13 @@ Scope: `hub`, `node`, `common`, or omitted for cross-cutting
warned about at startup, never obeyed. Never auto-pin again, and never resolve the
operator's key through the hub
- **NS5** DTLS channel binding in GEK-HMAC — `HMAC(GEK, nonce || offer_fp || answer_fp)` detects WebRTC signaling MitM ✅ DONE
-- **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE
+- **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE.
+ **Superseded as sufficient (2026-08-17):** that is the node's word. Messages must be
+ **signed with the sender's device key**, and clients pin `account → device keys`.
+ Likewise `_admin_exec_file_delete` authorizes against `entry.uploader_pk` — the exact
+ uploading key — which **device linking breaks**: it must become any non-revoked device
+ of `uploader_id`, resolved through the roster (never through a token claim).
+ See `docs/desktop-client-v1.md` §4.8
- **NS7** Node Ed25519 auth — node daemon authenticates to hub via `POST /v1/nodes/auth` (Ed25519 signed timestamp), no auth_key/password on node. JWT `scope: "node"` blocks group management (create/add/delete/join). Operator manages groups from browser only. ✅ DONE
- **NS8** GEK-required enforcement — node REFUSES connections when GEK is None (no `gek_required: false` bypass). GEK initialization via node local admin UI only. ✅ DONE
@@ -238,6 +246,83 @@ keypair bundle, or anything that looks like a user's public key.
the group named in the invitation (an operator pairs node-wide while opening a
group)
+## Desktop client — decided, not built (2026-08-17)
+
+See `docs/desktop-client-v1.md`. Nothing here is implemented; it is the design and the
+decision record for Phase 13. Read it before touching the roster, registration, or
+anything that assumes one key per person.
+
+- **Electron**, not pywebview — structural decision 18 is reversed. The SPA depends on
+ Chromium-class APIs (WebRTC, WebCrypto X25519/Ed25519, MSE, Service Workers), so
+ keeping Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js` and
+ `sw.js` **as the client**. They are no longer on the "delete once native" list. A
+ Python sidecar reusing `quic_client.py` covers hub-less `group://` only
+- **UI assets ship inside the package**, unchanged and non-negotiable. A shell pointing
+ at the hub's `/app/` fixes nothing
+- **Device linking**: `identities.user_id` is a PRIMARY KEY and `pin_identity` does
+ `INSERT OR REPLACE` — one key per person per node, silently overwritten. Both must
+ change. A second device is admitted by the **already-pinned key countersigning**,
+ bound by a one-time code the new device generates; the hub holds no user keys and so
+ cannot produce that signature. Never make the approval a human comparing digits —
+ that is the safety-number ritual 12.1 was abandoned for
+- **C4 is not fully closed by going native.** It closes for a native device
+ unconditionally, and stays open for any account that also uses a browser, which needs
+ a bundle on each node. An account is only as strong as its weakest client
+- **`gek_rotate` may become a signed MNP op** — the C5b rule forbids *key material
+ arriving from outside*, not an operator-signed instruction where the node generates
+ the key itself. The initial `gek-init` stays local: with no GEK there is no session
+- **Installation places files, never secrets.** No key generation in `%post`/`postinst`
+ or an MSI custom action — a golden image would give every machine the same key
+- **A group has several named roots, not one `shared_dir`.** The name is the chosen
+ directory's **basename**, derived once at add time and *stored* — recomputing it from
+ the path re-identifies a whole library the day someone renames a folder. Duplicates
+ refused case-insensitively, no root nested in another, one operator-designated upload
+ target, availability per root, and `kind` + `layout` reserved for the planned
+ video/audio libraries. `config.py:103` is the single string this replaces
+- **The planned video/audio libraries are VIEWS over the file index, not a catalogue.**
+ No metadata store, no server-side database, ever, and nothing reaching the hub — it
+ keeps no file names for private groups (H7). A file stays tied to its representation on
+ the filesystem: folders are the categories, and moving a file makes it a different
+ file. Everything a view needs already exists (whole-group index cached client-side,
+ 10b.5/10b.6). The only non-free piece is a video thumbnail
+- **Enrichment happens on the client; what it cannot compute, the node produces on demand
+ and the asking device caches.** Neither node nor hub keeps durable derived state. This
+ is already the rule for chat thumbnails (draft-v5 §5.2) and it is the answer for video
+ thumbnails too — a frame grab is strictly less than the decoding the node already does
+ for streaming, over the same authorized path
+- **A root that goes away must freeze, not empty.** `indexer.py` runs a watchdog
+ `Observer` and rebuilds on any change; unmounting a USB drive either emits deletions
+ for the whole tree or presents an empty directory to the next rescan. Both propagate as
+ though the owner erased their library. The per-root "unavailable" state ships **before**
+ root selection is offered
+- **exFAT/NTFS and Windows are the common case, not an edge case.** Most users are
+ expected to share from an external exFAT or NTFS drive, on Windows, whatever the build
+ order says. Consequences that are correctness, not portability: filenames need NFC normalization for identity while keeping original bytes
+ for display; Windows reserved names and `MAX_PATH` affect what can be downloaded;
+ `ReadDirectoryChangesW` drops events under load, so periodic reconciliation is
+ mandatory. Never assume POSIX, systemd or case sensitivity. The upload no-overwrite check was *not* affected — `Path.exists()` is already case-insensitive there (checked 2026-08-18); case folding is for comparisons the code makes itself
+- **Shipping the UI in a package creates version skew for the first time.** Today the SPA
+ and the hub deploy together, so a `/v1/` response shape and its caller change in one
+ commit. Once the UI is installed rather than served, `/v1/` is a compatibility surface
+ and `GET /v1/hub/version` needs a minimum client version — cheap now, awkward later
+
+- **A content-addressed index cannot represent the same bytes at two paths.**
+ `GroupIndex` is keyed by blake3, so `clip.mp4` at a root and in `uploads/` with
+ identical content is **one** entry — which is also why a scan can report ten
+ files and index nine. Reconciliation compares *paths*, so it decided the
+ second path was a missed event every 60 s, rewrote the entry, bumped the
+ version and pushed an index update to every connected peer. Found by watching
+ a live node, not by a test. Anything comparing disk against index must check
+ the id, not the path
+
+- **A CLI branch nobody has run is not covered by anything.** `reload` shipped
+ with `subprocess` unimported and crashed on first use; the module compiles
+ fine, which is the same "syntax, not names" trap already recorded for the SPA.
+ `test_cli_dispatch.py` walks every verb with the daemon stubbed, and refuses
+ to let a verb be added to the parser without an entry there. It also stubs
+ `os.kill` — the first version of that test SIGHUPed the developer's own
+ running node
+
## Two lessons that cost four rounds of live testing
- **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the
@@ -416,7 +501,12 @@ keypair bundle, or anything that looks like a user's public key.
gathering, one ISP validated. **ICE/STUN (WebRTC) is the traversal path**, for native
clients too (via `aiortc` in Python)
- Argon2id 256 MB was applied to the **hub only**; `crypto.py` keystore is still 64 MB
-- Sender keys must be distributed **pairwise to identity keys**, never GEK-derived
+- Sender keys must be distributed **pairwise to identity keys**, never GEK-derived — and
+ **per device, never per person** (2026-08-17). Two devices sharing one sending chain
+ both advance it, producing key/nonce reuse: that is C1 again, one level down.
+ `GroupSenderKeyStore.add_sender` currently does `self._states[dist.sender_id] = ...`,
+ so a second device under the same `sender_id` silently overwrites the first. Revoking a
+ device must rotate, like revoking a member. See `devel-phases-next.md` §15.0b
- Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC path
## Known calibration TODOs
@@ -521,7 +611,8 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
| pkX from private key | `static/transport.js` | `_pkFromSk()` — JWK export to derive X25519 public key |
| Group delete (hub) | `meshbay_hub.api.groups` | `DELETE /v1/groups/{group_id}` — admin only |
| JWT scope enforcement | `meshbay_hub.api.deps` | `require_user_scope` — blocks node-scoped tokens from mutations |
-| Node local admin UI | `meshbay_node.ui.app` | Dashboard, peers, groups, audit log (localhost:18000) |
+| Operator operations | `meshbay_node.ops` | **One implementation, several front doors.** The loopback API, the CLI and the signed MNP handlers all call these; they take the daemon `state`, raise `OpError`, and know nothing about HTTP. Two implementations of one operation with two authorization checks is C1/C6 one size down |
+| Node local admin UI | `meshbay_node.ui.app` | Dashboard, peers, groups, audit log (localhost:18000). Each operation endpoint is one `_op(...)` line — logic there is a rule the MNP path does not have |
| Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) |
| Video flow control (browser) | `static/app.js` | `pump()` — the only place credit is granted. Read-ahead bounded by `BUFFER_AHEAD_S` of film, `STREAM_WINDOW` segments in flight, driven by a clock and by playback, never by arriving data |
| Player under test | `tests/harness/mse_harness.mjs` | Runs the real `pump`/`flushQueue`/`evictBehind` against a fake SourceBuffer with a ceiling. Do not write a second model of them |
diff --git a/devel-phases-next.md b/devel-phases-next.md
index db9267d..338beb2 100644
--- a/devel-phases-next.md
+++ b/devel-phases-next.md
@@ -1,7 +1,9 @@
# 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: **docs/meshbay-draft-v5.md** (v4 superseded 2026-08-13)
+> Architecture reference: **docs/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: **docs/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.**
>
@@ -300,27 +302,28 @@ and requests to `/app/` fall through to the hub.
### Caddy integration
-Recommended Caddyfile snippet for meshbay.org:
-```
-meshbay.org {
- root * /path/to/meshbay/site
- try_files {path} {path}.html
- file_server
+**The real configuration lives at `packaging/caddy/meshbay.org.Caddyfile`** (added
+2026-08-17). Use it, not the snippet this section used to carry.
- handle /v1/* {
- reverse_proxy localhost:8000
- }
- handle /app* {
- reverse_proxy localhost:8000
- }
- handle /style.css {
- reverse_proxy localhost:8000
- }
- handle /*.js {
- reverse_proxy localhost:8000
- }
-}
-```
+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)
@@ -785,17 +788,31 @@ metadata minimization, residual schema cleanup.
---
-## Phase 13 — Native desktop client (pywebview + aiortc)
+## Phase 13 — Native desktop client (Electron + optional Python sidecar)
-> **Status (2026-08-13): 13.1 active, 13.2–13.11 DEFERRED to after Phase 15**, pending
-> decision D2 in `tmp-decisions.md` (browser extension vs native client vs both).
+> **Reworked 2026-08-17 by operator decision. `docs/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.
>
-> **13.1 (platform adapter split) proceeds regardless** — it is pure refactoring whose
-> acceptance criterion is "the browser SPA behaves identically", and it is the prerequisite
-> for every option under D2.
+> **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 `docs/desktop-client-v1.md` §4.
-**Objective:** ship a desktop application with durable key storage, hub-independent
-`group://` access, and a better media path than the browser allows.
+**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.
@@ -803,31 +820,37 @@ metadata minimization, residual schema cleanup.
> 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. Native also *costs* the browser sandbox, hands you patch
-> velocity for WebKitGTK and every bundled dependency, and adds the loopback media server,
-> the IPC bridge and the updater as new attack surface.
+> 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 permanently closes **C4** as a side effect, but C4 can also be closed
-> in a browser by not storing keypair bundles remotely at all.
+> on product grounds. It closes **C4** for native devices, but **not for accounts that also
+> use a browser** — see `docs/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
+### Why this is cheap (re-measured 2026-08-17)
-The SPA never touches a browser crypto or network primitive directly: `app.js` contains
-**0** occurrences of `crypto.subtle` and **0** of `RTCPeerConnection`. All crypto and
-transport go through three injected globals (`window.MeshBayCrypto`, `MeshBayKeys`,
-`MeshBayTransport` — 16 call sites) and all hub I/O through one function (`hubFetch`, 30
-call sites). That is the seam.
+| 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 |
-| Asset | Lines | Native |
-|---|---|---|
-| `style.css`, `i18n.js`, `vendor/htm-preact.js` | 1708 | **reuse as-is** |
-| `app.js` — components, routing, theme, admin | ~2050 | **reuse as-is** |
-| `app.js` — storage glue, `hubFetch`, download/upload callbacks, MSE `VideoPlayer` | ~550 | rewrite |
-| `transport.js`, `crypto.js`, `keyderive.js` | 1145 | **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.
-≈ **69 % reused unchanged**, and the 31 % that is not is largely code `second-review.md`
-says to delete anyway (WebCrypto AES variant, PBKDF2 password split, keypair bundles).
+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
@@ -835,30 +858,48 @@ says to delete anyway (WebCrypto AES variant, PBKDF2 password split, keypair bun
`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 | Description |
-|---|---|---|
-| 13.1 | Platform adapter split | Extract `platform-web.js` (WebRTC/WebCrypto/fetch — today's behaviour) and `platform-native.js` (pywebview bridge). `app.js` imports neither directly. **Acceptance: the browser SPA is byte-for-byte functional after the split** — this lands first, on its own, with no native code |
-| 13.2 | pywebview shell + Python bridge | `meshbay-client` package; `window.pywebview.api.*` implements the same surface as the three globals; single-instance, tray, window state |
-| 13.3 | Local keystore + Ed25519 client auth — **the real close for C4**, now partially mitigated by Argon2id (draft-v5 §7.1) | Reuse `keystore.py` (Argon2id 256 MB, OS keychain later). Client authenticates like the daemon does: signed timestamp, `POST /v1/users/auth`. **No password on the wire, no `auth_key`/`bundle_key`, no keypair bundle anywhere** → closes **C4** permanently |
-| 13.4 | aiortc client transport | `RTCPeerConnection` + `createDataChannel` + `createOffer` in Python; ICE/STUN via `aioice` — the traversal path validated on 2 ISPs. Calls the unified handshake from 11.5.4. QUIC (`quic_client.py`) retained as opt-in for LAN / port-forwarded / hub-less `group://` |
-| 13.5 | Local index cache | SQLite in the client profile dir, replacing IndexedDB (also restricted under `file://` in some WebViews) |
-| 13.6 | Loopback media server | Python decrypts and serves with HTTP Range; `<video src="http://127.0.0.1:…">`. Drops MSE + the fMP4 remux for native (WebKitGTK MSE is unreliable). **Hardening is mandatory and mirrors C1: bind `127.0.0.1` only, random port, per-file capability token scoped to the session, no CORS, reject non-local `Origin`** |
-| 13.7 | Native file dialogs | Replace `showSaveFilePicker`; stream decrypted chunks to disk with constant memory |
-| 13.8 | Safety-number UI | Consumes 12.2: display and compare fingerprints, warn on key change |
-| 13.9 | Signed releases + verified updates | **Gate for GA.** GPG/minisign release key, client verifies before applying, documented key + revocation procedure. Without this the update channel becomes the new T3 |
-| 13.10 | Packaging | AppImage + Flatpak (Linux, primary), MSI (Windows), dmg (macOS) |
-| 13.11 | Decision point | Retire the browser SPA, or keep it explicitly labelled reduced-trust (12.6). Deferring is fine; deciding by accident is not |
+| # | 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. `docs/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 ·
-MSE path (`stream_init/data/end`, `_probe_video` remux) · `_bundleKey` in IndexedDB +
-`_sessionKeys` in sessionStorage + `_pkFromSk`.
+`_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 (`docs/desktop-client-v1.md` §5.1); the browser path needs them.
-**Kept regardless:** WebRTC/aiortc transport, hub signaling relay, DTLS channel binding.
+**Kept regardless:** WebRTC transport, hub signaling relay, DTLS channel binding.
These carry NAT traversal and are not browser workarounds.
---
@@ -890,17 +931,45 @@ a browser.**
| 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` | ✅ DONE — prints the admin UI URL and the `ssh -L` line. Does not open a browser |
| 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` | List configured groups with online status |
+| 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) | Daemon reloads config and adds/removes groups without restart |
+| 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` | Inspect and clear the persisted revocation denylist (11.5.17) |
-| 14.11 | `meshbay-node file rm` | The one operator action still requiring a browser |
+| 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 `docs/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
@@ -944,6 +1013,43 @@ Distribution must be **pairwise to identity keys**: wrap each sender key with EC
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 (`docs/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
@@ -952,57 +1058,126 @@ chat from the node operator, because on this platform the operator is a group me
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 `docs/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 `docs/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.1 | Node: sender key init | Generate sender key on group join, distribute to members |
-| 15.2 | Node: encrypt chat on send | Encrypt payload with sender's chain key before broadcast |
+| 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 member removal | Admin removes member → all remaining members rotate keys |
+| 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 supported version range, not just a single `v` field (L2 — today `v` is sent by everyone and checked by no one) |
-| 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 |
+| 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 (`docs/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.
-> **Shares the Phase 13 design:** local keystore, Ed25519 client auth, no keypair bundles,
-> aiortc-equivalent WebRTC for traversal (Android has a native WebRTC stack — prefer it over
-> `punch_nat`, for the same reason the desktop client does). Do not re-derive a second
-> crypto or auth model here.
+>
+> **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** (`docs/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,
-chat. No node functionality on mobile (client-only).
+**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.
-**Stack:** Kotlin native + Jetpack Compose. QUIC via `quiche` (Cloudflare, Rust
-JNI binding). Crypto via Bouncy Castle JVM. Same NAT traversal as desktop native
-clients (`punch_nat` + QUIC).
+### What the previous text got wrong
-| # | Component | Tech | Priority |
-|---|---|---|---|
-| 14.1 | Hub client (auth, groups, GEK) | Kotlin + Retrofit | High |
-| 14.2 | Crypto (Ed25519, X25519, ChaCha20) | Bouncy Castle JVM | High |
-| 14.3 | QUIC client | quiche (Rust JNI) | High |
-| 14.4 | NAT traversal (STUN + punch) | Kotlin native UDP | High |
-| 14.5 | File browser + download | Kotlin + streaming IO | High |
-| 14.6 | Chat UI | Jetpack Compose | Medium |
-| 14.7 | Contact list integration | Android Contacts API (permission-gated) | Medium |
-| 14.8 | Account creation from app | Registration flow + keypair bundle | High |
+| 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 |
-**Cross-device compatibility:** the user may switch between web and Android.
-The `keypair_bundle` (encrypted, stored on hub) enables this — same credentials,
-same keys on both platforms. Notification state and read markers should sync
-via hub (small encrypted blob per user, minimal storage).
+**Stack:** Kotlin + Jetpack Compose. **WebRTC via Android's native stack** — the traversal
+path, same as every other client. Crypto via Bouncy Castle JVM.
-**Upload from mobile:** posting photos/videos to a group. The mobile uploads to
-the group's node(s), not to the hub. The node stores it. MNP protocol extended
-with an `upload` message type for client→node push.
+**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.
-**Out of scope:** node functionality on mobile, Mac/iPhone support.
+### 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
+ (`docs/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.
---
@@ -1073,11 +1248,11 @@ community developers. Core functionality must be complete and stable first.
```
Phase 11.5 (Security remediation) ⛔ BLOCKING — nothing else starts
-Phase 13.1 (Platform adapter split) ← free refactor, unblocks every D2 option
+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–13.11 (Desktop client) ← DECIDED: offered alongside the browser SPA
+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
@@ -1099,9 +1274,14 @@ content over an unauthenticated HTTP port (C1), lets any user hijack a node's si
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 (platform adapter split) 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 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):**
@@ -1142,11 +1322,21 @@ touches none of the security surface.
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**, UI assets shipped inside the package and loaded from
- disk — never fetched from the hub, or T3 is not fixed. ✅ (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 `docs/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)
+ 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
diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md
new file mode 100644
index 0000000..b167525
--- /dev/null
+++ b/docs/desktop-client-v1.md
@@ -0,0 +1,1087 @@
+# MeshBay — Desktop Client (design)
+
+> Status: **proposal**. Decided in discussion with the operator on 2026-08-17;
+> nothing here is implemented. Supersedes structural decision 18 in
+> `devel-phases-next.md` (pywebview) and rewrites Phase 13.
+>
+> Read `docs/invite-pairing-v1.md` and `docs/per-node-identity-v1.md` first — this
+> document extends the roster and the pinning model, and it is wrong to read the
+> device-linking section without them.
+>
+> Follows the v5 convention: **every claim names the adversary it holds against**,
+> and where a property does not hold, it says so.
+
+---
+
+## 1. What is decided
+
+| # | Question | Decision |
+|---|---|---|
+| E1 | Shell | **Electron**, with an optional Python sidecar for `group://` over QUIC. Reverses structural decision 18 |
+| E2 | A second device on a node where the user is already pinned | **Device linking**: the already-pinned key countersigns, bound by a one-time code the new device generates. Neither the hub nor the node can substitute a key |
+| E3 | Account creation from the native client | **Hybrid**: register with a passphrase-derived `auth_key` (existing path, browser-compatible immediately), then authenticate day to day with a device Ed25519 key |
+| E4 | Node management | **Signed admin ops over MNP** for everything group-scoped, including GEK *rotation*. First run stays local. The loopback admin API is never exposed to the network |
+| E5 | LAN enrolment of a headless node | **Out of scope for v1**, implementable later as an isolated, self-closing enrolment door. Not needed for the desktop persona |
+| E6 | The browser SPA | **Stays.** Objective: a native client must not prevent web use — several devices, remote access |
+| E7 | A group's content | **Several named roots**, unique names, forming one virtual root. Not one directory (§6.7) |
+| E8 | Target filesystems and platform | **exFAT/NTFS and Windows are the common case**, not an edge case. Linux ships first; that is build order, not population (§6.8, §7.5) |
+| E9 | Where group-related server state lives | **On the node. Always.** Not on the hub — putting group state there breaks the model, whatever the feature |
+
+**E9 is the rule that settles future arguments**, so it is worth stating on its own:
+
+> If a feature seems to need server-side state about a group — its files, its members'
+> devices, what anyone watched, a catalogue, a search index — that state belongs **on the
+> node**. The hub holds accounts, group registry and membership, signaling, and nothing
+> else about content. It stores no file names for private groups and registers content
+> hashes for **public** groups only (H7).
+
+Everything decided in this document already obeys it: the roster, the device rows, the
+pending device requests, the invitations, the index, the per-root availability state and
+the chat all live on the node. Nothing here adds a row to the hub.
+
+Priorities carried from the operator's statement: (1) UI not served by the hub · client at
+feature parity for a standard user · confidential material stored locally · web keeps
+working · Linux (Ubuntu 24+, Fedora 44+) then Windows. (2) local node management ·
+`group://` without a hub. (3) automatic updates · Windows.
+
+**Read the build order carefully.** Linux first, Windows later — but most users are
+expected on Windows, sharing from exFAT or NTFS volumes. A design that is convenient on
+ext4 and merely *portable* to NTFS has it backwards.
+
+---
+
+## 2. Electron, and why the recorded decision changes
+
+### 2.1 The measurement that drove it
+
+Phase 13 was costed in August on figures that have since moved. Measured 2026-08-17:
+
+| Asset | Doc said | Actual |
+|---|---|---|
+| `app.js` | ~2600 lines | **4586** |
+| `crypto.subtle` in `app.js` | 0 | **2** (`_pkFromSk`, `app.js:157`) |
+| `RTCPeerConnection` in `app.js` | 0 | 0 — still true |
+| `hubFetch` | 30 call sites | 43 occurrences |
+
+The seam the phase was justified on has already leaked, and the surface to rewrite grew.
+`transport.js` (1242) + `crypto.js` (362) + `keyderive.js` (303) + `downloads.js` (295) +
+`sw.js` (68), plus the MSE `VideoPlayer` and the storage glue inside `app.js`, is on the
+order of **2500–3000 lines** to reimplement under pywebview — plus a loopback media
+server, native dialogs and a SQLite index cache.
+
+**That cost is not intrinsic to being native. It is intrinsic to replacing the engine.**
+
+### 2.2 What the SPA actually depends on
+
+Not "the web" in general — Chromium-class platform APIs:
+
+| API | Chromium (Electron) | WebKitGTK (pywebview on Linux) |
+|---|---|---|
+| `RTCPeerConnection`, DataChannel, DTLS fingerprints | ✅ unchanged | ❌ reimplement in aiortc |
+| WebCrypto X25519 / Ed25519 / AES-GCM | ✅ | ❌ partial → Python |
+| MSE / `SourceBuffer` | ✅ | ⚠️ unreliable → loopback media server (old 13.6) |
+| Service Worker (streamed download) | ✅ | ❌ |
+| File System Access | ✅ + native dialog | ❌ |
+| IndexedDB, WASM (Argon2id) | ✅ | ⚠️ restricted under `file://` |
+
+Under Electron, `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js`, `sw.js` and
+the MSE player **survive unchanged**. The native work reduces to a main process and the
+part that has real value: local key storage.
+
+### 2.3 Correction to the D2 comparison table
+
+`tmp-decisions.md` counts "no browser sandbox" against native. That is true of
+pywebview/WebKitGTK. It is **false for Electron** with `sandbox: true` and
+`contextIsolation: true`, which keep the Chromium renderer sandbox — the strongest one
+available. On that criterion Electron is better than the decision it replaces.
+
+### 2.4 What Electron costs, stated plainly
+
+1. **We own Chromium's patch cadence.** This is the real cost. The renderer parses
+ decrypted content from nodes — video, images, filenames — which is attacker-controlled
+ input. A critical Chromium CVE every few months means a rebuild and a push. Mitigated
+ by distro repositories (§7.4) and CI rebuilding on each Electron release, not
+ eliminated.
+2. **npm enters the project.** Bounded: the UI has no build step (Preact + htm as native
+ ESM), so the dependency tree is Electron plus a packager.
+3. **~180 MB installed**, against ~90–120 MB for a Python shell with `av`/aiortc. Real,
+ not decisive.
+
+Tauri was considered and rejected: it uses the system webview, so it inherits every
+WebKitGTK problem above and adds a third language.
+
+### 2.5 The sidecar
+
+`group://` without a hub needs QUIC to a node's address. There is no usable equivalent in
+JS, and a manual SDP exchange is excluded (no copy-paste path, and the operator constraint
+forbids a browser on the node host). So hub-less access means reusing `quic_client.py` in
+a Python sidecar spoken to over loopback with a token.
+
+On Linux this is natural: the desktop package **depends on `python3-meshbay-common`**,
+which is already packaged. On Windows it means an embedded Python — consistent with
+Windows being priority 3.
+
+The sidecar is also what makes local management work on a LAN with no internet (§6.4).
+
+### 2.6 Shipping the UI introduces version skew — the tax nobody budgeted
+
+This is not an Electron property. It follows from shipping the interface at all, and it is
+the largest **recurring** cost of this phase — larger than the UI work, which is close to
+zero.
+
+Today the SPA and the hub deploy together and are always in sync: a `/v1/` response shape
+can change and `app.js` is fixed in the same commit, because the hub serves both. The
+moment the UI lives inside a package, **an old client meets a new hub** — for the first
+time in this project's life.
+
+Consequences to carry deliberately:
+
+- **`/v1/` becomes a compatibility surface.** Response shapes cannot change freely. The
+ N-2 MINOR rule already stated for MNP and MHP now applies to the hub API in practice.
+- **`GET /v1/hub/version` (10.10) should carry a minimum supported client version**, and
+ the client should warn below it and refuse below a hard floor. That is cheap to add now
+ and awkward to retrofit once clients exist in the wild.
+- A user running the desktop client and the web SPA against the same hub is running **two
+ versions of the same code at once**. They must agree on protocol, not on build.
+
+Decide this before the first public package, not after.
+
+### 2.7 One UI, one source
+
+`packages/meshbay-hub/src/meshbay_hub/static/` stays the single source of truth. The
+desktop package **copies** it at build time; it is never forked into a second tree. A
+silent fork is the only real way to end up maintaining the interface twice, so it should
+fail the build rather than be caught by discipline.
+
+Features that only make sense natively — the node management panel, local folder
+selection — live in the same codebase behind a capability check
+(`platform.capabilities.nodeAdmin`, `localFolders`). The browser simply does not render
+them. There is no second application.
+
+---
+
+## 3. What the shell must do
+
+Non-negotiable, carried from structural decision 18: **UI assets ship inside the package
+and load from disk.** A shell pointing a WebView at the hub's `/app/` is a browser with a
+different icon and fixes nothing.
+
+| Requirement | Why |
+|---|---|
+| `contextIsolation: true`, `sandbox: true`, `nodeIntegration: false` | keeps the renderer sandbox; the preload exposes a narrow, enumerated API |
+| Custom `app://` protocol via `protocol.handle`, **not** `file://` | service workers, ES modules and IndexedDB misbehave under `file://`; also gives a stable origin across updates |
+| `registerSchemesAsPrivileged` with `standard`, `secure`, `supportFetchAPI` and `stream` | a scheme without these is not a secure context, so **the service worker silently refuses to register** and streamed downloads break with no error — the same failure mode as an uncontrolled page, already learned once |
+| Strict CSP in-package, **keeping `wasm-unsafe-eval` in `script-src`** | the bundle KDF is Argon2id in WebAssembly; a policy forbidding it locks every user out of their keys |
+| `will-navigate` and `setWindowOpenHandler` refuse every non-local target | the hub must never become the document origin |
+| Electron version pinned to one whose Chromium has X25519 and Ed25519 in WebCrypto | the SPA uses both; **verify on the target version, do not assume** |
+
+The single structural change to the SPA: `HUB = ''` (`app.js:12`) becomes a configurable
+absolute base URL. That is what the old 13.1 platform split existed to do, and under
+Electron it is nearly all of it.
+
+---
+
+## 4. Device linking
+
+### 4.1 The problem
+
+Identity keys are per node (`docs/per-node-identity-v1.md`). A browser keeps nothing
+durable, so its identity is left with the node, encrypted under the passphrase — that is
+what lets a second browser work, and it is finding **C4**.
+
+A native client that keeps its keys locally has no bundle. Today it would be refused:
+
+```python
+# webrtc_server.py:886-894
+known = await roster.get_identity(user_id)
+if known:
+ if known["pk_ed25519"] != pk_ed_b64 or known["pk_x25519"] != pk_x_b64:
+ self._join_refuse("key_changed", ...)
+```
+
+and `roster.py` declares `identities(user_id TEXT PRIMARY KEY)` with `pin_identity` doing
+`INSERT OR REPLACE`. **One key per person per node**, and a silent overwrite if that ever
+changed.
+
+Without a linking mechanism, a user with a browser and a native client needs an operator
+code per node per device — and, worse, an account *created* natively would need an
+operator code before its owner could ever use a browser. E6 would be dead on arrival.
+
+### 4.2 Principle
+
+The authority is **a key the node pinned itself**, exactly as with `_verify_admin_sig`.
+The hub cannot produce it: the hub has stored no user keys since 2026-08-14
+(`users.pk_ed25519` / `pk_x25519` dropped, `PUT /me/keys` gone). So device linking adds
+**no hub-reachable authority**.
+
+The approval must not rest on a human comparing digits. Phase 12.1 abandoned safety
+numbers as "correct, unusable as the default", and reintroducing them here through the
+back door would be the same mistake. Instead the approval is bound by a **one-time code
+the new device generates and displays** — the same primitive as an invitation, with the
+code produced by the joining device rather than by the operator.
+
+### 4.3 Flow
+
+```
+native generate 40 bits CSPRNG → display 7K2M-BQ84
+ code_hash = sha256(code ‖ new_pk_ed25519 ‖ new_pk_x25519)
+native → device_add_request {new_pk_ed25519, new_pk_x25519, code_hash, sig_new}
+ node ← pre-proof window; stored as a pending row, TTL 1 h
+browser "Add a device" → the user types 7K2M-BQ84
+browser ← the node returns pending candidates for this user_id (req_id + both keys)
+ node the client recomputes sha256(code ‖ keys) and keeps the row that matches
+browser → device_add {req_id, sig_pinned}
+ node
+node verify sig_new, verify sig_pinned against a live pinned key of this user,
+ insert a device row, audit the event
+```
+
+Binding the keys *into* the code hash is what stops the node from substituting them: a
+node returning different keys produces no match, and the client refuses before signing.
+The fingerprint is still displayed on both ends, as a secondary check — **not**
+load-bearing.
+
+### 4.4 Transcripts
+
+```
+"meshbay:device_req:v1" ‖ len‖node_pk ‖ len‖user_id ‖ len‖new_pk_ed25519
+ ‖ len‖new_pk_x25519 ‖ len‖code_hash ‖ len‖nonce_s ‖ len‖ts
+
+"meshbay:device_add:v1" ‖ len‖node_pk ‖ len‖user_id ‖ len‖new_pk_ed25519
+ ‖ len‖new_pk_x25519 ‖ len‖nonce_s ‖ len‖ts
+```
+
+Length-prefixed and domain-separated per 11.5.21. `node_pk` binds an authorization to one
+node; `nonce_s` — the handshake nonce of the connection carrying the message — stops a
+replay onto another connection. The code is a bearer secret: never signed, never echoed,
+stored only as the hash.
+
+### 4.5 Schema change
+
+```sql
+-- identities: PRIMARY KEY (user_id) → PRIMARY KEY (user_id, pk_ed25519)
+-- + label TEXT, added_at TEXT, added_by_pk TEXT, revoked_at TEXT
+```
+
+`pin_identity` can no longer be `INSERT OR REPLACE` — today that silently overwrites, and
+with multiple devices it becomes a hole. `key_changed` becomes "this key is not among
+this user's devices".
+
+New table `device_requests(req_id, user_id, pk_ed25519, pk_x25519, code_hash, expires_at,
+created_at)`, covered by the existing `purge_expired()`.
+
+### 4.6 Bounds
+
+- **TTL 1 h**, configurable as `[node] device_request_ttl_minutes`, beside the existing
+ `invite_ttl_hours` (7 d) and `pair_ttl_hours` (24 h).
+- Only a `user_id` that already has at least one pinned identity may file a request. This
+ is anti-spam, **not** a security boundary — the filing key is unpinned by construction.
+- 5 attempts per connection and a node-wide lockout on failures, as for invitations.
+ Guessing is 40 uniformly random bits, single use, one account.
+- Cap devices per user (5 by default). The countersigning key must not be revoked.
+- Every filing and every approval is an audit event. `member unpin <user>` removes **all**
+ their devices; add `member device list|revoke` to the operator surface.
+
+### 4.7 What this does not fix
+
+**A device chain inherits the weakness of its weakest ancestor.** Whoever cracks a
+browser's keypair bundle (C4) can add a device — and that device **survives a passphrase
+change**. That is a genuinely new risk, not a relocation. It is answered by visibility,
+not by cryptography: audit events, devices listed to the operator and to the user, and
+unpin removing the lot.
+
+**Approval from a browser inherits T3.** A hub shipping malicious SPA code reads the code
+the user types into it and approves its own request. This is unchanged by anything here
+and is the reason Phase 13 exists. Its practical consequence: the **first** browser →
+native link is the moment of highest exposure for an account, and it happens once. Two
+free mitigations — the operator's invitation code remains available for anyone wanting
+out-of-band assurance, and an account created natively (§5) does the first link in the
+safe direction.
+
+### 4.8 Authorship: who said it, who uploaded it
+
+**Requirement added 2026-08-17.** Chat senders must be **cryptographically authenticated to
+each other**, and an upload must have an **identifiable owner** who can delete it, as the
+operator can. An earlier draft of this document recorded node-trusted attribution as an
+accepted limitation. It is now a requirement, and device linking both forces the work and
+supplies most of the machinery.
+
+#### A. Device linking breaks the existing owner-delete path — fix it in the same change
+
+```python
+# webrtc_server.py:1805-1817, _admin_exec_file_delete
+uploader_pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(entry.uploader_pk))
+if not (await self._verify_admin_sig(transcript, sig)
+ or self._verify_sig(uploader_pk, transcript, sig)):
+```
+
+Authorization is against **the exact key that uploaded**. Alice uploads from her phone and
+cannot delete from her desktop. So:
+
+- Authorize against **any non-revoked device of `entry.uploader_id`** in the roster.
+ `uploader_id` is already recorded beside `uploader_pk` (`_register_uploader`,
+ `webrtc_server.py:1626`), so the data is there.
+- Keep `uploader_pk` — it stops being the authorization key and becomes the **audit**
+ record of which device did it.
+- **This stays roster-rooted, not token-rooted**, which is the protection
+ `per-node-identity-v1.md` added: a hub minting a token that claims to be Alice still
+ holds no key the node pinned for Alice, so the signature fails. Whoever issues tokens
+ still does not decide who may delete a file.
+- Ownership must survive device revocation — a revoked laptop's uploads keep their owner —
+ and survive the account losing every device, where the operator remains able to delete.
+
+#### B. Make ownership provable, not merely recorded
+
+Today ownership is an assertion by the node. The uploader should **sign it**:
+
+```
+"meshbay:upload:v1" ‖ len‖node_pk ‖ len‖group_id ‖ len‖root ‖ len‖rel_path
+ ‖ len‖content_hash ‖ len‖user_id ‖ len‖ts
+```
+
+signed by the uploading device's key and stored with the index entry. Then ownership is
+verifiable by any member rather than taken on the node's word, the delete authorization has
+a signed record behind it, and — combined with the no-overwrite rule — the C5a path where
+overwriting a file made the attacker its recorded uploader is closed twice over. The
+content hash already exists.
+
+Chat attachments are ordinary uploads and get the same record, which is one reason they
+land in the designated upload root (§6.7).
+
+#### C. Chat sender authentication — the constraint first
+
+**Operator constraint, 2026-08-17: no new code exchanges between people.** Verification
+rituals between users are refused in this context, and that single rule decides the shape.
+
+It costs almost nothing, because the complexity here is in the implementation, not in the
+experience:
+
+| | What the user does in addition |
+|---|---|
+| Tier 1 — sign each message with the device key | **nothing**, entirely invisible |
+| Tier 2 — pin `account → device keys` on first sight | **nothing** in normal operation; a notice only when a key changes |
+| Tier 3 — operator-signed roster attestation | **nothing**; the operator's key is already pinned |
+| ~~Safety numbers~~ | compare digits with every contact ← **the only thing that adds an exchange** |
+
+The device-linking code is not an exchange between people: it is a code between **your own
+devices**, and it was already accepted. Nothing new appears between users.
+
+**Decided:** ship Tiers 1 and 2. **Defer Tier 3** — it changes nothing a user sees and can
+land at any time; it only covers "the node is compromised but the operator's key is not",
+which is real but narrow, since in most deployments the operator *is* the machine.
+**Safety numbers are refused for this purpose**, permanently.
+
+Total user-visible cost of the whole authorship requirement: **one notice**, "this
+account's key changed". Everything else — signing, pinning, upload records, delete by
+account — is invisible. And pinning is not a new concept: it is the third application of
+the one already used for `pk_node` (11.5.8).
+
+#### C bis. The tiers themselves
+
+**Tier 1 — sign every message with the sender's device key.** Independent of Phase 15:
+signing is not encryption, so this can land first and should. It gains: no member can forge
+another member; someone holding a stolen chat database cannot *extend* it with messages
+that verify; the node can no longer silently rewrite history. Today nothing but the node's
+own enforcement (NS6) stands between a member and impersonation.
+
+**Tier 2 — clients pin `account → device keys` on first sight.** The evidence already
+exists: **device linking produces a countersignature** — a statement, signed by an
+already-pinned key, that a new key belongs to the same account. Relay those with the
+roster and a member who has pinned Alice's first device can verify her second without
+trusting the node. A node substituting a key for an account you have already seen is then
+**detected**, not merely improbable.
+
+**Tier 3 — close first sight. Deferred.** The operator signs a roster attestation
+(`account → devices`) and members pin the operator's key, which protects when the **node is
+compromised but the operator's key is not** — a seized machine, a hosting provider, a
+subverted daemon, the same threat Sender Keys was scoped for. Deferred because it changes
+nothing a user sees and can be added later without touching Tiers 1 and 2.
+
+Safety numbers were the other candidate. **Refused** under the no-new-code-exchanges
+constraint: Phase 12.1 dropped them for key *delivery* and left the door open for identity
+verification, and that door is now closed for chat.
+
+#### D. The boundary that remains, stated per the convention
+
+Against **the node operator at first contact**, attribution cannot be made unforgeable:
+they run admission, and a member who has never seen Alice has nothing to compare against.
+But the property is better than "the operator can always impersonate anyone":
+
+> Once a member's client has pinned Alice's device key, **an operator who turns malicious
+> later cannot forge Alice to that member.** Forgery is limited to accounts the victim has
+> never seen.
+
+That is a real property, it is the same shape as `pk_node` TOFU pinning (11.5.8), and it is
+worth stating precisely rather than rounding to either extreme.
+
+#### E. Where this lands
+
+A is a **regression introduced by device linking**, so it ships with Stage C, not later.
+B and Tier 1 are small and independent of Phase 15 — they should land in the same stage.
+Tier 2 belongs with Phase 15, whose threat delta it changes. Tier 3 is deferred with no
+date, and nothing depends on it.
+
+---
+
+## 5. Account creation and authentication
+
+`POST /v1/users/register` (`api/users.py:100`) accepts `username` + `auth_key` **or**
+`password`, plus email; `auth_key` is PBKDF2-SHA512 600k derived client-side, so the hub
+never sees a passphrase (T1). There is **no** `POST /v1/users/auth` today — the old 13.3
+assumed one; it has to be written.
+
+**Decision E3 — hybrid:**
+
+1. Registration derives `auth_key` from a passphrase exactly as the browser does, using
+ the existing endpoint. The account is browser-compatible from the first minute, and
+ the passphrase is the account's **only recovery path** — a native-only account whose
+ local keystore is lost would otherwise be dead.
+2. Day-to-day authentication uses a device Ed25519 key registered with the hub, on the
+ pattern of `POST /v1/nodes/auth` (signed timestamp) which already exists for the
+ daemon. The passphrase is entered at install and at recovery, and stored nowhere.
+3. Identity keys for nodes are generated locally and **never bundled anywhere**.
+
+`keyderive.py` already implements the browser's derivation in Python and
+`test_bundle_kdf_parity.py` holds the two byte-identical. That test now covers a third
+consumer; the standing warning applies — **never change those parameters in one place**.
+
+### 5.1 What this means for C4
+
+C4 closes for an account **only when no browser needs a bundle on that node**. Two
+consequences to state rather than blur:
+
+- The native device's private key is in no bundle anywhere, so an operator cannot attack
+ it at all. That part is closed unconditionally.
+- As long as the account is also used from a browser, a bundle exists on each node and C4
+ stays open for the browser identity — which is admitted to the same groups and reads the
+ same content. **The native client raises the ceiling, not the floor.**
+- Closing it fully needs an opt-out: `device_policy {allow_bundle: false}`, **signed by a
+ pinned key** so the decision is the user's and not the hub's. The node then deletes the
+ stored bundle and refuses to store another for that user.
+
+---
+
+## 6. Node management
+
+### 6.1 The observation
+
+The roster-pinned operator key is a **better authenticator than the loopback admin API's
+token**. The token is a bearer string printed at startup; the operator key is an Ed25519
+identity bound by a one-time code, signing structured transcripts with a 120 s TTL and
+channel binding. Moving operator actions from the loopback API to signed MNP ops is a
+**strengthening**, not a relaxation.
+
+Existing ops (`meshbay_common/adminop.py`): `file_delete`, `dir_delete`, `invite_create`,
+`member_revoke`.
+
+### 6.2 GEK rotation over MNP — and the line that stays
+
+Draft-v5 §5.1 says *"nothing arriving over MNP can **activate** a GEK"*. That rule targets
+C5b — **a member supplying key material** the node adopts. An operator-signed instruction
+where **the node generates the key with its own CSPRNG** is a different shape: nothing
+arrives from outside, and the node remains the sole producer of every copy of the GEK,
+which is the §5.5 property.
+
+So `gek_rotate` may be a signed MNP op. Blast radius is small: the same key already
+authorizes `member_revoke` and `dir_delete` remotely, and rotation is self-healing because
+wrapping happens per connection.
+
+**The initial `gek-init` is different** and stays local. Without a GEK, `join_result`
+answers `no_gek` and no MNP session completes — no session, no signed op. Putting it in
+the pre-proof window is possible but is the neighbourhood where C4 and C5b were born; it
+is deferred (§11), and it costs only the headless case which is deferred anyway.
+
+### 6.3 Scope
+
+| | Reachable remotely | Note |
+|---|---|---|
+| Invitations, revoke, unpin, devices, file/dir delete, roster, audit, peers | ✅ signed MNP | mostly present already |
+| **GEK rotation** | ✅ signed MNP | node generates; nobody supplies |
+| Attaching a further group, a bounded set of config keys | ✅ signed MNP, empty `group_id` | the precedent exists: `roster.get_member("", user_id)` (`webrtc_server.py:899`) already authorizes the operator node-wide |
+| **First `gek-init`** | ❌ local | §6.2 |
+| Starting/stopping the daemon | ❌ | that is systemd's job; if the daemon is down nothing is reachable anyway |
+
+The node's loopback admin API (20 endpoints in `ui/app.py`, including `groups/attach` and
+`groups/{id}/gek`) **is never exposed to the network**. C1 and C6 were both "a second path
+into the node with its own weaker handshake"; a LAN-exposed admin API with a second
+authorization model beside the roster is that shape exactly.
+
+### 6.4 The honest limit
+
+MNP between client and node is established through the **hub's WebRTC signaling**. On a
+LAN, ICE picks a local candidate so the data path stays local — but setup needs the hub.
+**A LAN with no internet cannot manage its node over MNP.** The answer is direct QUIC
+`group://` to a local address, i.e. the sidecar (§2.5). That is what makes the sidecar
+worth more than a nice-to-have: it is the hub-independent local path, and the one that
+survives a hub that is down or hostile.
+
+### 6.5 Several nodes
+
+The client keeps a local node registry — identifier, pinned `pk_node`, hosted groups,
+last seen. This is also where the 11.5.8 TOFU pin belongs, so design it once.
+
+### 6.6 One implementation, three adapters — and Phase 14 comes first
+
+**Phase 14 (node CLI) should be finished before 13.12.** Two reasons, and the second
+changes how.
+
+1. The CLI is the only interface that works in the states the desktop client cannot
+ reach: daemon stopped, no GEK yet, no operator paired. Those are precisely the states
+ E5 and O1 send back to the local machine. An incomplete CLI leaves bootstrap holes
+ with no fallback. Remaining: 14.2 `group list`, 14.3 `group create`, 14.4 `group
+ join`, 14.8 SIGHUP reload, 14.10 `denylist`, 14.11 `file rm`.
+2. **Parity is not achieved by writing the same thing twice.** The CLI calls the loopback
+ admin API (`_daemon_api()` in `daemon.py`); 13.12 would add MNP handlers for the same
+ operations. Two paths to one operation, with different authorization, is the shape of
+ **C1 and C6**.
+
+So each operation should be factored into **one internal module** — `meshbay_node/ops.py`
+— with the CLI, the loopback API and the MNP handler as three thin adapters over it.
+Parity becomes structural rather than a discipline, authorization checks live in one
+place, tests have one target, and 13.12 becomes adapter code rather than a second
+implementation.
+
+The refactor costs less now, with six commands left, than after 13.12 exists.
+
+### 6.7 Several roots per group
+
+**Decided 2026-08-17.** A group's content is not one directory but **a set of named
+roots**, each mapping to a local directory. Names are unique within the group and a
+duplicate is refused. The group's virtual root is the union of them:
+
+```
+/ (group virtual root)
+├── Films/ → D:\Media\Films
+├── Musique/ → E:\Audio (external drive)
+└── Documents/ → C:\Users\me\Partage
+```
+
+`shared_dir` (one string per group, `config.py:103`) becomes a list of
+`{name, path, kind}` entries. A bare `shared_dir` in an existing config migrates to a
+single root.
+
+**Every index path gains a root segment**, uniformly — including in a single-root
+deployment, so there is one code path and not two. Consequences, all one-time and all
+worth accepting:
+
+- Existing deployments see every path change once (`/film.mkv` → `/Films/film.mkv`).
+ Resume positions, keyed by path in `localStorage`, are invalidated. The alternative —
+ leaving a lone root unprefixed — buys nothing and doubles the path logic forever.
+- **MNP messages do not change shape**, only the data they carry, so this is a MINOR bump
+ (0.2 → 0.3) alongside the new `device_*` and `gek_rotate` messages, not a MAJOR one. An
+ 0.2 client asking for a path that no longer exists gets a clean not-found, not a
+ protocol error.
+- **The hub needs no change at all.** `SwarmSource` carries `content_hash`, `node_id` and
+ `endpoint` — **no paths, no filenames** (`db/models.py:181`), and private groups
+ register nothing (H7). Verified 2026-08-17. Multi-root is a node + protocol + UI change,
+ end to end.
+
+**The name is the chosen directory's basename** (decided 2026-08-17), so adding a root is
+one gesture with no naming step. Four rules make that safe:
+
+- **Derived once, at add time, then stored.** Never recomputed from the path at startup —
+ otherwise renaming `D:\Films` to `D:\Cinéma` on disk silently re-identifies the whole
+ library and breaks every stored reference to it.
+- **A duplicate basename is refused**, compared **case-insensitively** (§6.8). Collisions
+ will be common in practice: `D:\Films` and `E:\Films`, `C:\Users\me\Music` and
+ `D:\Music`. Refusing is correct, but "rename your folder on disk" is a poor answer to
+ give a user, so an explicit **alias** should be available as the escape hatch (O11).
+- **The basename becomes a path segment every member sees**, including Windows clients
+ downloading. It must therefore pass the portability rules itself: a Linux user sharing
+ `/home/me/AUX` would create a root Windows cannot write to (§6.8). Failing that check
+ forces an alias.
+- **Uploads go to a root the operator designates.** Chat attachments land there too
+ (draft-v5 §5.2). If that root is unavailable the upload **fails with a clear reason** and
+ never silently falls back to another root; if none is designated, uploads are refused
+ rather than guessed.
+
+The requirement comes from what is planned on top of the file explorer and chat — a
+video library, an audio library — which will not live in one folder on one disk. The
+unique-name rule is what keeps the union well defined: it removes the ambiguous case
+where the same relative path exists under two roots, which is the hard part of any union
+mount, and it should be enforced at config load **and** at selection time.
+
+What it costs, and none of it is optional:
+
+1. **The root name is part of a file's identity.** Every index path becomes
+ `<root-name>/<relative-path>`. Renaming a root rewrites every path under it and churns
+ the whole subtree of the index — so renaming is an explicit, warned operation, not a
+ cosmetic setting.
+2. **No root may contain another.** `D:\Media` and `D:\Media\Films` mounted together
+ would index the same bytes twice under two identities. Canonicalize each root and
+ refuse a prefix relationship — **case-insensitively**, since on NTFS and exFAT
+ `d:\media` and `D:\Media` are the same directory (§6.8).
+3. **Availability is per root, and this is the good news.** With one directory, an
+ unplugged disk was a hazard. With named roots it becomes a supported state: `Musique/`
+ goes unavailable, its subtree freezes, and `Films/` and `Documents/` carry on. The
+ "unavailable, not deleted" state (§6.9) is therefore designed **per root** from the
+ start, not retrofitted.
+4. **Free space, quotas and capacity are per root** — different volumes. Anything the UI
+ says about space has to name which root it is talking about.
+5. **Path resolution must be per root.** A file request names `<root>/<path>`; the node
+ resolves it against that root's canonical path and refuses anything escaping it. One
+ implementation, in the indexer, not one per transport. The quarantine and no-overwrite
+ rules (draft-v5 §5.2) are likewise per root.
+6. **Reserve `kind` on each root** (`generic` | `video` | `audio` | `photo`), unused in
+ v1 — a default view hint, nothing more. See §6.10.
+
+### 6.8 Filesystem portability is a first-class constraint
+
+**Corrected 2026-08-17.** An earlier draft of this document filed exFAT and NTFS under
+"accessory". That was wrong: sharing from an external drive formatted exFAT or NTFS is
+expected to be the **common** case, not the exception, and Windows is expected to be the
+majority platform for both client and node (§7.5).
+
+So the index and the upload path must be correct on case-insensitive, permission-less
+filesystems — this is not a compatibility note, it is a correctness requirement.
+
+| Property | What it breaks | What has to be true |
+|---|---|---|
+| **Case-insensitive, case-preserving** (NTFS, exFAT) | `Film.mkv` and `film.mkv` cannot coexist; an index built on ext4 can be unrepresentable on NTFS | The index needs a defined canonical identity and a **case-folding collision check** at scan time, reported to the operator rather than resolved silently |
+| **No-overwrite on upload** (C5a) | Nothing — see the correction below | `Path.exists()` is case-insensitive on the filesystems where it matters, so the existing check already holds |
+| **Unicode normalization** | `Café.mkv` written on macOS (NFD) and on Windows (NFC) are different byte strings and compare unequal. For French filenames this is routine, not exotic | Normalize to NFC **for comparison and index identity**; preserve the original bytes for display and for opening the file |
+| **Reserved names and characters** (Windows) | `CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`, the characters `< > : " / \ | ? *`, trailing dots and spaces. A group indexed from a Linux node can contain names Windows cannot create | The client sanitizes on save and **tells the user it renamed something**; the upload allowlist should be the intersection across platforms, or some files are simply undownloadable on Windows |
+| **MAX_PATH 260** (Windows, unless long paths are enabled) | A deep media library exceeds it | Use `\\?\`-prefixed paths in the node and the client on Windows |
+| **Timestamp granularity** (FAT/exFAT: 2 s, local time, no timezone) | mtime as a change-detection fast path produces spurious rescans and, worse, missed changes across a DST shift | Do not trust mtime alone on exFAT — size + mtime with tolerance, and rehash when in doubt |
+| **No symlinks** | Nothing. This is a *simplification*: no traversal out of the root to defend against | — |
+| **No POSIX permissions or ownership** | Nothing, since the node runs as the user and access is all-or-nothing per volume | — |
+
+**Correction (2026-08-18, found while implementing).** An earlier version of this
+section called the no-overwrite check a security bug on case-insensitive
+filesystems. It is not: `_free_name` uses `Path.exists()`, and on NTFS and exFAT
+`stat()` is itself case-insensitive, so an upload of `readme.txt` beside an
+existing `README.TXT` already gets a free name rather than replacing it. C5a is
+not reachable through the filesystem. What case folding is actually needed for is
+**index identity, collision reporting and root names** — where a comparison the
+code performs itself, rather than one the OS performs, decides whether two names
+are the same file.
+
+Two watcher consequences, one per platform:
+
+- **Windows**: watchdog uses `ReadDirectoryChangesW`, whose buffer overflows under a burst
+ of changes and **drops events silently**. A periodic reconciliation scan is not optional
+ there; it is the only thing that recovers a missed event.
+- **Linux**: exFAT may be mounted through FUSE, where inotify does not reliably report
+ changes made outside the FUSE mount. Same answer: reconcile periodically.
+
+### 6.9 A root that goes away must freeze, not empty
+
+`indexer/indexer.py` runs a watchdog `Observer` and rebuilds on any change. When a volume
+is unmounted, either watchdog emits deletions for the whole tree or the next scan sees an
+empty directory — both end with that content's index emptied and the deletions propagated
+as though the owner had erased their library.
+
+The node needs an explicit **per-root `unavailable` state** that freezes the subtree
+instead of emptying it, and surfaces it to members as "temporarily unavailable" rather
+than "gone". **This ships before root selection is offered.**
+
+Detecting it differs by platform, and neither method is exotic:
+
+- **Windows**: `WM_DEVICECHANGE` announces volume arrival and removal; the volume is
+ identified by `\\?\Volume{GUID}\`, never by drive letter, which moves.
+- **Linux**: the mount table, plus `RequiresMountsFor=` on the unit so systemd orders the
+ service after the mount. Note a Linux-only trap that has no Windows equivalent: a unit
+ with `ProtectSystem` / `PrivateTmp` has its own mount namespace, so a volume mounted on
+ the host **after** the service started is invisible inside it — the directory reads as
+ empty even when everything else is configured correctly.
+
+### 6.10 The planned libraries are views, not a catalogue
+
+**Scope settled 2026-08-17, and it is deliberately small.** The planned "video library"
+and "audio library" are **alternative views over the existing file index** — a variant of
+the explorer. Presentation rests on the filename and the directory structure, plus a
+thumbnail for video. There is **no catalogue, no metadata store, and no server-side
+database, now or later**, and nothing about them reaches the hub.
+
+An earlier draft of this section over-designed against a richer feature than the one
+wanted. Retracted: the `layout` field, the per-root scan policy, and "a file is not an
+item". The governing rule is the opposite of that last one, and simpler:
+
+> **A file stays tied to its representation on the filesystem.** The view shows files and
+> folders. Subdirectories are subdirectories — a "category", a "style", an "album" is
+> whatever the folder is called. There is no item abstraction to keep in sync, and moving
+> a file on disk makes it a different file, exactly as it does in the explorer today.
+
+#### What this means is already true
+
+| A view needs | Where it comes from | Work required |
+|---|---|---|
+| The whole group's file list, client-side | already cached in IndexedDB (10b.5), already searched client-side across groups (10b.6) | none |
+| "Is this a video / audio file" | file extension, decided in the client | none |
+| Folder hierarchy as categories or albums | the index already carries paths | none |
+| A subtitle beside a video | a same-named `.srt` is fetched like any other file; the video view can hide it from the grid and offer it as a track | presentation only |
+| A thumbnail for a video | the one thing that is not free — see below | one decision |
+
+So the answer to "is anything blocking?" is **almost nothing, and the architecture is
+already in the right shape**. Four properties have to survive, and all four hold today:
+the whole-group index is available client-side; files are identified by their path; there
+is no server-side catalogue to build; and the hub is not involved.
+
+#### The one open point: video thumbnails
+
+There will usually be no embedded cover art — these are raw files. So a thumbnail is a
+frame grab, which is genuinely derived data, and draft-v5 §5.2 forbids the node keeping a
+second copy of an image it already holds.
+
+**The resolution follows the rule already in force for chat thumbnails**, and it is the
+ridge line being looked for:
+
+> Enrichment happens **on the client**, from data it already has. Anything the client
+> cannot compute is produced **on demand by the node** and cached **on the device that
+> asked**. Neither the node nor the hub keeps durable derived state.
+
+Concretely: the node grabs a frame on request — it already decodes video for streaming, so
+this is strictly less than it does today, over the same authorized path (GEK proof, group
+membership, same transport) with no new capability and no new authorization surface. The
+client caches the result locally, per device, disposable, never leaving it.
+
+That keeps the node free of a thumbnail store, keeps §5.2 intact, and is what makes
+"no server-side state" affordable rather than merely principled.
+
+**One operational rule, and it is not optional.** A grid of fifty videos means fifty frame
+grabs. Thumbnails must run in **their own small bounded pool with a short timeout, never
+in the stream pool** (`[node] max_concurrent_streams`, default 8), where a slot is held
+for the length of a film — one grid view would otherwise exhaust every streaming slot on
+the node. And the pipe-draining rule from the streaming work applies unchanged: drain
+stdout, wait with a timeout, release the slot regardless. A thumbnail is a short ffmpeg
+run, which is exactly the shape that has deadlocked this codebase before.
+
+#### The mistake to refuse
+
+"Search across my whole media library" is the tempting one, and it would be a real
+regression: the hub stores **no file names for private groups** and registers content
+hashes for **public** groups only (H7). Cross-device resume is the same shape — CLAUDE.md
+pins it as *"nothing new learns what you watch"*. Any library feature that seems to want a
+server-side index wants the **node**, and most of the time wants nothing at all.
+
+---
+
+## 7. Packaging and first run
+
+### 7.1 What already exists, and why it matters
+
+`packaging/systemd/meshbay-node.service` is a **per-user template** (`User=%i`,
+`WorkingDirectory=%h`, state under `~/.local/share/meshbay`, config under
+`~/.config/meshbay`), and `meshbay-node.spec` has **no `%post` scriptlets**.
+
+Consequence: in the standard case — client and node on the same machine — both run under
+the **same UID**. The client reads `data_dir/pair-code` and the loopback session token
+straight off the filesystem. No SSH, no polkit, no root. First `gek-init` and operator
+pairing happen from the client, locally, with no terminal.
+
+### 7.2 An ambiguity to settle
+
+As written, `User=%i` / `Group=%i` makes it a **system** template: `sudo systemctl enable
+--now meshbay-node@alice`. A true user unit cannot carry `User=`. The in-file comment says
+"per-user service"; the directive says system. Ship **both**:
+
+| Unit | Enabled by | Persona |
+|---|---|---|
+| system template, `User=%i` | `sudo systemctl enable --now meshbay-node@alice` | server — an ordinary admin gesture, once |
+| user unit, no `User=` | `systemctl --user enable --now meshbay-node` — **no privilege** | desktop — the client does it itself |
+
+With `loginctl enable-linger` where the node must run with no session open.
+
+### 7.3 The firm rule: installation places files, never secrets
+
+No identity material in `%post` / `postinst` / MSI custom actions. Three reasons, the
+second being a trap:
+
+1. Fedora and Debian guidelines forbid interactivity in scriptlets and discourage state
+ generation.
+2. **A package installed into a golden image or a container would give every machine the
+ same key.** Known, severe, and silent.
+3. `%post` runs as root; the key would belong to root while the daemon runs as the user.
+
+Keys are generated at **first daemon start**, on that machine, under the identity running
+it.
+
+### 7.4 Sequence, with no terminal
+
+```
+install files + units. Non-interactive. No service force-started (respect presets).
+client 1st run no local node detected → "install a node here?"
+ → systemctl --user enable --now meshbay-node (no privilege)
+daemon 1st run generates its Ed25519 pair into the keystore, creates data_dir,
+ writes the per-run session token (11.5.3)
+wizard the client reads the token (same UID) and walks through, on loopback:
+ link the node to the hub account · create or attach a group ·
+ gek-init · operator pair
+thereafter everything over signed MNP, local or remote
+```
+
+A shared directory outside the user's home needs `ReadWritePaths=` extended — the unit
+already says so. The client writes a drop-in at
+`~/.config/systemd/user/meshbay-node.service.d/paths.conf` rather than weakening
+`ProtectSystem=strict`.
+
+### 7.5 Windows
+
+**Corrected 2026-08-17.** Windows ships *after* Linux, but it is expected to be where
+**most users run both the client and the node**. Build order and population are not the
+same thing: nothing in the design may assume POSIX, systemd, or a case-sensitive
+filesystem, even while Linux is the only target that has shipped. Everything in §7.1–§7.4
+is Linux-specific *hardening*; it is not the model.
+
+| | Linux | Windows |
+|---|---|---|
+| Autostart | `systemctl --user enable --now` | logon task (Task Scheduler) or a Startup entry |
+| State | `~/.local/share/meshbay` | `%LOCALAPPDATA%` |
+| Key storage | `safeStorage` → libsecret / kwallet | `safeStorage` → DPAPI, bound to the user account |
+| Install | DEB / RPM, per-user unit, no privilege | **MSI per-user, no administrator rights** |
+| Sandbox around the node | `ProtectSystem=strict`, `ReadWritePaths=`, mount namespace | **none** — the node is an ordinary user process |
+| Volume identity | filesystem UUID | `\\?\Volume{GUID}\`, never a drive letter |
+| Removable media events | mount table + `RequiresMountsFor=` | `WM_DEVICECHANGE` |
+
+The sandbox row is an asymmetry worth stating rather than papering over: on Linux the node
+is confined by the unit, on Windows it has whatever the user has. That is a real
+difference in exposure, and it is inherent to the platform, not to this design.
+
+Three Windows-specific facts that shape the product, not just the packaging:
+
+1. **A per-user node stops when the user logs off**, and there is no equivalent of
+ `loginctl enable-linger`. **Decided 2026-08-17: the installer offers a service mode**,
+ as a deliberate, administrator-elevated choice, alongside the per-user default. Three
+ consequences:
+ - **Not `LOCAL SYSTEM`.** A service running as SYSTEM would have *more* privilege than
+ its owner, which inverts least privilege. Use a **dedicated low-privilege local
+ account**.
+ - **Granting access to each root becomes an explicit step.** The user picks a folder
+ from their own session; the service account must then be granted read access to it.
+ This belongs in the root-selection flow, not in a troubleshooting page.
+ - **`safeStorage`/DPAPI is bound to a user account**, so the client (per-user) and a
+ node running as a service cannot share a DPAPI-protected secret. This is not a
+ problem: the node already has its own keystore scheme (`keystore.py`, Argon2id plus
+ an unlock key) and keeps it. The separation exists; it just has to be stated.
+ - In per-user mode the node is **available only while its owner is logged in**. That
+ must be visible in the UI and honest in the docs — availability is the entire point
+ of hosting content.
+2. **The firewall prompt.** A node listening for QUIC needs an exception. A per-user MSI
+ cannot create firewall rules without elevation, so the first run raises the Windows
+ Firewall dialog, and allowing it on a public network needs administrator approval. The
+ first-run wizard has to expect this and explain it, rather than leaving the user with a
+ node that silently accepts no connections.
+3. **SmartScreen.** An unsigned or newly-signed MSI is flagged until reputation
+ accumulates. Authenticode signing is a cost item and belongs with 13.9, and its absence
+ is felt on Windows far more than the absence of a GPG signature is felt on Linux.
+
+Same rule on secrets as everywhere else: **no key generation in an MSI custom action.** A
+silent or imaged install has exactly the shared-key problem `%post` has.
+
+### 7.6 Android, later
+
+Phase 16 reuses this design. One structural difference is worth knowing now, stated
+without jargon:
+
+**An Android app cannot freely read the phone's folders.** It gets its own private
+directory, and for anything else the user must point at a folder in a system dialog; the
+app then has access to that one tree, and that access can be revoked.
+
+A node has to read a directory tree continuously, watch it for changes, and serve files
+around the clock. Android is hostile to all three: there is no reliable filesystem
+watching through that mechanism, background processes are killed aggressively, and a
+long-lived listening socket is not guaranteed.
+
+So: **an Android client, yes; an Android node hosting a library, no** — and nothing in the
+root model (§6.7) should assume otherwise. This is a "do not assume", not a permanent
+prohibition.
+
+### 7.7 Updates (priority 3)
+
+Prefer the **signed apt/dnf repository** (18.5) over a bespoke updater: the OS verifies
+the signature, the key was installed out of band at install time, and `electron-updater`
+handles deb/rpm poorly anyway. Windows later: `electron-updater` plus Authenticode.
+
+An updater is an injection channel. Without a release key whose fingerprint is published
+somewhere the hub does not control, it *is* the new T3.
+
+---
+
+## 8. The web tier
+
+### 8.1 URL-space contract
+
+| Space | Served by | Seen by the native client |
+|---|---|---|
+| `/`, `/about`, `/downloads`, `/news` | Caddy, `site/`, static, public | ❌ never |
+| `/app/*` | hub (SPA) | ❌ never |
+| `/v1/*`, signaling WS | hub (API) | ✅ only this |
+
+Testable, and worth a test: **the native client issues no request outside `/v1/` and the
+signaling socket**. That recovers the intent of the route-inventory blindness test dropped
+from Phase 12.
+
+### 8.2 Announcements
+
+Two channels, no third:
+
+1. **Existing notifications** (`api/notifications.py`, Phase 10.5) for anything aimed at a
+ signed-in user. Already reaches web and native. Add an admin-broadcast type.
+2. **`GET /v1/hub/announcements`** — a small public JSON list (id, date, severity, title,
+ body, locale) read by the landing page *and* by the native client. One source, one tab
+ in the existing admin UI, no CMS.
+
+Two guards, because this lets the hub display text **inside** the native app, which is a
+phishing surface:
+
+- plain text or a very restricted markdown subset, **never HTML**, never `innerHTML`;
+- a visually distinct region labelled as a message from the hub operator, never a modal
+ that can imitate app UI, no in-app link targets.
+
+### 8.3 The download page is a security page
+
+`downloads.html` is where the release key fingerprint is published — and a hostile hub
+serves that page too. Publish the fingerprint somewhere the hub does not control as well
+(repository README, a signed tag, ideally a third channel). Otherwise the relocation of
+trust described in `tmp-decisions.md` is circular.
+
+---
+
+## 9. Security: what changes, what does not
+
+| Claim | Passive hub | Active hub | Malicious node operator | Malicious member |
+|---|---|---|---|---|
+| Client code integrity | ✅ ships in the package | ⚠️ **detectable, not prevented** — realised by 18.7 + published hashes, not by the packaging format | ✅ | ✅ |
+| The hub can read content by shipping malicious code (**T3**) | ✅ removed for native | ✅ removed for native; **unchanged for the browser** | — | — |
+| Device linking grants the hub authority | ✅ no | ✅ **no** — the countersignature is a key the hub has never held | ⚠️ a node can only add a device to itself, where it already reads everything | ✅ no |
+| Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ still open for any account that also uses a browser (§5.1) | — |
+| Hub knows memberships, who spoke where and when | ❌ unchanged | ❌ unchanged | — | — |
+| Hub can deny service (it relays signaling) | ❌ unchanged | ❌ unchanged; `group://` over QUIC is the way out | — | — |
+| New surface | — | update channel, Chromium CVEs, sidecar loopback, node-admin panel in-process | — | — |
+
+**The claim this design supports:** *the hub's remaining power over a native client's
+confidentiality is the artifact it ships, and an artifact can be hashed and compared.*
+
+**The claim it must not make:** that the native client makes the hub untrusted. It does
+not, and a build signed with a key the hub operator holds relocates trust rather than
+removing it. That value is realised by **18.7 reproducible builds** — for which this
+project is unusually well placed, since the UI has no bundler or minifier and Electron's
+own binaries have published upstream hashes.
+
+**And the sentence to repeat in user-facing docs:** an account used from a browser is only
+as strong as the browser path.
+
+---
+
+## 10. Execution order
+
+The desktop client comes **last**, and deliberately so: everything before it is work the
+web SPA needs anyway, and shipping the client on top of a path model that is about to
+change would mean shipping the change twice, into an installed base, with version skew
+(§2.6) making it expensive.
+
+### Stage A — content model (node + protocol + UI, no client work)
+
+Everything in this stage is delivered to today's web SPA, and the hub is untouched.
+
+| # | Component | Prio |
+|---|---|---|
+| A1 | **Filesystem portability** in the indexer and upload path — case folding, NFC, reserved names, `\\?\`, reconciliation scans. **The case-folded no-overwrite check is a security fix** (§6.8) | 1 |
+| A2 | **Per-root `unavailable` state** — freeze the subtree, never empty it (§6.9) | 1 |
+| A3 | **Named roots per group** — `shared_dir` → `[{name, path, kind}]`, basename naming, no nesting, designated upload root, root segment in every path (§6.7) | 1 |
+| A4 | **MNP 0.2 → 0.3** and the SPA updated for root-aware paths | 1 |
+
+A1 before A3: the collision rules have to be right before roots multiply the paths that
+can collide. A2 before A3 is not strictly required but avoids a window where an unplugged
+disk is destructive.
+
+### Stage B — operator surface (node, no client work)
+
+| # | Component | Prio |
+|---|---|---|
+| B1 | ✅ **DONE 2026-08-18** — `group list`, `gek init\|rotate`, `reload` (SIGHUP), `denylist show\|clear`, `file list\|rm`. **No operator action requires a browser any more.** `group create` and `group join` are not built: creating a group on the hub is a browser or API action and `group add` already hosts one | 1 |
+| B2 | ✅ **DONE 2026-08-18** — `meshbay_node/ops.py`. The loopback API is one `_op(...)` line per endpoint; MNP handlers call the same functions. `test_ops.py` asserts the shape rather than trusting it | 1 |
+| B3 | ✅ **DONE 2026-08-18** for `gek_rotate` and `member_unpin`, both operator-signed. Root and device management deferred: device management needs Stage C, and root management needs writing into a hand-written TOML block, which `ops.add_root` validates and then refuses with the exact lines to paste | 1 |
+
+B2 is what stops B3 from becoming a second implementation. Doing B1 without B2 means
+refactoring the same six commands twice.
+
+### Stage C — identity, which the client cannot ship without
+
+| # | Component | Prio |
+|---|---|---|
+| C1 | **Device linking** — `identities` schema, `device_requests`, the two transcripts, operator surface (§4) | 1 |
+| C2 | **`POST /v1/users/auth`** — device Ed25519 authentication (§5). *The only hub change in the whole plan* | 1 |
+| C3 | **Minimum client version** in `GET /v1/hub/version` (§2.6, O8) | 1 |
+| C4 | Device management in the SPA — list, approve, revoke | 1 |
+
+C1 lands in the SPA first, where both ends of a link can be exercised without a desktop
+build existing.
+
+### Stage D — the desktop client
+
+| # | Component | Prio |
+|---|---|---|
+| D1 | Configurable hub base URL + storage/save-file adapter (old 13.1) | 1 |
+| D2 | Electron shell — `app://`, privileged scheme, CSP, preload, sandbox, single instance, tray, window state | 1 |
+| D3 | Local key storage: `safeStorage`, explicit fallback where no keyring exists | 1 |
+| D4 | Hybrid registration and first sign-in (§5) | 1 |
+| D5 | Node management panel over the Stage-B ops, root selection included | 2 |
+| D6 | First-run wizard — detect, enable the unit, link, group, `gek-init`, pair (§7.4) | 2 |
+| D7 | Native save dialog, streamed write from the main process | 2 |
+| D8 | `.deb` + `.rpm` | 1 |
+| D9 | Python sidecar — `group://` over QUIC | 2 |
+| D10 | Video thumbnails, own bounded pool (§6.10) | 2 |
+| D11 | Windows: MSI per-user, service mode offered, firewall and SmartScreen handled (§7.5) | 3 |
+| D12 | Release key, signed apt/dnf repository, updates via the OS | 3 |
+| ~~D13~~ | ~~Safety-number UI~~ — **dropped** (decision 19): no new code or fingerprint exchanges between people. Chat authentication is served by Tiers 1 and 2 (§4.8), which cost the user nothing | — |
+
+### Deleted from the old Phase 13
+
+`13.4` aiortc client transport · `13.5` SQLite index cache · `13.6` loopback media server —
+Chromium provides all three. `13.11` "retire the SPA?" is settled by E6.
+
+Deletions enabled once native is the recommended client are unchanged from
+`devel-phases-next.md`, **except** that `transport.js`, `crypto.js`, `keyderive.js`,
+`downloads.js` and `sw.js` are now *kept* — they are the client, not browser workarounds.
+
+---
+
+## 11. Open items, each needing its own review
+
+| # | Item | Why it is not decided here |
+|---|---|---|
+| O1 | `gek-init` in the pre-proof window | Would let a headless node be initialised remotely. It runs before the GEK proof, which is the neighbourhood C4 and C5b came from. Deferred with E5 |
+| O2 | LAN enrolment door | One endpoint, bounded window, one-time code, closes permanently on success. Small but it executes before any authentication |
+| O3 | `device_policy {allow_bundle: false}` | The mechanism that actually closes C4 (§5.1). Needs to be signed by a pinned key, never settable by the hub |
+| O4 | Node-admin panel isolation | Node-supplied strings (filenames, hub-originated usernames) rendered in a process holding the user's keys. H2 was exactly this. Separate window or partition at minimum |
+| O5 | `MESHBAY_UNLOCK_KEY` in `node.env` | Plaintext in the user's home. The client could move it to the OS keychain for the desktop persona |
+| O6 | Electron version floor | X25519 and Ed25519 in WebCrypto must be verified on the pinned version, not assumed |
+| ~~O7~~ | Several directories in one group | **Decided 2026-08-17** — named roots, unique names, union root. See §6.7 |
+| O8 | Minimum client version in `GET /v1/hub/version` | Needed before the first public package (§2.6). Trivial now, awkward once clients are in the wild |
+| ~~O9~~ | Windows node availability | **Decided 2026-08-17** — the installer offers a service mode under a dedicated low-privilege account, alongside the per-user default (§7.5) |
+| O10 | Canonical file identity across filesystems | Case folding and NFC normalization decide when two names are "the same file". It affects the index, the no-overwrite rule and cross-platform replication, so it must be defined once and shared (§6.8) |
+| O11 | Root alias when the basename cannot be used | Duplicate basenames across volumes will be common, and a basename can fail the portability rules. Refusing is correct; "rename the folder on your disk" is a poor answer, so an explicit alias is the escape hatch (§6.7) |
+| O13 | Hub identity pinning | The client points at a hub by URL and nothing pins that hub's identity. `GET /v1/hub/pubkey` exists; pinning it on first use — as `pk_node` already is (11.5.8) — would stop an account's hub being silently swapped. Bounded today, because a substituted hub cannot read content and no longer ships the code; worth doing all the same |
+| ~~O12~~ | Derived thumbnails vs draft-v5 §5.2 | **Resolved 2026-08-17** — frame grab produced on demand by the node, cached on the requesting device. The node keeps no thumbnail store, so §5.2 stands unchanged (§6.10) |
+
+---
+
+## 12. Decisions record
+
+Settled with the operator on 2026-08-17:
+
+| # | Question | Decision |
+|---|---|---|
+| 1 | Shell | Electron + optional Python sidecar. Structural decision 18 (pywebview) is reversed |
+| 2 | Device approval | Pending request on the node, bound by a one-time code generated and displayed by the new device. No human digit comparison on the critical path |
+| 3 | Device request TTL | **1 h**, configurable. Made a comfort setting rather than a security parameter by the code binding |
+| 4 | Account creation | Hybrid: passphrase-derived `auth_key` at registration, device Ed25519 key thereafter |
+| 5 | Node management | Signed MNP ops; the loopback admin API is never exposed to the network |
+| 6 | First run and initial `gek-init` | Local, on the machine hosting the node |
+| 7 | LAN enrolment of a headless node | Out of scope for v1, kept implementable (O2) |
+| 8 | Sequencing | **Phase 14 (node CLI) before 13.12**, factored into one `ops.py` with the CLI, the loopback API and the MNP handler as adapters (§6.6) |
+| 9 | Directory selection, removable media included | Allowed, and **gated on the per-root "unavailable" state** shipping first (§6.9) |
+| 10 | One UI source | `meshbay_hub/static/` copied into the desktop package at build time, never forked; native-only features gated by capability (§2.7) |
+| 11 | **Several roots per group** | Named roots, unique names, union virtual root, no nesting, `kind` reserved for the planned libraries (§6.7) |
+| 12 | **exFAT/NTFS and Windows are the common case** | Not an edge case to accommodate later. The index and the upload path must be correct on case-insensitive, permission-less filesystems, and no design may assume POSIX or systemd (§6.8, §7.5) |
+| 13 | Root naming | The chosen directory's **basename**, derived once at add time and stored. Duplicates refused, case-insensitively. The operator designates the upload root (§6.7) |
+| 14 | Windows node availability | The installer **offers a service mode** under a dedicated low-privilege account; per-user remains the default (§7.5) |
+| 15 | Future libraries | **Views over the existing index**, not a catalogue. No metadata store, no server-side database, ever. A file stays tied to its representation on the filesystem. Reserve `kind` as a view hint; `layout` and a scan policy were over-design and are retracted (§6.10) |
+| 16 | Where enrichment happens | **On the client, from data it already has.** What it cannot compute, the node produces **on demand** and the requesting device caches. Neither node nor hub keeps durable derived state — the rule already in force for chat thumbnails (draft-v5 §5.2) |
+| 17 | **Where group-related server state lives** | **On the node, always** (E9). The hub gains no row from anything in this document. Verified for multi-root: `SwarmSource` carries hashes and endpoints, no paths |
+| 19 | **No new code exchanges between people** | Verification rituals between users are refused. Safety numbers are out for chat authentication, permanently. The device-linking code is between a user's **own** devices and is unaffected. Total user-visible cost of the authorship work: one "this account's key changed" notice (§4.8) |
+| 18 | Execution order | Content model → operator surface → identity → client (§10). The client is last because everything before it is owed to the web SPA anyway, and shipping it first would mean shipping the path change twice into an installed base |
+
+Item 3 is a default for v1, not a constraint. Items 1, 2, 4, 5, 8, 9, 11, 12, 17 and 18
+are structural and should not be revisited without revisiting this document.
diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md
index b03328b..e4218ee 100644
--- a/docs/meshbay-draft-v5.md
+++ b/docs/meshbay-draft-v5.md
@@ -1,8 +1,15 @@
# MeshBay — Architecture Draft v5
+> **SUPERSEDED 2026-08-17 by `docs/meshbay-draft-v6.md`.** v6 restates only what
+> changed; everything below that v6 does not touch is still authoritative — the
+> handshake (§4), node authority (§5), the hub's role (§6), cryptography (§7) and
+> the testing posture (§10). Read v6 first, then this.
+>
> Status: **Phase 11.5 (security remediation) complete. Invite redesign complete
> (2026-08-14) — H3 and M3 closed.** See `devel-phases-next.md` and, for the invite
-> and pairing design, `docs/invite-pairing-v1.md`.
+> and pairing design, `docs/invite-pairing-v1.md`. For the desktop client — shell,
+> device linking, account creation, node management — `docs/desktop-client-v1.md`
+> (2026-08-17) is authoritative and supersedes §8.2 here.
> Supersedes `meshbay-draft-v4.md`. Sections not restated here are unchanged from v4.
>
> v5 exists because the second security review (`second-review.md`, 2026-08-13) found
@@ -220,7 +227,7 @@ was 32 opaque random bytes signed blind — an unbound signing oracle.
|---|---|
| `file_delete` | Node operator, or the user who uploaded the file (verified by the key recorded at upload) |
| `invite_create` | **Node operator only** (delegation designed, deferred) |
-| GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK |
+| GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK. The rule targets *key material arriving from outside* (C5b), not the instruction: a future operator-signed `gek_rotate` where **the node generates the key itself** is a different shape and is allowed (`docs/desktop-client-v1.md` §6.2). The initial `gek-init` stays local — with no GEK there is no completed session to carry a signed op |
| ~~`gek_bundle_store`~~ | **Message deleted.** No member ever hands the node key material |
`gek_bundle_store` previously accepted any member's bundle and auto-activated one
@@ -513,17 +520,40 @@ end-to-end *integrity* for that path.
### 8.2 Native client
-pywebview + aiortc, sharing ~69% of the existing UI unchanged (the SPA contains no direct
-`crypto.subtle` or `RTCPeerConnection` usage; everything goes through three injected
-globals and one fetch helper). Assets ship **inside the package** and load from disk — a
-shell pointing its WebView at the hub's `/app/` would be a browser with a different icon
-and would fix nothing.
+**Electron** (decided 2026-08-17 — see `docs/desktop-client-v1.md`, which is authoritative
+for the desktop client and supersedes this section's earlier pywebview form), with an
+optional Python sidecar reusing `quic_client.py` for hub-less `group://`.
+
+Assets ship **inside the package** and load from disk — a shell pointing its WebView at the
+hub's `/app/` would be a browser with a different icon and would fix nothing. That
+constraint is unchanged and is the whole point.
+
+What changed is the engine. The SPA does not depend on "the web" in general but on
+Chromium-class APIs: `RTCPeerConnection`, WebCrypto X25519/Ed25519, MSE, Service Workers,
+File System Access. Keeping Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`,
+`downloads.js`, `sw.js` and the MSE player **unchanged**; replacing it with a system webview
+meant reimplementing ~2500–3000 lines plus a loopback media server and native dialogs. The
+"~69 % reused" figure quoted here previously was measured in August against an `app.js` of
+~2600 lines; it is 4586 lines as of 2026-08-17, and `app.js` now contains 2 direct
+`crypto.subtle` calls (`_pkFromSk`), so the three-globals seam had already leaked.
Native does **not** remove trust in the hub operator; it converts an undetectable,
per-request, per-user attack into a persistent artifact that can be hashed and compared.
That value is realised by reproducible builds and published hashes, not by the packaging
format.
+Two properties of the desktop client bear on this document directly:
+
+- **Identity keys are generated and kept locally**, never bundled anywhere. C4 closes for a
+ native device unconditionally — but **not for an account that also uses a browser**, which
+ still needs a bundle on each node (§7.1). An account is only as strong as its weakest
+ client, and the docs must say so.
+- **A second device is admitted by device linking**, not by an operator code: the
+ already-pinned key countersigns, bound by a one-time code the new device generates. The
+ hub cannot produce that countersignature — it has stored no user keys since 2026-08-14
+ (§6.1) — so this adds no hub-reachable authority. `identities` gains a device dimension;
+ see `docs/desktop-client-v1.md` §4 before touching the roster.
+
---
## 9. Open items
@@ -546,7 +576,7 @@ format.
| # | Item | Status |
|---|---|---|
-| C4 | Remote keypair bundles | Bounded (4 pre-proof fetches, audited); closes in Phase 13.3 when the native client stops storing them remotely |
+| C4 | Remote keypair bundles | Bounded (4 pre-proof fetches, audited). **Closes for a native device unconditionally** (its keys are never bundled), but **stays open for any account that also uses a browser** — a browser has no durable storage of its own and needs a bundle on each node. Full closure needs the opt-out in `docs/desktop-client-v1.md` §5.1, signed by a pinned key so the hub cannot set it |
| — | Chat encryption (Sender Keys) | Phase 15. Distribution must be **pairwise to identity keys**, never GEK-derived |
| — | Chat attachments | Plaintext on the node; decide in Phase 15.7 |
| — | Delegation | Designed, deferred: a group admin who does not run the node still cannot invite |
diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md
new file mode 100644
index 0000000..6a161cd
--- /dev/null
+++ b/docs/meshbay-draft-v6.md
@@ -0,0 +1,270 @@
+# MeshBay — Architecture Draft v6
+
+> Status: **current specification.** Supersedes `meshbay-draft-v5.md`.
+> **Sections not restated here are unchanged from v5**, which remains the reference for
+> everything v6 does not touch — the handshake (§4), node authority (§5), the hub's role
+> (§6), cryptography (§7) and the testing posture (§10) are all still v5's.
+>
+> v6 exists because a design discussion on 2026-08-17 settled the desktop client and, in
+> doing so, changed four things v5 states: what a group's content *is*, how a person's
+> devices are admitted, how authorship is established, and which shell the native client
+> uses. It also records one rule v5 assumed without writing down.
+>
+> The v5 convention is carried forward and is not negotiable: **a claim in this document
+> must name the adversary it holds against.** A property that holds against a passive hub
+> and not an active one is written that way.
+
+---
+
+## 0. Reading order
+
+| Read | For |
+|---|---|
+| **this document** | what is true now, and what changed from v5 |
+| `meshbay-draft-v5.md` | everything v6 does not restate — still authoritative there |
+| `second-review.md` | the findings (C1–C6, H1–H7, M*, L*) referenced throughout the code |
+| `docs/invite-pairing-v1.md` | invitations, pairing codes, the node roster — **as built** |
+| `docs/per-node-identity-v1.md` | identity keys are per node; the hub stores none |
+| `docs/desktop-client-v1.md` | the desktop client in full — shell, device linking, roots, packaging, execution order |
+| `devel-phases-next.md` | the roadmap |
+
+---
+
+## 1. Changes from v5
+
+| # | Category | Change | Source |
+|---|---|---|---|
+| 1 | Content model | A group's content is **several named roots**, not one directory. Names are unique, derived from the directory's basename, and form a union virtual root | E7 / decision 11, 13 |
+| 2 | Identity | **Device linking**: one person may hold several devices on a node, admitted by a key the node already pinned, bound by a one-time code the new device generates | E2 / decision 2 |
+| 3 | Client | The native client is **Electron**, not pywebview. Structural decision 18 reversed | E1 / decision 1 |
+| 4 | Node authority | `gek_rotate` may be a signed MNP op — the C5b rule forbids *key material arriving from outside*, not an operator-signed instruction where the node generates the key | §2.3 below |
+| 5 | Server state | **Group-related server state lives on the node, always.** Written down because v5 assumed it | E9 / decision 17 |
+| 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 |
+| 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 |
+| 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b |
+
+---
+
+## 2. What v6 changes in v5's text
+
+### 2.1 §5.2 Uploads — now per root
+
+v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite,
+chunk ordering and a size cap. All four protections stand. Two amendments:
+
+- There is no single `shared_root`. **The operator designates one root as the upload
+ destination**; the quarantine lives inside it. If that root is unavailable the upload
+ fails with a stated reason and never falls back to another; if none is designated,
+ uploads are refused rather than guessed.
+- **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier
+ draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does
+ not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those
+ filesystems, so the upload already gets a free name. C5a is not reachable through the
+ filesystem. Case folding is needed for index identity, collision reporting and root
+ names — comparisons the code makes itself.
+
+v5's rule that nothing derived is written beside the originals is **unchanged**, and it
+decides the video-thumbnail question: a frame grab is produced on demand and cached on the
+device that asked, so the node keeps no thumbnail store.
+
+### 2.2 §5.5 Admission — devices, not one key per person
+
+v5 and `invite-pairing-v1.md` bind **one** key pair to an account per node: `identities`
+has `user_id` as its primary key and `pin_identity` does `INSERT OR REPLACE`. A person
+with a browser and a native client needs two keys on the same node, so:
+
+- `identities` becomes keyed by `(user_id, pk_ed25519)`, with `label`, `added_at`,
+ `added_by_pk` and `revoked_at`. **`INSERT OR REPLACE` must go** — today it silently
+ overwrites, which becomes a hole the moment a second key is legitimate.
+- A new device is admitted when **a key the node already pinned countersigns it**, bound
+ by a one-time code the new device generates and displays, hashed together with the new
+ keys so the node cannot substitute them.
+- The operator's one-time code remains available and is unchanged. Device linking is an
+ addition to admission, not a replacement.
+
+**Against an active hub this holds**, and for the same reason §5.5 holds: the hub has
+stored no user keys since 2026-08-14, so it cannot produce the countersignature. Against a
+malicious node operator it is not a new exposure — a node can only add a device to itself,
+where it already reads everything it serves.
+
+**Where it does not hold:** approval performed *in a browser* inherits T3, because the hub
+serves that browser its code and can read the typed code. The first browser-to-native link
+is therefore the moment of highest exposure for an account, and it happens once.
+
+Full design: `docs/desktop-client-v1.md` §4.
+
+### 2.3 §5.1 GEK activation — the rule, stated precisely
+
+v5 says *"nothing arriving over MNP can activate a GEK"*. Read precisely: the rule targets
+**key material arriving from outside** (C5b), not the instruction. An operator-signed
+`gek_rotate` where **the node generates the key with its own CSPRNG** satisfies the
+property v5 §5.5 actually establishes — the node produces every copy of the key — and is
+allowed.
+
+**The initial `gek-init` stays local.** With no GEK, `join_result` answers `no_gek` and no
+MNP session completes, so there is no authenticated session to carry a signed op. Placing
+it in the pre-proof window is possible and is deliberately deferred; that window is where
+C4 and C5b were born.
+
+### 2.4 §8.2 Native client — Electron
+
+pywebview is replaced by Electron plus an optional Python sidecar for hub-less `group://`
+over QUIC. The non-negotiable is unchanged and is the entire point: **UI assets ship
+inside the package and load from disk.** A shell pointing at the hub's `/app/` is a browser
+with a different icon.
+
+What changes is the engine, not the claim. Native still does **not** remove trust in the
+hub operator; it converts an undetectable, per-request attack into an artifact that can be
+hashed and compared, and that value is realised by reproducible builds (18.7), not by the
+packaging format.
+
+Two corrections to v5's client table:
+
+- **Key storage.** Identity keys are generated and kept locally, never bundled. C4 closes
+ for a native device unconditionally — and **stays open for any account that also uses a
+ browser**, which has no durable storage of its own and still needs a bundle on each
+ node. An account is only as strong as its weakest client.
+- **Crypto.** The client keeps WebCrypto *and* gains local Argon2id and ChaCha20 in the
+ main process. v5 implied WebCrypto is lost with the browser engine; under Electron it is
+ not.
+
+### 2.4b §5.1 Authorship — authenticated, not asserted
+
+v5 §5.1 authorizes `file_delete` by "the node operator, or the user who uploaded the file
+(verified by the key recorded at upload)". Two changes:
+
+**Authorization moves from the key to the account.** With several devices per person,
+`_admin_exec_file_delete` — which verifies against `entry.uploader_pk`, the exact uploading
+key — would refuse Alice's desktop the right to delete what her phone uploaded. It becomes
+**any non-revoked device of `uploader_id` in the roster**, with `uploader_pk` kept as the
+audit record of which device acted. This remains **roster-rooted, not token-rooted**: a hub
+minting a token that claims to be Alice holds no key the node pinned for Alice, so the
+signature fails — the property `per-node-identity-v1.md` established is preserved.
+
+**Ownership becomes provable.** The uploader signs `meshbay:upload:v1` over node, group,
+root, path, content hash, account and timestamp; the node stores it with the index entry.
+Ownership is then verifiable by any member rather than asserted by the node, and the C5a
+path — overwriting a file to become its recorded uploader — is closed a second time.
+
+**Chat senders must be cryptographically authenticated to each other.** v5 relied on NS6,
+where the node enforces `sender_id` from the authenticated session; that is the node's
+word. Messages are signed with the sender's **device** key, clients pin `account → device
+keys` on first sight using the device-add countersignatures as evidence, and the operator
+may sign a roster attestation to close first contact.
+
+**Against whom this holds.** Against another member: fully — no member can forge another's
+signature. Against someone holding the node's disk: fully — a stolen chat store cannot be
+*extended* with messages that verify. Against the node operator: **partially, and the
+partial part is worth having** — once a member's client has pinned Alice's device key, an
+operator who turns malicious later cannot forge Alice to that member; forgery is limited to
+accounts the victim has never seen. Full protection at first contact requires an
+attestation rooted outside the node, which is what the operator-signed roster and safety
+numbers provide.
+
+Design: `docs/desktop-client-v1.md` §4.8.
+
+### 2.5 §6.1 Hub role — one addition, one rule
+
+The hub gains exactly one endpoint from all of this: **`POST /v1/users/auth`**, device
+Ed25519 authentication on the pattern of `POST /v1/nodes/auth`. Nothing else in the
+desktop-client design adds a row or a column to the hub.
+
+And the rule v5 assumed without writing:
+
+> **Group-related server state lives on the node.** Files, indexes, members' devices,
+> pending device requests, invitations, chat, per-root availability, and anything a future
+> feature wants to keep about a group — all on the node. The hub holds accounts, the group
+> registry and membership, signaling, notifications and the moderation surface, and
+> nothing else about content.
+
+Verified for the multi-root change: `SwarmSource` carries `content_hash`, `node_id` and
+`endpoint` — **no paths, no filenames** — and private groups register nothing (H7). The
+content model changes end to end without the hub moving.
+
+### 2.6 §7 Cryptography — unchanged, one consumer added
+
+No parameter changes. `keyderive.py` now has a third consumer: the desktop client derives
+`auth_key` exactly as the browser does at registration. `test_bundle_kdf_parity.py` covers
+it, and the standing warning is unchanged — **never change those parameters in one
+place**; a mismatch does not look like an error, it looks like an account nobody can open.
+
+---
+
+## 3. Filesystem portability as a security property
+
+New in v6, because it was treated as an edge case and is not one. Most users are expected
+to share from an external exFAT or NTFS volume, on Windows.
+
+| Property | Consequence |
+|---|---|
+| Case-insensitive, case-preserving | `Film.mkv` and `film.mkv` cannot coexist. The index needs a canonical identity and a **case-folding collision check** at scan time. The no-overwrite rule must be case-folded — **this one is a security fix** (§2.1) |
+| Unicode normalization | `Café.mkv` written on macOS (NFD) and Windows (NFC) are different byte strings. Normalize to **NFC for identity**, preserve the original bytes for display and opening |
+| Windows reserved names, `MAX_PATH` | A group indexed on Linux can hold names Windows cannot create. The client sanitizes on save **and says so**; use `\\?\` paths |
+| FAT/exFAT timestamps (2 s, local time) | mtime alone is not a change detector. Size + mtime with tolerance, rehash when in doubt |
+| Watcher reliability | `ReadDirectoryChangesW` drops events under load; inotify on a FUSE mount is unreliable. **Periodic reconciliation is mandatory on both platforms** |
+| No symlinks, no POSIX permissions | Simplifications: nothing to defend against, and the node runs as the user anyway |
+
+A volume that disappears must **freeze** the affected root's subtree, never empty it.
+Emptying propagates deletions for a whole library as though the owner had erased it.
+
+---
+
+## 4. Security claims — deltas only
+
+v5 §2's table stands. Three rows change, and they are the honest version:
+
+| Claim | Passive hub | Active hub | Malicious node operator |
+|---|---|---|---|
+| Client code integrity | ✅ ships in the package (native) | ⚠️ **detectable, not prevented** — realised by 18.7, not by packaging | ✅ |
+| Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ **open for any account that also uses a browser** |
+| Devices | ✅ | ✅ the hub cannot countersign a device — it holds no user keys | ⚠️ a node adds devices only to itself, where it already reads everything |
+
+**The claim v6 supports:** *the hub cannot read your content, and against a native client
+its only remaining lever is the artifact it ships — which can be hashed and compared.*
+
+**The claim it must not make:** that a native client makes the hub untrusted, or that C4
+is closed for an account that still signs in from a browser.
+
+---
+
+## 5. Still open
+
+v5 §9's list stands, with these movements:
+
+| # | Item | Status |
+|---|---|---|
+| C4 | Remote keypair bundles | **Partially closed.** Gone for native devices; open for browser-using accounts until the signed `device_policy {allow_bundle: false}` opt-out ships |
+| T3 | Hub serves the SPA | **Accepted permanently** for browser users. Removed for native clients, whose value depends on 18.7 |
+| — | Chat encryption (Sender Keys) | Phase 15, unchanged. Pairwise to identity keys, never GEK-derived — and **now to devices**, which multiplies the recipients per person |
+| — | Delegation | Designed, deferred, unchanged |
+| — | Hub identity pinning | New. `GET /v1/hub/pubkey` exists and nothing pins it; bounded, because a substituted hub can neither read content nor ship the code to a native client |
+
+**Phase 15 has been re-read against device linking (2026-08-17) and was wrong as written.**
+The correction is recorded in `devel-phases-next.md` §15.0b; the load-bearing part:
+
+- **A sender key is per device, never per person.** A shared per-person chain advanced by
+ two devices produces key and nonce reuse — which is exactly why `first-review.md` C1
+ rejected a shared Double Ratchet for groups. The same mistake, one level down.
+- `senderkeys.py` already fails this silently: `GroupSenderKeyStore.add_sender` does
+ `self._states[dist.sender_id] = ...`, so a second device under the same `sender_id`
+ **overwrites the first and drops its chain**. `sender_id` must become a device
+ identifier.
+- **Revoking a device must rotate**, like revoking a member.
+- **A newly linked device cannot read history** until every sender redistributes, unless
+ the linking device hands over its own state sealed to the new device's key.
+- **Sender attribution stays node-trusted.** A sender key proves a *device*; the mapping
+ from device to account comes from the node's roster. Encrypted chat does not make
+ senders cryptographically authenticated to each other, and the docs must not imply it.
+
+Ordering consequence: **device linking (Stage C) lands before Phase 15**, or Phase 15 is
+built against an identity model that is about to change underneath it.
+
+---
+
+## 6. Where the detail lives
+
+This document states what changed and what holds. It does not restate the desktop client's
+design, which is long and belongs in one place:
+**`docs/desktop-client-v1.md`** — shell requirements, device-linking protocol and schema,
+account creation, node management over signed MNP ops, several roots per group, filesystem
+portability, packaging and first run, the web tier, and the execution order for all of it.
diff --git a/packaging/caddy/meshbay.org.Caddyfile b/packaging/caddy/meshbay.org.Caddyfile
new file mode 100644
index 0000000..c161202
--- /dev/null
+++ b/packaging/caddy/meshbay.org.Caddyfile
@@ -0,0 +1,64 @@
+# Caddy configuration for meshbay.org
+#
+# Two things share one origin: the public site (`site/`, static, meshbay.org
+# specific) and the hub (FastAPI on loopback:8000, generic and reusable).
+#
+# The rule is an ALLOWLIST for the site, and everything else to the hub.
+# Not the reverse. The hub mounts its whole static directory at "/"
+# (`app.py`, RevalidatingStatics), so a `root * site` with `try_files` would
+# shadow it and break the application in ways that are not obvious:
+#
+# /sw.js the service worker MUST stay at the root or its scope stops
+# covering the pages it intercepts downloads for. A 404 here
+# silently breaks streamed downloads on Firefox and Safari.
+# /a/<hash>/* the versioned module graph. The old snippet in
+# devel-phases-next.md proxied `/style.css` and `/*.js`, which
+# matches neither this prefix nor /locales/*.js — it predates
+# asset versioning and would 404 the entire bundle.
+# /style.css old bookmarks, still served unversioned by the hub.
+#
+# Deployment: the site is NOT pushed by the hub deploy procedure. Sync it
+# separately to /srv/meshbay/site (see QE/server-state/meshbay.org.md).
+
+meshbay.org {
+ encode zstd gzip
+
+ root * /srv/meshbay/site
+
+ # The public site. Extensionless URLs work: /about → about.html.
+ # Keep this list explicit — anything not named here belongs to the hub.
+ @site path / /about /about.html /downloads /downloads.html /assets/*
+ handle @site {
+ # These pages are pure HTML and CSS: no script, no external asset, no
+ # form. The policy says exactly that, so an injection has nowhere to go.
+ header {
+ Content-Security-Policy "default-src 'none'; style-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
+ X-Content-Type-Options "nosniff"
+ Referrer-Policy "same-origin"
+ # Deliberate: this host is HTTPS only. Removing it later takes
+ # max-age to expire in every browser that saw it.
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
+ }
+ try_files {path} {path}.html
+ file_server
+ }
+
+ # Everything else is the hub: /v1/*, /app, /app/*, /a/<hash>/*, /sw.js,
+ # /style.css, /locales/*, /vendor/*, and the /v1/nodes/ws WebSocket
+ # (reverse_proxy upgrades it without extra configuration).
+ #
+ # The hub sets its own CSP for the application, which needs
+ # `wasm-unsafe-eval` for the Argon2id bundle KDF. Do not add a header here:
+ # a second policy on the same response is intersected with the first, and
+ # the strictest wins — which would lock every user out of their keys.
+ handle {
+ reverse_proxy 127.0.0.1:8000 {
+ # The hub honours X-Forwarded-For from a trusted proxy only, and
+ # reads the rightmost hop (draft-v5 §6.4). Caddy's default is to
+ # APPEND the real address to whatever the client sent, which the
+ # rightmost-hop rule already handles; this replaces it outright so
+ # nothing a client invents ever reaches the compliance log.
+ header_up X-Forwarded-For {remote_host}
+ }
+ }
+}
diff --git a/second-review.md b/second-review.md
index 01ee2af..1b684af 100644
--- a/second-review.md
+++ b/second-review.md
@@ -796,7 +796,7 @@ people's nodes.
```
Phase 11.5 Security remediation ⛔ blocking, everything else waits
Phase 12 Hub minimization makes "the hub cannot read" structural
-Phase 13 Native desktop client pywebview + aiortc; removes T3 and C4
+Phase 13 Native desktop client Electron (2026-08-17); reduces T3, C4 partly
Phase 14 Node CLI (was Phase 12)
Phase 15 Sender Keys (was Phase 13) — 15.0 distribution decision first
Phase 16 Android (was Phase 14) — reuses the Phase 13 design
diff --git a/site/about.html b/site/about.html
index 512047d..d61ac28 100644
--- a/site/about.html
+++ b/site/about.html
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>About — MeshBay</title>
- <link rel="stylesheet" href="/site/assets/site.css">
+ <link rel="stylesheet" href="/assets/site.css">
</head>
<body>
diff --git a/site/downloads.html b/site/downloads.html
index 7602fdb..f231a25 100644
--- a/site/downloads.html
+++ b/site/downloads.html
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Downloads — MeshBay</title>
- <link rel="stylesheet" href="/site/assets/site.css">
+ <link rel="stylesheet" href="/assets/site.css">
</head>
<body>
@@ -20,34 +20,83 @@
<div class="page">
<h1>Downloads</h1>
- <p>MeshBay packages will be available through standard package managers.
- Package repositories are being set up.</p>
+ <p><strong>No packages are published yet.</strong> This page describes what is
+ coming and how you will be able to check that what you install is what we
+ published. Until a signing key is published here, nothing on this page is a
+ download link.</p>
- <h2>Web Client</h2>
- <p>No installation needed. Open the
- <a href="/app">web app</a> in any modern browser (Chrome, Firefox, Edge, Safari).</p>
+ <h2>Web client — available now</h2>
+ <p>Nothing to install. Open the <a href="/app">web app</a> in a modern browser.
+ Tested on Chrome and Firefox; Edge and Safari are expected to work.</p>
+ <p>Be aware of what this costs you: <strong>the web client's code is served by
+ this hub</strong>. A hub operator who wanted to read your content could do it
+ by serving you modified code, and no protocol design prevents that. It is the
+ convenience tier, not the secure tier. Everything else — your files, your
+ group keys, your messages — is unreadable to the hub, and the desktop client
+ below removes this last exception by shipping its code with the application
+ instead of receiving it from us.</p>
+
+ <h2>Desktop client — in development</h2>
+ <p>A desktop application that ships its own interface, keeps your keys on your
+ own machine, and can install and manage a node running on that machine. It
+ will not replace the web client: the same account works with both, on as many
+ devices as you like.</p>
+
+ <table class="pkg-table">
+ <thead>
+ <tr><th>Platform</th><th>Format</th><th>Status</th></tr>
+ </thead>
+ <tbody>
+ <tr><td>Ubuntu 24.04+</td><td>DEB, apt repository</td><td>In development</td></tr>
+ <tr><td>Fedora 44+</td><td>RPM, dnf repository</td><td>In development</td></tr>
+ <tr><td>Windows 10+</td><td>MSI (per-user, no administrator rights)</td><td>Planned, after Linux</td></tr>
+ </tbody>
+ </table>
+
+ <p>Updates will come through your distribution's package manager rather than a
+ built-in updater, so your system verifies the signature the same way it does
+ for every other package you have installed.</p>
+
+ <h2>Server packages — in development</h2>
+ <p>You do not need these to use MeshBay. They are for running your own node — the
+ machine that stores and serves your group's files — or your own hub.</p>
- <h2>Server Packages</h2>
<table class="pkg-table">
<thead>
<tr><th>Package</th><th>Platform</th><th>Status</th></tr>
</thead>
<tbody>
- <tr><td>meshbay-hub</td><td>Fedora / RHEL (RPM)</td><td>Coming soon</td></tr>
- <tr><td>meshbay-hub</td><td>Ubuntu / Debian (DEB)</td><td>Coming soon</td></tr>
- <tr><td>meshbay-node</td><td>Fedora / RHEL (RPM)</td><td>Coming soon</td></tr>
- <tr><td>meshbay-node</td><td>Ubuntu / Debian (DEB)</td><td>Coming soon</td></tr>
+ <tr><td>meshbay-node</td><td>Fedora / RHEL (RPM)</td><td>In development</td></tr>
+ <tr><td>meshbay-node</td><td>Ubuntu / Debian (DEB)</td><td>In development</td></tr>
+ <tr><td>meshbay-hub</td><td>Fedora / RHEL (RPM)</td><td>In development</td></tr>
+ <tr><td>meshbay-hub</td><td>Ubuntu / Debian (DEB)</td><td>In development</td></tr>
</tbody>
</table>
- <h2>Android</h2>
- <p>An Android client is planned. It will be available as an APK download
- and eventually on F-Droid.</p>
+ <p>A node runs as a normal user account, not as root, and it stores its data
+ under that user's home directory. Running one on your own desktop needs no
+ administrator rights; running one on a server is an ordinary system service.
+ Python 3.12 or later is required.</p>
+
+ <h2>Android — planned</h2>
+ <p>An Android client is planned. It will be distributed as an APK from this site,
+ and on F-Droid if the project's requirements can be met.</p>
+
+ <h2>Verifying what you download</h2>
+ <p>Packages will be signed, and the repositories will be signed. That protects
+ you against everyone <em>except</em> whoever controls this website — including
+ us. If this site were compromised, it could serve you a modified package
+ <em>and</em> a matching fingerprint on this page, and both would look correct.</p>
+ <p>So the signing key's fingerprint will also be published somewhere this website
+ does not control, and you should compare the two before trusting a package.
+ The locations will be listed here once the key exists.</p>
+ <p>We would rather say this plainly than let a page of checksums imply a guarantee
+ it cannot give.</p>
- <h2>Source Code</h2>
+ <h2>Source code</h2>
<p>Clone the repository from
- <a href="https://github.com/cbesson/meshbay">GitHub</a>
- and follow the instructions in the README.</p>
+ <a href="https://github.com/cbesson/meshbay">GitHub</a> and follow the README.
+ Building from source is the one path that depends on nothing published here.</p>
</div>
<footer class="site-footer">
diff --git a/site/index.html b/site/index.html
index 39339e0..e9691e1 100644
--- a/site/index.html
+++ b/site/index.html
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MeshBay — Decentralized P2P Platform</title>
- <link rel="stylesheet" href="/site/assets/site.css">
+ <link rel="stylesheet" href="/assets/site.css">
</head>
<body>
diff --git a/tmp-decisions.md b/tmp-decisions.md
index 347d771..76d33d2 100644
--- a/tmp-decisions.md
+++ b/tmp-decisions.md
@@ -11,10 +11,14 @@
| # | Decision | State |
|---|---|---|
| D1 | Does the hub keep serving the web UI? | ✅ **DECIDED 2026-08-13 — yes** |
-| D2 | Browser extension, native desktop client, or both? | ✅ **DECIDED 2026-08-13 — native client, offered alongside the hub-served SPA** |
-| D3 | Transport: aiortc primary, QUIC at parity, TCP+HTTP removed | ✅ Decided 2026-08-13 |
+| D2 | Browser extension, native desktop client, or both? | ✅ **DECIDED 2026-08-13 — native client, offered alongside the hub-served SPA.** Shell revised 2026-08-17: **Electron**, see `docs/desktop-client-v1.md` |
+| D3 | Transport: aiortc primary, QUIC at parity, TCP+HTTP removed | ✅ Decided 2026-08-13. Unchanged for the **node**; the desktop client uses Chromium's WebRTC rather than aiortc, and QUIC via a Python sidecar |
| D4 | Hub minimization (old Phase 12) | ⏸️ **Deferred, may be dropped** |
+> **2026-08-17 — the browser-extension options are no longer live.** 12.2 (signed digest
+> of the served bundle) is what Option A needed, and it remains scheduled; nothing else
+> from A or B is planned. The desktop client is the answer to T3 for users who want one.
+
**What was decided.** The hub keeps serving the web UI — that is the zero-install path
and it stays. A native desktop client is offered *in addition*, not as a replacement.
Hub minimization is off the critical path and may be dropped entirely.
@@ -103,10 +107,24 @@ hub operator does not control**. Manifest V3 forbids remote code, which works in
the structure enforces exactly what we want. Keys live in extension storage, isolated from
page JS. Moderate effort.
-**Option C — Native desktop client (pywebview + aiortc)** ← **CHOSEN**
+**Option C — Native desktop client** ← **CHOSEN**
Phase 13. Full control, durable keys in an OS keystore, QUIC, hub-less access, best UX.
Highest effort, and the security argument depends on 18.7.
+> **Shell revised 2026-08-17: Electron, not pywebview** (+ an optional Python sidecar for
+> `group://` over QUIC). See `docs/desktop-client-v1.md` §2. The comparison table below
+> was written against pywebview and **two of its rows are wrong for the chosen shell**:
+>
+> - *Browser sandbox* — Electron with `sandbox` and `contextIsolation` **keeps** the
+> Chromium renderer sandbox. The row reads "❌ None (partial under Flatpak)"; that was
+> WebKitGTK's property, not native's.
+> - *Crypto available* — the client keeps WebCrypto **and** gains local Argon2id/ChaCha20
+> in the main process; the row implied WebCrypto is lost with the browser engine.
+>
+> The row that gets *worse*: **patch velocity**. "You own it" is more true of Chromium
+> than of a distro-maintained WebKitGTK, and the renderer parses attacker-controlled
+> content from nodes. That is the real cost and it is accepted deliberately.
+
### Comparison
| | Hub-served SPA (today) | A: extension verifies | B: extension ships UI | C: native desktop |