aboutsummaryrefslogtreecommitdiffstats
path: root/docs/desktop-client-v1.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/desktop-client-v1.md')
-rw-r--r--docs/desktop-client-v1.md1087
1 files changed, 1087 insertions, 0 deletions
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.