From 481d1ef89fa89366a6484a703491dee2c767cdfc Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 10 Sep 2026 18:45:15 +0200 Subject: docs(claude): cut the architecture, keep the lessons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md was half a second specification. It carried summaries of three security reviews, the invite redesign, per-node identity, the desktop client, a protocol-version history and a sixty-row module table — all of it now in MESHBAY_DESIGN.md, and some of it wrong: an errata list asserted a keystore parameter that had been raised months earlier, and the module table pointed at two implementations that no longer exist. An errata list beside a specification is a second specification, and the older one wins by being read first. Those sections become a pointer table naming which part of the design document answers which question. What stays is what has no other home: the conventions, and the engineering lessons — the ones that are not deducible from the design because they are what the code and the platforms actually do. They keep every word. The module table stays as locators, stripped of the design prose it duplicated, with a note kept only where it is a rule about editing the code. Every path in it was verified to exist; two were wrong, and the harness directory was wrong throughout. Two references to files outside the repository are gone from the reference table — a document about this repository should not send a reader somewhere they cannot follow. The rules about that directory stay, because they are rules about what must never be committed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7 --- CLAUDE.md | 604 +++++++++++++++++++------------------------------------------- 1 file changed, 183 insertions(+), 421 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 269598e..df80c54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,16 @@ ## What this project is -MeshBay is a decentralized peer-to-peer platform for file sharing, video streaming, and group messaging. -See `docs/meshbay-draft-v6.md` for the architecture specification. v6 restates only -what changed on 2026-08-17; `docs/meshbay-draft-v5.md` remains authoritative for -everything v6 does not touch (v3/v4 superseded). +MeshBay is a platform for **private, encrypted, self-hosted groups with an +application store** — a group is a set of people, a set of directories on +somebody's machine, and a set of applications over them (chat, files, video, +music, photos). It is not a public file-sharing network; public groups are an +optional hub feature and are off on the reference deployment. + +**`docs/MESHBAY_DESIGN.md` is the architecture specification.** +`docs/MESHBAY_NODE_PROTOCOL.md` is the wire format. Everything else under +`docs/` is either an operational guide, or a superseded document kept for its +cross-references and carrying a banner that says so. ## Repository structure @@ -72,22 +78,18 @@ dans le bon `pyproject.toml`. - All three packages share the same version number (released together) ### Protocol versions (independent) -- MNP: `1.0` → bumped independently of package version. **`handshake.py`'s - `MNP_MIN_SUPPORTED` is the other half**: both peers declare `v` and `v_min` on - the handshake and refuse each other with a code (`version_too_old` / - `version_too_new` / `version_unreadable`), so a mismatch is a refusal rather - than a field that turns up missing. Shipped with 1.0 because that flag day was - already being paid for; the next breaking change now costs a refusal message - - 0.2 added `PING`/`PONG` and backward chat paging (`before` / `has_more`). - Additive, so an 0.1 peer still works: it sends no `before` and is answered - with the newest page, which is what it wanted - - 0.4 added `apps_enabled`/`apps_enabled_ack` and `enabled_apps` on the - handshake ack, for the group-applications registry (`docs/apps.md`). - Additive, same reasoning -- MHP: `0.1` → bumped independently of package version -- Every wire message carries a `v` field -- Breaking change → MAJOR bump; backward-compatible → MINOR bump -- N-2 MINOR backward compatibility guaranteed + +MNP and MHP version independently of the package version, and both peers declare +`v` and `v_min` on the handshake so a mismatch is a stated refusal rather than a +field that turns up missing. **The rules, the current values and the history of +what each bump changed are `docs/MESHBAY_DESIGN.md` §5.6 and +`docs/MESHBAY_NODE_PROTOCOL.md` §13** — kept in one place because a version list +maintained in two drifts, and this copy did. + +The one rule worth repeating where changes get made: **a change to what a peer +must be able to *do* is MAJOR even when the messages are additive**, and there is +no opt-in compatibility switch — that leaves the old branch reachable on every +node, which is finding C6 one feature later. ## Commit messages (Conventional Commits) @@ -109,232 +111,59 @@ Scope: `hub`, `node`, `common`, or omitted for cross-cutting - **Never log GEK, private keys, or plaintext passwords** — even at DEBUG level - **meshbay.org is internet-facing** — open port → test → close port + kill processes in same block -## First security review (2026-08-10) — see `docs/first-review.md` - -**Critical (before Phase 7):** -- **C1** Chat: Sender Keys protocol, NOT shared Double Ratchet (pairwise protocol - would cause key/nonce reuse in group context). `ratchet.py` kept for future 1:1 DM. -- **C2** JWT must carry `"groups": [group_ids]` claim. Node MNP handshake must verify - group membership before serving content. Without this, any authenticated user - accesses any group. - -**Significant (Phase 7-8):** -- **S1** Admin revocation endpoint has no authz check ✅ DONE (Phase 8.1 — config-based require_admin) -- **S2** Email stored in plaintext (spec says encrypted at rest) ✅ DONE (Phase 8.2 — AES-256-GCM, HKDF from hub key) -- **S3** jti denylist push via hub→node WebSocket → Phase 7.2 -- **S4** AES-GCM keystore IV fixed: 128-bit → 96-bit (NIST SP 800-38D) ✅ DONE -- **S5** Refresh token rotation (one-time use) ✅ DONE (Phase 8.3 — family-based reuse detection) - -**Node sovereignty (2026-08-12):** -- **NS1** GEK-HMAC proof in handshake — blocks hub admin from accessing any group content ✅ DONE -- **NS2** Ed25519 challenge-response for admin operations — blocks hub admin impersonation ✅ DONE -- **NS3** `gek_req` endpoint removed — node never serves GEK in plaintext ✅ DONE -- **NS4** ~~`admin_pk_ed25519` auto-pinned from keystore~~ ❌ **that was finding M3.** The - keystore key is not the key the browser signs with, so every admin operation failed - closed. Authority now comes from the node's roster — `meshbay-node operator pair` - (2026-08-14). `admin_pk_ed25519` was kept as a legacy form and is now **removed** - (2026-08-15) — one source of authority, the roster. A config still naming it is - warned about at startup, never obeyed. Never auto-pin again, and never resolve the - operator's key through the hub -- **NS5** DTLS channel binding in GEK-HMAC — `HMAC(GEK, nonce || offer_fp || answer_fp)` detects WebRTC signaling MitM ✅ DONE -- **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE. - **Superseded as sufficient (2026-08-17):** that is the node's word. Messages must be - **signed with the sender's device key**, and clients pin `account → device keys`. - Likewise `_admin_exec_file_delete` authorizes against `entry.uploader_pk` — the exact - uploading key — which **device linking breaks**: it must become any non-revoked device - of `uploader_id`, resolved through the roster (never through a token claim). - See `docs/desktop-client-v1.md` §4.8 -- **NS7** Node Ed25519 auth — node daemon authenticates to hub via `POST /v1/nodes/auth` (Ed25519 signed timestamp), no auth_key/password on node. JWT `scope: "node"` blocks group management (create/add/delete/join). Operator manages groups from browser only. ✅ DONE -- **NS8** GEK-required enforcement — node REFUSES connections when GEK is None (no `gek_required: false` bypass). GEK initialization via node local admin UI only. ✅ DONE - -**Known remaining trust assumptions (Phase 12 — all actionable items done):** -- **T1** ✅ DONE: password split (auth_key / bundle_key, independent PBKDF2). Legacy migration on first login. -- **T2** ✅ **CLOSED 2026-08-14** (the finding is H3). Not by safety numbers: the invite - path stopped reading the directory. The node holds the GEK and wraps it for a key the - recipient proves possession of; identities are bound to accounts by one-time codes the - hub never sees. See `docs/invite-pairing-v1.md` -- **T3** SPA served by hub → fundamentally unsolvable in browser. Fix: native client or browser extension - -**T3 attack surface reduction (2026-08-12, all phases complete):** -- **Phase 1** ✅ DONE: GEK bundles moved from hub to node P2P (WebRTC DataChannel). No hub fallback. -- **Phase 2** ✅ DONE: Keypair bundles moved from hub to node P2P. Registration stores locally, pushed to node on first connect. Hub never stores keypair bundles. -- **Phase 3** ✅ DONE: Hub GEK cleanup — `GET /gek` endpoint removed, `GEKBundle` model removed, `gek_bundles` table dropped, `keypair_bundle` column removed, member-add URL cleaned (`/gek` suffix removed), Alembic migrations updated. - -**Browser crypto hardening (2026-08-13):** -- `_bundleKey` persisted in IndexedDB (CryptoKey survives page refresh) -- `_sessionKeys` persisted in sessionStorage (survives refresh, cleared on tab close) -- `_pkFromSk()`: derive X25519 public key from recovered private key via JWK export (no hub fetch) -- `regenerateKeys()` is gone entirely (2026-08-14): identity keys are per node, so - rotation is `meshbay-node member unpin ` plus a fresh code -- Raw answer SDP saved before `setRemoteDescription` (Chrome strips sha-256 from multi-hash SDP) -- Upload chunk size: 48KB (fits aiortc SCTP limit after msgpack overhead) - -**Architecture validated:** crypto primitives, GEK wrapping (ECIES), trust model, -key hierarchy, on-the-fly encryption, transport abstraction, DTLS channel binding. - -## Second security review (2026-08-13) — see `docs/second-review.md` - -**6 critical, 7 high findings. Phase 11.5 is BLOCKING — see `docs/devel-phases-next.md`.** -The current build must not host real private data. - -The claims above about node sovereignty and P2P crypto material were **overstated**. The -GEK-HMAC proof, Ed25519 admin challenge and channel binding are real, but they are enforced -on the WebRTC path only, and three other paths into the node were left behind. - -- **C1** Node HTTP API (`http_server.py`) serves private group **index and plaintext files - with no authentication**, on `0.0.0.0`, for every group — bypasses the entire sovereignty layer -- **C2** `/v1/nodes/ws` trusts a client-supplied `node_id` → any user hijacks a node's - signaling identity and impersonates it to browsers -- **C3** The node never authenticates itself to the client (`node_pk` is never verified, no proof of possession) -- **C4** Keypair bundles are served pre-proof and pushed to every node joined; PBKDF2-only → offline password attack -- **C5** Any member can overwrite arbitrary shared files (upload) and seize the group GEK (`gek_bundle_store` + auto-activation) -- **C6** GEK proof exists on WebRTC only — QUIC and TCP accept a bare JWT (chat injection) -- **H1** Multi-group nodes share one `chat_store` and one peer registry → cross-group chat leak -- **H2** Stored XSS in the node admin UI via uploaded filename → node takeover -- **H3** Hub is the key directory → key substitution at invite yields the GEK. "Unreadable - even by the hub" is true against a *passive* hub only - -## Invite redesign (2026-08-14) — closes H3 and M3 - -See `docs/invite-pairing-v1.md`. Read it before touching invites, admin authority or -`gek_bundle_store`. - -- **The node wraps the group key**, on every connection, for the X25519 key the joiner - signed with their pinned Ed25519 identity. **Nothing fetches a public key from the hub - to wrap for** — not the SPA, not `gek-init`. That lookup *was* H3 -- **`gek_bundle_store` is deleted**, not gated. No member hands the node key material -- **The node's roster decides who gets the key**, not hub membership: a hub that invents - an account and mints it a token gets `not_authorized_for_group` -- **One-time codes** bind a key to an account without the directory. 40 bits, single use, - one account, node-wide lockout. 7 days for invitations, 24 h for operator pairing, both - in `[node]` of node.toml -- **`join_policy`** (`invite`|`open`) is read from **node.toml, never the hub** — a hub - able to declare a group open would be handed its key. Unknown group ⇒ `invite` -- Operator surface over SSH: `operator pair`, `member list|invite|revoke|unpin`. Deleting - a file is the last browser-only operation -- Revocation now works for key delivery (nothing stored survives it) — but **still rotate - the GEK**, the ex-member holds the current one - -## Keypair bundles and the browser KDF (2026-08-14) - -- The bundle key is **Argon2id 128 MB / t=3 / p=1**, WebAssembly vendored under - `static/vendor/` (CSP forbids external hosts; 12.2 must keep `wasm-unsafe-eval`). - **Do not change the parameters in one place**: `keyderive.js`, the QE harness and - `test_bundle_kdf_parity.py` are held byte-identical by that test, and a mismatch - presents as an account nobody can open -- Bundles carry an `MBK2` marker; the PBKDF2 form is still readable and is - re-encrypted on the next backup. Both keys are derived at sign-in because the - passphrase is deliberately not retained -- Cost is paid **once per sign-in** (650 ms bundle + 239 ms auth_key); reloading a - page derives nothing — the key lives in IndexedDB -- The bundle is stored on **every node its owner joins**. That is what makes a - second browser work, and it is C4: cracking one yields identity keys, hence - content on *other* nodes and the ability to sign as that user. Draft-v5 §7.1 has - the measured numbers. **The passphrase is the wall; the KDF is a speed bump** -- Floor: 12 characters and ~60 estimated bits, enforced client-side only — with the - password split (T1) the hub never sees a passphrase - -## Identity keys are PER NODE (2026-08-14) - -See `docs/per-node-identity-v1.md`. Read it before touching registration, the -keypair bundle, or anything that looks like a user's public key. - -- A keypair is created at **first contact with a node**, encrypted under the - passphrase, and left on that node. Never reused elsewhere. Cracking one yields - the identity used with that operator and nothing anywhere else -- **The hub stores and publishes no user keys.** `users.pk_ed25519`/`pk_x25519` - are dropped, `PUT /me/keys` is gone, `/pubkeys` returns an account id and the - node linking key. Do not reintroduce a key directory — that was H3 -- **Tokens carry no `pk_user`.** The node recorded it as the uploader's identity - and authorized deletion against it, so whoever issued tokens decided who could - delete a file. Attribution uses the roster pin (`_pinned_pk`) -- Registration generates nothing, so a scripted signup is a real account — - `QE/deploy/demo.py bootstrap` takes a wiped hub and node to a working demo -- The key handed back on a join belongs to the **group of the connection**, not - the group named in the invitation (an operator pairs node-wide while opening a - group) - -## Where Phase 13 stands (2026-08-19) - -Stages A–D are **built and running**, not designed. `docs/desktop-client-v1.md` is still -the decision record; the sections below it describe what was decided, and this says what -exists. - -- Built: named roots, `ops.py` + CLI, device linking and hub device auth, the Electron - client (protocol handler, CSP header, `safeStorage` keys, streamed downloads to disk, - native folder picker), the group Settings tab, per-account resume positions, uploads - the operator can close -- Deployed: the hub runs MNP 0.3 with migration `e5a2b7d31f88`. **The SPA served in - production is older than this tree** — check `/a//` against - `meshbay_hub.api.webapp.ASSET_V` before concluding a fix is missing. `site/` and the - Caddy config have never been deployed -- Not built: D5 (node management panel), D6 (first-run wizard), D8 (`.deb`/`.rpm` — the - package must ship `chrome-sandbox` root-owned 4755), D9 (Python sidecar `group://`), - D10 (video thumbnails), D11 (Windows), D12 (release key and repo) -- One UI source: `packages/meshbay-hub/src/meshbay_hub/static/` is the interface, for the - web and the app alike. `packages/meshbay-client/build/sync-ui.js` copies it and CI - fails if the copy drifts — **never edit `packages/meshbay-client/ui/` by hand** -- Throwaway `e2e*` accounts accumulate on the production hub; the operator deletes them - -## Desktop client — decided, not built (2026-08-17) - -See `docs/desktop-client-v1.md`. Nothing here is implemented; it is the design and the -decision record for Phase 13. Read it before touching the roster, registration, or -anything that assumes one key per person. - -- **Electron**, not pywebview — structural decision 18 is reversed. The SPA depends on - Chromium-class APIs (WebRTC, WebCrypto X25519/Ed25519, MSE, Service Workers), so - keeping Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js` and - `sw.js` **as the client**. They are no longer on the "delete once native" list. A - Python sidecar reusing `quic_client.py` covers hub-less `group://` only -- **UI assets ship inside the package**, unchanged and non-negotiable. A shell pointing - at the hub's `/app/` fixes nothing -- **Device linking**: `identities.user_id` is a PRIMARY KEY and `pin_identity` does - `INSERT OR REPLACE` — one key per person per node, silently overwritten. Both must - change. A second device is admitted by the **already-pinned key countersigning**, - bound by a one-time code the new device generates; the hub holds no user keys and so - cannot produce that signature. Never make the approval a human comparing digits — - that is the safety-number ritual 12.1 was abandoned for -- **C4 is not fully closed by going native.** It closes for a native device - unconditionally, and stays open for any account that also uses a browser, which needs - a bundle on each node. An account is only as strong as its weakest client -- **`gek_rotate` may become a signed MNP op** — the C5b rule forbids *key material - arriving from outside*, not an operator-signed instruction where the node generates - the key itself. The initial `gek-init` stays local: with no GEK there is no session -- **Installation places files, never secrets.** No key generation in `%post`/`postinst` - or an MSI custom action — a golden image would give every machine the same key -- **A group has several named roots, not one `shared_dir`.** The name is the chosen - directory's **basename**, derived once at add time and *stored* — recomputing it from - the path re-identifies a whole library the day someone renames a folder. Duplicates - refused case-insensitively, no root nested in another, one operator-designated upload - target, availability per root, and `kind` + `layout` reserved for the planned - video/audio libraries. `config.py:103` is the single string this replaces -- **The planned video/audio libraries are VIEWS over the file index, not a catalogue.** - No metadata store, no server-side database, ever, and nothing reaching the hub — it - keeps no file names for private groups (H7). A file stays tied to its representation on - the filesystem: folders are the categories, and moving a file makes it a different - file. Everything a view needs already exists (whole-group index cached client-side, - 10b.5/10b.6). The only non-free piece is a video thumbnail -- **Enrichment happens on the client; what it cannot compute, the node produces on demand - and the asking device caches.** Neither node nor hub keeps durable derived state. This - is already the rule for chat thumbnails (draft-v5 §5.2) and it is the answer for video - thumbnails too — a frame grab is strictly less than the decoding the node already does - for streaming, over the same authorized path -- **A root that goes away must freeze, not empty.** `indexer.py` runs a watchdog - `Observer` and rebuilds on any change; unmounting a USB drive either emits deletions - for the whole tree or presents an empty directory to the next rescan. Both propagate as - though the owner erased their library. The per-root "unavailable" state ships **before** - root selection is offered -- **exFAT/NTFS and Windows are the common case, not an edge case.** Most users are - expected to share from an external exFAT or NTFS drive, on Windows, whatever the build - order says. Consequences that are correctness, not portability: filenames need NFC normalization for identity while keeping original bytes - for display; Windows reserved names and `MAX_PATH` affect what can be downloaded; - `ReadDirectoryChangesW` drops events under load, so periodic reconciliation is - mandatory. Never assume POSIX, systemd or case sensitivity. The upload no-overwrite check was *not* affected — `Path.exists()` is already case-insensitive there (checked 2026-08-18); case folding is for comparisons the code makes itself -- **Shipping the UI in a package creates version skew for the first time.** Today the SPA - and the hub deploy together, so a `/v1/` response shape and its caller change in one - commit. Once the UI is installed rather than served, `/v1/` is a compatibility surface - and `GET /v1/hub/version` needs a minimum client version — cheap now, awkward later +## Design, security findings and protocol — one document + +**`docs/MESHBAY_DESIGN.md` is the specification.** Everything that used to be +summarised here — the three security reviews, node sovereignty, the invite +redesign, per-node identity, the keypair-bundle KDF, the desktop client, the +content model — lives there, stated as the rule it is rather than the incident +that produced it. + +| Looking for | Read | +|---|---| +| What a label means (`C1`, `H3`, `NS6`, `T3`, `C5b`, `W2`, `E9`, `F1`, …) | `docs/MESHBAY_DESIGN.md` §13 | +| Trust model, and what the project may and may not claim | §2 | +| Identity, devices, admission, recovery, the keypair bundle | §3 | +| Cryptography, key hierarchy, the group and chat envelopes | §4 | +| The protocol: handshake, authorization, signed ops, leases, versioning | §5, and `docs/MESHBAY_NODE_PROTOCOL.md` for the wire format | +| The node, the hub, the clients, the applications | §6, §7, §8, §9 | +| Structural decisions that are not revisited | §14 | +| What is built, what is not, what is open | §15 | +| An older document's section reference (`draft-v5 §5.2`, `apps.md §3`, …) | §16, the concordance | + +The documents under `docs/` that this replaced are kept and carry a banner +saying where their content went. **Where any of them disagrees with +`MESHBAY_DESIGN.md`, the design document is right; where either disagrees with +the code, the code is.** + +### Working rules that live here, not there + +These are about working on the tree rather than about the design: + +- **The SPA served in production may be older than this tree.** Check the served + `/a//` against `meshbay_hub.api.webapp.ASSET_V` before concluding a fix + is missing. `site/` and the Caddy config have never been deployed +- **One UI source.** `packages/meshbay-hub/src/meshbay_hub/static/` is the + interface, for the web and the app alike; `packages/meshbay-client/build/ + sync-ui.js` copies it and CI fails if the copy drifts — **never edit + `packages/meshbay-client/ui/` by hand** +- **Never change the KDF parameters in one place.** `keyderive.js`, + `keyderive.py`, the QE harness and `test_bundle_kdf_parity.py` are held + byte-identical by that test, and a mismatch does not look like an error — it + looks like an account nobody can open +- **Raw answer SDP is saved before `setRemoteDescription`** — Chrome strips + sha-256 from a multi-hash SDP, and the fingerprint is the channel binding +- **Upload chunk size is 48 KB**, which is what fits the aiortc SCTP limit after + msgpack overhead +- Throwaway `e2e*` accounts accumulate on the production hub; the operator + deletes them + + +## Engineering lessons + +Each of these cost real time to find. They are kept because they are not +deducible from the design — they are what the code and the platforms actually +do. Read them before writing anything that touches the same mechanism. - **A content-addressed index cannot represent the same bytes at two paths.** `GroupIndex` is keyed by blake3, so `clip.mp4` at a root and in `uploads/` with @@ -436,7 +265,7 @@ anything that assumes one key per person. permanently a few pixels too tall: `.page-center` and `.layout` each reserving `100vh - 52px`, then the chat panel sized to `viewport - top - 16` while `.main` adds 24px of padding underneath it. Neither is visible in the - stylesheet, and both read as correct on their own. `tests/harness/ + stylesheet, and both read as correct on their own. `packages/meshbay-hub/tests/harness/ scroll_probe.py` measures the document against the window and runs the real `fit()` lifted out of `app.js` — the sizing code is never reimplemented in a test, or the test outlives the code it was written for @@ -471,7 +300,7 @@ anything that assumes one key per person. next visit to the tab, since the node had stored it and answered. Every line of `chat-app.js` is correct and every routed message in `transport.js` is routed correctly; the defect is in the seam, which is why - `tests/harness/chat_send_probe.py` drives the two together. `ack` was matched + `packages/meshbay-hub/tests/harness/chat_send_probe.py` drives the two together. `ack` was matched by request type (`chat_msg`, or the keypair-bundle store/delete that name themselves in `detail`), and that closed the instance — **but it left the class open, and it came back on 2026-09-06 through the other door.** A @@ -584,12 +413,12 @@ anything that assumes one key per person. bottom edge of every element), not by reading the stylesheet, which is the standing rule here -## Two lessons that cost four rounds of live testing +### More of the same, from four rounds of live testing -- **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the - client, written in the right order by construction: it proves the protocol and - nothing about the SPA. Three ordering bugs passed it and failed in a browser. - `test_spa_ordering.py` exists for that class and is worth extending +- **A protocol harness cannot test the SPA.** Any second implementation of the + client is written in the right order by construction, so it proves the protocol + and nothing about `app.js`. Three ordering bugs passed one and failed in a + browser. `test_spa_ordering.py` exists for that class and is worth extending - **An unbounded `await` on the hub socket makes a node silently unreachable.** Three instances found in `maintain_ws`: the offer handler awaited inside the read loop, `ws.recv()` for auth with no timeout, and `return` on auth refusal ending @@ -641,7 +470,7 @@ anything that assumes one key per person. - **A test that models a fix agrees with it by construction.** The first buffer-ceiling test transcribed the player's credit loop into a small model and passed, while the player it was written for still hung on a phone. The model and - the fix had the same author and the same misunderstanding. `tests/harness/ + the fix had the same author and the same misunderstanding. `packages/meshbay-hub/tests/harness/ mse_harness.mjs` lifts `bufferedAhead`, `evictBehind`, `flushQueue` and `pump` out of `app.js` *as text* and executes them; what it models is the browser. When even that was not enough, a headless Chrome driven against real fragmented MP4 @@ -658,7 +487,7 @@ anything that assumes one key per person. because the video is WebRTC. Renew on a margin, on returning to the tab, and on a 401 with a replay; coalesce concurrent renewals, or the second presents what the first just spent and looks exactly like theft. - `tests/harness/session_harness.mjs` runs it against a hub that enforces + `packages/meshbay-hub/tests/harness/session_harness.mjs` runs it against a hub that enforces rotation — a lax stub would pass the broken client - **An effect keyed on a value that used to be constant.** The WebRTC dial @@ -679,7 +508,7 @@ anything that assumes one key per person. panel: `width: 330px` was never the problem, the problem was that it is anchored to a button which is not at the right edge of the screen, so it hung 138 px off the left of a 320 px phone and hid the file names. No reading of - the rule would have shown that. `tests/harness/layout_probe.py` renders the + the rule would have shown that. `packages/meshbay-hub/tests/harness/layout_probe.py` renders the real stylesheet at a given width (in an iframe — a headless window will not go below ~500 px) and returns rectangles; one browser measures every width, because one apiece put three minutes on the suite. Assert on geometry, and @@ -705,7 +534,7 @@ anything that assumes one key per person. was in flight, granted no further credit, and the node waited for ever while logging a stream it had fed perfectly. A race, so it worked twice and hung on the third try; "it works now" is not evidence against a race, and - `tests/harness/window_leak.mjs` forces the worst case instead + `packages/meshbay-hub/tests/harness/window_leak.mjs` forces the worst case instead - **A new stream starts from a known state, and that list grows.** `appendingRef` and `endedRef` were the first two; `awaitingInitRef` and `seekTargetRef` @@ -757,55 +586,13 @@ anything that assumes one key per person. needs a person with a large file. Confirmed by the operator on 2026-08-15: Firefox, 180 MB, written to disk. Nothing multi-gigabyte has been measured -**Corrections to remember:** -- `punch_nat()` is **not** a NAT traversal stack — one UDP probe, no STUN, no candidate - gathering, one ISP validated. **ICE/STUN (WebRTC) is the traversal path**, for native - clients too (via `aiortc` in Python) -- Argon2id 256 MB was applied to the **hub only**; `crypto.py` keystore is still 64 MB -- ~~Sender keys must be distributed pairwise to identity keys, never GEK-derived.~~ - ~~Reversed 2026-09-03: sender keys are distributed GEK-wrapped.~~ - **Sender keys are not what group chat uses at all (decided 2026-09-07, built).** - Read `docs/chat-sender-keys.md` before touching chat. The reasoning that ended the - question: once distribution is under the group key *and* the node serves history to - devices that were not present, the node must retain each chain's **earliest** key, and - a chain key at iteration *i* yields every message key from *i* on by pure HKDF. Forward - secrecy is therefore zero either way, and what the ratchet was left buying was a large - amount of stateful client code with silent failure modes — three of them reproduced: - any member could sign as any other (`add_sender` accepts any distribution and the - signing key is bound to nothing), a second device dropped the first's chain, and - `_skipped_keys` grew without bound. `senderkeys.py` joins `ratchet.py` as "kept for a - possible future 1:1 DM"; **nothing in production imports it**, and its green tests are - not evidence that chat is encrypted -- ~~Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC - path.~~ **Both halves have changed.** Chat: 2026-09-07, **MNP 2.0** — see the row above and - `docs/chat-sender-keys.md`. **There is no switch**: chat is encrypted, the node refuses - any message that is not sealed, and a 1.x peer is refused *at the handshake* with - `version_too_old` rather than admitted and then unable to speak. An opt-in flag was - proposed and refused — every node is a test node, so it would have bought nothing and - left a plaintext branch reachable, which is C6's lesson one feature later. Existing node - data is migrated by `QE/migration/migrate_chat_encryption.py`, node stopped. - **The index half changed 2026-09-03 (MNP 1.0).** `index_sync`, - `index_delta` and the `handshake_ack` configuration payload are sealed under a - GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/ - `openGroup` in `crypto.js`); only `type`, `v`, `group_id` and the ack's own - `node_pk`/`proof`/`sig` stay in clear, because a receiver must route and - **authenticate** before it would trust a decryption. Chat is unchanged and out of - scope by decision. Read `MESHBAY_NODE_PROTOCOL.md` §11.1a before touching either - message. Three things to keep straight: - - **The ack line is integrity, not confidentiality.** `handshake_transcript` - names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the - rest were authenticated by the DTLS channel alone. The AEAD tag comes from a - key the hub does not hold - - **The index line is defence in depth against our own next bug**, of a class - already shipped twice: C1 (node HTTP API served the index on `0.0.0.0` - unauthenticated) and C6 (TCP accepted a bare JWT with no GEK proof). It buys - nothing against an observer, the hub, or a member. That is the whole claim - - **A payload that does not open ends the session**, never a default: an - unopenable `enabled_apps` reads as "the operator disabled every app" and an - unopenable index as "the group is empty", both indistinguishable from - legitimate states. `index_progress` is deliberately *not* sealed (counters - only, every 2 s) — the reason lives next to the code, re-read it before - changing it +**Corrections that used to live here** — `punch_nat()` is not a traversal stack, +the node keystore's Argon2id parameters, what group chat actually uses, and what +is sealed on the wire — are now design statements in `docs/MESHBAY_DESIGN.md` +(§5.1, §4.6, §4.5, §4.4). They were kept here as a running errata list and had +drifted: one of them asserted a keystore parameter that had been raised months +earlier. An errata list beside a specification is a second specification, and the +older one wins by being read first. ## Known calibration TODOs @@ -813,129 +600,104 @@ anything that assumes one key per person. Existing v1 users (64 MB) are transparently rehashed on next successful login. CLI `calibrate` command still TODO for per-hardware tuning. -## NAT traversal — empirical results +## NAT traversal -### QUIC native clients (demo-v2) +Two residential ISPs validated, both without TURN; the hub relays under a +kilobyte of signaling and the data path is peer to peer. **The measurements are +`docs/MESHBAY_DESIGN.md` §11.1**, and the design that rests on them is §5.1. -SFR residential Fedora 44 → meshbay.org OVH VPS: -- **NAT type**: Port-Restricted Cone -- **Mechanism**: `QuicChunkServer.punch_nat()` sends probe from QUIC server socket -- **Scripts**: `QE/demo-v2/` +## Key modules — where to find what -### WebRTC browser clients (Phase 9 spike, 2026-08-10) +Locators only. **Why any of this is shaped the way it is, is +`docs/MESHBAY_DESIGN.md`**; the section is named where it is not obvious. Notes +here are kept only where they are a rule about *editing* the code. -**SFR residential NAT** — Mobile 4G SFR → node behind SFR residential (Port-Restricted Cone + CGNAT 4G): +### Common -| Test | ICE path | Result | +| Need | File | Note | |---|---|---| -| WiFi LAN | IPv6 direct | OK, ~100ms | -| 4G + IPv6 | IPv6 inter-network | OK, ~600ms | -| 4G + IPv4 only (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~650ms | - -**Orange Livebox NAT** — Firefox/Chrome laptop (SFR) → node behind Orange residential NAT: - -| Test | ICE path | Result | +| Chunk encryption | `meshbay_common/crypto.py` | §4.3 | +| GEK wrap/unwrap (ECIES) | `meshbay_common/crypto.py` | §4.2 | +| Sealing a payload under the group key | `meshbay_common/groupbox.py` + `sealGroup`/`openGroup` in `static/crypto.js` | §4.4. **Never reuse the chunk key with a pseudo-file for this** | +| Chat sealing and signing | `meshbay_common/chatbox.py` + `sealChat`/`openChat`/`verifyChatSignature` in `static/crypto.js` | §4.5 | +| Handshake, version range | `meshbay_common/handshake.py` — `MNP_MIN_SUPPORTED`, `check_version` | read by both servers and both clients | +| Wire messages, `req_id`, `IndexEntry` | `meshbay_common/protocol.py` | §5.3 | +| Signed admin transcripts | `meshbay_common/adminop.py`, `join.py`, `device.py` | §5.4 | +| Key derivation from a passphrase | `meshbay_common/keyderive.py` + `static/keyderive.js` | §3.1. **Parity-tested — never change the parameters in one place** | +| Path folding, NFC, long paths, reserved names | `meshbay_common/paths.py` | §10 | +| ~~Double Ratchet / Sender Keys~~ | — | **Deleted.** Both were written and never called. Kept code that nothing calls reads as an alternative somebody may reach for, and its green tests read as evidence of a protection that is not in the product. The reasoning that ruled them out is at the top of `chatbox.py` | + +### Node + +| Need | File | Note | |---|---|---| -| Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms | -| Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms | -| Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms | - -- **Two ISPs validated** — SFR + Orange residential NAT, both work without TURN -- **No TURN relay needed** — ICE/STUN handles both NAT types automatically -- **Hub role**: signaling only (SDP/ICE relay via WebSocket, <1 KB) -- **Data path**: browser ↔ node P2P via WebRTC DataChannel -- **Scripts**: `QE/demo-v3/run_node_webrtc.py`, test page at `/webrtc-test.html` - -## Key modules — où trouver quoi - -| Need | Module | File | +| Operator operations | `meshbay_node/ops.py` | **One implementation, several front doors.** The loopback API, the CLI and the signed MNP handlers all call these | +| Local control API | `meshbay_node/ui/app.py` | JSON only, loopback + per-run token. Each operation endpoint is one `_op(...)` line | +| Roster, identities, devices, invites, group settings | `meshbay_node/roster.py` | §3.3, §3.4 | +| Roots, availability, eject/plug | `meshbay_node/roots.py`, `config.py` | §6.2 | +| Indexing, hashing, enrichment | `meshbay_node/indexer/` — `indexer.py`, `cache.py`, `group_index.py`, `enrich*.py`, `title_parse.py` | §6.3 | +| Transfer leases and queueing | `meshbay_node/transfers.py` | §5.5. Deliberately free of asyncio and of the transport | +| Uploads, resume state | `meshbay_node/uploads.py` | §6.4 | +| WebRTC transport and every MNP handler | `meshbay_node/transport/webrtc_server.py` | the big one | +| QUIC transport | `meshbay_node/transport/quic_server.py` | off by default; **must stay at parity with the WebRTC handlers** | +| Background tasks (node) | `webrtc_server.py` — `_spawn()` | the only way to start one; a bare `ensure_future` can be collected | +| Stream handover, backpressure | `webrtc_server.py` — `_replace_stream`, `shutdown_tasks`, `DOWNLOAD_BUFFER_HIGH` | §8.5 | +| Stream diagnosis | `webrtc_server.py` — `client_diag` at DEBUG | the player's own view in the node's log; the only window into a phone | +| Chat store and paging | `meshbay_node/chat/store.py` | `get_recent`/`get_before`/`has_before`; `get_messages` pages *forwards* and is not what a chat opens with | +| Chat epochs | `meshbay_node/ops.py` — `open_chat_epoch`, `ensure_chat_epoch`, `chat_epoch_keys` | §4.5 | +| Keystore | `meshbay_node/keystore.py` | §4.6 | +| Bundle store (GEK + keypair bundles) | `meshbay_node/bundle_store.py` | §3.7 | +| Media cache, third-party metadata | `meshbay_node/media_cache.py`, `tmdb.py`, `musicbrainz.py`, `media_probe.py` | §6.5 | +| Link previews | `meshbay_node/linkpreview.py` — `safe_url` | §6.5. SSRF gate | +| Audit log | `meshbay_node/audit.py` | legal compliance | +| Hub socket client | `meshbay_node/hub_client.py` | `login()` (Ed25519) + `maintain_ws()` | +| Daemon, CLI, config | `meshbay_node/daemon.py`, `config.py`, `platform.py` | §6.7, §6.8 | + +### Hub + +| Need | File | Note | +|---|---|---| +| Auth dependencies | `meshbay_hub/api/deps.py` — `require_admin`, `require_moderator`, `require_user_scope` | §7.5 | +| Accounts, devices, recovery | `meshbay_hub/api/users.py` | §3.6 | +| Node auth and registration | `meshbay_hub/api/nodes.py` | §7.2 | +| Signaling relay | `meshbay_hub/api/signaling.py` | §7.2 | +| Groups, membership, presence, public-group quota | `meshbay_hub/api/groups.py` | §7.3 | +| Admin API, instance policy, moderation | `meshbay_hub/api/admin.py`, `hub.py` | §7.4, §7.5 | +| Notifications, federation, relays, reports | `meshbay_hub/api/notifications.py`, `federation.py`, `relay.py`, `moderation.py` | §7.6 | +| Asset versioning | `meshbay_hub/api/webapp.py` — `_asset_version()`, `_ASSETS` | the whole module graph is served under `/a//`. **A new static file must be added to `_ASSETS`** | +| Token lifetimes | `meshbay_hub/config.py` — `[jwt]` | 4 h access, 30 days refresh. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there | + +### Browser / desktop UI (`meshbay_hub/static/`) + +| Need | File | Note | |---|---|---| -| Chunk encryption (prod) | `meshbay_common.crypto` | `crypto.py` | -| Sealing a payload under the GEK | `meshbay_common.groupbox` | `groupbox.py` + `sealGroup`/`openGroup` in `static/crypto.js`. `index_sync`, `index_delta`, `handshake_ack` — one envelope, purpose-separated subkeys, AAD = `"\|"`. **Never reuse `chunk_key_aes` with a pseudo-file for this** | -| MNP version range | `meshbay_common.handshake` | `MNP_MIN_SUPPORTED`, `check_version` — read by both servers and both clients | -| Key derivation from password | `meshbay_common.keyderive` | `keyderive.py` | -| Key bundle (web) | `meshbay_common.keyderive` | `keyderive.py` + `static/keyderive.js` | -| GEK wrap/unwrap (ECIES) | `meshbay_common.crypto` | `crypto.py` | -| Double Ratchet (1:1 DM, future) | `meshbay_common.ratchet` | `ratchet.py` | -| Chat encryption (group chat) | `meshbay_common.chatbox` | `chatbox.py` + `sealChat`/`openChat`/`verifyChatSignature` in `static/crypto.js`. One key per group, per epoch, per **device**, derived by name from an epoch key the node generates and delivers wrapped under the GEK — so rotating the GEK is a re-wrap and does not destroy the archive. **No mutable sending state**, which is the C1/§15.0b hazard removed rather than partitioned — and not the same claim as "two devices never share a key", which is false: two clients of one account normally recover the *same* identity key from the keypair bundle, so they share a subkey. Safe because the nonce is 96 random bits and never a counter. Messages are signed over the **ciphertext** with the device's pinned Ed25519 key | -| Chat epochs (node) | `meshbay_node.ops` | `open_chat_epoch` / `ensure_chat_epoch` / `chat_epoch_keys`. Epoch 1 is opened at group load (`daemon._ensure_chat_epoch`) — a group with no epoch is a group nobody can speak in. A new epoch on every removal (member, device, unpin, `gek_rotate`); **old epochs are kept and still delivered**, which is what keeps history readable, and nothing anywhere deletes one. Keys are wrapped to the node's own X25519 key in `bundles.db`, never stored raw | -| ~~Sender Keys (group chat)~~ | `meshbay_common.senderkeys` | **Unused.** Kept for a possible future 1:1 DM, like `ratchet.py` — see the corrections above | -| AES-GCM (browser) | `meshbay_common.webcrypto` | `webcrypto.py` + `static/crypto.js` | -| Node keystore | `meshbay_node.keystore` | `keystore.py` | -| QUIC NAT punch (native) | `meshbay_node.transport.quic_server` | `QuicChunkServer.punch_nat()` | -| WebRTC transport (browser) | `meshbay_node.transport.webrtc_server` | Phase 9.3 — `aiortc` DataChannel | -| WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay | -| Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel | -| Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router. Routing and every page except a group's — see `docs/apps.md` for the group UI's file layout (2026-08-23 split) | -| Group applications (adding one) | `docs/apps.md` | Chat/Files/Videos/Music today (see `docs/mediacenter.md` for Videos: poster grid, TMDB metadata, season tabs, manual match correction; `docs/musicbay.md` for Music: album grid, MusicBrainz metadata, no new streaming path, persistent shell-level player), Photos designed but not built (see `docs/photos.md`: several photo roots per group, one album-grid view, EXIF read locally with no third party). Props contract, enablement mechanism, checklist | -| File download (large) | `static/file-utils.js` | `downloadEntry`/`_openDownloadTarget`, File System Access API (`showSaveFilePicker`) — stream to disk | -| Background tasks (node) | `meshbay_node.transport.webrtc_server` | `_spawn()` — the only way to start one; a bare `ensure_future` can be collected | -| Stream handover (node) | `meshbay_node.transport.webrtc_server` | `_replace_stream` + `shutdown_tasks` — one viewer, one film, and the slot comes back when they leave | -| Download backpressure (node) | `meshbay_node.transport.webrtc_server` | `DOWNLOAD_BUFFER_HIGH` — 8 × 1 MB answered blind queues 8 MB on the channel | -| Streamed download (browser) | `static/downloads.js` + `static/sw.js` | Needs the page *controlled*, and the worker confirms it served the request | -| Leave a group (hub) | `meshbay_hub.api.groups` | `POST /v1/groups/{id}/leave` — self only; the owner is refused | -| Public group cap (hub) | `meshbay_hub.api.groups` | `_check_public_group_quota` — 10 live public groups per owner, staff exempt. **Checked at creation only, because PATCH refuses to change visibility** | -| Uploads on/off (node) | `meshbay_node.roster` + `transport.webrtc_server` | `member_upload_allowed` / `set_member_upload`, gate in `_do_file_upload`. Per group, **operator-signed** (`OP_MEMBER_UPLOAD`), stored in `roster.db`, cached in the group context because the upload path is synchronous. **Absent means allowed** at every layer | -| Node presence (hub) | `meshbay_hub.api.groups` | `node_online` on `/v1/groups/mine`, read from the signaling registry — no poll, no timer | -| Account → device pinning (Tier 2) | `meshbay_node.roster` + `static/transport.js` | `group_devices` relays each live device of each active member **with the countersignature that admitted it** (`add_sig`/`add_nonce`/`add_ts` — kept since 2026-09-07; before that the proof was verified and discarded, which is what blocked this). `group_roster_req/resp` is sealed and answers **any member**. The client walks the chain itself (`_verifyRoster`) — the node decides nothing, because it is the party the property holds against. **Once a client has seen an account, a later key substitution is detected; nothing is gained at first sight** | -| Chat message handling (node) | `meshbay_node.transport.webrtc_server` | `_do_chat_message` + `_check_chat_envelope`. `sender_id` from the session (NS6); the *device* claim is checked against the connection's own `device_hello`, or a member could sign as anyone. Replay refused by a unique `(device, nonce)` in `chat.db` — a replay is a validly signed copy, so nothing about the signature refuses it | -| Which device is on a connection | `meshbay_node.transport.webrtc_server` | `_do_device_hello` (MNP 1.2, additive). The handshake proves the *account*; this proves the *device*. Before it, `_load_pinned_pk` used the account's oldest key and recorded it as the uploader of every file | -| Chat paging (node) | `meshbay_node.chat.store` | `get_recent` / `get_before` / `has_before`. `get_messages` pages *forwards* and is not what a chat opens with | -| Liveness (MNP) | `meshbay_common.protocol` | `PING`/`PONG` on an **already-open** channel; never for discovery — a handshake costs 0.6-7 s | -| Profile page (browser) | `static/app.js` | `ProfilePage` — identity, node link, pins, account deletion. Settings keeps behaviour | -| i18n (browser) | `static/i18n.js` | `t()` lookup + `Intl.PluralRules`, region-aware resolution, localStorage lang selection | -| Translation catalogues | `static/locales/*.js` | One per language, fetched on demand. `en.js` is the source; `test_locales.py` holds the other nine to its key set | -| Admin API (hub) | `meshbay_hub.api.admin` | Phase 10.2 — user/group mgmt, audit logs, stats | -| Admin UI (browser) | `static/app.js` | Phase 10.3–10.4 — AdminPage component, 5 tabs | -| Auth dependencies | `meshbay_hub.api.deps` | `require_admin`, `require_moderator`, `get_current_user`, `require_user_scope` | -| Node auth (hub) | `meshbay_hub.api.nodes` | `POST /v1/nodes/auth` — Ed25519 challenge-response, node-scoped JWT | -| Site overlay | `site/` | Phase 10.1 — landing, about, downloads (meshbay.org-specific) | -| Notifications (hub) | `meshbay_hub.api.notifications` | Phase 10.5 — CRUD, per-user, triggered by admin/group actions | -| Version check (hub) | `meshbay_hub.api.hub` | Phase 10.10 — `GET /v1/hub/version` | -| Group self-service (hub) | `meshbay_hub.api.groups` | Phase 10b — create, join, members (GEK exchange is P2P) | -| File upload (node) | `meshbay_node.transport.webrtc_server` | Phase 10b.4 — FILE_UPLOAD MNP handler | -| GEK wrap AES (browser) | `static/crypto.js` | Phase 10b.2 — AES-256-GCM ECIES for WebCrypto | -| GEK HMAC proof (browser) | `static/crypto.js` | `hmacGEK()` — HMAC-SHA256 with DTLS channel binding | -| DTLS fp extraction (browser) | `static/transport.js` | `_extractDtlsFingerprint()` — SDP fingerprint for channel binding | -| DTLS fp extraction (node) | `meshbay_node.transport.webrtc_server` | `_extract_dtls_fingerprint()` — SDP fingerprint for channel binding | -| Ed25519 sign (browser) | `static/keyderive.js` | `signChallenge()` — admin challenge-response | -| Auth key derivation (browser) | `static/keyderive.js` | `deriveAuthKey()` — password split, hub never sees raw password | -| GEK wrap AES (Python) | `meshbay_common.crypto` | Phase 10b.2 — `wrap_gek_aes()` / `unwrap_gek_aes()` | -| IndexedDB cache (browser) | `static/hub-client.js` | Phase 10b.5 — group index caching, moved out of app.js in the 2026-08-23 split | -| Cross-group search (browser) | `static/app.js` | Phase 10b.6 — SearchPage, client-side | -| MSE video streaming (node) | `meshbay_node.transport.webrtc_server` | Phase 10c — ffmpeg fMP4 remux + encrypted segments | -| MSE video streaming (browser) | `static/video-player.js` | Phase 10c — MediaSource + SourceBuffer progressive playback, moved out of app.js in the 2026-08-23 split | -| Video codec detection | `meshbay_node.transport.webrtc_server` | Phase 10c — `_probe_video()` ffprobe + MSE codec strings | -| Node daemon (production) | `meshbay_node.daemon` | Phase 11 — WebRTC + WS + chat + HTTP + audit all wired | -| Node config | `meshbay_node.config` | `node.toml` loader, `data_dir` for chat/audit DBs | -| Hub WS client | `meshbay_node.hub_client` | `login()` (Ed25519) + `maintain_ws()` + `send_ws()` — no auth_key on node | -| Chat store | `meshbay_node.chat.store` | SQLite per-group, `data_dir/{group_id}/chat.db` | -| Audit store | `meshbay_node.audit` | SQLite IP/action log, `data_dir/audit.db` (legal compliance) | -| Bundle store (node) | `meshbay_node.bundle_store` | SQLite P2P GEK + keypair bundles, `data_dir/bundles.db` — hub never stores crypto | -| P2P bundle exchange (MNP) | `meshbay_common.protocol` | GEK + keypair bundle STORE/FETCH/RESP message types | -| Bundle via DataChannel | `static/transport.js` | GEK + keypair bundle fetch during handshake, store after connect | -| Key persistence (browser) | `static/hub-client.js` | `session.bundleKey` (renamed from the bare `_bundleKey` in the 2026-08-23 split) in IndexedDB, `_sessionKeys` in sessionStorage | -| pkX from private key | `static/transport.js` | `_pkFromSk()` — JWK export to derive X25519 public key | -| Group delete (hub) | `meshbay_hub.api.groups` | `DELETE /v1/groups/{group_id}` — admin only | -| JWT scope enforcement | `meshbay_hub.api.deps` | `require_user_scope` — blocks node-scoped tokens from mutations | -| Operator operations | `meshbay_node.ops` | **One implementation, several front doors.** The loopback API, the CLI and the signed MNP handlers all call these; they take the daemon `state`, raise `OpError`, and know nothing about HTTP. Two implementations of one operation with two authorization checks is C1/C6 one size down | -| Node local control API | `meshbay_node.ui.app` | JSON only, loopback + per-run token (localhost:18000): status, groups/roots, roster, denylist, node settings, peers, audit. Clients: the `meshbay-node` CLI and the desktop client's Node page. Each operation endpoint is one `_op(...)` line. The server-rendered dashboard, the `ui` CLI verb and the never-wired chat/config endpoints were removed 2026-09-01 (`docs/refactor-node-ui.md`) | -| Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) | -| Video flow control (browser) | `static/video-player.js` | `pump()` — the only place credit is granted. Read-ahead bounded by `BUFFER_AHEAD_S` of film, `STREAM_WINDOW` segments in flight, driven by a clock and by playback, never by arriving data | -| Player under test | `tests/harness/mse_harness.mjs` | Runs the real `pump`/`flushQueue`/`evictBehind` against a fake SourceBuffer with a ceiling. Do not write a second model of them | -| Asset versioning (hub) | `meshbay_hub.api.webapp` | `_asset_version()` — content hash; whole module graph served under `/a//` so a cache cannot mix two builds | -| Stream capacity (node) | `meshbay_node.config` | `[node] max_concurrent_streams` (default 8) — a slot is held for the length of a film, so it counts simultaneous viewers | -| Stream diagnosis (node) | `webrtc_server.py` | `client_diag` at DEBUG — the player's own view (`ready`, `quota`, `ranges`, `err`) in the node's log. The only window into a phone | -| Stream probe (no browser) | — | `QE/deploy/stream_probe.py` — pulls a real film over real MNP, `--start` to seek. Answers "is it the node or the browser" in one run (not versioned) | -| Seeking (browser) | `static/video-player.js` | `requestSeek` → node restarts ffmpeg with `-ss`; `reinitAt` clears the buffer and sets `timestampOffset`. `-copyts` does *not* preserve position — measured — so the offset comes from the client | -| Seeking (node) | `webrtc_server.py` | `start` on `stream_req`; `-ss` **before** `-i` (index seek, not decode-and-discard), clamped away from the end, echoed in `stream_init` | -| Resume position | `static/video-player.js` | `readResumePosition` / `writeResumePosition` — localStorage, per file, per browser. No protocol, and nothing new learns what you watch | -| Layout, measured | `tests/harness/layout_probe.py` | Renders `style.css` in Chrome at any width and returns bounding boxes. Use it for layout, not `test_layout_responsive.py`, which only pins CSS values | -| Chat scrolling, measured | `tests/harness/chat_scroll_probe.py` | Mounts the real `ChatPanel` in Chrome and reads a conversation back. Answers "can the reader scroll up" and "does the panel resize itself"; `test_chat_scroll_bottom.py` only pins the source's shape | -| Chat sending, measured | `tests/harness/chat_send_probe.py` | Mounts the real `ChatPanel` over the real `MeshBayTransport` (only the DataChannel is a stand-in) and types a message. Answers "does the send come back" — the freeze it was written for lives in the seam between the two, so neither source shows it | -| Group landing tab, measured | `tests/harness/group_tab_probe.py` | Renders the real `GroupPage` against a stub node answering a chosen `enabled_apps`, and reads the tab bar back. The landing tab is picked from a preference at mount; the app list arrives from the handshake later, and the two can disagree | -| Session renewal (browser) | `static/hub-client.js` | `refreshAccessToken` / `ensureFreshToken` — one writer (`setAuth`), one in-flight renewal, rotated refresh token stored. `hubFetch` renews on 401 and replays. Moved out of app.js in the 2026-08-23 split | -| Token lifetimes (hub) | `meshbay_hub.config` | `[jwt] access_token_ttl` 4 h, `refresh_token_ttl` 30 days. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there | +| Routing and every non-group page | `app.js` | | +| Group shell, index, tabs, modals | `group-page.js` | §9.1 | +| Application registry | `apps.js` | §9.4 — one entry per application | +| Applications | `chat-app.js`, `files-app.js`, `video-app.js`, `music-app.js`, `photos-app.js` (+ `*-app-settings.js`) | §9.5–§9.9 | +| Shared settings widgets | `settings-ui.js`, `folder-tree.js` | **A pane must not import `group-settings.js`** — that is an import cycle, and it fails as a component that silently does not render | +| Transport, handshake, device hello, roster verify | `transport.js` | §5.2, §3.3 | +| Crypto | `crypto.js`, `keyderive.js` | §4 | +| Hub session, token renewal, IndexedDB cache | `hub-client.js` | §3.1 | +| Where the hub is | `platform.js` — `hubBase()` | **the only file allowed to decide this** (§8.3) | +| Downloads, decrypt pipeline | `file-utils.js`, `downloads.js`, `sw.js` | §8.5 | +| Video player | `video-player.js` — `pump()` is the only place credit is granted | §8.5 | +| Transfers widget | `transfers.js` | §5.5 | +| Cross-group merge | `source-merge.js`, `group-name.js` | §9.11 | +| Node page | `node-page.js` | §6.7 | +| i18n | `i18n.js`, `locales/*.js` | `en.js` is the source; **ten catalogues, and a new key goes in all ten** | + +### Test harnesses that drive the real thing + +| Need | File | +|---|---| +| Layout, measured in a browser | `packages/meshbay-hub/tests/harness/layout_probe.py` | +| Chat scrolling / sending, measured | `packages/meshbay-hub/tests/harness/chat_scroll_probe.py`, `chat_send_probe.py` | +| Group landing tab, measured | `packages/meshbay-hub/tests/harness/group_tab_probe.py` | +| Page height against the window | `packages/meshbay-hub/tests/harness/scroll_probe.py` | +| The real player against a fake source buffer | `packages/meshbay-hub/tests/harness/mse_harness.mjs` — **do not write a second model of `pump`/`flushQueue`/`evictBehind`** | +| Flow-control worst case | `packages/meshbay-hub/tests/harness/window_leak.mjs` | +| Session renewal against a hub that enforces rotation | `packages/meshbay-hub/tests/harness/session_harness.mjs` | ## meshbay.org server (état cible) -- cgit v1.2.3