# MeshBay — Desktop Client (design)
> **Superseded by `MESHBAY_DESIGN.md`.** This was the desktop client design and decision record; its design
> content now lives in §8.2 (shell), §3.3 (devices), §3.6 (accounts), §6.2 (roots), §6.5 (derived data), §10 (portability), §13.8 and §14 (decisions).
>
> It is kept because code comments, tests and other documents cite its
> sections and its labels, and because it records reasoning a synthesis
> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
> document is right; where either disagrees with the code, the code is.**
> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
> replacement, and §13 defines every label.
> Status: **largely built.** Decided in discussion with the operator on
> 2026-08-17; Stages A–D are running, and §10's table is the per-stage record.
> Stages D5, D6, D9, D11 and D12 remain — see `MESHBAY_DESIGN.md` §15.2. This
> header said "nothing here is implemented" long after most of it was.
> 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.
### 3.1 What running it changed
Four of the statements above were wrong, and only launching the application found them.
**"Nothing here needs a camera, a microphone or a location" was true, and the handler
written from it was still wrong.** Denying every permission also denied `fullscreen`, and
Chromium's own video controls ask for it — so a film could not be watched full-screen.
What makes this worth recording rather than just fixing: **a denied `fullscreen` does not
reject.** `requestFullscreen()` returns a promise that never settles. No error, no console
message, nothing in the renderer that names a permission; the button simply does nothing,
and the operator reported it as "impossible to go full-screen" with no lead to follow. The
probe reported `NEVER SETTLED` while the main process logged `PERMISSION ASKED:
fullscreen`, which is what tied the two ends together. The handler now enumerates what is
*granted* — one entry — so anything Chromium adds later still arrives refused.
**The CSP cannot live in a `` tag.** `frame-ancestors` is ignored there — Chromium
says so in the console — so a policy carrying it has one directive that silently does
nothing. It is sent as a header by the protocol handler instead, which is also the only
thing that serves the interface, so there is one source rather than two.
**`secure: true` is not what makes the service worker register.** Chromium refuses a
worker on a custom scheme whatever its privileges: *"The URL protocol of the current
origin ('app://meshbay') is not supported"*. The application therefore has **no service
worker**, and does not need one — it saves through a native dialog, which is the better
of the two paths anyway. `sw.js` stays in the package because the same files serve the
browser, where it is one of only three ways to write a large file.
What `secure: true` *is* for was measured at the same time: without it **the whole of
`crypto.subtle` is undefined**. The first probe loaded a `data:` URL and every algorithm
failed with `TypeError`, AES-GCM included. X25519 and Ed25519 are both present on
Chromium 148, which settles the version floor that was open (O6).
**The renderer cannot call the hub.** Its origin is `app://meshbay` and CORS refuses it —
the hub has *no CORS middleware at all*, which is a posture worth keeping: its API is
reachable from no web origin whatever. Widening it for `app://meshbay` would be worse
than it looks, because that origin is not a credential: any Electron application on any
machine can claim the same scheme and host name.
So **every hub call leaves from the main process**, exactly as saving a file does. The
renderer asks; the main process goes, and refuses any origin that is not the hub the user
signed in to. `platform.apiFetch()` is `fetch` in a browser and the bridge in the
application, so no caller has to know which one it got. Verified from inside Electron:
the main process reaches `https://meshbay.org/v1/hub/version` (200), the renderer is
refused by CORS, and **a script served by the hub is refused by the policy** — T3's
mitigation demonstrated rather than asserted.
---
## 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 ` 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
`/`. 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 `/`; 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
> **Revised 2026-08-23 for the Videos group app** — see `docs/mediacenter.md` §2.
> The "no thumbnail store, ever" conclusion below (O12) was an extension by
> analogy to draft-v5 §5.2, not a direct consequence of it. The Videos app
> now caches thumbnails and TMDB metadata durably in the node's own
> `data_dir` — still never inside a shared root, and still no per-file
> identity beyond the file's own content hash. Everything else in this
> section stands: views over the index, 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 | ✅ **DONE 2026-08-18** — `identities` keyed by (user_id, pk_ed25519) with a rebuild migration that preserves existing pins, `device_requests`, both transcripts in `meshbay_common/device.py`, handlers and audit events | 1 |
| C2 | ✅ **DONE 2026-08-18** — `POST /v1/users/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration. *The only hub change in the whole plan* | 1 |
| C3 | ✅ **DONE 2026-08-18** — `client.minimum` and `client.recommended` in `GET /v1/hub/version` | 1 |
| C4 | ✅ **DONE 2026-08-18** — the `unknown_device` refusal offers a linking code; the Members panel lists devices, approves by code and retires one | 1 |
**One correction the implementation forced.** §4.3 has the approver look a request
up by its hash — which is circular, because computing that hash needs the keys
being asked about. What shipped: the node lists this account's pending requests
**with their stored hashes**, and the client recomputes `sha256(code ‖ keys)` for
each and keeps the match. The code never reaches the node, which is what makes
substitution impossible: a node offering fabricated keys would have to produce a
hash over a code it has never seen.
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 | ✅ **DONE 2026-08-18** — `static/platform.js`; `HUB` is `platform.hubBase()` and the transport is built with the same base. Browser behaviour identical, which was the acceptance criterion | 1 |
| D2 | ✅ **RUNS** (2026-08-18, Electron 42 / Chromium 148 under xvfb). The packaged interface mounts over `app://`, secure context, `crypto.subtle` present, Argon2 WASM loaded, zero console errors. Three things were learned by running it — see §3.1 |
| D3 | ✅ **DONE, verified on a real desktop** (Ubuntu 24.04 GNOME, 2026-08-18). Settings reports `gnome-libsecret`, and `secrets.bin` is written 0600 with Chromium's **`v11`** prefix — the marker for keyring-backed encryption; the fixed-key fallback writes `v10`. The key name does not appear in clear. On a headless session the same code reports `unavailable` and **refuses to store**, which is the honest outcome and is now explained in Settings rather than left as a bare word |
| D4 | ✅ **DONE 2026-08-18, verified against a hub running this code** — first-run hub prompt (no default, on purpose), passphrase sign-in registers this device, later launches sign in with the device key and no passphrase. **The renderer never holds that key**: it is generated, stored and used entirely in the main process, which signs on request — the same rule as the save dialog, because the renderer is the part that parses hostile input. Measured: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → a stranger's key 401. **Verified end to end on a real desktop** (Ubuntu 24.04 GNOME, 2026-08-18): the application was quit and relaunched, and signed in **without asking for the passphrase**. safeStorage really uses the keyring — `secrets.bin` carries Chromium's `v11` prefix, the marker for keyring-backed encryption | 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 | ✅ **DONE 2026-08-18, and it was not optional** — the two browser paths are both absent in the application (`showDirectoryPicker` does not exist, and Chromium refuses a service worker on a custom scheme), so the chain fell through to its floor: **the whole file collected in the page and handed over as a blob**. A gigabyte of film meant a gigabyte of RAM, and a Save As dialog at the *end* — which is how the operator noticed. The main process now streams to disk, honours "save automatically" with a remembered folder and no dialog, never overwrites, applies backpressure, and unlinks a cancelled download rather than leaving a truncated file. Measured: the file on disk grows 256 KB → 512 KB → 768 KB → 1 MB as chunks arrive |
| D8 | ◐ **PARTIAL** — `electron-builder` configured for deb and rpm, and `meshbay-node-user.service` added. **Found doing it:** the spec installed the *system* template (`User=%i`) into the user unit directory, where systemd refuses the file — the packaged unit could never have started. Fixed, with `test_packaging_units.py`. Nothing has been built: no npm, no rpmbuild here | 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.
---
## 10b. Group settings, and where they live
The group tab that was "Members" is **"Settings"**, and it is where anything about the
group is administered: invitations, operator pairing, your devices on this node, whether
members may upload, leaving or deleting the group, and the roster — last, because it is
the only part with no upper bound.
Two rules came out of building it and both are easy to undo by accident:
- **The tab bar does not wait for the node.** Membership is hub-side. Gating the tab on a
live connection makes "leave this group" unreachable exactly when a node is down, which
is when someone most wants it. Sections that do need the node say so.
- **A setting that governs the node is stored on the node and enforced by the node.**
`member_upload` is the worked example: signed operator instruction, row in `roster.db`,
refusal in `_do_file_upload`. Hiding the Upload button and the chat paperclip is a
courtesy to people who are not trying; it is not the control. See draft-v6 §2.1b.
## 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` | Still open for the **node**. For the **client**, the OS keychain path is proven: `safeStorage` on a real GNOME desktop uses the keyring (`v11`), and refuses rather than downgrading where there is none |
| 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, revised 2026-08-23** — 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). **Revision:** for the Videos group app, the node now also caches thumbnails and TMDB metadata durably in its own `data_dir` (not a shared root) — see `docs/mediacenter.md` §2 for the reasoning (TMDB quota is per-node not per-device; thin clients benefit from a node that does more) |
---
## 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.