summaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
diff options
context:
space:
mode:
Diffstat (limited to 'CLAUDE.md')
-rw-r--r--CLAUDE.md576
1 files changed, 169 insertions, 407 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 <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)