diff options
Diffstat (limited to 'CLAUDE.md')
| -rw-r--r-- | CLAUDE.md | 576 |
1 files changed, 169 insertions, 407 deletions
@@ -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 <user>` 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. +## Design, security findings and protocol — one document -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. +**`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. -- **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 +| 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 | -## Invite redesign (2026-08-14) — closes H3 and M3 +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.** -See `docs/invite-pairing-v1.md`. Read it before touching invites, admin authority or -`gek_bundle_store`. +### Working rules that live here, not there -- **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 +These are about working on the tree rather than about the design: -## Keypair bundles and the browser KDF (2026-08-14) +- **The SPA served in production may be older than this tree.** Check the served + `/a/<hash>/` 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 -- 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) +## Engineering lessons -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/<hash>/` 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 +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 | +| 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` | -**Orange Livebox NAT** — Firefox/Chrome laptop (SFR) → node behind Orange residential NAT: +### Node -| Test | ICE path | Result | +| 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 | +| 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 -- **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` +| 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/<hash>/`. **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 | -## Key modules — où trouver quoi +### Browser / desktop UI (`meshbay_hub/static/`) -| Need | Module | File | +| 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 = `"<msg_type>\|<group_id>"`. **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/<hash>/` 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) |