# MeshBay — Project Conventions ## What this project is 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 ``` meshbay/ ├── packages/ │ ├── meshbay-common/ # Shared crypto + protocol — python3-meshbay-common RPM │ ├── meshbay-hub/ # Hub server (FastAPI + PostgreSQL) — meshbay-hub RPM │ └── meshbay-node/ # Node daemon + local UI — meshbay-node RPM ├── poc/ # POC spike scripts (reference, not production) ├── docs/ # Architecture drafts and POC plans ├── packaging/ # RPM spec files, DEB control files, systemd units └── QE/ # NOT versioned (.gitignore) — test artefacts, credentials, demos ├── demo-v1/ # Scripts démo opérationnels (setup_demo.py, run_node.py, download.py) ├── spikes/ # Expérimentations futures (remplace ~/draft/) └── server-state/ # Inventaire de ce qui tourne sur meshbay.org ``` **Règle QE/** : tout test sur meshbay.org doit ouvrir le port UFW, tester, et fermer le port + tuer les processus dans le MÊME bloc de commandes. Jamais de processus orphelins ni de ports ouverts après un test. ## Python environment - **Minimum Python:** 3.12 - **Build backend:** hatchling (per package `pyproject.toml`) ```bash # Créer le venv (--clear si recréation sur une autre machine/OS) python3 -m venv .venv --clear source .venv/bin/activate # Toutes les dépendances sont déclarées dans les pyproject.toml — un seul pip install suffit pip install -e packages/meshbay-common -e packages/meshbay-hub -e packages/meshbay-node pip install pytest pytest-asyncio aiosqlite # extras dev ``` Les deps clés (aioquic, watchdog, fastapi, blake3, etc.) sont dans les `pyproject.toml` et installées automatiquement. Ne pas ajouter manuellement des packages sans les déclarer dans le bon `pyproject.toml`. > **Ne jamais copier `.venv/` entre machines d'OS différents.** Si rsync depuis Fedora vers Ubuntu, > exclure `.venv/` et recréer sur la cible avec `python3 -m venv .venv --clear`. > Sans `--clear`, `certifi.where()` pointe vers un chemin Fedora inexistant sur Ubuntu → `FileNotFoundError`. ```bash # Lancer les tests .venv/bin/pytest ``` ## Code conventions - **Linter/formatter:** ruff (`uv run ruff check .` / `uv run ruff format .`) - **Line length:** 100 - **Type hints:** required on all public functions - **Comments:** only when the WHY is non-obvious; no docstrings restating the function name - **No prints in library code** — use `logging` module ## Versioning ### Package versions (SemVer) - Format: `MAJOR.MINOR.PATCH` - Pre-1.0: breaking changes bump MINOR, not MAJOR - All three packages share the same version number (released together) ### Protocol versions (independent) 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) ``` feat(node): add directory watcher with watchdog fix(hub): include jti in all JWT tokens chore(common): add Argon2id calibration to crypto.py docs: update draft v3 with POC findings test(common): add wrap/unwrap GEK round-trip test ``` Types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `perf` Scope: `hub`, `node`, `common`, or omitted for cross-cutting ## Security rules - **Never commit private keys** (hub_private.pem, *.key, unlock.key, keystore.enc) - **Never commit QE/** — credentials, test keys, demo data go there - **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 ## Design, security findings and protocol — one document **`docs/MESHBAY_DESIGN.md` is the specification.** Everything that used to be summarised here — the three security reviews, node sovereignty, the invite redesign, per-node identity, the keypair-bundle KDF, the desktop client, the content model — lives there, stated as the rule it is rather than the incident that produced it. | Looking for | Read | |---|---| | What a label means (`C1`, `H3`, `NS6`, `T3`, `C5b`, `W2`, `E9`, `F1`, `AV4`, …) | `docs/MESHBAY_DESIGN.md` §13 | | What one member can cost the others (`AV1`–`AV19`) | §13.5b — the newest category, and the one the first three reviews had no question for | | 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 | | A reference to a document that no longer exists (`draft-v5 §5.2`, `apps.md §3`, …) | §16, the concordance — it maps every one onto its replacement section | The documents under `docs/` that this replaced are kept and carry a banner saying where their content went. **Where any of them disagrees with `MESHBAY_DESIGN.md`, the design document is right; where either disagrees with the code, the code is.** ### Working rules that live here, not there These are about working on the tree rather than about the design: - **The SPA served in production may be older than this tree.** Check the served `/a//` against `meshbay_hub.api.webapp.ASSET_V` before concluding a fix is missing. `site/` and the Caddy config have never been deployed - **One UI source.** `packages/meshbay-hub/src/meshbay_hub/static/` is the interface, for the web and the app alike; `packages/meshbay-client/scripts/ sync-ui.js` copies it (`npm run sync-ui`) and CI fails if the copy drifts — **never edit `packages/meshbay-client/ui/` by hand** - **`%(here)s` in `alembic.ini` makes a copy of it correct only where it was copied from.** The packaged unit ran `alembic -c /opt/meshbay-hub/migrations/alembic.ini`, a file the build did stage — so the path existed and the *contents* were wrong: `script_location` resolved to `…/migrations/src/meshbay_hub/db/migrations`, which nothing installs, because the migrations ship inside `meshbay_hub` in the shared venv. `ExecStartPre` failing stops the unit, so **a hub installed from the RPM or the DEB could not start at all**, and nothing noticed because the one live deployment was assembled by hand. The same trap had already been found on the server, where a stray `alembic.ini` pointed at a month-old snapshot. Twice is a trap: the path is not written down anywhere now — `meshbay-hub migrate` asks the installed package where its own migrations are, which is right for a package, a venv and a checkout alike - **`node --check` reports success on a module-syntax error, and a green one cost a full suite run.** An unclosed `.map(` inside a tagged template came back clean four times in a row. `test_spa_syntax.py` says this in its own docstring — copying to `.mjs` first is what forces the module parser — and it is still the reflex that reaches for `node --check`. Use `node --input-type=module --eval "$(cat file.js)"`, or just run that test - **A test fixture narrower than production tests the fixture — and when it writes down *why* it has to be narrow, that is a bug report nobody filed.** `test_federation.py` built its MHP envelopes by hand without an `aud`, and `test_public_groups_toggle.py` signed its own token with a comment saying `_issue_mhp_token` "binds `_hub_sk_pem` at import time, before the lifespan loads it, so it cannot be used from a test", and another saying PyJWT rejects a token carrying `aud` when decode is given none. Both observations were exactly right. Between them, MHP could not complete a single authenticated request between two real hubs, and the hub announced itself as `meshbay.org` whatever it was configured as — for a month, with the suite green. When a test has to route around the code to run, the thing it routed around is the finding. Federation's tokens now come from the real issuer - **A global's state is not a given, in a test least of all.** One assertion — that a refusal never logs the address — took three attempts: `caplog` saw nothing because the app configures logging, then a handler on the module's own logger saw nothing because an earlier test had raised its level, then because `logger.disabled` was left True. Each version passed alone and failed in the full run. Pin what you depend on (level, `.disabled`, `logging.disable`) or assert on a value instead of on a side effect - **A blocking call in an async handler is the whole instance's problem.** `mail._send` is `smtplib` with a ten-second timeout, and it was called straight from four handlers: while the MTA thought about it, nothing else was served, no node socket read, no offer relayed. It has no symptom a test catches — everything works, slowly, for everyone, whenever the mail server is having a bad day. `mail.send_off_loop` is the door, and `test_no_mail_is_sent_from_the_event_loop` reads the source for direct calls, because there is nothing else to read - **Before adding an endpoint or a message, ask who pays.** A participant supplies input; if anyone other than the sender bears the cost, there is a ceiling to write, and it goes on **every** path that writes the state — the gap between `_authorize_node_ws` and `update_groups` was one message wide. `docs/MESHBAY_DESIGN.md` §13.5b is the register; `test_availability_between_ members.py` is where a new case goes, and every test in it is two accounts, because a one-member test proves a one-member property - **Never change the KDF parameters in one place.** `keyderive.js`, `keyderive.py`, the QE harness and `test_bundle_kdf_parity.py` are held byte-identical by that test, and a mismatch does not look like an error — it looks like an account nobody can open - **Raw answer SDP is saved before `setRemoteDescription`** — Chrome strips sha-256 from a multi-hash SDP, and the fingerprint is the channel binding - **Upload chunk size is 48 KB**, which is what fits the aiortc SCTP limit after msgpack overhead - Throwaway `e2e*` accounts accumulate on the production hub; the operator deletes them ## Engineering lessons Each of these cost real time to find. They are kept because they are not deducible from the design — they are what the code and the platforms actually do. Read them before writing anything that touches the same mechanism. - **A content-addressed index cannot represent the same bytes at two paths.** `GroupIndex` is keyed by blake3, so `clip.mp4` at a root and in `uploads/` with identical content is **one** entry — which is also why a scan can report ten files and index nine. Reconciliation compares *paths*, so it decided the second path was a missed event every 60 s, rewrote the entry, bumped the version and pushed an index update to every connected peer. Found by watching a live node, not by a test. Anything comparing disk against index must check the id, not the path - **A CLI branch nobody has run is not covered by anything.** `reload` shipped with `subprocess` unimported and crashed on first use; the module compiles fine, which is the same "syntax, not names" trap already recorded for the SPA. `test_cli_dispatch.py` walks every verb with the daemon stubbed, and refuses to let a verb be added to the parser without an entry there. It also stubs `os.kill` — the first version of that test SIGHUPed the developer's own running node - **Device linking is live (2026-08-18).** `identities` is keyed by `(user_id, pk_ed25519)`, so one account holds several devices on a node; the migration rebuilds the table and preserves existing pins. A new device files a request bound by `sha256(code ‖ its own keys)`, and a key the node already pinned countersigns it — the hub holds no user keys and so cannot. **The code never reaches the node**: it lists pending requests with their hashes and the approver recomputes to find the match, which is what makes a substituted key impossible rather than merely detectable. `member unpin` still removes every device; `revoke_device` marks one, because a deleted row is a key the node would happily pin again - **`user_devices` on the hub is not the key directory that was H3.** Nothing reads it but the hub, nothing wraps a group key for it, and it is a *different* key from the per-node identities. What it does cost is metadata: the hub now knows how many devices an account has and when each last signed in - **The desktop client runs** (2026-08-18, Electron 42 / Chromium 148 under xvfb). Build needs Node ≥ 22 — **Ubuntu 24.04's nodejs 18 cannot install Electron at all**, its download script `require()`s an ESM module. Node 24 LTS lives in `/opt/nodejs`, fetched and checksum-verified against nodejs.org. `test_desktop_shell.py` pins the security contract by reading the source, and that is still weak evidence — it catches a property being removed. Launching it is what found the three things below - **Three things only launching it could find.** (1) A CSP in a `` tag silently drops `frame-ancestors`; it is sent as a header by the protocol handler now. (2) **Service workers do not work on a custom scheme** — Chromium refuses whatever the privileges — so the app has none and uses the native save dialog; `sw.js` stays for the browser. What `secure: true` actually buys was measured at the same time: without it **all of `crypto.subtle` is undefined**, AES-GCM included. X25519 and Ed25519 are present on Chromium 148. (3) **The renderer cannot call the hub**: its `app://` origin is refused by CORS, and the hub deliberately has no CORS middleware — its API is reachable from no web origin. Every hub call therefore leaves from the main process, which also refuses any origin that is not the signed-in hub. A script served by the hub is refused by the policy, which is T3's mitigation demonstrated rather than asserted - **A user unit cannot carry `User=`.** `meshbay-node.spec` installed the system template into `%{_userunitdir}`, where systemd refuses the file outright — the packaged unit could never have started, and nothing noticed because nobody had built and installed the RPM. Two units now, `test_packaging_units.py` holds each in its own directory. Note the test's own first version searched the whole file and matched the *comment* explaining why `User=` is absent; parse directives, not text — the same mistake as reading a CSP out of the comment above the meta tag - **The device's hub key lives in the main process, never in the renderer.** Generated, stored and used there; the interface asks for a signature over `meshbay:user_auth::` and is never handed a key. Same rule as the save dialog, for the same reason: the renderer parses decrypted content from nodes, which is attacker-controlled input. It is **not** a per-node identity key — nothing here correlates a person across operators - **A local hub is the way to test hub changes.** `uvicorn meshbay_hub.app:create_app --factory` with a SQLite URL and a throwaway key runs the current code on loopback in seconds. meshbay.org runs whatever was last deployed — it reported MNP 0.2 and 405 on the Stage-C endpoints while the tree had 0.3 — so testing against it proves what is deployed, not what is written - **A second copy of the hub address is what breaks the app, not the protocol.** `keyderive.js` carried `const HUB = '' // same origin` — true of a page the hub served, false of one loaded from a package, where the origin is `app://meshbay` and `/v1/users/register` hits the application's own protocol handler. **Sign-up and sign-in, the first two things anybody does, failed with "Not found."** Found by a person clicking Register. `test_hub_address_seam.py` now refuses any file that decides where the hub is, or fetches `/v1/…` relative to the page origin - **safeStorage is real on a desktop and honest without one** (checked 2026-08-18, Ubuntu GNOME). `secrets.bin` carries Chromium's `v11` prefix, which means keyring-backed; the fixed-key fallback writes `v10`. Headless, the same code reports `unavailable` and refuses to store rather than downgrading silently - **`globalThis.navigator` is read-only from Node 22.** `test_locales.py` assigned to it and broke the moment the suite ran under a newer Node — which the desktop client's build already requires, so the first CI machine set up for it would have failed these tests for no visible reason. Use `Object.defineProperty`; the suite is now green on 18 and 24 - **Two subtractions in different files, one scrollbar.** Twice now a page was 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. `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 - **A handler bound to the event its own writes produce.** The chat panel's `fit()` set a height, read `documentElement.scrollHeight` back and subtracted the overflow — so the document alternately did and did not overflow the window, the page scrollbar appeared and vanished with it, and `visualViewport` fired `resize` at every pass. `fit()` listens to that event: it re-entered itself ~120 times a second for the life of the panel (measured: 240 firings in 2 s on an idle page, against 2 for a bare document). Each pass re-pinned the list to the bottom, which undid every attempt to scroll up **inside the frame it happened in** — before the `scroll` event that would have recorded it was delivered, so `atBottomRef` never went false, no jump button appeared, and the older messages were unreachable. Every pin in that file reads as correctly guarded; the reader simply never got to stop being at the bottom. Mutate-then- measure is a loop wherever layout can raise the event you are handling: learn the correction once and write nothing in the steady state. And a scroll position that must survive a gesture has to be released **by the gesture** (`wheel`/`touchmove`/`pointerdown`), never by the `scroll` event alone - **A reply that names nothing is routed by luck.** The node answers a chat message with a bare `{"type": "ack"}` — no request id, no type of its own — so `_dispatch` had nothing to match it on and fell through to its arrival-order guess, which hands a reply to whichever request happens to be oldest. That is wrong the moment anything else this browser asked for is still waiting, and one *is*: the node refuses an unknown `file_id` with a bare `error`, which names no request either and so reaches none, leaving the Videos tab's `media_meta_req` in `_pending` for the full 30 s. The ack went to that, the send waited out its own timeout, and because the composer is disabled while a send is in flight, **typing a message froze the Chat tab**: no click, no keystroke, no message — and the message there all along on the 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 `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 refusal has no type of its own to key on: `_dispatch_message`'s catch-all answers every unforeseen failure with `{"type": "error", "detail": "Request failed"}`, and 238 of `webrtc_server.py`'s 240 error sends name nothing either. So a chat send the node refused was routed by luck all over again — same frozen composer, same 30 s, and rare enough (it needs an older request still waiting, which a `music_meta_req` behind a failing third-party lookup supplies for over a hundred seconds) to look like once every couple of days. The keys were never the fix, only a workaround for a protocol that carried no correlation id at all: `_seqId` existed, indexed `_pending`, and was never put on the wire. It is now (`req_id`, see `protocol.py`) — the node stamps it on the reply from `_send`, via a ContextVar so a handler's spawned work still answers under the right id, and never on a broadcast, which answers nothing. With that, the arrival-order fallback is gone for any node that stamps. The lesson is not "key the replies": it is that **matching by arrival order is a guess that fails silently and asymmetrically** — the victim is never the request that was answered wrongly, it is the unrelated one that now waits for a reply already delivered elsewhere. A reply needs an identifier the protocol guarantees, not a field it happens to have - **A refusal that never rejects.** Denying Chromium's `fullscreen` permission does not make `requestFullscreen()` throw — the promise never settles. The deny-everything handler was written from a true sentence ("nothing here needs a camera") and quietly broke watching a film full-screen, with no error anywhere to lead back to it. Prefer enumerating what is *granted*: the list is short, and the next thing Chromium invents arrives refused rather than silently allowed - **A fallback chain reaches its floor silently.** `_openDownloadTarget` tries a granted folder, then a service worker, then "collect it in memory and hand the browser a blob". In the desktop application the first two do not exist — `showDirectoryPicker` is absent and Chromium refuses a worker on a custom scheme — so **every download under 512 MB went through RAM**, and the only visible symptom was a Save As dialog at the *end* instead of the start. Nothing errored. When a chain degrades, check what the floor costs on every platform that will reach it - **A service worker with nothing to do is killed, and a streaming response is not "something to do".** Firefox terminates an idle worker after about thirty seconds; `event.respondWith(new Response(stream))` does not extend its life while the page is still writing to that stream. So the reader vanished mid-file and `writable.write()` **never resolved and never rejected** — no error, no log, no failed transfer, just a progress bar that stopped near the end. Measured 2026-09-08 in Firefox 154, writing 1 MB every 2 s: stalled at 17 MB after 59 s; with a 10-second ping to the worker, 40 MB in 80 s, complete. The page pings while it writes and the worker answers, because receiving a message is the event that resets the timer. Three things this cost, all worth remembering. **The first stress probe wrote 450 MB in two seconds and passed** — fast enough to hide the bug entirely, so a probe for anything time-based has to be paced like the real thing. **The node was innocent and three measurements proved it** (615 MB pulled whole over MNP, three files interleaved on one connection, three concurrent worker streams), which is exactly what made the fault unfindable: nothing was wrong anywhere anyone looked. And **the empty console was the evidence**, not the absence of it: `_sendAndWait` logs every timeout, so silence eliminated everything that reports itself and left the one `await` on that path with no bound. Every await on a download path is now bounded and says which chunk it gave up on — an unbounded one is a freeze nobody can report - **A name with no spaces in it sets a table column's minimum width, and on Android that unpins the whole page.** Sticky headers were added to Files, Videos, Music and Photos on 2026-09-09 and reported broken on a phone: not the new bands but *everything*, the navigation bar included, which had been `position: sticky` for months. That is the tell. A document wider than the screen leaves everything pinned attached to a viewport the reader can no longer see, so a header doing exactly what it was told looks like one that was never pinned — **look for horizontal overflow before doubting the sticky rules**. The overflow came from two ``s that had no wrapping rule because `.file-name` was only ever on a *file's* name: a folder called `Rage_Against_The_Machine_Discography_1992-2000_FLAC` made a 527px table in a 390px window, and Search's group column did the same at 442px with an underscored group name. `word-break: break-word` is what lets such a cell stop driving the column, and it has to be on every cell that carries a name somebody else chose. Two things cost more than the fix. The harness had measured this page in two engines at three widths and found nothing, because its fixture said `note-007.txt` and `un groupe` — **a fixture narrower than real data tests the fixture**, and names are the one thing a file browser cannot be given short. And a first diagnosis blamed the soft keyboard (the Search field is `autofocus`, and Android's `interactive-widget` default splits the visual viewport from the layout one), which was plausible, cost a round trip, and was wrong; the screenshot showing no keyboard was already in hand. - **Three headers decide whether a page may frame itself, and they must agree.** The same streamed download navigates a hidden iframe to `/_mbdl/`. `frame-src` was reCAPTCHA's two origins with no `'self'`, `frame-ancestors` was `'none'`, and `X-Frame-Options` was `DENY`. Each was fixed in turn, each time costing a redeploy and a retest, and **all three were visible in one `curl -I` against the deployed hub** — which is where that should have started. `'self'`/`SAMEORIGIN` refuse every foreign origin exactly as `'none'`/`DENY` do; what they add is this origin framing itself, which is all the download needed. When a symptom points at a mechanism, enumerate everything that governs that mechanism and check the set at once - **`encodeURIComponent` does not escape `'`, and `'` is RFC 5987's delimiter.** `Content-Disposition: filename*=UTF-8''` became unparseable for any name with an apostrophe, so the browser named the file after the URL: 449 MB of film arrived complete and correct, called `mtsshk9w-ohqty535`. `(`, `)` and `*` are excluded from attr-char for the same reason. A plain ASCII `filename=` rides alongside now, so the next surprise loses accents rather than the name - **Two elements each claiming `100vh - 52px`, one inside the other's padding.** `.layout` and `.page-center` both reserved the viewport below the header, and `main`'s `24px` top and bottom were added on top — a permanent 48px scrollbar on sign-in at every window size. Found by measuring in the running app (`document.documentElement.scrollHeight` against `innerHeight`, then the bottom edge of every element), not by reading the stylesheet, which is the standing rule here ### More of the same, from four rounds of live testing - **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 the task for good. Symptom is always the same — daemon running, logging nothing, `connected_nodes: 0`, socket in CLOSE-WAIT. Look there first - **A background task nobody holds can be collected mid-flight.** asyncio keeps only a *weak* reference to a task, so `asyncio.ensure_future(coro)` with the result thrown away may be garbage-collected while still running — the loop logs "Task was destroyed but it is pending!" and nothing else happens. For `_stream_video` that meant its `async with sem` never reached `__aexit__` and a transcode slot was lost for good. `WebRTCPeerSession._spawn()` exists for this; never call `ensure_future` there directly, and `test_task_lifetime.py` fails the build if anyone does - **`await proc.wait()` after `kill()` still deadlocks on a full pipe.** ffmpeg outruns a credit-paced viewer; stop reading its stdout — which is what closing the player does — and the transport cannot finish closing, SIGKILL or not. Measured 2026-08-16: closing a viewer after 99 segments held a slot past the 15 s handover timeout, so the next video hung and the one after was refused. Drain the pipes, then wait with a timeout, and release the slot regardless - **Losing a peer must *stop* its work, not merely forget it.** The WebRTC `connectionstatechange` handler popped the session from a dict and nothing else, so a closed tab went on transcoding for the full 120 s credit timeout. Anything holding a resource needs `shutdown_tasks()` on the way out - **A service worker being *active* is not the page being *controlled*.** An uncontrolled page's requests never reach the worker's fetch handler, so the streamed-download path handed over its stream and was never asked for it — `writer.write()` then blocks on backpressure that will never lift, and the download freezes after exactly one chunk. Require `navigator.serviceWorker.controller`, and have the worker confirm it actually served the request before trusting the sink - **A removed `useState` leaves its setter behind and nothing complains.** `setActionsOpen` outlived `actionsOpen` and shipped: every action in the Files panel threw ReferenceError on click. `grep actionsOpen` does not find `setActionsOpen` — the capital breaks the match, which is exactly how it got through. `test_transport_contracts.py` compares called setters against declared ones - **`node --check` validates syntax, not names.** It caught none of the above. Neither can `e2e.py`, which is a second implementation of the client. Source- reading tests are weak evidence and are the only evidence available for the SPA; prefer ones that re-derive a value from the source over ones that restate it - **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. `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 reproduced the defect in one run. Model the environment, never the code under test - **A refresh token that rotates must be stored, or it is spent once.** The hub revokes the refresh token presented, returns a replacement, and treats a revoked one presented again as theft — revoking the whole family. The SPA kept only the access token out of that response, so renewal worked once and the second attempt destroyed the session, which is why signing out and in was the only cure. Nothing used the path at all: `hubFetch` reported 401 like any other error, and watching a film is an hour in which the hub hears nothing, 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. `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 listed `token` among its dependencies. Harmless while a token only ever expired; once the session renewed itself the string rotated, and the effect tore the connection down and rebuilt it — worst at mount, where a stale token is renewed exactly while ICE is negotiating, so the browser abandoned the handshake and the node sat in `connecting` for ever. Depend on whether there is a token, not which one, and read the live one where it is used. Before making something vary that never varied before, grep the dependency arrays it appears in — this and the hook-ordering fault above are the same shape: code that is correct read on its own and wrong against the component lifecycle - **A stylesheet does not tell you where anything lands.** The responsive tests pinned numbers out of `style.css` and said in their own docstring that a layout could not be measured because there was no browser in the suite. There is one — Chrome, from the video work — and the difference is the transfers 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. `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 check the test fails with the fix removed - **A hook cannot depend on one declared below it.** `const a = useCallback(fn, [b])` evaluates `[b]` where it is written, so a `b` further down the component is still in its temporal dead zone: `ReferenceError` on every render, before anything the component does can run. The component simply does not appear — clicking a video did nothing at all, with no error on screen and nothing in the node's log because nothing was ever requested. It reached production. `node --check` passes; the code is well-formed. Worse, the MSE harness extracted the player functions in a list order of its own and therefore *reordered* them, quietly repairing the one class of defect it was best placed to catch — it now sorts by position in the file. `test_hook_ordering.py` checks the whole SPA - **Flow-control accounting comes before every early return.** A segment that arrived is no longer in flight, whatever is then done with it. Discarding one before decrementing the in-flight window leaked a slot per discard, and `reinitAt` is asynchronous, so a seek's whole window could arrive while it was still awaiting its `updateend`s — the player then believed a full window 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 `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` repeated the same bug a session later, and worse — only `reinitAt` lowers `awaitingInit`, and the next film never reaches it, so every one of its segments would have been discarded. Reset at the *start* of the stream, never in the teardown of the one before, which is skippable - **`Cache-Control: no-cache` only binds a browser that asks.** One that cached the SPA before that header existed applies heuristic freshness — a fraction of the file's age, days for a file dated weeks ago — and does not ask at all. A fix can be written, tested, deployed, served and still not be what runs, which is indistinguishable from a fix that does not work. The whole module graph is now served under `/a//` so relative imports inherit the prefix and no cache can serve yesterday's build or half of each. The hash covers every file under `static/`: it once covered a hand-kept list of top-level modules, the catalogues were not on it, and a heading rewritten only in `en.js` stayed on a phone after the deploy — immutable for a year at a URL that had not moved. `test_asset_versioning.py` - **Redeploying during someone else's test destroys the evidence.** A node deploy restarts the daemon, which kills every live WebRTC session — the tester sees "transport not connected" caused by nothing they did — and `deploy-node.sh` truncates `/tmp/meshbay-node.log`, taking the reproduction with it. Ask before deploying while a reproduction is in flight - **`updateend` fires for `remove()` as well as `appendBuffer()`.** Crediting the node from that event paid it for the player's own evictions: every time room was made, more was asked for to fill it. Credit now follows the buffer, decided in one place, and the append path grants nothing - **Flow control on a media stream is a window, not a debt.** Granting a credit per append means pulling at network speed, which for a film is far faster than watching it and fills the browser's SourceBuffer ceiling; accumulating those credits and releasing the balance when the buffer finally drains sends the lot in one burst and then says nothing for forty-six seconds. Bound the read-ahead by *time past the playhead* and top a small window up as segments land. A viewer deliberately holding credit must still say so, or the node's stall timeout ends a film that is merely paused - **`create_all()` is not a migration.** It creates missing *tables* and never a missing *column*, so a new column reaches the tests (fresh DB every run) and never reaches the deployed hub. Symptom: one endpoint answering 500 with an HTML body while everything else works, and a `psycopg` `UndefinedColumn` in the journal. `deploy-hub.sh` runs `alembic upgrade head` before restarting the service; a schema change that skips a migration file will still pass every test you have - **Some paths only exist in a browser, and only one browser has them.** The download-to-disk story is three different mechanisms — File System Access (Chrome/Edge), a service worker streaming a response (Firefox/Safari), and a blob as the floor — and no test in this repo exercises any of them. `test_downloads.py` pins the contracts by reading the source; the behaviour 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 - **An empty claim read as a claim on everything, and a list taken as a ranking.** Two defaults, in two codebases, a month old and harmless separately. The hub resolved a node's group claim with `set(claimed_groups or authorized)`, and the node omitted `group_ids` entirely when it hosted nothing — so "I host no groups" arrived as "I host all of this account's groups", other members' included. Such a node can serve none of them: it has no GEK, and its own handshake refuses them. But `/v1/groups/{id}/nodes` answers in **registration order**, `_node_groups` is in-memory, and GroupPage took `nodes[0]` with no fallback — so the group opened or did not depending on who reconnected first after a hub restart. On 2026-09-11 a hub deploy at 20:14 did exactly that: this node needed 16 s to come back (502 while the hub restarts, then its access token had expired during the outage, so a refusal and two 5 s backoffs), a second member's unconfigured node won the race, and a group went dark for everyone with its only real host online the whole time. **A member could take any of their groups down, by accident, by leaving an empty node running.** Four things worth keeping. The node's own logs and audit database were the evidence that cleared it — every handshake OK, no refusal recorded since August — which is what said the refusal came from somewhere else entirely; believing the error message names the machine you are standing on is how an hour goes. `git log -S` on the three suspect lines dated them all to August, which is what turned "what did I just break" into "what changed around it": the answer was a restart, not a commit. The read-only probe that settled it is four lines — the node's own keystore, a node-scoped token, `/v1/groups/mine` and `/v1/groups/{id}/nodes` — and node-scoped tokens are accepted by `get_current_user`, so a node can ask the hub what the hub thinks of it. And the rule under all of it: **a falsy empty collection must never mean "unspecified"** — `if gids:` and `or authorized` are the same mistake written twice, and the ceiling that made C2 hold on connection was stepped over one message later by `update_groups`, which assigned its list verbatim. A limit enforced on one path is not enforced **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 - Argon2id `memory_cost`: ✅ DONE — bumped to 262144 (256 MB) in pw_version=2. Existing v1 users (64 MB) are transparently rehashed on next successful login. CLI `calibrate` command still TODO for per-hardware tuning. ## NAT traversal 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. ## Key modules — where to find what 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. ### Common | Need | File | Note | |---|---|---| | Chunk encryption | `meshbay_common/crypto.py` | §4.3 | | GEK wrap/unwrap (ECIES) | `meshbay_common/crypto.py` | §4.2 | | Sealing a payload under the group key | `meshbay_common/groupbox.py` + `sealGroup`/`openGroup` in `static/crypto.js` | §4.4. **Never reuse the chunk key with a pseudo-file for this** | | Chat sealing and signing | `meshbay_common/chatbox.py` + `sealChat`/`openChat`/`verifyChatSignature` in `static/crypto.js` | §4.5 | | Handshake, version range | `meshbay_common/handshake.py` — `MNP_MIN_SUPPORTED`, `check_version` | read by both servers and both clients | | Wire messages, `req_id`, `IndexEntry` | `meshbay_common/protocol.py` | §5.3 | | Signed admin transcripts | `meshbay_common/adminop.py`, `join.py`, `device.py` | §5.4 | | Key derivation from a passphrase | `meshbay_common/keyderive.py` + `static/keyderive.js` | §3.1. **Parity-tested — never change the parameters in one place** | | Path folding, NFC, long paths, reserved names | `meshbay_common/paths.py` | §10 | | ~~Double Ratchet / Sender Keys~~ | — | **Deleted.** Both were written and never called. Kept code that nothing calls reads as an alternative somebody may reach for, and its green tests read as evidence of a protection that is not in the product. The reasoning that ruled them out is at the top of `chatbox.py` | ### Node | Need | File | Note | |---|---|---| | Operator operations | `meshbay_node/ops.py` | **One implementation, several front doors.** The loopback API, the CLI and the signed MNP handlers all call these | | Local control API | `meshbay_node/ui/app.py` | JSON only, loopback + per-run token. Each operation endpoint is one `_op(...)` line | | Roster, identities, devices, invites, group settings | `meshbay_node/roster.py` | §3.3, §3.4 | | Roots, availability, eject/plug | `meshbay_node/roots.py`, `config.py` | §6.2 | | Indexing, hashing, enrichment | `meshbay_node/indexer/` — `indexer.py`, `cache.py`, `group_index.py`, `enrich*.py`, `title_parse.py` | §6.3 | | Transfer leases and queueing | `meshbay_node/transfers.py` | §5.5. Deliberately free of asyncio and of the transport | | Uploads, resume state | `meshbay_node/uploads.py` | §6.4 | | WebRTC transport and every MNP handler | `meshbay_node/transport/webrtc_server.py` | the big one | | QUIC transport | `meshbay_node/transport/quic_server.py` | off by default; **must stay at parity with the WebRTC handlers** | | Background tasks (node) | `webrtc_server.py` — `_spawn()` | the only way to start one; a bare `ensure_future` can be collected | | Stream handover, backpressure | `webrtc_server.py` — `_replace_stream`, `shutdown_tasks`, `DOWNLOAD_BUFFER_HIGH` | §8.5 | | Stream diagnosis | `webrtc_server.py` — `client_diag` at DEBUG | the player's own view in the node's log; the only window into a phone | | Chat store and paging | `meshbay_node/chat/store.py` | `get_recent`/`get_before`/`has_before`; `get_messages` pages *forwards* and is not what a chat opens with | | Chat epochs | `meshbay_node/ops.py` — `open_chat_epoch`, `ensure_chat_epoch`, `chat_epoch_keys` | §4.5 | | Keystore | `meshbay_node/keystore.py` | §4.6 | | Bundle store (GEK + keypair bundles) | `meshbay_node/bundle_store.py` | §3.7 | | Media cache, third-party metadata | `meshbay_node/media_cache.py`, `tmdb.py`, `musicbrainz.py`, `media_probe.py` | §6.5 | | Link previews | `meshbay_node/linkpreview.py` — `safe_url` | §6.5. SSRF gate | | Audit log | `meshbay_node/audit.py` | legal compliance | | Hub socket client | `meshbay_node/hub_client.py` | `login()` (Ed25519) + `maintain_ws()` | | Daemon, CLI, config | `meshbay_node/daemon.py`, `config.py`, `platform.py` | §6.7, §6.8 | ### Hub | Need | File | Note | |---|---|---| | Auth dependencies | `meshbay_hub/api/deps.py` — `require_admin`, `require_moderator`, `require_user_scope` | §7.5 | | Accounts, devices, recovery | `meshbay_hub/api/users.py` | §3.6 | | Node auth and registration | `meshbay_hub/api/nodes.py` | §7.2 | | Signaling relay | `meshbay_hub/api/signaling.py` | §7.2 | | Groups, membership, presence, public-group quota | `meshbay_hub/api/groups.py` | §7.3 | | Admin API, instance policy, moderation | `meshbay_hub/api/admin.py`, `hub.py` | §7.4, §7.5 | | Notifications, federation, relays, reports | `meshbay_hub/api/notifications.py`, `federation.py`, `relay.py`, `moderation.py` | §7.6 | | Asset versioning | `meshbay_hub/api/webapp.py` — `_asset_version()` | the whole module graph is served under `/a//`, and the hash covers **every file under `static/`**, subdirectories included — nothing to register | | Token lifetimes | `meshbay_hub/config.py` — `[jwt]` | 4 h access, 30 days refresh. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there | ### Browser / desktop UI (`meshbay_hub/static/`) | Need | File | Note | |---|---|---| | 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) - OS: Ubuntu 26.04 LTS, Python 3.14.4 - SSH: `ssh cbesson@meshbay.org` - Caddy : reverse proxy HTTPS sur 80/443 - UFW rules: **22/tcp, 80/tcp, 443/tcp uniquement** - Services légitimes : `meshbay-hub.service`, Caddy, PostgreSQL (local) - Inventaire détaillé : `QE/server-state/meshbay.org.md` - Deploy hub : voir `QE/server-state/meshbay.org.md` ## new rules, from now Documents and demo/comments are written in english unless requested in french. No copyrighted names. Never put real brand names, trademarks, artist names, or copyrighted titles (film titles, show titles, song/artist names, character names, release-group handles) into code, comments, docstrings, test fixtures, documentation, or commit messages — even when the bug being fixed or documented was genuinely found against real content with real names. Describe the *shape* instead ("a franchise-origin film", "a two-part saga", "a 3-season show") and use invented placeholders in fixtures ("Some Saga", "A Different Show"). This holds for every file, including throwaway test data and one-line commit subjects.