summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
commitf059cb118c556d1f0279350507f74b8a47d5a98a (patch)
tree9a97762a844038a06134b4b7dcead1758477dfc1 /docs
parentb045ba0010d69360b6a0265eb7c73a07900fe328 (diff)
downloadmeshbay-f059cb118c556d1f0279350507f74b8a47d5a98a.tar.gz
docs: remove the documents MESHBAY_DESIGN.md replaces
Twenty-four files, about 17 000 lines: the two architecture drafts, the three security reviews, eleven design notes, the roadmap, the decisions file, the v1–v4 archive, the deprecated user guide and the stale quickstart. Their content is in MESHBAY_DESIGN.md, and git history holds the originals. The reason to delete rather than keep bannered: a document that is superseded but present still gets read, and a reader cannot always tell which of two accounts of one mechanism is the live one. That was the argument for retiring the user guide rather than repairing it, and it applies to the whole set. What made this safe is the concordance. Roughly 290 comments and docstrings cite these files by section — `musicbay.md §6`, `mediacenter.md §5.5`, `draft-v6 §2.11` — and section 16 maps every one onto its replacement, so not a single comment needs editing to stay followable. It now says plainly that the files are gone and where to recover them, and it gained rows for the three reviews (their findings are section 13), and for the two guides. Four kept documents pointed into the set and were repointed first: `playlists.md` (nine references — it is a live proposal and must not dangle), `WINDOWS-PORT.md`, and CLAUDE.md's example. No dangling reference remains outside section 16. Two files were dropped from the list after checking what they hold. `HTTPS.md` is an operational runbook — Caddy, certificate renewal, DNS, troubleshooting — and MESHBAY_DESIGN.md deliberately covers no operations, so nothing would replace it; the versioned Caddyfile is the config, not the procedure. `cast-smart-tv.md` is the plan for the unbuilt DLNA phase of a feature whose first two phases ship, and section 11.4 summarises it in four lines rather than carrying the SSDP/UPnP work. There is no user guide now, and section 0.1 says so rather than leaving a reader to discover it. Suites green: 2258 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
Diffstat (limited to 'docs')
-rw-r--r--docs/MESHBAY_DESIGN.md20
-rw-r--r--docs/QUICKSTART.md243
-rw-r--r--docs/USERGUIDE.md1184
-rw-r--r--docs/WINDOWS-PORT.md4
-rw-r--r--docs/apps.md333
-rw-r--r--docs/auth-confirm.md560
-rw-r--r--docs/captcha.md555
-rw-r--r--docs/chat-sender-keys.md859
-rw-r--r--docs/desktop-client-v1.md1179
-rw-r--r--docs/devel-phases-next.md1388
-rw-r--r--docs/first-review.md382
-rw-r--r--docs/indexing-v2.md324
-rw-r--r--docs/invite-pairing-v1.md559
-rw-r--r--docs/mediacenter.md1035
-rw-r--r--docs/meshbay-draft-v5.md622
-rw-r--r--docs/meshbay-draft-v6.md620
-rw-r--r--docs/musicbay.md502
-rw-r--r--docs/old-draft.md4497
-rw-r--r--docs/per-node-identity-v1.md225
-rw-r--r--docs/photos.md516
-rw-r--r--docs/playlists.md21
-rw-r--r--docs/refactor-groups.md916
-rw-r--r--docs/refactor-node-ui.md413
-rw-r--r--docs/refactoring-search.md426
-rw-r--r--docs/second-review.md880
-rw-r--r--docs/third-review.md723
-rw-r--r--docs/tmp-decisions.md191
27 files changed, 29 insertions, 19148 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index d34351b..d24d7a9 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -33,10 +33,15 @@
| `MESHBAY_NODE_PROTOCOL.md` | the MNP wire format, message by message |
| `transfers-v1.md` | the transfer system's failure-mode analysis, kept because a synthesis cannot carry "every way a slot can be lost" |
| `playlists.md` | the playlist design in full — decided, not built (§9.10) |
-| ~~`USERGUIDE.md`~~ | **deprecated** — a large majority of it predates per-node identity, named roots and the sealed wire, and is wrong. Kept for provenance; its own banner lists what is false |
-| `PACKAGING-GUIDE.md`, `QUICKSTART.md`, `HTTPS.md`, `MAIL-SERVER.md`, `windows-build.md` | installation and server operations |
+| `cast-smart-tv.md` | the DLNA/UPnP device backend — designed, not built (§11.4) |
+| `WINDOWS-PORT.md` | the Windows port's audit and packaging detail (§11.2) |
+| `PACKAGING-GUIDE.md`, `HTTPS.md`, `MAIL-SERVER.md`, `windows-build.md` | installation and server operations |
| `CLAUDE.md` | project conventions and the engineering lessons that govern how changes are made |
-| `old-draft.md` | historical archive of drafts v1–v4. Nothing there is authoritative |
+
+**There is no user guide.** The one that existed described the system before
+per-node identity, named roots and the sealed wire, and was retired rather than
+repaired — a document a reader cannot tell the sound parts of is worse than none.
+Writing a new one starts here.
### 0.2 Reference labels
@@ -2597,7 +2602,11 @@ account recovery, and the Windows port through packaging.
## 16. Concordance
Code comments, tests and older documents cite sections of the documents this one
-replaces. The mapping:
+replaces. **Those documents are no longer in the tree** — they were removed on
+2026-09-11, once their content was here and this table could resolve every
+reference they left behind. `git log -- docs/` recovers any of them.
+
+Nothing needs editing to follow a reference: look the citation up here.
| Cited as | Read |
|---|---|
@@ -2643,6 +2652,9 @@ replaces. The mapping:
| `per-node-identity-v1.md` — identity keys per node | §3.2 |
| `captcha.md` — registration gate | §7.7 |
| `tmp-decisions.md` D1–D4 | §14.2 |
+| `first-review.md`, `second-review.md`, `third-review.md` — any finding | §13, which defines every label. §13.1 is the first review, §13.3 the second, §13.4 the third |
+| `USERGUIDE.md` — anything | it was wrong and is gone. §2 for the trust model, §3 for identity, §6.7 for the operator surface |
+| `QUICKSTART.md` — anything | it was stale and is gone. `PACKAGING-GUIDE.md` installs; §6.7 lists the operator commands |
| `devel-phases-next.md` structural decisions | §14.1 |
| `cast-smart-tv.md` | §11.4 |
diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md
deleted file mode 100644
index f9ab79b..0000000
--- a/docs/QUICKSTART.md
+++ /dev/null
@@ -1,243 +0,0 @@
-# MeshBay — Quickstart
-
-MeshBay partage des fichiers entre utilisateurs d'un groupe via un réseau pair-à-pair.
-Le hub (`meshbay.org`) gère les identités et les clés — il ne voit jamais vos fichiers.
-Le node tourne sur votre machine et héberge vos fichiers.
-
----
-
-## Ce qu'il faut
-
-- Python 3.12+
-- Le dépôt MeshBay (en local)
-- Un accès à `https://meshbay.org`
-
-```bash
-cd ~/meshbay # le dépôt local (pas encore publié sur GitHub)
-```
-
-> **Si vous synchez le repo depuis une autre machine (rsync, scp)** :
-> ne pas copier `.venv/` — il est lié à l'OS source et casse pip sur l'OS cible.
-> Toujours recréer le venv avec `--clear` sur la machine cible.
-
-```bash
-# Créer (ou recréer proprement) le venv
-python3 -m venv .venv --clear
-source .venv/bin/activate
-
-# Installer les packages — les dépendances (aioquic, watchdog, etc.) viennent automatiquement
-pip install -e packages/meshbay-common -e packages/meshbay-node -e packages/meshbay-hub
-```
-
-Les dépendances déclarées dans les `pyproject.toml` sont installées automatiquement :
-
-| Package | Vient de | Rôle |
-|---|---|---|
-| `cryptography` | meshbay-common | crypto (Ed25519, ChaCha20, Argon2id) |
-| `PyJWT` | meshbay-common | JWT EdDSA |
-| `blake3`, `msgpack`, `zstandard` | meshbay-common | hashing, sérialisation, compression |
-| `fastapi`, `uvicorn` | meshbay-node | API de contrôle loopback |
-| `httpx` | meshbay-node | client hub |
-| `watchdog` | meshbay-node | surveillance répertoire |
-| `aioquic` | meshbay-node | transport QUIC (MNP v2) |
-| `aioice` | meshbay-node | ICE/STUN NAT traversal |
-| `websockets` | meshbay-node | notifications hub→node |
-
----
-
-## Étape 1 — Setup (alice crée le groupe et invite bob)
-
-Un seul script fait tout : créer les comptes, générer les clés depuis les mots de passe,
-créer le groupe, distribuer la clé de chiffrement.
-
-```bash
-python QE/demo-v1/setup_demo.py \
- --hub https://meshbay.org \
- --alice-user alice_demo --alice-pass "AliceDemo2026!" \
- --bob-user bob_demo --bob-pass "BobDemo2026!"
-```
-
-Sortie attendue :
-```
-[1/6] Génération des clés d'alice depuis son mot de passe...
- Ed25519 public: VyVUcjPXwJfGhzr44Cb5...
-[2/6] Inscription d'alice sur le hub...
- OK — user_id=9b50a8c2...
-[3/6] Génération des clés de bob + inscription...
- OK
-[4/6] Alice se connecte au hub...
- JWT reçu (424 chars)
-[5/6] Alice crée le groupe 'demo-group'...
- group_id=e358fb8b-5b3f-44...
-[6/6] Génération et distribution de la clé de groupe (GEK)...
- GEK → alice: 201
- GEK → bob: 201
-
-✓ Setup terminé.
- Creds: QE/demo-v1/creds.json
-```
-
-Les credentials sont sauvegardés dans `QE/demo-v1/creds.json` (clés privées incluses —
-ce fichier ne doit pas être partagé ni versionné, il est dans `.gitignore`).
-
-**Pourquoi les clés sont dérivées du mot de passe ?**
-La commande `derive_keys_from_password(username, password)` génère toujours les mêmes
-clés Ed25519 et X25519 à partir des mêmes identifiants. Pas besoin de stocker ou
-transporter un fichier de clés séparé — le mot de passe suffit pour retrouver les clés
-sur n'importe quelle machine.
-
----
-
-## Étape 2 — Démarrer le node d'alice
-
-Le node indexe un répertoire et le rend accessible aux membres du groupe.
-Il crée automatiquement `QE/demo-v1/shared/` avec un fichier exemple.
-
-```bash
-# Terminal 1 — node d'alice (écoute en local)
-python QE/demo-v1/run_node.py --host 127.0.0.1 --port 19001
-```
-
-Sortie :
-```
-=== Node d'alice — répertoire partagé : QE/demo-v1/shared ===
-Fichiers disponibles :
- README.txt 93 octets
-
-1 fichier(s) indexé(s)
-
-✓ Node actif — MNP sur le port 19001
- Contrôle : meshbay-node status (API loopback, jeton requis)
-
-CTRL+C pour arrêter.
-```
-
-Vérification rapide dans un autre terminal :
-```bash
-meshbay-node status
-# état du node, clés, groupes configurés, fichiers indexés
-```
-
-> **Le node n'expose aucune API HTTP publique.** Les endpoints `/`, `/index` et
-> `/file/{id}` ont été supprimés en 0.2.0 (findings C1 et C6) : ils servaient l'index
-> et les fichiers en dehors du handshake qui décide de ce qu'un pair a le droit de
-> voir. Le port 19001 est le listener MNP, pas un serveur web. La seule surface HTTP
-> est l'API de contrôle JSON sur la boucle locale, protégée par un jeton — utilisée
-> par le CLI et la page Node du client desktop.
-
-**Ajouter vos propres fichiers :**
-```bash
-cp ~/Videos/ma_video.mp4 QE/demo-v1/shared/
-# Le node le détecte automatiquement (watchdog)
-```
-
----
-
-## Étape 3 — Bob télécharge un fichier
-
-Bob se connecte au hub, récupère sa clé chiffrée (GEK), la déchiffre localement,
-puis télécharge et déchiffre le fichier depuis le node d'alice.
-
-```bash
-# Terminal 2 — client de bob
-python QE/demo-v1/download.py --node http://localhost:19001
-```
-
-Sortie complète :
-```
-[1/5] Bob se connecte au hub https://meshbay.org...
- ✓ JWT reçu
-[2/5] Bob récupère son bundle GEK depuis le hub...
- ✓ Bundle chiffré reçu (hub ne peut pas le lire)
-[3/5] Bob déchiffre la GEK localement (X25519)...
- ✓ GEK récupérée (32 octets)
-[4/5] Bob browse le node d'alice (http://localhost:19001)...
- ✓ 1 fichier(s) dans 'demo-group':
- [document] README.txt 93 octets
-[5/5] Bob télécharge et déchiffre 'README.txt'...
- chunk 0: 93o réseau=10ms decrypt=0.0ms ✓
-
-✓ 'README.txt' sauvegardé dans QE/demo-v1/downloads/README.txt
- Total : 93 octets en 1 chunk(s)
-```
-
-Télécharger un fichier spécifique :
-```bash
-python QE/demo-v1/download.py --node http://localhost:19001 --file ma_video.mp4
-```
-
----
-
-## Étape 4 — Tester depuis une autre machine
-
-Si le node d'alice est sur une machine avec IP publique (ou port ouvert sur le routeur),
-bob peut télécharger depuis n'importe où :
-
-```bash
-# Alice — démarrer le node sur toutes les interfaces
-python QE/demo-v1/run_node.py --host 0.0.0.0 --port 19001
-
-# Bob — depuis une autre machine
-python QE/demo-v1/download.py --node http://<IP-D-ALICE>:19001
-```
-
-> **NAT résidentiel :** si alice est derrière une box internet, il faut soit
-> ouvrir le port 19001 dans les règles NAT de la box, soit utiliser un tunnel
-> (cloudflared, ngrok). La traversée NAT automatique par STUN/ICE est prévue
-> pour la v2 du protocole.
-
----
-
-## Ce qui se passe sous le capot
-
-```
-alice génère ses clés depuis son mot de passe (Argon2id)
- ↓
-alice s'inscrit sur le hub (envoie les clés publiques seulement)
- ↓
-alice génère une GEK (clé symétrique 256 bits) pour le groupe
- ↓
-alice envoie à bob sa GEK chiffrée avec la clé publique X25519 de bob
- ↓
-bob récupère son bundle GEK depuis le hub (opaque, hub ne peut pas lire)
- ↓
-bob déchiffre la GEK localement avec sa clé privée X25519
- ↓
-bob télécharge les chunks chiffrés depuis le node d'alice
- ↓
-bob déchiffre les chunks avec la GEK → fichier en clair
-```
-
-Le hub ne voit jamais la GEK ni les fichiers. Il stocke uniquement les clés
-publiques et les bundles GEK chiffrés qu'il ne peut pas déchiffrer.
-
----
-
-## Scripts disponibles dans `QE/demo-v1/`
-
-| Script | Rôle |
-|---|---|
-| `setup_demo.py` | Créer comptes + groupe + distribuer GEK |
-| `run_node.py` | Démarrer le node HTTP d'alice |
-| `download.py` | Télécharger un fichier comme bob |
-
-Tous les paramètres ont des valeurs par défaut ; lancer avec `--help` pour les options.
-
----
-
-## Dépannage rapide
-
-**`ModuleNotFoundError: No module named 'meshbay_common'`**
-→ Activer le venv : `source .venv/bin/activate`
-
-**`ERREUR: creds.json introuvable`**
-→ Lancer d'abord `setup_demo.py`
-
-**`HTTPStatusError: 409 Conflict`** lors du setup
-→ Les comptes existent déjà. Soit changer les noms (`--alice-user`), soit continuer normalement — le script gère le 409 et continue.
-
-**`Connection refused` sur le node**
-→ Vérifier que `run_node.py` tourne dans un autre terminal.
-
-**`InvalidTag` lors du déchiffrement**
-→ Le bundle GEK du hub ne correspond pas aux clés locales. Relancer `setup_demo.py` pour régénérer les bundles.
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
deleted file mode 100644
index c9febff..0000000
--- a/docs/USERGUIDE.md
+++ /dev/null
@@ -1,1184 +0,0 @@
-# MeshBay User Guide
-
-> # ⚠️ DEPRECATED — do not rely on this document
->
-> **A large majority of it is wrong.** It describes the system as it stood before
-> several of the changes that define it now, and it has not been maintained since.
-> It is kept for provenance only. **Read `MESHBAY_DESIGN.md` for the architecture
-> and the security model, and `MESHBAY_NODE_PROTOCOL.md` for the wire format.**
->
-> What is known to be false here, so that nothing below is mistaken for current:
->
-> | Section | What it claims | What is true |
-> |---|---|---|
-> | §2 Account Management | Identity keys are derived from username + password, or generated in the browser and stored on the hub as an encrypted bundle | Identity keys are **per node**, created at first contact with each node and left there. Registration generates nothing, and the hub stores no keypair bundle and no user public key |
-> | §4 Setting up a Node, §5 Sharing Files | A group has one `shared_dir`, and everything members send lands in `shared_dir/uploads/` | A group has **several named roots**, each read-only or read-write. The `uploads/` quarantine was removed on 2026-09-06 |
-> | §8 Security Model — ciphers | ChaCha20-Poly1305 is the principal cipher, AES-GCM an optional browser variant | **AES-256-GCM encrypts everything.** No production path constructs a ChaCha20 cipher |
-> | §8 Security Model — what the hub stores | The hub holds users' identity public keys and the wrapped group keys | It holds neither. Removing the key directory is what closed finding **H3**; the bundles moved to the node |
-> | §8 GEK wrapping | A member wraps the group key for another member, and the hub stores the bundle | **The node wraps it**, on every connection, for a key the joiner proved possession of. The described flow *is* finding H3 |
-> | §10 Troubleshooting | `GET /v1/groups/{id}/gek` returns 404 | That endpoint no longer exists |
->
-> And it predates, so never mentions: encrypted chat, the sealed index and upload
-> path, transfer leases, device linking, and the group-application framework.
->
-> Two passages are also still in French, and one code fence is unbalanced.
->
-> **Do not repair this file section by section.** Enough of it is wrong that a
-> reader cannot tell the sound parts from the stale ones, which is worse than
-> having no guide — and a patch that fixes one section leaves that problem intact.
-> A user guide, when there is one again, starts from `MESHBAY_DESIGN.md`.
-
-
-This guide covers MeshBay in depth — architecture, configuration, security, and the full API. Read the [Quickstart](QUICKSTART.md) first if you have not set up a node yet.
-
----
-
-## Table of Contents
-
-1. [Architecture overview](#1-architecture-overview)
-2. [Account management](#2-account-management)
-3. [Groups](#3-groups)
-4. [Setting up a node](#4-setting-up-a-node)
-5. [Sharing files](#5-sharing-files)
-6. [Accessing files](#6-accessing-files)
-7. [Video streaming](#7-video-streaming)
-8. [Security model](#8-security-model)
-9. [Moderation and legal](#9-moderation-and-legal)
-10. [Troubleshooting](#10-troubleshooting)
-11. [API reference](#11-api-reference)
-
----
-
-## 1. Architecture Overview
-
-MeshBay has three components. Understanding which role each plays avoids a lot of confusion.
-
-```
-┌─────────────────────────────────────────────┐
-│ Mesh Hub (meshbay.org) │
-│ │
-│ • User accounts and public keys │
-│ • Group registry (name, membership) │
-│ • Encrypted GEK bundles (opaque blobs) │
-│ • JWT issuance and verification key │
-│ • Connection logs (legal compliance) │
-│ • No file content, no indexes, no GEKs │
-└──────────────┬──────────────────────────────┘
- │ HTTPS (identity + routing only)
- │
- ┌──────────┴──────────┐
- │ │
-┌───▼────────┐ ┌──────▼───────┐
-│ Mesh Node │ │ Mesh Client │
-│ │ │ │
-│ Your files │ MNP │ Browser or │
-│ Your keys │◄───►│ Android app │
-│ TCP+TLS │ │ │
-└────────────┘ └──────────────┘
-```
-
-**Mesh Hub** — a lightweight registrar. Its job is to vouch for identities, track group membership, and store encrypted GEK bundles. After login, clients talk directly to nodes. The hub is never in the data path for file transfers.
-
-**Mesh Node** — the program you run on your server or home machine. It watches a directory, maintains a group index, handles connections from clients, encrypts files at read time, and holds your private keys. You are the legal host of everything in your shared directory.
-
-**Mesh Client** — a web browser or Android app. It authenticates with the hub, fetches the encrypted GEK bundle, and connects directly to nodes for file browsing and download.
-
-**Protocol versioning:** MNP (Mesh Node Protocol) is currently at v0.1 over TCP+TLS 1.3. QUIC transport is planned for v2 with no protocol changes. Every wire message carries a `v` field; N-2 minor version backward compatibility is guaranteed.
-
----
-
-## 2. Account Management
-
-### Register
-
-Registration creates an account and nothing else: a username, an email, and a value derived from your passphrase that lets the hub check it without ever seeing it.
-
-**No keys are generated here.** An identity keypair belongs to a *node*, not to the hub: one is created the first time you join a given node, encrypted under your passphrase, and left with that node. So an operator who takes their own disk holds a key that is worthless on anyone else's, and the hub has no key directory to publish — which is what finding H3 read.
-
-**Deux modes de génération de clés :**
-
-**Mode CLI / native node** (`setup_demo.py`, `meshbay-node`) :
-Les clés sont *dérivées* de votre username + password via Argon2id — pas besoin de
-fichier de clés séparé. Même identifiants → mêmes clés sur n'importe quelle machine.
-Implémenté dans `meshbay_common.keyderive.derive_keys_from_password()`.
-
-```python
-from meshbay_common.keyderive import derive_keys_from_password
-sk_ed, sk_x = derive_keys_from_password("alice", "MonMotDePasse!")
-```
-
-**Mode navigateur** (interface web) :
-Le navigateur génère des clés aléatoires via WebCrypto, les chiffre avec une clé
-dérivée du mot de passe (PBKDF2-SHA512), et envoie le bundle chiffré au hub.
-À la prochaine connexion, le hub retourne le bundle et le navigateur le déchiffre
-localement. Le hub stocke le bundle mais ne peut pas le lire.
-Implémenté dans `static/keyderive.js`.
-
-```
-POST /v1/users/register
-{
- "username": "string",
- "email": "string",
- "auth_key": "base64 (PBKDF2-SHA512 of your passphrase — the hub never sees the passphrase itself)"
-}
-→ 201 {"user_id": "uuid"}
-→ 409 if username is taken
-```
-POST /v1/users/login
-{"username": "yourname", "password": "yourpassword"}
-→ {
- "access_token": "JWT (Ed25519, 4 hour validity)",
- "refresh_token": "opaque 256-bit token (30 days)",
- "token_type": "bearer",
- "expires_in": 14400,
- }
-```
-
-Le trousseau ne vient pas d'ici : chaque nœud conserve celui qui lui est propre,
-chiffré par votre phrase de passe, et un nouveau navigateur le récupère auprès du
-nœud auquel il se connecte.
-
-```bash
-curl -s -X POST https://meshbay.org/v1/users/login \
- -H "Content-Type: application/json" \
- -d '{"username":"alice_test","password":"AliceTest2026!"}'
-```
-
-### Token refresh
-
-Access tokens are valid for 4 hours. **The web app does this for itself** — it
-renews ten minutes before expiry, on returning to the tab, and on any 401, then
-replays the request. Nobody should meet an expired token in the browser; what
-follows is for other clients.
-
-The endpoint **rotates**: it revokes the refresh token you present and returns a
-new one, so store the replacement. Presenting a revoked token is treated as
-theft and revokes the whole family, which is a full sign-out.
-
-```
-POST /v1/users/token/refresh
-{"refresh_token": "your-refresh-token"}
-→ {"access_token": "new JWT", "refresh_token": "USE THIS NEXT TIME",
- "token_type": "bearer", "expires_in": 14400}
-```
-
-```bash
-curl -s -X POST https://meshbay.org/v1/users/token/refresh \
- -H "Content-Type: application/json" \
- -d '{"refresh_token":"YOUR_REFRESH_TOKEN"}'
-```
-
-Refresh tokens are valid for 30 days — that is the session — and can be
-invalidated by the hub at once on account compromise. Revoking one means the
-next renewal fails; an access token already issued keeps working for up to 4
-hours. That window is not the whole story: the hub reloads the account on every
-request and refuses a suspended one immediately, and it pushes signed
-revocations to nodes, so suspending an account or revoking a membership takes
-effect at once regardless of the token's remaining life.
-
-### Access token structure
-
-The JWT payload contains:
-
-| Claim | Value |
-|---|---|
-| `iss` | Hub ID (`meshbay.org`) |
-| `sub` | Your `user_id` (UUID4) |
-| `hub_id` | `meshbay.org` |
-| `jti` | UUID4 — unique per token, enables revocation, prevents replay |
-| `groups` | The `group_id`s you are a member of, for node-side authorization |
-| `scope` | `user` for a browser, `node` for a daemon |
-| `iat` | Issued at (Unix timestamp) |
-| `exp` | Expires at (Unix timestamp, 4 hours from issue — `[jwt] access_token_ttl`) |
-
-The token carries **no public key of yours**. It used to carry `pk_user`, and a node
-recorded that key as the uploader of a file — which meant the party issuing tokens
-decided who was allowed to delete it. The hub certifies *accounts*; keys are generated
-on each node and pinned there (§4).
-
-Nodes verify this JWT locally using the hub's cached Ed25519 public key. No hub roundtrip is needed — verified at 884µs in testing. This means your files remain accessible even if the hub is temporarily unreachable.
-
-### Deleting your account
-
-**Settings → Delete account.** You re-enter your passphrase: a live session may be a
-borrowed laptop or a tab left open, and this cannot be undone. A hub administrator can
-also delete an account, from Administration → Users.
-
-What deletion does:
-
-- Releases the username — someone else may register it afterwards
-- Clears the email and password hash, and drops the node linking key
-- Removes group memberships, notifications and refresh tokens
-- Refuses any access token still within its validity, immediately
-
-What deletion does **not** do:
-
-- **It does not touch anything on a node.** Your files stay where you uploaded them, and
- so do the identity pinned in the node's roster and the keypair bundle it holds for you.
- Nodes are other people's machines; the hub cannot command them. To be removed there,
- ask the operator — `meshbay-node member unpin <user>` and deleting your files are
- their commands to run (§4).
-- **It does not erase the connection log.** IP records are kept for their legal retention
- period and stay attributable: the username is copied onto those rows as the account is
- deleted, so the log still says *who*, and does not answer `deleted-3f9a1c` for exactly
- the records anyone would be asking about. Releasing the name for re-registration and
- keeping it in the log are separate things.
-
-Deletion is refused while you still own a group. Hand the group over or delete it first —
-otherwise its members would be stranded. The error names the groups blocking you.
-
-```
-DELETE /v1/users/me
-Authorization: Bearer <access_token>
-{"auth_key": "<derived from your passphrase, as at login>"}
-→ 200 {"status": "deleted", "username": "alice_test"}
-→ 403 {"detail": "Passphrase does not match"}
-→ 409 {"detail": "This account still owns groups: ..."}
-```
-
-Node registrations are removed as well, so a deleted operator's nodes stop being
-announced. The daemons keep running and keep their data — again, the hub does not
-command them.
-
----
-
-## 3. Groups
-
-Groups are the primary unit of organization. Every file on a node belongs to a group.
-
-### Create a group
-
-```bash
-TOKEN="your-access-token"
-
-curl -s -X POST https://meshbay.org/v1/groups \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer $TOKEN" \
- -d '{"name": "my-group"}'
-```
-
-Response:
-```json
-{"group_id": "uuid", "name": "my-group"}
-```
-
-Save `group_id` — you will need it in your `node.toml` and when adding members.
-
-### The description
-
-Set it at creation with `"description"`, or later from the group's page — the owner
-sees an **Edit description** link under the name. Members see it on their home page
-and, for public groups, in Explore.
-
-```
-PATCH /v1/groups/{group_id}
-Authorization: Bearer <access_token> (the group's owner)
-{"description": "host grenoble"}
-→ 200 {"group_id": "...", "description": "host grenoble"}
-```
-
-An empty string clears it; anything past 512 characters is trimmed rather than
-refused. The description is all this endpoint changes: the name, the visibility and
-the join policy are the terms members joined on, and a private group that could
-quietly become public is not the group they agreed to be in. Changing those needs a
-decision about who gets told, so it is not a field on a form.
-
-### Public vs. private groups
-
-| | Public | Private |
-|---|---|---|
-| File index | Plaintext + Ed25519 signed | GEK-encrypted, members only |
-| Content | TLS transport only (no application-layer encryption) | GEK-encrypted per chunk |
-| Join | Open / approval-gated | By invitation only |
-| GEK | Not applicable | Required |
-
-For private groups the node holds a Group Encryption Key (GEK) — a random 32-byte key that is never sent over the wire in cleartext. Each member receives a copy wrapped for their own X25519 public key (ECIES: X25519 + HKDF + AEAD).
-
-**The node does the wrapping, and it never asks the hub for anybody's key.** That matters: the hub is the account directory, so a hub that answered a key lookup with its own key would be handed the group key by an honest member following the protocol exactly (finding H3). Instead the recipient presents their own public keys over the authenticated P2P channel, signed by their identity key, and the node wraps for what it just verified.
-
-### Add a member to a private group
-
-The node operator issues a one-time code, from the server or from their browser:
-
-```bash
-# On the node, over SSH — no browser needed
-meshbay-node member invite bob
-
-INVITATION CODE R3H8-TB6V
-valid until 2026-08-21T12:00:00+00:00
-```
-
-Send the code to Bob however you already talk to him — it never passes through the hub, which is what stops the hub from claiming to be Bob. He enters it the first time he opens the group, and the node then wraps the group key for the key he proved he holds.
-
-After that first time the pin is his credential: he is recognised on every later connection, and asked for nothing. You do not need to be online when he joins.
-
-| | |
-|---|---|
-| Code lifetime | 7 days (`[node] invite_ttl_hours`) |
-| Reuse | Single use; re-inviting supersedes the previous code |
-| If it expires | Issue another one — nothing else is affected |
-| Wrong code, repeatedly | Bounded per connection and node-wide, and logged in the node's audit log |
-
-The same operation is available in the web app: the group's **Members** tab, if your browser is paired with the node (`meshbay-node operator pair`).
-
-### Removing a member
-
-```bash
-meshbay-node member revoke bob
-meshbay-node gek-init # rotate: Bob still holds the old key
-```
-
-Revoking stops the node serving Bob the key from his next connection onward — there is no stored bundle left behind that could outlive the decision. It does **not** take back the key he already has, which is why the second command exists.
-
-### What revocation does and does not do
-
-Rotating the GEK (`meshbay-node gek-init`) makes the node encrypt new content with a new key, which every remaining member picks up automatically on their next connection — nothing has to be re-uploaded or re-wrapped by hand.
-
-A former member can still decrypt content they already received: there is no retroactive re-encryption, and there is no way to reach into someone's disk. Revocation controls what happens next, not what already happened.
-
-### Notifications
-
-The bell in the top bar counts what you have not read. Clicking an entry takes you to
-what it is about and dismisses it.
-
-- **Chat is one entry per group, not one per message.** A conversation that has been busy
- all afternoon is a single line whose date moves to the last thing said and which turns
- unread again each time. Opening the group clears it.
-- **You are never notified of your own messages.** The node names the author when it
- tells the hub a message was posted, and the hub skips them.
-- **An invitation disappears once you have joined**, i.e. after you enter the pairing
- code — not when you first look at it.
-- **Muting a group works from anywhere.** The setting lives on the hub with your
- membership, so a muted group creates no notification at all rather than hiding one
- after the fact. It follows you to another browser. (It used to be a checkbox in the
- browser's local storage that nothing read, so it did nothing.)
-- **Clear all** empties the list in one action.
-
-```
-GET /v1/notifications → {"notifications": [{id, kind, group_id, title, link, read, created_at}], "unread": 3}
-DELETE /v1/notifications/{id} → dismiss one — the row is deleted
-POST /v1/notifications/{id}/read → the same thing, under the name older clients use
-POST /v1/notifications/read-all → dismiss every one
-DELETE /v1/notifications → delete them all
-POST /v1/groups/{group_id}/mute {"muted": true}
-```
-
-`GET /v1/groups/mine` reports `muted` for each group, so the browser shows the checkbox
-in the state the hub actually holds.
-
----
-
-## 4. Setting up a Node
-
-### Configuration file
-
-Full `~/.config/meshbay/node.toml` reference:
-
-```toml
-[hub]
-url = "https://meshbay.org"
-
-[auth]
-username = "yourname"
-password = "YourPassword123!"
-
-# Each group this node hosts gets its own [[groups]] block
-[[groups]]
-group_id = "uuid-of-your-group"
-shared_dir = "/srv/meshbay/my-group"
-
-[[groups]]
-group_id = "uuid-of-second-group"
-shared_dir = "/srv/meshbay/second-group"
-
-[node]
-# MNP listener port (must be internet-reachable)
-listen_port = 19001
-
-# Local web UI port (loopback only, not exposed externally)
-ui_port = 18000
-
-# Announce this address to the hub (auto-detected via STUN if not set)
-# endpoint_hint = "203.0.113.42:19001"
-
-[keystore]
-# "secure" — password prompt at each startup
-# "lazy_file" — password read from ~/.config/meshbay/unlock.key (chmod 600)
-# "service" — password read from MESHBAY_UNLOCK_KEY env var
-unlock_mode = "secure"
-path = "~/.config/meshbay/keystore.enc"
-
-[crypto]
-# Argon2id parameters for keystore password derivation.
-# Run `meshbay-node --calibrate-argon2` to tune for your hardware.
-# Target: ~500ms on your machine.
-argon2_iterations = 4
-argon2_memory_cost = 262144 # 256 MB
-argon2_parallelism = 1
-```
-
-### Hosting another of your groups
-
-A node can host several groups, each with its own directory and its own key.
-Create the group in the web app first, then, on the node:
-
-```bash
-meshbay-node group add grenet --dir ~/grenet-share
-# grenet (480d553f) added to /home/cbesson/.config/meshbay/node.toml
-# shared_dir /home/cbesson/grenet-share
-
-# restart the daemon, then:
-meshbay-node gek-init --group grenet
-```
-
-`group add` looks the name up among your groups on the hub, appends a
-`[[groups]]` block to your `node.toml` — comments and all, it is appended, not
-rewritten — and creates the directory. The daemon reads its config at startup, so
-it needs a restart before the group exists for it; `gek-init` then generates that
-group's key.
-
-Three things follow from the design, and are worth being explicit about:
-
-- **Each group's key is its own.** Members of one group cannot read another's
- files, and admitting someone to one says nothing about the other. That is why
- `gek-init` is per group.
-- **Pairing is not.** `meshbay-node operator pair` pairs a *browser* with the
- *node*: one paired browser can invite to, and delete files in, every group the
- node hosts. It takes no `--group`.
-- **Members are per group.** `meshbay-node member invite alice --group grenet`
- admits alice to that group only. The roster keeps one row per group.
-
-`meshbay-node status` prints what the node hosts, with each directory — the
-quickest way to see whether a group made in the browser is attached here yet.
-
-### Environment variables (alternative to node.toml)
-
-| Variable | Equivalent config |
-|---|---|
-| `MESHBAY_HUB_URL` | `[hub] url` |
-| `MESHBAY_USERNAME` | `[auth] username` |
-| `MESHBAY_PASSWORD` | `[auth] password` |
-| `MESHBAY_UNLOCK_KEY` | keystore unlock key (for `service` mode) |
-| `MESHBAY_LISTEN_PORT` | `[node] listen_port` |
-
-### Keystore unlock modes
-
-The keystore is an Argon2id-derived AES-256-GCM encrypted file holding your Ed25519 and X25519 private keys plus GEK copies.
-
-**secure (default):** prompts for a password at startup. Suitable for interactive use. The password is not stored anywhere.
-
-**lazy_file:** reads the password from `~/.config/meshbay/unlock.key` (must be `chmod 600`). Use on a physically secure home server where you want unattended restarts.
-
-```bash
-echo -n "YourKeystorePassword" > ~/.config/meshbay/unlock.key
-chmod 600 ~/.config/meshbay/unlock.key
-```
-
-**service:** reads the unlock key from the `MESHBAY_UNLOCK_KEY` environment variable. Standard practice for systemd deployments:
-
-```ini
-# /etc/systemd/system/meshbay-node.service
-[Unit]
-Description=MeshBay Node
-After=network.target
-
-[Service]
-User=meshbay
-EnvironmentFile=/etc/meshbay/unlock.env # chmod 600, owned by meshbay
-ExecStart=/usr/bin/meshbay-node --config /etc/meshbay/node.toml
-Restart=on-failure
-
-[Install]
-WantedBy=multi-user.target
-```
-
-```bash
-# /etc/meshbay/unlock.env (chmod 600, owned by meshbay user)
-MESHBAY_UNLOCK_KEY=YourKeystorePassword
-```
-
-### Argon2id calibration
-
-The keystore password derivation is intentionally slow. Tune it to your hardware:
-
-```bash
-meshbay-node --calibrate-argon2
-```
-
-This prints the derivation time for several parameter combinations. Choose the set that gives ~500ms. The default (iterations=4, memory=256MB) is calibrated for a modern home server.
-
-### Hardware sizing (upload bandwidth is the constraint)
-
-| Scenario | Simultaneous users | Upload needed | RAM |
-|---|---|---|---|
-| Files + chat, no streaming | 10 | 20–50 Mbps | 512 MB |
-| 1080p streaming, 5–6 streams | 10 | 50–80 Mbps | 1 GB |
-| Mixed, light streaming | 50 | 200–300 Mbps | 2 GB |
-| Heavy streaming | 50 | 400 Mbps | 2–4 GB |
-
-A standard home fiber line (100–500 Mbps symmetric) handles 10–30 concurrent users. Beyond that, a dedicated server is needed.
-
----
-
-## 5. Sharing Files
-
-### How indexing works
-
-The node watches `shared_dir` for file changes using filesystem events (`watchdog` library). When a file is added, modified, or removed:
-
-1. The node computes `blake3(file)` as the file identifier
-2. It builds or updates a Mesh Group Index entry for that file
-3. The index entry is serialized as msgpack, compressed with zstd, then encrypted with the GEK (for private groups) or signed with the node's Ed25519 key (for public groups)
-4. Connected members receive an index delta push; new connections receive the full index
-
-### Index entry structure
-
-```python
-{
- "version": 1,
- "id": "<blake3_hash_hex>", # file identity and chunk key input
- "name": "filename.mkv",
- "path": "Movies/2024/", # relative path within shared_dir
- "size": 4294967296, # bytes
- "type": "video", # video | audio | image | document | archive | other
- "duration": 7245, # seconds (media files only)
- "thumb_hash": "<blake3>", # thumbnail, also GEK-encrypted
- "added_at": 1720000000 # Unix timestamp
-}
-```
-
-### Supported file types
-
-The node detects type by file extension and MIME sniffing:
-
-| Type | Extensions |
-|---|---|
-| `video` | mp4, mkv, avi, mov, webm |
-| `audio` | mp3, flac, ogg, opus, m4a |
-| `image` | jpg, jpeg, png, gif, webp, avif |
-| `document` | pdf, txt, md, epub, doc, docx, odt |
-| `archive` | zip, tar, gz, bz2, xz, 7z |
-| `other` | everything else |
-
-### Where uploaded files land
-
-Everything a member sends arrives in **`shared_dir/uploads/`** — both files uploaded from
-the Files panel and attachments sent in the chat. One visible directory, so an operator can
-look at what was sent, move it, or empty it without hunting through the tree.
-
-- Filenames are checked against a conservative allowlist and nothing is ever overwritten:
- a colliding name gets a suffix, and the sender is told the name it was stored under.
-- Chat thumbnails are scaled by the browser from the file itself. The node writes no
- derived images, so nothing accumulates beside your files.
-- Uploads are attributed to the identity the node pinned for that member, and that is what
- decides who may delete the file later — not anything the hub says.
-
-### Selecting files, and where transfers live
-
-The Files panel has a **Select** button. Turning it on puts a checkbox on every
-row — files and folders — and the **⋮ Actions** button next to it acts on what is
-ticked: download, play, view, download folders as a zip, delete. There is no
-per-row menu: several transfers at once is the normal case, and starting them one
-context menu at a time was the thing that made it awkward.
-
-Selection is remembered as you walk into folders, so you can tick something in
-one and something else in another before choosing an action.
-
-**Where downloads are written** is a setting, under Settings → Downloads:
-
-- **Save automatically** (the default) writes into a folder you pick once, with
- no dialog. Downloading twenty files puts twenty files there. A name already in
- use gets a suffix — `clip (2).mp4` — rather than replacing what is there.
-- **Ask every time** opens a Save As dialog per file, which is right for one file
- and wrong for a selection of twenty.
-
-With no folder chosen, automatic still does not put a dialog in your way, and it
-still does not hold the file in memory: a service worker hands the browser a
-stream, which it writes to its own download folder as the bytes arrive. That is
-how this works in Firefox and Safari, which have no way to open a file for writing
-from a page. If even that is unavailable, a download under 512 MB is collected in
-memory and handed over; a larger one asks where to put it, because a tab does not
-survive a multi-gigabyte blob.
-
-**What has been exercised**, as of 2026-08-15, so the next person knows which of
-this is measured and which is designed:
-
-| Path | Browser | State |
-|---|---|---|
-| Streamed into a granted folder | Chrome | works |
-| Streamed by the service worker | Firefox | works — 180 MB, written to disk |
-| Collected in memory (no folder, no worker) | any | works, bounded at 512 MB |
-| Save As for a download over 512 MB | Chrome | works |
-| Multi-gigabyte download, any path | — | designed for, not yet measured |
-
-The 180 MB run is the one that matters most, because the service worker is the
-only way Firefox writes a download to disk rather than building it in a tab. It
-has not been tried at the scale it exists for.
-
-A finished download offers **Open** in the transfers widget when it went into a
-folder you granted: the file is handed to a new tab and the browser decides what
-to do with it. That is the whole of what a web page can do here — it cannot start
-a desktop application, and it cannot show you a file manager. No browser offers an
-API for either, deliberately.
-
-A web page cannot be given a filesystem path, and cannot read one either: there
-is no `~/Downloads` to configure, on any operating system, and nothing changes
-here on Windows for the same reason. What a browser grants is access to a folder
-the user picked in a dialog, and MeshBay only ever writes inside it. That grant
-is remembered, but the browser may ask you to confirm it once per session.
-
-Firefox and Safari have no File System Access API, so no folder can be granted:
-downloads go to the browser's own download folder, and Settings says so instead
-of offering a choice that would do nothing.
-
-**Transfers run outside the page.** They are listed in the widget next to the
-bell, with a progress bar, the current rate, and a cancel button each:
-
-- Leaving the group, or the group page, does not stop them. The connection stays
- open until the last transfer using it is finished.
-- **Signing out cancels them all** — they are moving data on a token that is about
- to stop being yours.
-- Cancelling stops the work, not just the display; a partly written file is left
- where you told the browser to put it.
-- Rates are measured over the last few seconds, so a stalled transfer reads as
- stalled rather than reporting the average it once managed.
-
-### Downloading a folder as a zip
-
-Any member can take a whole folder: **⋮ → Download as zip** on the folder's row.
-The archive is built in the browser as the files arrive and written straight to
-disk, so it costs disk space and only a few megabytes of memory.
-
-- **A folder has to be 512 MB or smaller.** Larger than that and the button
- refuses, naming the folder's size — take a subfolder at a time, or the files
- individually. This is a deliberate cap, not a technical one: the writer would
- happily stream a hundred gigabytes. Selecting several folders at once applies
- it to each of them separately, so one oversized folder does not stop the rest.
-- Nothing is compressed. Group content is video, images and archives — already
- compressed — so deflating would spend CPU on every byte to save nothing, in the
- same thread that is decrypting.
-- The archive opens as the folder you asked for: a zip of `Holidays/2026` unpacks
- as `2026/…`, not as a chain of empty parents.
-- Files over 4 GiB, and archives over 4 GiB, use zip64. Anything current reads
- them; a tool from before 2003 may not.
-- **Firefox and Safari cannot write a download straight to disk** (no File System
- Access API). There, the archive has to be assembled in memory first, and the
- browser says so, with the size, before starting. Use Chrome or Edge for a large
- one.
-
-### Removing a member
-
-The group's owner can remove someone from the Members tab. It does two things, in
-the order that fails safe:
-
-1. **The node stops serving them the group key** — an operator-signed request, so
- it works only from a paired browser (§3). This is the half that matters.
-2. **The hub drops their membership**, which is what stops them reaching the node
- through signaling at all.
-
-What it does **not** do:
-
-- It does not delete their account. Their other groups, their files and their
- identity are untouched — one group's owner cannot erase someone from the hub.
-- It does not make the node forget them. The pinned key stays, so they can be
- admitted again without a new pairing code; `meshbay-node member unpin` forgets.
-- It does not take back the key they already hold. Anyone who has connected has
- unwrapped the current GEK, and no protocol reaches into their browser to remove
- it. Rotate it with `meshbay-node gek-init --group <name>` if that matters —
- members still in the group pick the new one up on their next connection.
-
-Removal is per group: on a node hosting several, someone removed from one keeps
-the others.
-
-### Deleting a directory
-
-**⋮ → Delete folder**, for the node operator, from a paired browser (§3). The
-directory must be **empty** — nothing here is recursive, and a folder with
-anything in it is refused before a signature is even asked for. Delete the files
-first, where you can see what you are losing.
-
-Like every privileged action on a node, it is signed with the key the node pinned
-for that browser and refused otherwise: hub membership, or an admin role on the
-hub, grants nothing here.
-
-### Creating a directory
-
-Any active member can create a directory from the Files panel (**New folder**). It is
-created relative to the folder you are looking at, under `shared_dir`, and the same name
-rules apply. Paths that try to leave the shared root are refused.
-
-### Files are stored in plaintext on disk
-
-The node holds your files in plaintext. Encryption happens at read time — the node encrypts each 1 MB chunk using a per-chunk key derived from the GEK before sending it over the wire. This means:
-
-- Disk-level encryption (LUKS, etc.) is your responsibility if you need at-rest protection
-- Backups of the shared directory are plaintext
-- Node compromise exposes all files in plaintext
-
----
-
-## 6. Accessing Files
-
-**There is no HTTP file API.** Files are requested over MNP — the node's authenticated
-message channel, carried by WebRTC DataChannel or QUIC — and nothing on the node answers
-an unauthenticated request. The `GET /index`, `GET /file/{id}` and `GET /stream/...`
-endpoints documented before 0.2.0 were removed (findings C1 and C6): they served the index
-and file bytes to anyone holding a token, outside the handshake that decides what a peer
-is allowed to see. Port 19001 is the MNP listener, not a web server.
-
-The node's only HTTP surface is its admin UI, bound to loopback and requiring a token
-(§4). It is for the operator, on the machine, over SSH.
-
-### Browse the index
-
-After the handshake, ask for the index:
-
-```
-→ {"type": "index_sync", "v": "0.1"}
-← {"type": "index_sync", "entries": [{"id": "<blake3 hex>", "name": "...", "size": 1234,
- "type": "video", "path": "uploads/"}, ...]}
-```
-
-For a private group the index itself is encrypted with the GEK, so a peer that never
-proved possession of the key is served nothing to read.
-
-### Download a file, chunk by chunk
-
-```
-→ {"type": "file_req", "v": "0.1", "file_id": "<blake3 hex>", "chunk_index": 0}
-← {"type": "file_chunk", "file_id": ..., "chunk_index": 0,
- "nonce": <bytes>, "ct": <bytes>, "plaintext_size": 1048576}
-```
-
-Chunks are 1 MB. Chunk 0 is the first megabyte; for a 5 MB file, request 0–4.
-
-**Per-chunk key derivation** — each chunk has an independent key derived from the GEK and
-the chunk's position, so a leaked chunk key opens exactly one chunk of one file, and a
-player can seek without decrypting from the start:
-
-```python
-chunk_key = HKDF(
- algorithm=hashes.SHA256(), length=32, salt=None,
- info=b"file:" + file_hash_bytes + b":chunk:" + chunk_index.to_bytes(4, "big"),
-).derive(gek)
-
-plaintext = ChaCha20Poly1305(chunk_key).decrypt(nonce, ciphertext, None)
-```
-
-The browser client derives the same key the same way but uses **AES-GCM**: WebCrypto has
-no ChaCha20-Poly1305. The node picks the cipher from what the peer negotiated at
-handshake; the key schedule above is identical in both.
-
-### Identifying the node
-
-The node's `pk_node` (Ed25519) comes from the hub — `GET /v1/nodes/{node_id}` — and the
-client checks the handshake signature against it. A node that cannot sign the transcript
-with the key the hub published for it is refused, so hub signaling can introduce you to a
-node but cannot substitute one.
-
----
-
-## 7. Video Streaming
-
-Video is streamed over the same MNP channel and played through Media Source Extensions.
-The node remuxes to fragmented MP4 on the fly — the container changes, the video and audio
-streams are copied untouched — and encrypts each segment exactly like a file chunk, so a
-standard `<video src=...>` cannot play it: the segments are ciphertext until the client
-decrypts them.
-
-```
-→ {"type": "stream_req", "v": "0.1", "file_id": "<blake3 hex>"}
-← {"type": "stream_init", "codec": "video/mp4; codecs=\"avc1.640028,mp4a.40.2\"",
- "duration": 5124.3}
-← {"type": "stream_data", "segment_index": 0, "nonce": <bytes>, "ct": <bytes>}
-← ... one message per segment ...
-← {"type": "stream_end"}
-```
-
-Each `stream_data` segment is decrypted with the chunk key for its `segment_index` and
-appended to a `SourceBuffer`. The web client does this in `static/app.js` (`VideoPlayer`);
-ffmpeg must be installed on the node for transcoding.
-
-**Flow control.** The client says how many segments it can take — `stream_req`
-carries a credit count — and the node sends no more than that until `stream_more`
-grants more. Segments are 256 KB.
-
-What governs the credit is the playhead, not the append: the client grants more
-only while it holds less than **90 seconds of film ahead of where you are
-watching**. That bound is the whole point. ffmpeg runs with `-c copy` — a remux,
-not a re-encode — so the bytes on the wire are the file's own, and a 500 MB film
-really does try to put 500 MB somewhere. Granting credit per append instead meant
-taking it as fast as the network could deliver, which filled the browser's
-SourceBuffer ceiling (a few hundred megabytes) in the first minute and wedged the
-player at "buffering" for good. Buffering by time costs the same for a two-hour
-film as for a two-minute clip — around 20 MB at a typical bitrate.
-
-A client that sends no credit count gets the old unpaced behaviour.
-
-**A viewer that is well ahead still says so.** Holding credit back means granting
-nothing for minutes at a time, which the node would otherwise read as a closed
-tab. The client sends `stream_more` with `n = 0` every 20 seconds: it grants no
-room but proves someone is there. The node ends a stream on silence, not on
-stinginess.
-
-**Closing the viewer stops the stream.** `stream_stop` tells the node nobody is
-watching, so ffmpeg is killed and its slot released at once. Before this, leaving
-a video held one for the two-minute credit timeout, which is what made the next
-video answer "server busy".
-
-**How many people may watch at once.** A slot is now held for as long as someone
-is watching, so it is a limit on simultaneous viewers rather than on bursts. The
-default is 8. The operator sets it in `node.toml`:
-
-```toml
-[node]
-max_concurrent_streams = 8
-```
-
-or with `MESHBAY_MAX_CONCURRENT_STREAMS` in the environment. One ffmpeg runs per
-viewer, remuxing rather than encoding — little CPU, roughly 50 MB of memory, idle
-most of the film — so raise it on a machine with memory to spare and lower it on
-a Pi. Past the limit a viewer is told the server is busy. Zero, a negative number
-or a non-number is refused with a warning naming the setting, because a limit of
-zero is a node where no video ever plays and nothing says why.
-
-**The player drops what has been watched.** A SourceBuffer is not a file: browsers
-cap it and refuse the append that goes past, so anything more than a minute behind
-the playhead is evicted. A segment refused for want of room is retried rather than
-dropped — dropping it leaves a hole in the middle of the film and no error
-anywhere. The retry is driven by a timer and by playback progress, never by the
-arrival of the next segment: an append refused for want of room produces no
-`updateend` and so grants no credit, and a pipeline whose only wakeup is the
-segment it is waiting for cannot restart itself.
-
----
-
-## 8. Security Model
-
-Understanding what the hub knows — and does not know — is essential for evaluating MeshBay's threat model.
-
-### Cipher choices — symmetric, not asymmetric
-
-Clarification terminology : Ed25519 et X25519 sont des algorithmes **asymétriques** (paire clé publique/privée). Ils servent à la signature et à l'échange de clés. Les ciphers de chiffrement de contenu sont eux **symétriques** (une seule clé partagée, la GEK) :
-
-| Cipher | Usage | Où |
-|---|---|---|
-| **ChaCha20-Poly1305** | Chiffrement contenu (MNP) | Node → client natif (Python, Android) |
-| **AES-256-GCM** | Chiffrement contenu (navigateur) | Variante pour les groupes accessibles depuis un browser (WebCrypto ne supporte pas ChaCha20) |
-
-Les deux sont des AEAD 256 bits avec authentification intégrée. ChaCha20 est le cipher **principal** — AES-GCM est une variante optionnelle pour la compat navigateur, pas un remplacement. Un groupe ne peut pas mélanger les deux : un groupe "browser-accessible" utilise AES-GCM pour tous ses membres.
-
-### What the hub stores
-
-| Data | Stored as |
-|---|---|
-| Username, email, optional phone | Encrypted at rest |
-| Password | Argon2id hash (never cleartext) |
-| Ed25519 and X25519 public keys | Plaintext (they are public) |
-| GEK bundles (private groups) | Opaque ciphertext — hub cannot decrypt |
-| Connection logs | IP + timestamp, retained ≥1 year (legal) |
-| Node endpoint hints | Ephemeral (signaling only, not persisted) |
-
-### What the hub never stores
-
-- File content or any file metadata
-- Private group indexes
-- Message content
-- The GEK in cleartext
-- Your private keys
-
-### GEK wrapping — why the hub cannot decrypt your files
-
-When Alice adds Bob to a private group, she wraps the GEK with Bob's X25519 public key using an ECIES-like construction:
-
-```
-sk_eph, pk_eph ← X25519.generate() fresh ephemeral keypair per bundle
-shared ← X25519(sk_eph, pk_bob)
-wrap_key ← HKDF(shared, salt=pk_eph, info="meshbay:gek_wrap:v1")
-bundle ← ChaCha20-Poly1305(wrap_key).encrypt(nonce, GEK, aad=pk_bob)
-```
-
-The hub receives `{pk_eph, nonce, bundle}` and stores it opaquely. To decrypt it, an attacker would need `sk_bob` (Bob's X25519 private key), which never leaves Bob's device. The AAD (`pk_bob`) also binds the bundle to its intended recipient — a bundle cannot be repurposed for a different member.
-
-Each call to `wrap_gek` uses a fresh `sk_eph`, so the same GEK wrapped for the same member twice produces different ciphertext. The hub sees only different random-looking blobs.
-
-Wrap and unwrap operations each take ~0.5–1.2ms (measured in testing).
-
-### Forward secrecy
-
-Two layers:
-
-1. **Per-connection session keys:** each MNP connection performs X25519 ECDH + HKDF to derive ephemeral session keys independent of the GEK. Compromise of the GEK does not expose historical session traffic.
-
-2. **Per-chunk keys:** each 1 MB chunk uses a distinct key derived from the GEK + file hash + chunk index. Compromise of one chunk key does not compromise other chunks.
-
-### JWT security properties
-
-- `jti` (UUID4) is mandatory in every token — prevents replay (Ed25519 signing is deterministic; without `jti`, two tokens issued in the same second are byte-for-byte identical) and enables individual revocation
-- Nodes verify JWTs offline using the hub's cached public key — no hub roundtrip, no hub downtime dependency
-- Revocation: hub invalidates refresh token → next access token renewal fails → node access expires within the access token's life. For immediate revocation: hub adds `jti` to a denylist that nodes periodically fetch
-
-### What node compromise exposes
-
-If an attacker gains access to your node:
-- All files in `shared_dir` (stored in plaintext)
-- The keystore file (protected by Argon2id-derived AES-256-GCM; requires the keystore password to open)
-- The GEK copies in the keystore (if the keystore is unlocked)
-
-If the keystore password is not stored on the node (`unlock_mode = "secure"`), a node compromise does not immediately expose the GEK or private keys — the attacker gets the encrypted keystore and must break Argon2id. At the production parameters (iterations=4, memory=256MB, target=500ms), this is designed to limit offline attacks to a tractable rate.
-
----
-
-### Where a node is, in the administration panel
-
-The **Nodes** tab lists every registered node with two addresses, and the
-difference between them matters:
-
-- **Seen from** is the address the node's announcement arrived from. That request
- carries an Ed25519 signature over a fresh timestamp made with the node key, so
- the address belongs to whoever holds that key. It is IPv4 or IPv6, whichever
- the node connected over, and it is the one to answer a question with.
-- **Announced hint** is what the node believes its own address to be, discovered
- through a STUN server and sent to us. It is useful for reaching the node
- directly and it is a claim, not evidence.
-
-Clients are recorded the same way: `webrtc_offer` in the log is written when a
-browser starts a peer connection, with the address the hub saw it come from.
-Whatever the two peers then discover through STUN is theirs to negotiate and does
-not belong in a log.
-
-Deleted accounts are not counted or listed anywhere in the panel. The tombstone
-row exists so the connection log stays readable (§2) and is not a user.
-
----
-
-## 9. Moderation and Legal
-
-### Who is the legal host
-
-**You, the node operator, are the legal host of all content you serve.** MeshBay is a protocol and a registrar service, not a content host. By running a node, you take full legal responsibility for what your node shares.
-
-The hub (`meshbay.org`) is a registrar analogous to a domain registrar — it handles identity and routing, not content. Its legal exposure is similar to that of a registrar, not a hosting provider.
-
-### Content reports (public groups)
-
-```
-Report #1 → public access suspended automatically
- → node operator notified via email
-One republication allowed
-Report #2 → escalated to hub moderators
-Confirmed → group revoked on local hub
- → revocation token propagated to federated hubs
-```
-
-Mechanism: the file's `blake3` hash is added to the hub blocklist. A signed revocation token (Ed25519) is sent to the node. Nodes verify the revocation token offline.
-
-### CSAM policy
-
-All public content hashes are checked against the NCMEC and IWF databases at the time of indexing. Any match results in immediate revocation of the group and the account, and mandatory reporting to NCMEC. No scanning of private encrypted content is performed — it is technically infeasible.
-
-This hash-matching step is mandatory for hub operators and reduces legal exposure under applicable law (NCMEC CyberTipline obligations).
-
-### Copyright
-
-Takedown on receipt of a valid DMCA notice or equivalent. The hub can revoke a group on confirmed legal request. No automated technical blocking (high false-positive risk, fair use concerns).
-
-### Private content
-
-Private group content is E2E encrypted. The hub cannot read it. Action available on a formal legal request: revoke the user or group at the hub level. The hub issues an Ed25519-signed revocation token verifiable offline by all member nodes. This terminates future access without retroactively decrypting past content.
-
-### Connection logging
-
-The hub logs the following events with timestamp and source IP for a minimum of one year (LCEN, EU e-Commerce Directive, DSA compliance):
-
-| Event |
-|---|
-| Account creation |
-| Login (success and failure) |
-| Group creation |
-| Group join / leave |
-| Group deletion |
-| Revocation actions |
-
-These logs are not used for any purpose other than responding to legal requests. They are not exposed to users, group operators, or third parties without a legal order.
-
----
-
-## 10. Troubleshooting
-
-### JWT expired
-
-**Symptom:** node returns 401, error says "expired" or "Token signature expired".
-
-In the web app this should not happen: it renews before expiry and retries once
-on a 401. If you see it there, the renewal path itself is broken — check the
-browser console rather than the token.
-
-**Fix (other clients):** your access token is over 4 hours old. Refresh it, and
-**keep the refresh token that comes back** — the one you sent is now revoked:
-
-```bash
-curl -s -X POST https://meshbay.org/v1/users/token/refresh \
- -H "Content-Type: application/json" \
- -d '{"refresh_token":"YOUR_REFRESH_TOKEN"}'
-```
-
-If the refresh token is also expired (>30 days), log in again.
-
-### GEK bundle not found (404 on `/v1/groups/{id}/gek`)
-
-**Symptom:** `404 {"detail": "No GEK bundle for this user in this group"}`.
-
-**Causes:**
-- You are not a member of this group — the admin needs to add you and upload a GEK bundle for you
-- Your public key registered on the hub differs from your current keypair — this happens if you regenerated your keys after registration (see below)
-
-### GEK decryption fails (`InvalidTag`)
-
-**Symptom:** `ChaCha20Poly1305.decrypt()` raises `cryptography.exceptions.InvalidTag`.
-
-**Cause:** your local X25519 private key does not match the public key that was on the hub when the GEK bundle was created. This happens when:
-
-- You ran the registration script more than once without persisting `my_keys.json`
-- You deleted and recreated your keystore
-
-**Fix:** contact the group admin. They need to fetch your current public key from the hub and re-wrap the GEK for you.
-
-### Node is not reachable from outside
-
-**Symptom:** curl to `http://YOUR-IP:19001/` times out from another machine.
-
-**Checklist:**
-1. Is the port open in your firewall? (`sudo ufw allow 19001/tcp` on Ubuntu)
-2. If behind a home router: have you set up a port forward for `19001/tcp` to your machine's local IP?
-3. Is the node actually listening? (`ss -tlnp | grep 19001`)
-4. Is your ISP blocking inbound connections on that port? (Some mobile ISPs do this — use a VPS)
-
-NAT traversal without manual port configuration (STUN/hole-punching for most residential connections) is coming in v2.
-
-### `Connection refused` on port 19001
-
-The node is not running, or it started on a different port. Check your `node.toml` `listen_port` and the node's startup log output.
-
-### Hub returns 422 (Unprocessable Entity)
-
-Usually a malformed request body. Check that:
-- `Content-Type: application/json` header is present
-- Your public keys are base64-encoded raw 32-byte values (not PEM, not hex)
-- Password is at least 8 characters
-
-### Node announces but no files appear in index
-
-- Check that `shared_dir` exists and contains files
-- Check the node log for indexing errors (permission denied, symlinks, etc.)
-- The node re-indexes on startup and watches for changes. If a file was added while the node was down, restart the node or touch the file to trigger a watch event.
-
-### `AEAD decryption failed` on chunk download
-
-- Verify you fetched the GEK bundle for the correct group
-- Verify `chunk_index` in the HKDF `info` matches the `chunk_index` field in the JSON response
-- Verify `file_hash_b64` is decoded to bytes before use in HKDF `info`
-- Verify the GEK itself is correct by re-fetching and re-unwrapping the bundle
-
----
-
-## 11. API Reference
-
-All hub endpoints are under `https://meshbay.org`. **Nodes have no public HTTP API** —
-they speak MNP (§6), and their only HTTP surface is the operator's admin UI on loopback.
-
-### Hub API
-
-**Hub metadata**
-
-| Method | Path | Auth | Description |
-|---|---|---|---|
-| GET | `/v1/hub/info` | None | Hub metadata: hub_id, MNP/MHP versions, user count, node count |
-| GET | `/v1/hub/pubkey` | None | Hub Ed25519 public key (PEM) — cache this for offline JWT verification |
-
-**User management**
-
-| Method | Path | Auth | Description |
-|---|---|---|---|
-| POST | `/v1/users/register` | None | Register account. Body: `username, email, auth_key`. No keys — identity keypairs are per node. Returns `user_id`. |
-| POST | `/v1/users/login` | None | Authenticate. Body: `username, password`. Returns `access_token, refresh_token`. |
-| POST | `/v1/users/token/refresh` | Refresh token | Issue new access token. Body: `refresh_token`. |
-| GET | `/v1/users/{username}/pubkeys` | Access token | Returns `user_id`, `username` and `pk_node_ed25519` only. It no longer returns identity keys: wrapping the group key for whatever this endpoint answered was finding H3, and the node wraps it now (§3). |
-| DELETE | `/v1/users/me` | Access token | Delete your own account. Body: `auth_key` — the passphrase is re-checked. `409` if you still own groups. |
-| DELETE | `/v1/admin/users/{user_id}` | Access token (hub admin) | Delete someone else's account. Same tombstone, same refusal if they own groups. |
-
-**Node management**
-
-| Method | Path | Auth | Description |
-|---|---|---|---|
-| POST | `/v1/nodes/announce` | Access token | Register node. Body: `pk_node, endpoint_hint`. Returns `node_id`. |
-| GET | `/v1/nodes/{node_id}` | Access token | Retrieve node record: `pk_node, endpoint_hint, username`. |
-
-**Group management**
-
-| Method | Path | Auth | Description |
-|---|---|---|---|
-| POST | `/v1/groups` | Access token | Create group. Body: `name`. Returns `group_id`. |
-| GET | `/v1/groups` | None / Access token | List/search groups. Private groups require membership. |
-| GET | `/v1/groups/{group_id}` | None / Access token | Group metadata. |
-| PATCH | `/v1/groups/{group_id}` | Access token (owner) | Edit the description. Body: `description`. Nothing else is editable — see §3. |
-| DELETE | `/v1/groups/{group_id}` | Access token (admin) | Revoke and delete group. |
-
-**GEK distribution — removed.** The hub used to carry wrapped group keys between members.
-It does not any more: the node holds the GEK and wraps it itself, for a key the recipient
-proved possession of over an authenticated channel (§3, and `docs/invite-pairing-v1.md`).
-There is no hub endpoint that touches group key material.
-
-**Notifications**
-
-| Method | Path | Auth | Description |
-|---|---|---|---|
-| GET | `/v1/notifications` | Access token | Your notifications, newest first, plus an `unread` count. Chat is one entry per group. |
-| DELETE | `/v1/notifications/{id}` | Access token | Dismiss one. The row is deleted — a notification is a signal, not a record, and the group, the message and the invitation it pointed at are all still there. |
-| POST | `/v1/notifications/{id}/read` | Access token | The same thing. Kept because the interface ships inside the desktop package, so a hub is always answering some client older than itself. |
-| POST | `/v1/notifications/read-all` | Access token | Dismiss every one. Same as `DELETE /v1/notifications`. |
-| DELETE | `/v1/notifications` | Access token | Delete all of yours. |
-| POST | `/v1/groups/{group_id}/mute` | Access token (member) | Body: `muted`. A muted group creates no notifications at all. |
-
-**Revocation**
-
-| Method | Path | Auth | Description |
-|---|---|---|---|
-| POST | `/v1/revoke/user/{user_id}` | Access token (hub admin) | Revoke a user account. |
-| POST | `/v1/revoke/group/{group_id}` | Access token (hub admin) | Revoke a group. |
-| GET | `/v1/revoke/denylist` | None | Current `jti` denylist for active access tokens. Nodes poll this to enable individual token revocation. |
-
-### Node API
-
-The unauthenticated HTTP API (`/`, `/index`, `/file/{id}`, `/stream/...`) was **removed in
-0.2.0**, findings C1 and C6. Everything a member does now goes through MNP after a
-handshake that establishes what they are allowed to see:
-
-| Client → node | Node → client | Purpose |
-|---|---|---|
-| `handshake` | `handshake_ack` | Token, group, and the node's signature over the transcript |
-| `handshake_challenge` (node first) | `handshake_response` | Proof the peer holds the GEK |
-| `join_request` | `join_result` | Pairing code, or recognition of a pinned identity; carries the wrapped GEK on success |
-| `index_sync` | `index_sync` / `index_delta` | The group index |
-| `file_req` | `file_chunk` | One encrypted 1 MB chunk |
-| `file_upload` | `file_upload_ack` | One chunk into the folder you are browsing; both halves sealed under the group key, so the filename and the bytes never appear on the wire in clear. The ack names the file as stored |
-| `dir_create` | `dir_create_ack` | Create a directory |
-| `file_delete` | `file_delete_ack` | Delete a file you uploaded |
-| `stream_req` | `stream_init`, `stream_data`, `stream_end` | MSE video |
-| `chat_msg`, `chat_hist` | `chat_hist_resp` | Chat and its history |
-| `gek_bundle_fetch` | `gek_bundle_resp` | Your own wrapped group key |
-| `keypair_bundle_fetch` / `_store` / `_delete` | `keypair_bundle_resp` | Your encrypted keypair backup on that node |
-
-**Auth:** the JWT is verified offline against the hub's Ed25519 public key — the hub is not
-contacted (~884µs). The token proves which account you are; it does not decide what the
-node serves you. That is the node's roster and the GEK proof.
-
-**The node's admin UI** (`/api/*`, loopback, token required) is the operator's, not a
-member API.
-
----
-
-*MeshBay protocol: MNP v0.1 over WebRTC DataChannel (browsers) and QUIC (native). The
-TCP+TLS transport was removed in 0.2.0 — finding C6.*
-*Hub: https://meshbay.org — FastAPI + PostgreSQL + Caddy.*
-*Packages: python3-meshbay-common, meshbay-hub, meshbay-node.*
diff --git a/docs/WINDOWS-PORT.md b/docs/WINDOWS-PORT.md
index efc00e3..1894706 100644
--- a/docs/WINDOWS-PORT.md
+++ b/docs/WINDOWS-PORT.md
@@ -16,7 +16,7 @@
> Created 2026-09-03 from a full codebase scan; progress notes added 2026-09-04,
> 2026-09-05.
> This document is both the audit results and the implementation plan.
-> It does not repeat the design decisions already in `desktop-client-v1.md`
+> It does not repeat the design decisions already in `MESHBAY_DESIGN.md`
> (§6.8, §7.5, decisions E8/E12) — read that first.
>
> **What shipped (branch `win-webrtc-stun`):** platform dirs / signals / perms /
@@ -290,7 +290,7 @@ SIGHUP is already guarded. No other signal handling in the codebase.
**Scope:** `daemon.py` CLI + `meshbay_node.platform` + `src/main.js` IPC handlers
+ `node-page.js`
-Two modes ship, matching `desktop-client-v1.md` §7.5, chosen at install time
+Two modes ship, matching `MESHBAY_DESIGN.md` §11.2, chosen at install time
and **switchable afterwards** (see below — that "afterwards" part was not the
original design and exists because of a real bug):
diff --git a/docs/apps.md b/docs/apps.md
deleted file mode 100644
index 991af1b..0000000
--- a/docs/apps.md
+++ /dev/null
@@ -1,333 +0,0 @@
-# Group applications — adding one
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the group-application framework; its design
-> content now lives in §9.1–§9.4.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **superseded, and accurate as far as it goes.** Describes the plug-in architecture that
-> replaced the monolithic `static/app.js`, landed 2026-08-23. See
-> `meshbay-draft-v6.md` §2.7 for why this exists and what it changes; this
-> document is the how-to.
-
-A group has "applications" — Chat, Files, and Videos today (a poster-grid
-browser; see `docs/mediacenter.md`), Music/Photos planned (a music player, an
-album viewer). Video/audio/image files are already classified by the node's
-indexer (`meshbay_node/indexer/indexer.py`, `type: video|audio|image`) and
-flow through the same `index_sync`/`file_req`/`stream_req` messages Files and
-`VideoPlayer` already use — Music/Photos need no MNP change beyond that.
-Videos itself did need one: TMDB metadata (`media_meta_req`/`resp`), per-season
-overview (`season_meta_req`/`resp`), and operator match correction
-(`tmdb_search_req`/`resp`, `tmdb_override`/`_ack`) are all additive message
-pairs on top of the same index/chunk plumbing, not a replacement for it.
-Adding a new app is still a new file plus one registry entry — nothing about
-the group shell changes.
-
----
-
-## 1. The shape
-
-```
-group-page.js ─┬─ owns: connection (transportRef/gekRef), the file index
- (the shell) │ (entries/nodeDirs/nodeRoots), admin flags, which apps are
- │ enabled, the tab bar, the video/preview modals
- │
- ├─ apps.js ─── the registry: [{ key, icon, labelKey, Component }]
- │
- ├─ chat-app.js ──────── ChatPanel
- ├─ files-app.js ─────── FilesPanel, FilePreview
- └─ (video-app.js, music-app.js, photos-app.js — not built)
-
-group-settings.js ─── not an app. Always present, not toggleable — disabling
- it would strand an operator with no way to re-enable
- anything. Holds the "Applications" checkbox list.
-
-Shared infrastructure (imported by app.js AND every per-app file — this is
-why they exist as separate modules rather than being re-exported from app.js,
-which would make a circular import):
- icon.js — the <Icon> component and its SVG path table
- file-utils.js — formatSize/formatDate/canPreview/FILE_ICONS, the
- download/decrypt pipeline (pipelinedDownload, downloadEntry,
- _openDownloadTarget, _saveBlob), CHUNK_SIZE
- hub-client.js — HUB, hubFetch, the auth/session/token-renewal machinery,
- the group-index IndexedDB cache, the keypair-bundle cache,
- `session` (mutable {bundleKey, pendingJoinCode}), navigate
-```
-
-`app.js` itself is what's left after the split: routing, every *other* page
-(Login/Register/Home/Explore/Search/Profile/Settings/Admin/Node/
-CreateGroupWizard), and nothing group-application-specific.
-
-## 2. What every app receives
-
-`group-page.js` builds one `commonProps` object per render and spreads it into
-whichever app is active:
-
-```js
-const commonProps = {
- groupId, transportRef, gekRef, status, username, deviceReady,
- entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
- isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
- onRefreshIndex: refreshIndex, onActivity: touchActivity,
-};
-...
-${apps.map(a => tab === a.key && html`<${a.Component} key=${a.key} ...${commonProps} />`)}
-```
-
-Every registered component gets the same context and destructures what it
-needs — a new app does not get a bespoke prop list. Notable ones:
-
-| Prop | What it is | Why it's here, not local state |
-|---|---|---|
-| `entries`, `nodeDirs`, `nodeRoots` | the group's file index | Chat needs it too, for image attachments — lifting it avoids two copies going stale against each other |
-| `applyIndex(indexMsg)` | writes a fresh index into the three above, plus the search cache | anything that mutates files (upload, delete, mkdir) calls this so every app sees the result |
-| `onPreview(entry)` | opens the shell's video/preview modal | `entry.type === 'video'` routes to `VideoPlayer`, anything else to `FilePreview` — an app just calls this, it does not own modal state |
-| `transportRef`, `gekRef` | refs to the live MNP transport and the imported group key | never state — a ref, so reconnects don't force a re-render of every app |
-| `deviceReady` | whether this connection has identified a device to the node (`device_hello`) | **the exception to the row above, and why it is a prop.** A ref not re-rendering is right for a transport an app reaches into on demand, and wrong for a *fact about the connection* an app renders from. Chat's composer gates on this one: a reconnect clears it and settles it again inside `connect()`, and while it was read off `transportRef.current.devicePk` during render, the panel latched shut on whatever unrelated re-render came next and had no event that would open it again. See `test_chat_send.py` |
-| `mayUpload` | `memberUpload || isNodeAdmin`, computed once | Files' toolbar and Chat's composer both gate on it; a second derivation would eventually disagree with the first |
-
-### 2b. The same app, rendered by the Search page
-
-Videos, Music and Photos are mounted twice: by `group-page.js` for one group,
-and by `search-page.js` across every group the reader belongs to. The second
-caller passes the same prop shape, and the difference lives entirely on the
-**entries**, in underscore-prefixed fields the group page never sets:
-
-| Field | What it is |
-|---|---|
-| `groupId`, `groupName`, `groupOwner` | which group serves this entry |
-| `_tRef`, `_gRef` | that group's transport and key — read as `entry._tRef \|\| transportRef`, which is why a single-group mount needs no special case |
-| `_connGen` | bumped when that group reconnects; use it as a refetch key so a tile recovers instead of staying a spinner |
-| `_sources` | every group that has this file, after the de-duplication below |
-
-**A file shared by two groups is one entry, not two** (`source-merge.js`,
-`docs/refactoring-search.md`). Entries are folded on their content hash and one
-source is resolved per *unit* — a film, a show, an album — using each app's own
-grouping function to decide what a unit is. Two consequences for a new app:
-
-- if it renders a group name, use **`SourceTag`** from `group-name.js` rather
- than `entry.groupName`: a merged entry has several groups and must say
- `N sources` instead of naming one. Pass it the **whole unit** (a show's
- episodes, an album's tracks), not the entry the card was drawn from — that
- entry is usually chosen for its thumbnail, and would under-report;
-- if it needs a merge unit key of its own, add a `<name>Units()` helper to
- `search-page.js` that calls the app's **exported** grouping function. Never
- re-derive the keys there: a copy keeps agreeing until one of them changes,
- and the symptom is a show whose episodes stream from two different nodes.
-
-The Files explorer is deliberately **not** merged — there each group is a
-top-level folder, and merging would remove a file from one of them.
-`test_search_files_unmerged.py` refuses a build that changes this.
-
-An app that needs **local** state (Files' `selecting`/`sortKey`/`currentPath`,
-for instance) owns it itself with `useState`, same as before the split. One
-thing worth keeping if you add a tab with a notion of "current location within
-the group" the way Files has a path: reset it on `groupId` change.
-`files-app.js` does this —
-
-```js
-useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]);
-```
-
-— because a directory from the group just left rarely exists in the one just
-entered, and without the reset the panel shows a stale path and lists
-nothing. This was a real bug, fixed before the split; carry the pattern into
-any app with similar per-group local state.
-
-## 3. Enable/disable: the mechanism
-
-Same shape as a root's `writable` flag (`refactor-groups.md` §1.1) — an
-operator-signed setting, stored on the node, enforced by absence rather than
-by the client's honesty. It used to be described against `member_upload`,
-which was the group-wide upload switch; that was removed in the same refactor.
-
-**Node side** (`meshbay_node/roster.py`):
-```python
-SETTING_ENABLED_APPS = "enabled_apps" # in the existing group_settings table
-DEFAULT_APPS = ("chat", "files") # what an unset group gets
-async def enabled_apps(group_id) -> list[str]: ...
-async def set_enabled_apps(group_id, apps, set_by="") -> list[str]: ...
-```
-`meshbay_node/ops.py` has `set_enabled_apps(state, group_id, apps)`, called
-from exactly one place: `webrtc_server.py`'s `_admin_exec_apps_enabled`, after
-`_verify_admin_sig` — nothing is applied before the signature checks out.
-
-**An app's directories are the same shape one level down** (2026-09-06):
-`ops.set_app_directories(state, group_id, app_key, paths)`, stored under
-`<app_key>_directories`, reached by one MNP message (`app_directories`) and one
-loopback route. Adding an app adds no function, no message type and no route —
-which is what "plugin architecture" has to mean to be worth the phrase.
-
-`_do_apps_enabled` in `webrtc_server.py` validates before it ever issues a
-challenge:
-- `apps` non-empty — the operator can never lock a group down to nothing.
-- every entry in `WebRTCPeerSession.ALLOWED_APPS`
- (`{"chat", "files", "video", "music", "photo"}` today) — **this is the line
- a new app's node-side registration touches.**
-- `files` is added to the list if it is absent, at both writers
- (`_do_apps_enabled` and `ops.set_enabled_apps`, both at the front so the two
- agree). It is not a toggle: MNP permits root exploration regardless of what
- this list says, so hiding the tab only ever misled.
-
-The whole set is signed in one message (`apps_enabled`, `OP_APPS_ENABLED` in
-`meshbay_common.adminop`) rather than one op per app — ticking several boxes
-in Settings costs one signature, not N. The transcript's subject is the
-sorted, comma-joined app list (`"chat,files"`), built the same way on both
-sides so the operator's browser and the node arrive at identical bytes to
-sign/verify.
-
-`enabled_apps` rides in `handshake_ack` and `node_status`, next to the roots
-table. Changing it broadcasts `apps_enabled_ack` to everyone already connected
-— `transport.js`'s `onAppsEnabled` — so a disabled tab disappears without
-waiting for a reconnection. The root ops (`root_update_ack`, `root_eject_ack`,
-`root_plug_ack`) broadcast the same way, through `onRootsChanged`.
-
-### 3b. An app's settings
-
-Each app that has settings exports a component from
-`static/<app>-app-settings.js` and names it in its `apps.js` entry. The Settings
-page renders one collapsible section per registry entry, with the app's own
-on/off switch in the header — the toggle *is* the enablement control, rather
-than a checkbox list somewhere else that could disagree with it.
-
-Every pane takes the same props, and nothing else: `roots`, `dirs`, `settings`,
-`saveDirectories` (bound to this app), `transport`, `signFn`. The split is the
-point — **what every app has, the page does generically; what one app alone
-has, the pane does itself.** Pointing an app at folders goes through
-`saveDirectories`; a TMDB credential or a link-preview switch is the pane's own
-business, made with the transport it is handed. An app that only needs
-directories therefore touches neither `group-settings.js` nor `group-page.js`,
-and `test_app_settings_plugin.py` fails if either of them starts naming apps
-again.
-
-Two constraints that are not obvious:
-
-- **A pane must not import `group-settings.js`.** That is a cycle
- (`group-settings` → `apps` → pane → `group-settings`), and ES modules answer
- it with a temporal-dead-zone `ReferenceError` at first render — the component
- does not appear, with nothing in the console to say why. The shared widgets
- (`CollapsibleSection`, `ToggleSwitch`, `useSaver`) live in `settings-ui.js`
- for this reason.
-- **A new module must be added to `_ASSETS`** in `meshbay_hub/api/webapp.py`.
- A file reached through the registry is not imported by name anywhere, so
- nothing else would notice it changing, and a browser would go on serving the
- cached copy. `test_asset_versioning` enforces it.
-
-**Client side:** `apps.js`'s `visibleApps(enabledKeys)` filters the registry;
-`group-page.js` calls it with `enabledApps` state (from the ack, `null` until
-one arrives, which `visibleApps` reads as "show everything registered" — a
-node that predates an app, or hasn't answered yet, hides nothing). The
-Settings toggle list in `group-settings.js` iterates the *same* `APPS`
-registry, so a newly-registered app gets a checkbox for free.
-
-## 4. Adding an app — checklist
-
-1. **`<name>-app.js`**, exporting a component with the standard props shape
- (§2). Use `files-app.js` as the reference if the app is file/media-centric
- (it will be, for Videos/Music/Photos — all three are views over `entries`
- filtered by `type`), or `chat-app.js` if it needs its own local realtime
- state. Import shared helpers from `file-utils.js`/`hub-client.js`/
- `icon.js` — do not re-implement `formatSize`, the download pipeline, or
- `Icon`.
-2. **Register it** in `apps.js`'s `APPS` array: `{ key, icon, labelKey,
- Component, Settings? }`. `key` is the wire identifier — it must match what
- you add to the node's allow-list next, and it is also the row an app's
- directories are stored under (`<key>_directories`). One identifier per app,
- everywhere; `test_app_settings_plugin.py` checks the registry against
- `ALLOWED_APPS`.
-2b. **`<name>-app-settings.js`**, if the app has anything to configure,
- exporting a component that takes `{ roots, dirs, settings,
- saveDirectories, transport, signFn }` and nothing else (§3b). Folders go
- through `saveDirectories`; anything only this app has, it does itself with
- the transport. **Do not import `group-settings.js`** — that is a cycle, and
- it fails as a component that silently does not render.
-2c. **The toolbar pins.** If the app has a toolbar — a row of controls above
- whatever it is the app shows — give it `position: sticky` on the pattern
- `style.css`'s "Sticky chrome" section holds, so that scrolling a library
- does not take its own controls off the screen. Two conditions come with it,
- and both are structural rather than cosmetic: the toolbar must be a
- **direct child of the page root** (an app renders a fragment, so it already
- is — do not wrap it in a container of your own), and it must be **opaque**,
- or the content scrolls visibly through it. If anything of the app's pins
- *below* that toolbar, as Files' column heads do, the toolbar has to publish
- its own height with `useStickyBand` from `sticky.js` — its height is never
- a constant, since it wraps on a phone. An app with no toolbar renders none:
- an empty band still holds a strip of the page open, which is why Photos
- draws no toolbar on the Search page.
-3. **Node-side allow-list**: add the key to `ALLOWED_APPS` in
- `webrtc_server.py`. Without this the node refuses `apps_enabled` for any
- set naming it (`"Unknown app(s): ..."`), so an operator can never turn it
- on.
-4. **i18n**: at minimum, a `group.tab_<name>` key (the tab's tooltip/label,
- reused as the Settings checkbox label) in all ten `static/locales/*.js`
- files. `test_locales.py` holds them to the same key set.
-5. **`webapp.py`'s `_ASSETS`** tuple: add both new files. This is the
- cache-busting hash's input list — a file imported by the page but missing
- here can change without the served URL changing, which is the exact bug
- class `test_asset_versioning.py` exists for. Forgetting this step used to be
- silent: nothing errors, a browser just keeps an old copy. It is now caught —
- `test_every_static_script_participates_in_the_fingerprint` holds `_ASSETS`
- to every `.js` in `static/` (`sw.js` excepted, unversioned on purpose).
- Written after `source-merge.js` shipped missing from the list.
-6. **Test coverage that scans the file set**: `test_hook_ordering.py`
- (`STATIC_FILES`), `test_sticky_header.py` (add a case to its probe if the
- app has a toolbar — a band that stopped pinning looks exactly like one that
- never did) and `test_transport_contracts.py`
- (`test_no_setter_survives_the_state_it_belonged_to`, `SPLIT_FILES`) walk a
- fixed list of files looking for a whole class of bug each — add the new
- file to both lists, or it is simply never checked, which fails silently
- rather than loudly.
-7. **`sync-ui.js`** needs no change — it copies the whole `static/` tree
- verbatim. Run `npm run sync-ui` in `meshbay-client` after adding the file
- and confirm it's reported.
-
-No protocol change, no hub change, no `daemon.py` change — steps 3 and 6 are
-the only node-side touches, and both are allow-lists, not new wire messages.
-Directories in particular need nothing server-side at all: `app_directories` is
-one generic op keyed by the app's name (§3), and an app storing its folders
-under a key nobody wrote code for is the case
-`test_app_directories.py::test_an_app_nobody_wrote_code_for_stores_its_directories`
-pins.
-
-## 5. What does not exist yet
-
-- **Thumbnails/posters — built for Videos, 2026-08-23, see `docs/mediacenter.md`.**
- The plan below (lazy, client-side, no node-side store) turned out to be
- wrong once a real design pass ran the numbers: `docs/mediacenter.md` §2
- revises `desktop-client-v1.md`'s O12 and has the node generate thumbnails
- (an `ffmpeg` frame grab, its own bounded worker pool) and cache them
- durably in its own `data_dir`, delivered over the existing `file_req`/
- chunk path addressed by their own blake3 hash. TMDB posters/metadata are
- fetched and cached by the node the same way — no client ever talks to
- TMDB directly. A virtualized grid (`IntersectionObserver`-based lazy
- mount) is built in `video-app.js`, per the note below. A future Photos
- app can reuse the same node-side machinery (thumbnail cache, chunk-path
- delivery) without re-deciding any of this.
-- **Videos, Music, Photos themselves.** Videos is now built (`video-app.js`,
- `docs/mediacenter.md`). Music is now built (`music-app.js`,
- `docs/musicbay.md`) and reuses Videos' node-side thumbnail/chunk-delivery
- machinery, with no new streaming path (a track is small enough to
- download-then-play, unlike a film). Photos is **designed, not built** —
- see `docs/photos.md` — and reuses the same `thumb_hash`/chunk-delivery
- machinery again; unlike Videos/Music it needs several root folders per
- group rather than one, has a single album-grid view with no third-party
- matching step, and reads EXIF locally on the node instead.
-- **The offline/loopback settings path.** A root's flags can be changed two
- ways: over a live MNP connection (any browser, anywhere), or — Electron
- only, and only when MNP is not connected — via the node's local HTTP API
- (`platform.node.call('PATCH', '/api/groups/<id>/roots/<name>')`,
- `SharedDirectoriesTable` in `group-settings.js`). `apps_enabled` only has
- the MNP path today. Adding the loopback twin is a `meshbay_node.ui` endpoint
- plus a branch in the table's `run()` helper, mirroring the root ops.
-
- **MNP is the path that must exist, not the fallback.** The operator of a
- node is not necessarily sitting at it. The first version of the shared
- directories table read its roots exclusively from the loopback API, which
- resolves to "not available" in a browser — so the whole section rendered for
- nobody on the web, while the controls it replaced had worked there. Any
- operator-facing setting added here needs the MNP route first.
diff --git a/docs/auth-confirm.md b/docs/auth-confirm.md
deleted file mode 100644
index 3e8b488..0000000
--- a/docs/auth-confirm.md
+++ /dev/null
@@ -1,560 +0,0 @@
-# MeshBay — Password change and recovery
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the passphrase change and recovery; its design
-> content now lives in §3.6.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **built** (the reset endpoints, the recovery key and the node fan-out
-> all shipped; this header said "not built" long after they did). The decision
-> record for two features that
-> look like one and are not: changing a passphrase you still know, and recovering
-> from one you have lost.
-> Follows the house convention: every claim names the adversary it holds against.
-> Builds on the email-verification work merged 2026-08-31 (`c6fd7ea`) — the
-> `EmailVerification` table, `mail.py`, `_generate_code`, the `limiter` — and reuses
-> all of it rather than adding a parallel mechanism.
-
----
-
-## 1. Why this is not one feature
-
-The passphrase never leaves the client. It derives **two independent values**, both
-salted by the username only (`keyderive.js`):
-
-| Value | Derivation | Consumer | Where it lives |
-|---|---|---|---|
-| `auth_key` | PBKDF2-SHA512 600k, domain `meshbay:auth:v1:<user>` | hub authentication | hub stores an Argon2id hash (`users.pw_hash` / `pw_salt` / `pw_version`) |
-| `bundle_key` | Argon2id 128 MB / t=3, domain `meshbay:bundle:v2:<user>` | AES-GCM key for the per-node identity bundle | on **each node**, in `keypair_bundles.bundle_enc` — never on the hub (C4, `per-node-identity-v1.md`) |
-
-Consequences that split the work in two:
-
-- **`auth_key` the hub can reset.** It holds a verifier for it and nothing else depends
- on that verifier. An email code is enough.
-- **`bundle_key` nobody can reset.** The hub has held no key material since H3 closed
- (2026-08-14); it cannot reach a `keypair_bundles` row (it is not a group member and
- bundles are served only over MNP to authenticated members). A lost passphrase means
- the per-node identities encrypted under it are gone unless a **second wrapping** was
- put in place beforehand.
-
-So:
-
-- **Flow A** (§3): the user knows the passphrase and wants a new one. Re-wrap every
- reachable node's bundle, then swap the hub verifier.
-- **Flow B** (§4): the user has lost the passphrase. An email code restores hub login;
- a **recovery key** set up at registration restores the per-node identities. Without
- that key, hub login is all that comes back.
-
----
-
-## 2. What already exists (reuse, do not duplicate)
-
-From `c6fd7ea`:
-
-- `EmailVerification(id, email_hash, email_encrypted, code, purpose, user_id, group_id,
- created_at, expires_at, verified_at, attempts)` — `purpose` is a free string today
- (`registration` | `email_change` | `invitation`).
-- `_generate_code()` → 6 digits; `VERIFICATION_TTL = 86400`; `VERIFICATION_MAX_ATTEMPTS = 10`.
-- `mail.py` — localhost Postfix, `_send`, `_mask_email`, one `send_*` per purpose.
-- `hash_email_blind(email)` — HMAC-SHA256 blind index, `users.email_hash` unique.
-- `limiter` on `/verify-email` etc. (`10/minute`).
-- Cleanup task prunes expired codes and stale pending accounts (`tasks/cleanup.py`).
-
-This design adds `purpose = "password_reset"`, one `mail.send_*`, three hub endpoints,
-one nullable column node-side, and an additive MNP field.
-
----
-
-## 3. Flow A — change a known passphrase
-
-Lives on the **Profile page**, next to the e-mail change and account deletion, behind a
-re-entry of the current passphrase (same bar as `DELETE /v1/users/me`: a live token is
-not enough for something with blast radius).
-
-### 3.1 Client sequence
-
-1. Prompt: current passphrase, new passphrase (enforce the existing floor — 12 chars,
- ~60 bits, client-side).
-2. Derive four values: `old_auth_key`, `new_auth_key`, `old_bundle_key`, `new_bundle_key`
- (`deriveAuthKey` / `deriveEncryptionKey`, once each).
-3. **Fan-out over nodes** (§3.2) — re-wrap every reachable identity bundle from
- `old_bundle_key` to `new_bundle_key`. Do this *before* touching the hub: if it fails
- the account is unchanged.
-4. `POST /v1/users/password` `{ old_auth_key, new_auth_key }` (§3.5).
-5. On success, keep the session (the caller proved the new passphrase); other sessions
- are dropped by the refresh-family revocation in §3.6.
-
-### 3.2 The fan-out over nodes
-
-`MeshBayTransport.rewrapAllNodes({ hubUrl, token, username, userId, oldPassphrase,
-newPassphrase, onProgress })` does the work. It derives the old key as a `{v2, v1}`
-pair (an old bundle may still be v1) and the new key as v2, then, for each group in
-`GET /v1/groups/mine`, visits every node in `GET /v1/groups/{id}/nodes`:
-
-- connect with the **old** key → the transport fetches and decrypts this node's
- identity bundle as part of its handshake, exposing the private keys on
- `transport.sessionKeys`;
-- `encryptBundleWithKey(skEd, skX, new_key_v2)` → `keypair_bundle_store`.
-
-A node the transport reports as a **first join** (`transport.newNodeBundle` set) had no
-bundle for this account — connect just minted one under the old key, which is *not*
-persisted: nothing is stranded there, and the normal group-open flow will create one
-under the current key later. Re-creating it here could also walk back a deliberate
-bundle withdrawal.
-
-It returns `{ updated, unreachable, failed, newBundleKey }`:
-
-- **`unreachable`** — the group has no online node right now;
-- **`failed`** — a node was online but the re-wrap errored (wrong current passphrase,
- a join that needs a code, a mid-flight drop);
-- a group **left** since an identity was created is simply absent from `/v1/groups/mine`
- and is never visited.
-
-Each bundle not re-wrapped stays encrypted under the old passphrase. At the next
-sign-in with the new passphrase, connecting to that node fails to recover the identity
-there and it looks broken for this account — hence the operator fallback in §3.4.
-(The `mb_nodepin_*` set in `localStorage` was considered as an extra source but dropped:
-a pinned node with no current membership offers no group to connect through, and such a
-node is exactly a group that must be rejoined anyway.)
-
-### 3.3 The confirmation dialog
-
-A modal, shown **always** — even when every group is reachable — because the operation
-is irreversible per node and partly outside the user's control:
-
-```
-Change passphrase
-
-These groups will be updated now (their node is online):
- • photos@ana • trip-2026@ana • books@sam
-
-These groups CANNOT be reached right now:
- • archive@sam — node offline
-
-For any group that cannot be reached, you will have to ask that group's
-operator to run `member unpin <you>` and send you a fresh invitation code,
-then rejoin. Your files and messages in that group are not lost; your
-ability to open it from a new sign-in is, until you rejoin.
-
-[ Cancel ] [ I understand — change it ]
-```
-
-The two lists are a pre-flight estimate from `node_online`. The **actual** per-node
-result is reported once the fan-out (§3.2) has run: any node that was expected online
-but failed mid fan-out is moved into the second list in the result screen, with the
-same guidance.
-
-### 3.4 Unreachable nodes — the fallback, spelled out
-
-This is the operator surface that already exists (`invite-pairing-v1.md`): the group
-operator runs `meshbay-node member unpin <user>`, then `member invite <user>` for a
-fresh single-use code. The user redeems it and the client generates a **new** per-node
-identity there, wrapped under the new passphrase. Nothing on the hub changes; the GEK is
-re-wrapped by the node on the next connection as usual.
-
-### 3.5 Hub endpoint
-
-```
-POST /v1/users/password (require_user_scope, limiter 5/minute)
- body: { old_auth_key, new_auth_key }
- - verify_password(old_auth_key, user.pw_hash, user.pw_salt, user.pw_version) or 403
- - new_auth_key == old_auth_key → 400
- - user.pw_hash, user.pw_salt = hash_password(new_auth_key)
- - user.pw_version = current_pw_version()
- - revoke all RefreshToken rows for user.id (see §3.6)
- - issue a fresh access token + a new refresh-token family for the caller
- - IPLog(event="password_change", user_id=...)
- - 200 { status: "changed", access_token, refresh_token, token_type, expires_in }
-```
-
-No email round-trip here: the current passphrase is the second factor, exactly as for
-account deletion. The fresh pair in the response is what keeps the tab that made the
-change signed in; the client swaps it in with `setAuth` and moves `session.bundleKey`
-(and its IndexedDB copy) forward to the new key.
-
-### 3.6 What is invalidated
-
-- **All refresh tokens** for the account are revoked (`UPDATE refresh_tokens SET
- revoked=1 WHERE user_id=?`), then the caller is handed a fresh pair in the response.
- Other browsers fail their next renewal and fall back to the sign-in form.
-- **Registered devices (`user_devices`) are kept.** The passphrase is still known and the
- device Ed25519 keys are independent of it; a device keeps working. (Contrast Flow B,
- §4.7.)
-
----
-
-## 4. Flow B — recover a lost passphrase
-
-### 4.1 What is and is not recoverable
-
-| | Recovered by |
-|---|---|
-| Hub login (`auth_key`) | email code alone |
-| Per-node identity keys → GEK unwrap, provable upload ownership, chat-sender identity, device countersigning | the **recovery key** (§4.3), per reachable node |
-| Chat history that needs forward-secret sender-key state (Phase 15) | not by this; sender redistribution on rejoin |
-| Identity on a node with no recovery-wrapped copy, or offline at recovery time | operator `member unpin` + fresh code (§3.4) |
-
-### 4.2 Email code — resetting hub login — built
-
-```
-POST /v1/users/password/reset-request (limiter 5/minute, per IP)
- body: { username, email } # both required
- - resolve the User by username
- - matched = user && user.status == "active"
- && user.email_hash == hash_email_blind(email)
- - if matched:
- code = _generate_code()
- EmailVerification(purpose="password_reset", user_id, email_hash, code=code,
- expires_at = now + 3600) # 1 h, shorter than sign-up
- mail.send_password_reset_code(decrypt_email(user.email), code)
- - always 200 { status: "sent_if_exists" }
-```
-
-The **username and the e-mail must be the pair on file**, checked against the blind
-`email_hash` (never decrypted). A mismatch — wrong e-mail, unknown username, non-active
-account — takes the identical no-op path: no `EmailVerification` row, no mail, same 200.
-So the endpoint reveals nothing, and it cannot be used to spray reset mail at an inbox
-from a username alone. A malformed e-mail is a 422 from the field validator.
-
-```
-POST /v1/users/password/reset (limiter 10/minute)
- body: { username, code, new_auth_key }
- - look up the newest unverified password_reset EmailVerification for that user
- - expiry / attempts / code checks exactly as verify_email
- - on match:
- verif.verified_at = now # code is single-use
- user.pw_hash, user.pw_salt = hash_password(new_auth_key)
- user.pw_version = current_pw_version()
- revoke all RefreshToken rows for the user
- delete all UserDevice rows for the user # §4.7
- IPLog(event="password_reset", user_id=...)
- - 200 { status: "reset" }
-```
-
-Both endpoints also write a `password_reset_request` / `password_reset` `IPLog` row.
-`reset-request` for an unknown or non-active account logs the attempt without a
-`user_id` and still answers `{status: "sent_if_exists"}`. Expired codes are pruned by
-the existing `tasks/cleanup.py` sweep (it deletes every expired `EmailVerification`,
-purpose-agnostic).
-
-The client derives `new_auth_key` from the new passphrase the user is choosing now, then
-signs in through the normal `login` path (which issues the tokens and the membership
-claim). **This step recovers nothing about group content** — see §4.6.
-
-### 4.3 The recovery key
-
-Set up once at registration, and re-loadable any time from Profile:
-
-- The **client** generates a full-entropy random secret `R` (32 bytes), rendered for the
- human as a mnemonic / grouped Base32 string. The hub never generates it.
-- `recovery_key = HKDF-SHA256(R, info = "meshbay:recovery:v1:" + username)`. HKDF, not
- Argon2: `R` has 256 bits, so there is nothing to brute-force and no reason to make the
- legitimate derivation slow. The username domain-separates it, as with `bundle_key` —
- which is why every client folds in the **trimmed** username (`RegisterPage` /
- `LoginPage` / `ResetPasswordPage` all `.trim()` before any derivation), matching the
- hub's stored form.
-- Every time an identity bundle is written to a node, a **second copy** is written next
- to it, wrapped under `recovery_key` instead of `bundle_key`, same AES-GCM bundle
- format. On the node: a new nullable column, opaque like the first.
-- The derived key lives in `session.recoveryKey` and is **persisted in IndexedDB**
- (slot `rk`, beside `bk`), so a group joined in a *later* session still leaves a
- recovery copy — not only groups joined in the unbroken session that generated `R`.
- Cleared with everything else on sign-out.
-- **Profile → Recovery key** re-loads `R` in a browser that never had it (or lost it)
- and runs `rewrapAllNodes` in `bundleKey` mode over every group: keep the live
- passphrase key, add the recovery-wrapped copy where it is missing. This is the answer
- to "I joined groups before entering `R`, or on another device."
-
-```
-keypair_bundles(
- user_id TEXT PRIMARY KEY,
- bundle_enc TEXT NOT NULL, -- wrapped under bundle_key (passphrase)
- bundle_enc_recovery TEXT, -- wrapped under recovery_key (R) [NEW]
- stored_at TEXT NOT NULL
-)
-```
-
-MNP: `keypair_bundle_store` gains an optional `bundle_enc_recovery` field and
-`keypair_bundle_resp` returns it when present. Additive — an older node ignores the
-field and simply holds no recovery copy; **MNP minor bump** (0.13 → 0.14).
-
-### 4.4 Delivering `R` — built
-
-Default: **folded into the registration verification e-mail**, the one that already
-carries the 6-digit code. The user's mailbox becomes the backup, which is the whole
-point of the convenience.
-
-How it is wired (step 3):
-
-- The client generates `R` **before** `registerUser`, so the mnemonic can travel in the
- register body: `keyderive.js registerUser(username, email, password, recoveryMnemonic?)`
- adds `recovery_key` to the `POST /v1/users/register` payload only when it is present.
-- `RegisterRequest.recovery_key` is an optional field. `register` passes it straight to
- `_create_and_send_verification(..., recovery_key)` → `mail.send_verification_code(email,
- code, recovery_key=...)`, which appends a fenced "Account recovery key" block to the
- body. Same path on the pending-account resend.
-- `R` is a **pass-through**. It is never written to the database — not to
- `EmailVerification.code`, not to a `User` column, nowhere. `mail.py` logs only
- `_mask_email` and `bool(recovery_key)`, never the value.
-- The registration form carries an **"Also email this recovery key to me"** checkbox,
- checked by default. Unchecking it omits `recovery_key` from the body; the `recovery`
- screen then says the key was *not* e-mailed and must be saved now. `session.recoveryKey`
- is set either way, so joins later in the session still leave a recovery copy.
-
-Optional hardening, documented but not mandated: split `R = R_screen ⊕ R_mail`, show
-one half, e-mail the other; recovery needs both. It defeats the "mailbox alone is my
-backup" convenience, so it is an opt-in, not the default.
-
-### 4.5 Recovery sequence — built
-
-`ResetPasswordPage` (route `#/reset`, linked from the sign-in form):
-
-1. **request phase** — username **and e-mail** → `POST /v1/users/password/reset-request`
- → moves on regardless of the answer.
-2. **form phase** — reset code, recovery key (a textarea, optional), new passphrase ×2.
- On submit: derive `new_auth_key`, `POST /v1/users/password/reset`, then `onLogin`
- (the normal sign-in, which sets `session.bundleKey`).
-3. With a recovery key: set `session.recoveryKey`, then
- `MeshBayTransport.rewrapAllNodes({ newPassphrase, recoveryKey, ... })`. `connect`
- tries the new passphrase key on `bundle_enc`, fails, and **falls back to
- `bundle_enc_recovery` + the recovery key**; the fan-out then re-wraps that identity
- under the new passphrase and writes a fresh recovery copy.
-4. **done phase** — reports which groups were restored and lists any that still need the
- operator fallback (offline / no recovery copy), same shape as §3.3.
-
-### 4.6 Without a recovery key — built
-
-If the recovery-key box is left blank, step 3 is skipped and the **norecovery phase**
-says it plainly: sign-in is restored, group identities are not; for each group ask the
-operator to `member unpin` you and send a fresh code, then rejoin. Files and messages
-are untouched; you rejoin with a new per-node identity.
-
-### 4.7 What Flow B invalidates — and what it does **not**
-
-Three things are called "device" around here; only one is touched.
-
-| | What it is | Flow B |
-|---|---|---|
-| `user_devices` (hub table) | an Ed25519 key that lets a client skip the passphrase prompt on launch (`POST /v1/users/auth`). A **hub-login convenience**, nothing else — no group key is wrapped for it, no node reads it | **deleted** |
-| per-node identity (`identities` on each node) | the Ed25519 + X25519 keys that unwrap the GEK, prove upload ownership and sign chat — **this is group access** | **recovered** from `bundle_enc_recovery` (§4.5), or via the operator fallback for the gaps |
-| roster pin `(user_id, pk_ed25519)` on a node | which per-node identities a node has admitted | untouched |
-
-So deleting `user_devices` does **not** cost group access. It costs one passphrase
-prompt per client on next launch: the client signs in with the new `auth_key`, gets a
-session, and re-registers itself (`POST /v1/users/devices` needs only a live session,
-which now means the new passphrase was just entered). That is the point — after a
-"control may be lost" event, a laptop still carrying a stored hub-auth key must stop
-signing in on its own until its owner proves the new passphrase on it.
-
-- All refresh-token families are revoked as well (as Flow A).
-
----
-
-## 5. Change list
-
-> **Step 1 (Flow A):** `POST /v1/users/password`, `MeshBayTransport.rewrapAllNodes`, the
-> Profile-page form + confirmation flow, `settings.passphrase*` locale keys — **built**
-> (`test_password_change.py`).
->
-> **Step 2 (recovery-key plumbing):** the node `bundle_enc_recovery` column + migration,
-> MNP 0.14, `keyderive.js` `generateRecoveryKey` / `deriveRecoveryKey`,
-> `storeKeypairBundle(bundleEnc, recoveryEnc?)`, the join path writing a recovery copy
-> when `session.recoveryKey` is set, and the registration-time `R` screen — **built**
-> (`test_bundle_store_recovery.py`, `test_recovery_key.py`).
->
-> **Step 3 (`R` by e-mail):** `registerUser` forwards an optional recovery mnemonic,
-> `RegisterRequest.recovery_key` → `mail.send_verification_code(..., recovery_key)`
-> appends it to the verification e-mail (never stored, never logged), and the
-> registration form has an "also e-mail it" opt-out — **built** (`test_recovery_email.py`).
->
-> **Step 4 (Flow B):** `POST /v1/users/password/reset-request` and `/reset`,
-> `mail.send_password_reset_code`, `rewrapAllNodes` recovery mode + `connect`'s
-> recovery-copy fallback, the `ResetPasswordPage` screen — **built**
-> (`test_password_reset.py`). Includes the step-5 items (device wipe, session
-> revocation, `password_reset*` IPLog events).
->
-> **Step 6 (coverage):** `test_rewrap_fanout.py` runs `rewrapAllNodes` under node
-> with the hub and per-node handshake stubbed and pins the bucketing and the
-> Flow A / B / C store calls; `test_recovery_key.py` gained the
-> wrong-key-rejected / right-key-opens check `connect`'s fallback rests on.
->
-> **Post-testing fixes:** `session.recoveryKey` is persisted in IndexedDB (slot
-> `rk`) and lazy-loaded on connect, so coverage is not limited to the unbroken
-> registration session; **Profile → Recovery key** re-loads `R` and backfills
-> every node via `rewrapAllNodes` in `bundleKey` mode (no passphrase);
-> `RegisterPage` / `LoginPage` `.trim()` the username so every key derivation
-> matches; a keyless browser gets a **passphrase prompt** on the group page
-> instead of a dead end.
->
-> **The stale-bundle trap (found in live logs).** `member unpin` deleted the
-> roster pin but left the `keypair_bundles` row. The next connection was handed
-> that stale bundle, could not open it (wrapped under the pre-reset passphrase,
-> no usable recovery copy), and `connect` **threw in the identity step before
-> ever reaching the join** the unpin was meant to enable — the client then
-> closed the channel, which read as "the node hung up". Two fixes: `ops.unpin_member`
-> now also `delete_keypair`s (the correct semantic — "start over" forgets the
-> bundle too), and `connect` treats an unopenable fetched bundle like `found:
-> false` — mint a fresh identity and let the join path take over — **except**
-> under `_rewrapOnly` (set by `rewrapAllNodes`), which must recover the exact
-> identity or report the node.
->
-> The one part with no automated coverage is the real WebRTC handshake and
-> `connect`'s identity/recovery branch in situ — integration territory, by hand.
-
-**Hub**
-
-- `"password_reset"` is a valid `EmailVerification.purpose` — the `purpose` column is a
- free string, and `tasks/cleanup.py` prunes expired rows purpose-agnostically, so no
- other change was needed there.
-- ✅ `POST /v1/users/password` (§3.5), `POST /v1/users/password/reset-request` and
- `POST /v1/users/password/reset` (§4.2) — in `api/users.py`, rate-limited via `limiter`.
-- ✅ `send_verification_code(to, code, recovery_key=None)` appends an `R` block (§4.4);
- `RegisterRequest.recovery_key` threads it through; `mail.send_password_reset_code(to,
- code)` for the reset e-mail.
-- ✅ `IPLog` events `password_change`, `password_reset`, `password_reset_request`.
-- No migration. (`email_verifications` is unchanged.)
-
-**Node**
-
-- ✅ `keypair_bundles.bundle_enc_recovery TEXT`. Node-only (`bundle_store.py`, plain
- aiosqlite, no Alembic): a `PRAGMA table_info` check plus `ALTER TABLE ADD COLUMN` in
- `BundleStore.open()` for existing DBs, and the column in `_SCHEMA_KEYPAIR` for fresh
- ones. The hub stores no keypair bundles and gains nothing here.
-- ✅ `store_keypair(user_id, bundle_enc, bundle_enc_recovery=None)` — an upsert that keeps
- an existing recovery copy when the new call omits one (a passphrase re-wrap does).
- `fetch_keypair` now returns `{bundle_enc, bundle_enc_recovery}`.
-- ✅ MNP handlers for `keypair_bundle_store` / `keypair_bundle_resp` pass the optional
- `bundle_enc_recovery` field through. **Version bump 0.13 → 0.14**, additive, N-2 intact.
-
-**Browser / client (`static/`)**
-
-- ✅ `keyderive.js`: `deriveEncryptionKey` / `deriveEncryptionKeyV1` (step 1);
- `generateRecoveryKey()` → `{ rawB64, mnemonic }` (32 bytes, grouped Base32);
- `deriveRecoveryKey(R, username)` → HKDF-SHA256, info `meshbay:recovery:v1:<username>`,
- accepts the mnemonic string or raw bytes; `generateNodeIdentity` takes an optional
- recovery key and returns `bundleEncRecovery`.
-- ✅ `transport.js`: `rewrapAllNodes(opts)` — passphrase-change mode
- (`{oldPassphrase, newPassphrase}`), Flow B (`{newPassphrase, recoveryKey}` — reads
- `bundle_enc_recovery` via `connect`'s fallback), and `bundleKey` mode
- (`{bundleKey, recoveryKey}` — keep the live key, just add the recovery copy: the
- Profile backfill, no passphrase). `storeKeypairBundle(bundleEnc, recoveryEnc?)`;
- `connect(...)` takes a 10th `recoveryKey` arg, exposes `newNodeBundleRecovery`, and
- its recovery fallback throws named errors, not an empty `OperationError`.
-- ✅ `hub-client.js`: `session.recoveryKey`, **persisted** in IndexedDB slot `rk`
- (`_storeRecoveryKey` / `_loadRecoveryKey`), cleared with `bk` on sign-out.
-- ✅ `group-page.js`: lazy-loads `session.recoveryKey` (like `bundleKey`) and passes it
- into `connect`; the recovery copy rides `storeKeypairBundle`. When `session.bundleKey`
- cannot be loaded (fresh browser, cleared storage, device-key sign-in), it shows a
- **passphrase prompt** (`needsPass`, `group.pass_*`) that derives and persists the
- bundle key and retries — instead of the old "go back to the browser you registered
- on" dead end. `connect`'s `no_keys` error is the backstop for the same case.
-- ✅ `profile-page.js`: the **Recovery key** section — paste `R`, `rewrapAllNodes` in
- `bundleKey` mode over every group. `settings.recovery*` keys in all ten catalogues.
-- ✅ `RegisterPage` / `LoginPage` / `onResend` `.trim()` the username before any
- derivation or request, matching the hub's stored form and `ResetPasswordPage`.
-- ✅ `profile-page.js`: passphrase-change form + §3.3 confirmation flow (step 1).
-- ✅ `auth-page.js`: the post-registration `recovery` phase (mnemonic shown once, "also
- e-mail it" checkbox, `registerUser` forwards it when checked), and `ResetPasswordPage`
- (route `#/reset`, linked from sign-in) — the Flow B screen of §4.5–§4.6.
-- ✅ `app.js`: `#/reset` route.
-- ✅ Locales: `settings.passphrase*` (step 1), `register.recovery_*` /
- `register.recovery_email*` (steps 2–3), `login.forgot` + `reset.*` (step 4) across all
- ten catalogues.
-
-**Tests**
-
-- ✅ `test_password_change.py` (step 1): new-passphrase sign-in, old refused, new must
- differ, unauthenticated rejected, other sessions die while the caller keeps a fresh
- pair, the change is logged.
-- ✅ `test_bundle_store_recovery.py` (step 2): recovery-column round-trip, a re-backup
- without a recovery copy keeps the existing one, the `ALTER TABLE` migration on a
- pre-0.14 database.
-- ✅ `test_recovery_key.py` (step 2): the mnemonic round-trips its exact bytes, the
- derived key is deterministic per account and domain-separated between accounts, a
- malformed key is rejected. Runs the real `keyderive.js` under node.
-- ✅ `test_recovery_email.py` (step 3): the register e-mail carries `R` when the body
- has it and only the code when it does not; `R` reaches no table; `mail.py` builds
- both body variants.
-- ✅ `test_password_reset.py` (step 4): reset lets the user sign in with the new
- passphrase and the old one stops working; `reset-request` needs the username **and
- e-mail** to match (a wrong e-mail is answered like an unknown account, no code
- created) and rejects a malformed e-mail with 422; never reveals whether an account
- exists; a wrong code is refused and counts toward the attempt cap; an expired code is
- refused; the code is single-use; the reset revokes sessions and wipes every device key
- (a stored one can no longer sign in); both events are logged.
-- ✅ `test_rewrap_fanout.py` (step 6): runs the real `rewrapAllNodes` under node with
- the hub HTTP calls and the per-node handshake stubbed — a reachable node with an
- identity is `updated` and gets one `keypair_bundle_store`; no online node ⇒
- `unreachable`; a `/nodes` error or a thrown handshake or a node returning no identity
- ⇒ `failed`; a freshly-minted identity ⇒ `updated` with no store; Flow A writes only
- the passphrase copy, Flow B writes both.
-- ✅ `test_recovery_key.py` also pins the crypto `connect`'s Flow B fallback rests on:
- a recovery-wrapped bundle opens under the matching key and not another.
-- **Not covered:** the real WebRTC handshake and `connect`'s recovery fallback in situ —
- no harness exists; verify by hand.
-
----
-
-## 6. Security — who this holds against
-
-| Capability | Passive hub | Active hub | Malicious node operator (a node you joined) | Mailbox compromise |
-|---|---|---|---|---|
-| Take over hub login | — | mint an OTP, get a session | — | read the OTP, get a session |
-| Read group content via that login | no | no — the session carries no key and no bundle | already can, on its own node | no |
-| Recover a per-node identity | needs `R` **and** a bundle handed over by a node as an authenticated member | sees `R` once at registration send-time; still not a group member, still cannot pull the bundle from any node | holds `bundle_enc` already (C4); `R` is a second target but full-entropy, so no easier | reads `R`; still cannot pull the bundle without being an authenticated member of that group |
-
-The load-bearing property: **the weak, emailed factors (OTP, and `R` in transit) cannot
-reach content on their own.** OTP grants a hub session, and a hub session opens nothing.
-`R` opens a bundle, but only a node hands out bundles, and only to a member over MNP.
-
-The honest cost of e-mailing `R`: a mailbox compromise becomes **equivalent to a
-passphrase compromise for identity recovery** — the passphrase's Argon2id wall no longer
-matters for an attacker who has `R`. It still requires reaching each node as an
-authenticated member, which a mailbox alone does not grant; combined with a stolen live
-session or hub↔node collusion it is game over for that node's identity. This is why `R`
-is shown on screen with an opt-out, and why the split-secret variant (§4.4) exists for
-users who want it.
-
-Not in scope, unchanged: a substituted hub (`GET /v1/hub/pubkey` is unpinned) can serve
-a malicious reset page to a browser — that is T3, and native clients load the page from
-the package.
-
----
-
-## 7. Still not solved
-
-- A node whose recovery copy was never written — joined before `bundle_enc_recovery`
- shipped, or before `R` was loaded in that browser. **Mitigated:** Profile → Recovery
- key backfills every reachable node (§4.3). What it cannot reach — a node offline at
- backfill time, or a group left since — still needs the operator fallback.
-- Nodes offline at recovery time — operator fallback.
-- Chat history needing Phase 15 forward-secret state — recovered identity can re-request
- sender-key distribution on rejoin, but past forward-secret segments stay unreadable by
- design.
-- The fallback itself: the operator runs `member unpin` and issues a fresh single-use
- code. `member unpin` now also drops the stored keypair bundle, and `connect` mints a
- fresh identity when handed a bundle it cannot open, so the rejoin actually completes
- (it used to throw before reaching the code prompt).
-
----
-
-## 8. Build order
-
-1. ✅ **Done.** Hub `POST /v1/users/password` + the §3.3 confirmation flow +
- `rewrapAllNodes` + locale keys — Flow A, no schema change, usable immediately.
-2. ✅ **Done.** `keypair_bundles.bundle_enc_recovery` + MNP 0.14 + `deriveRecoveryKey` +
- the registration-time `R` display (screen only, no e-mail yet).
-3. ✅ **Done.** `mail.py` `R` block + the register-body plumbing + the "also e-mail it"
- opt-out.
-4. ✅ **Done.** `password_reset` purpose + the two reset endpoints + the Flow B screen —
- and, folded in from step 5 because a reset is not safe without them, device wipe on
- reset, session revocation, and the `password_reset*` IP-log events.
-5. ✅ Folded into step 4.
-6. ✅ **Done.** Locale parity kept current throughout; `test_rewrap_fanout.py` covers the
- fan-out bucketing for both flows, and `test_recovery_key.py` covers the fallback
- crypto. The live WebRTC path stays a manual check.
diff --git a/docs/captcha.md b/docs/captcha.md
deleted file mode 100644
index 7013804..0000000
--- a/docs/captcha.md
+++ /dev/null
@@ -1,555 +0,0 @@
-# reCAPTCHA on Registration and Password Reset
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the registration and reset captcha; its design
-> content now lives in §7.7.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Goal: verify the user is not a bot **before** sending any email — registration
-> verification code or password reset code. The captcha gate sits between form
-> submission and the email-sending call, so a failed check never triggers an email.
-
----
-
-## 1. reCAPTCHA v2 (checkbox)
-
-reCAPTCHA v2 with the "I'm not a robot" checkbox. Reasons:
-
-- Binary pass/fail — no score threshold to tune or monitor.
-- The user is already filling a form; one checkbox is negligible friction.
-- Works in the web SPA. It works in the Electron client too, but not for the
- reason "both run Chromium" — reCAPTCHA validates the *domain*, not the
- rendering engine, and the desktop client's is not the hub's. See §6.
-- v3 (invisible, score-based) is an option later if the checkbox proves annoying;
- the server-side verification call is identical, only the client widget differs.
-
-**Google Console setup:** create a reCAPTCHA v2 key pair at
-`https://www.google.com/recaptcha/admin`. Register the hub's domain(s) —
-`meshbay.org` and `localhost` for development. This produces a **site key**
-(public, embedded in HTML) and a **secret key** (server-only, in `hub.toml`).
-If the desktop client is in use, also turn *off* "Verify the origin of
-reCAPTCHA solutions" on that key and set `allowed_hosts` — §6 says why, and
-what is given up.
-
----
-
-## 2. Configuration
-
-### `hub.toml`
-
-```toml
-[captcha]
-site_key = "6Le..." # public — served to the frontend
-secret_key = "6Le..." # private — never leaves the server
-```
-
-When the `[captcha]` section is absent or both keys are empty, the captcha is
-**disabled** — the registration endpoint accepts requests without a token. This
-keeps development, tests and self-hosted instances that do not need it
-frictionless.
-
-### `config.py` — new dataclass
-
-```python
-@dataclass
-class CaptchaConfig:
- site_key: str = ""
- secret_key: str = ""
-
- @property
- def enabled(self) -> bool:
- return bool(self.site_key and self.secret_key)
-```
-
-Add `captcha: CaptchaConfig` to `HubConfig` (default: disabled). Parse the
-`[captcha]` section in `load_config` on the same pattern as `[jwt]`:
-
-```python
-if cap := raw.get("captcha", {}):
- cfg.captcha.site_key = cap.get("site_key", cfg.captcha.site_key)
- cfg.captcha.secret_key = cap.get("secret_key", cfg.captcha.secret_key)
-```
-
-Environment variable overrides: `MESHBAY_CAPTCHA_SITE_KEY`,
-`MESHBAY_CAPTCHA_SECRET_KEY`.
-
----
-
-## 3. Serving the site key to the frontend
-
-The site key is public and the SPA needs it before the user reaches the
-registration form. Two options:
-
-**Option A — extend `/v1/hub/info`** (recommended). Add `captcha_site_key` to
-the response (empty string when disabled). The SPA already calls this endpoint
-at startup for `allow_public_groups`; no new request. The endpoint is
-unauthenticated, which is correct — the site key is public by design.
-
-```python
-# hub.py — hub_info()
-return {
- ...
- "captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "",
-}
-```
-
-**Option B — inject in the HTML shell.** Add a `<script>` line in `webapp.py`'s
-`_HTML` template: `window.__MB_CAPTCHA_KEY = "{captcha_key}";`. Advantage: the
-key is available synchronously, before any fetch. Disadvantage: `_HTML` is built
-once at import time, so the config must be available then — currently it is, via
-`set_config` in `app.py`.
-
-Recommendation: **option A**. It follows the existing pattern, avoids touching
-the HTML shell, and the tiny latency of waiting for the `/v1/hub/info` response
-is irrelevant — the user has not reached the form yet.
-
----
-
-## 4. Server-side verification
-
-### `captcha.py` (new module, `meshbay_hub/captcha.py`)
-
-```python
-import httpx
-import logging
-
-log = logging.getLogger(__name__)
-
-VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
-
-async def verify_captcha(secret_key: str, token: str, remote_ip: str | None = None) -> bool:
- payload = {"secret": secret_key, "response": token}
- if remote_ip:
- payload["remoteip"] = remote_ip
- try:
- async with httpx.AsyncClient(timeout=5) as client:
- resp = await client.post(VERIFY_URL, data=payload)
- resp.raise_for_status()
- result = resp.json()
- if not result.get("success"):
- log.info("captcha rejected: %s", result.get("error-codes", []))
- return result.get("success", False)
- except Exception:
- log.exception("captcha verification request failed")
- return False
-```
-
-Notes:
-- `httpx` is already a dev dependency and is light. Add it to `[project.dependencies]`
- in `packages/meshbay-hub/pyproject.toml`.
-- 5-second timeout — a Google outage should not hang registration indefinitely.
-- On network failure the function returns `False` (fail-closed). If this is too
- aggressive for availability, a retry or a fallback to allowing registration
- can be discussed — but for a bot-prevention gate, fail-closed is correct.
-- `remote_ip` is optional — Google uses it for risk analysis, not as a hard check.
-
-### `users.py` — gate the registration endpoint
-
-Add `captcha_token: str | None = None` to `RegisterRequest`:
-
-```python
-class RegisterRequest(BaseModel):
- username: str
- email: str
- password: str | None = None
- auth_key: str | None = None
- captcha_token: str | None = None
-```
-
-In `register()`, **before** any database work:
-
-```python
-@router.post("/register", status_code=201)
-@limiter.limit("5/minute")
-async def register(body: RegisterRequest, request: Request, db: AsyncSession = Depends(get_db)):
- # ── Captcha gate ────────────────────────────────────────────────
- if _cfg and _cfg.captcha.enabled:
- if not body.captcha_token:
- raise HTTPException(400, "captcha_required")
- from meshbay_hub.captcha import verify_captcha
- ok = await verify_captcha(
- _cfg.captcha.secret_key,
- body.captcha_token,
- request.client.host if request.client else None,
- )
- if not ok:
- raise HTTPException(400, "captcha_failed")
-
- # ── Existing registration logic (unchanged) ────────────────────
- eh = hash_email_blind(body.email)
- ...
-```
-
-The check runs **before** the email blind hash, the username lookup, and the
-Argon2id hash — none of which should execute for a bot. This also means a
-failed captcha does not increment the rate limiter's cost beyond the existing
-`5/minute` on the endpoint itself.
-
-### `users.py` — gate the password reset request
-
-Same principle: `POST /v1/users/password/reset-request` sends an email with a
-6-digit code. The captcha must be verified before the email is sent.
-
-Add `captcha_token: str | None = None` to `ResetRequestRequest`:
-
-```python
-class ResetRequestRequest(BaseModel):
- username: str
- email: str
- captcha_token: str | None = None
-```
-
-In `password_reset_request()`, **before** the user lookup:
-
-```python
-if _cfg and _cfg.captcha.enabled:
- if not body.captcha_token:
- raise HTTPException(400, "captcha_required")
- from meshbay_hub.captcha import verify_captcha
- ok = await verify_captcha(
- _cfg.captcha.secret_key,
- body.captcha_token,
- request.client.host if request.client else None,
- )
- if not ok:
- raise HTTPException(400, "captcha_failed")
-```
-
-No `auth_key` exemption here: the reset form is web-only (it requires
-`window.MeshBayKeys`), and the captcha gate applies to everyone on this path.
-
----
-
-## 5. Client-side implementation
-
-### Loading the reCAPTCHA script (`auth-page.js`)
-
-Load the script **lazily** when the registration form mounts, not in the HTML
-shell — it is useless on every other page, and loading it globally adds ~150 KB
-of Google JS to every visit.
-
-```javascript
-function loadRecaptchaScript(siteKey) {
- if (document.getElementById('recaptcha-script')) return;
- const s = document.createElement('script');
- s.id = 'recaptcha-script';
- s.src = `https://www.google.com/recaptcha/api.js?render=explicit`;
- s.async = true;
- s.defer = true;
- document.head.appendChild(s);
-}
-```
-
-### Rendering the widget
-
-In `RegisterPage`, after the component mounts:
-
-```javascript
-export function RegisterPage() {
- const [captchaToken, setCaptchaToken] = useState(null);
- const [captchaSiteKey, setCaptchaSiteKey] = useState('');
- const captchaRef = useRef(null);
- const widgetId = useRef(null);
-
- useEffect(() => {
- // Fetch the site key from /v1/hub/info (already cached by the SPA)
- hubFetch('/v1/hub/info').then(info => {
- if (info.captcha_site_key) {
- setCaptchaSiteKey(info.captcha_site_key);
- loadRecaptchaScript();
- }
- });
- }, []);
-
- useEffect(() => {
- if (!captchaSiteKey || !captchaRef.current) return;
- const interval = setInterval(() => {
- if (window.grecaptcha && window.grecaptcha.render) {
- clearInterval(interval);
- widgetId.current = window.grecaptcha.render(captchaRef.current, {
- sitekey: captchaSiteKey,
- callback: (token) => setCaptchaToken(token),
- 'expired-callback': () => setCaptchaToken(null),
- theme: document.documentElement.getAttribute('data-theme') === 'dark'
- ? 'dark' : 'light',
- });
- }
- }, 100);
- return () => clearInterval(interval);
- }, [captchaSiteKey]);
- ...
-}
-```
-
-The widget div goes in the form, **above** the submit button:
-
-```javascript
-${captchaSiteKey && html`
- <div ref=${captchaRef} style="display:flex;justify-content:center;margin:12px 0"></div>
-`}
-```
-
-### Sending the token
-
-In `onSubmit`, include `captcha_token` in the request body. Both paths
-(native `MeshBayKeys.registerUser` and the web fallback) must send it:
-
-```javascript
-// Web fallback path
-await hubFetch('/v1/users/register', {
- method: 'POST',
- body: { username, email, password, captcha_token: captchaToken },
-});
-```
-
-For the `MeshBayKeys.registerUser` path, `registerUser()` in `keyderive.js`
-must accept and forward the token:
-
-```javascript
-async registerUser(username, email, password, captchaToken) {
- const authKey = await deriveAuthKey(password, username);
- await hubFetch('/v1/users/register', {
- method: 'POST',
- body: { username, email, auth_key: authKey, captcha_token: captchaToken },
- });
-}
-```
-
-### Resend flow
-
-`onResend` re-POSTs to `/register`. On a resend, the captcha token has already
-been consumed by Google (one-time use). Two options:
-
-1. **Reset the widget** after each submission (`grecaptcha.reset(widgetId)`) so
- the user solves it again before resending. Safe but mildly annoying.
-2. **Skip the captcha on resend** server-side — the account already exists in
- `pending` state, proving it passed the captcha once. The server already
- detects this case (`found.status == "pending" and found.email_hash == eh`,
- `users.py:126`). Move the captcha gate to run only when no pending account
- matches.
-
-Recommendation: **option 2** — skip the captcha when resending to an existing
-pending account. The bot-prevention goal is met by the first check; a resend
-is a human who lost the email.
-
-Server-side, restructure the check order:
-
-```python
-# Check for existing pending account first (resend case)
-existing = await db.execute(select(User).where(User.username == body.username))
-found = existing.scalar_one_or_none()
-if found and found.status == "pending" and found.email_hash == eh:
- # Resend — captcha already passed on initial registration
- await _create_and_send_verification(db, found, body.email, eh)
- await db.commit()
- return {"user_id": str(found.id), "email_verification_required": True}
-
-# New registration — require captcha
-if _cfg and _cfg.captcha.enabled:
- if not body.captcha_token:
- raise HTTPException(400, "captcha_required")
- ...
-```
-
-### Password reset page (`ResetPasswordPage`)
-
-The same captcha widget is rendered in the `request` phase of
-`ResetPasswordPage` — the form where the user enters username + email before a
-reset code is sent. The implementation is identical: lazy-load the script, render
-the widget, send `captcha_token` in the body of
-`POST /v1/users/password/reset-request`.
-
-The `form` phase (entering the code + new passphrase) does **not** need a
-captcha — the code itself is the proof the user controls the email.
-
-### Shared captcha helper
-
-Both `RegisterPage` and `ResetPasswordPage` need the same logic: load the
-script, render the widget, track the token. Extract a reusable `useCaptcha()`
-hook to avoid duplicating the setup code across both components.
-
-### Error handling
-
-The SPA must handle two new error codes from the server:
-
-- `captcha_required` — the server expects a captcha token but none was sent.
- Display a message asking the user to complete the captcha. Should not happen
- in normal flow unless JS failed to load.
-- `captcha_failed` — the token was rejected. Reset the widget and ask the user
- to try again.
-
-Add i18n keys:
-```
-captcha.required: "Please complete the captcha"
-captcha.failed: "Captcha verification failed — please try again"
-```
-
----
-
-## 6. Desktop client (Electron), and the domain problem
-
-**This section replaced two earlier designs, and both are worth naming because
-the reasoning that produced them is the trap.**
-
-The first said: open the CSP to Google's reCAPTCHA domains, or skip the captcha
-for native clients — and recommended skipping it, keyed on `auth_key` being
-present. That carve-out shipped and was a hole: *every* real client sends
-`auth_key`, the browser included (it is the password split), so the gate was
-off for everybody and a bot skipped it by including the field. It is gone;
-`users.py` gates on `captcha.enabled` alone, and says so at the call site.
-
-The second is the sentence in §1 above: "works in the Electron client too, both
-run Chromium". The CSP was opened (`RECAPTCHA_SRC` in `main.js`, covering
-`script-src`, `img-src` and `frame-src`) and the widget does render. It renders
-**"ERROR for site owner: Invalid domain for site key"**.
-
-**Why.** reCAPTCHA validates the hostname of the page the widget is rendered
-in, against the domain list on the site key. The desktop client's interface
-ships inside the package and is served from `app://meshbay` (`main.js`:
-`win.loadURL('app://meshbay/index.html')`). Not a preference — `file://`
-breaks ES modules and IndexedDB, and the hub must never become the document
-origin, which is enforced by the `will-navigate` handler. So the hostname
-Google sees is `meshbay`, it is not on the key's list, and it never can be:
-the check happens on Google's servers and no client-side configuration reaches
-it. Widening the CSP does not help, because the CSP was never what refused.
-
-**What is done instead.** Turn *off* "Verify the origin of reCAPTCHA
-solutions" on the key, and check the origin on the hub, where it belongs:
-
-```toml
-[captcha]
-site_key = "6Le..."
-secret_key = "6Le..."
-allowed_hosts = ["meshbay.org", "localhost"]
-# Only with the desktop client. See below — this is the loose one.
-allow_unattributed_host = true
-```
-
-`verify_captcha` then refuses a solve whose reported hostname is not in that
-list. The hostname comes from `siteverify` — it is what Google *observed*, not
-something the caller asserts — so this is a real check and not a formality: the
-site key is public, and the thing turning the origin check off opens is a bot
-rendering the widget on a page of its own, which this refuses on the hostname
-it actually served from.
-
-Empty (the default) means "do not check", so a hub that never touched this
-setting keeps the behaviour it has, with reCAPTCHA doing the origin check
-itself. **The two settings go together**: turning the console check off without
-setting `allowed_hosts` leaves no origin check anywhere.
-
-### The hostname a desktop solve reports is empty, not `meshbay`
-
-Built first as an allowlist entry, on the assumption that Google would report
-the host component of the origin. It does not, and registration from the
-client failed with `captcha_failed` while the checkbox was green — a worse
-symptom than the one being fixed, because the widget now looked fine. The log
-line said it outright:
-
-```
-captcha solved on an unexpected host ''; allowed: ['localhost', 'meshbay', 'meshbay.org']
-```
-
-A solve Google cannot attribute to a domain reports an **empty** hostname. No
-allowlist entry can match that, and an empty entry is not the answer either:
-a blank in a TOML list is a typo far more often than an intention, and
-`load_config` drops blanks for that reason. `allow_unattributed_host` is a
-named flag instead, so the trade is stated where it is made.
-
-**What it admits, plainly.** Every non-web client, not only ours — a `file://`
-page or somebody else's Electron application report the same nothing. That is
-the same bar the client's own origin would have been (`main.js` already records
-that `app://meshbay` is not a credential; any application can claim it), and it
-*is* a bar: the captcha still has to be solved, per token, in something that
-can render it. What is given up is the origin restriction for non-web clients,
-not the captcha. A hub that does not ship the desktop client should leave the
-flag off.
-
-**Reading the value yourself.** Any refusal is logged at WARNING, with the
-hostname spelled out and the allowed list beside it when there is one. That is
-how the empty hostname was found, and it is the way to check what a given
-client actually reports rather than guess — which is what went wrong here.
-
----
-
-## 7. Dependencies
-
-Add `httpx` to `packages/meshbay-hub/pyproject.toml` runtime dependencies
-(it is already in `[project.optional-dependencies] dev`):
-
-```toml
-dependencies = [
- ...
- "httpx>=0.28",
-]
-```
-
-No other new dependency. The reCAPTCHA client-side is a single `<script>` tag
-from Google — no npm package.
-
----
-
-## 8. Files changed
-
-| File | Change |
-|---|---|
-| `packages/meshbay-hub/src/meshbay_hub/config.py` | Add `CaptchaConfig` dataclass, `captcha` field on `HubConfig`, parse `[captcha]` section + env vars |
-| `packages/meshbay-hub/src/meshbay_hub/captcha.py` | New module — `verify_captcha()` |
-| `packages/meshbay-hub/src/meshbay_hub/api/users.py` | Add `captcha_token` to `RegisterRequest` and `ResetRequestRequest`, gate before email send on both endpoints |
-| `packages/meshbay-hub/src/meshbay_hub/api/hub.py` | Add `captcha_site_key` to `/v1/hub/info` response |
-| `packages/meshbay-hub/src/meshbay_hub/app.py` | Pass `cfg` to `hub.py` (for site key access) — may already be sufficient via `_cfg` |
-| `packages/meshbay-hub/src/meshbay_hub/static/auth-page.js` | Load reCAPTCHA script, render widget in `RegisterPage` and `ResetPasswordPage`, send token, handle errors |
-| `packages/meshbay-hub/src/meshbay_hub/static/locales/en.js` | Add captcha error i18n keys |
-| `packages/meshbay-hub/src/meshbay_hub/static/locales/*.js` | Same keys in each locale |
-| `packages/meshbay-hub/pyproject.toml` | `httpx` to runtime deps |
-
----
-
-## 9. Testing
-
-- **Unit test (`test_captcha.py`):** mock `httpx.AsyncClient.post` to return
- `{"success": true}` / `{"success": false}`, verify `verify_captcha()` returns
- the right bool. Test timeout and network-error handling (returns `False`).
-- **Integration test (`test_register_captcha.py`):** with captcha enabled in
- config, POST to `/v1/users/register` without `captcha_token` → 400
- `captcha_required`. With a mocked passing token → 201. With a mocked failing
- token → 400 `captcha_failed`. Resend to an existing pending account without
- token → 201 (skip). Same tests for `/v1/users/password/reset-request`.
-- **SPA source test:** verify `auth-page.js` sends `captcha_token` in the
- registration body (add to `test_transport_contracts.py` or equivalent).
-- **Manual test:** deploy to a local hub with real Google keys, register from a
- browser, confirm the widget appears and the email is only sent after solving it.
-
----
-
-## 10. Deployment steps
-
-1. Obtain reCAPTCHA v2 keys from Google (register `meshbay.org` + `localhost`).
- For a deployment with the desktop client, also turn off "Verify the origin
- of reCAPTCHA solutions" on the key — §6.
-2. Add to `/etc/meshbay/hub.toml` on the production server:
- ```toml
- [captcha]
- site_key = "6Le..."
- secret_key = "6Le..."
- # Required whenever the console's origin check is off, and only then.
- allowed_hosts = ["meshbay.org", "localhost"]
- # Only with the desktop client — §6 says what it gives up.
- allow_unattributed_host = true
- ```
-3. Deploy the new hub code (`deploy-hub.sh` — runs `alembic upgrade head` +
- restart; no migration needed for this change).
-4. Verify registration: open `https://meshbay.org/#/register`, confirm the
- checkbox appears. Complete registration, confirm email arrives only after
- solving the captcha.
-5. Verify reset: open `https://meshbay.org/#/reset`, confirm the checkbox
- appears. Request a reset code, confirm email arrives only after solving it.
-6. Verify the desktop client by actually registering from it. Two distinct
- failures, and the first hides the second: "Invalid domain for site key"
- inside the widget means the console's origin check is still on, while a
- green checkbox followed by `captcha_failed` means the hub refused it — the
- WARNING in the journal says which host, or that there was none.
diff --git a/docs/chat-sender-keys.md b/docs/chat-sender-keys.md
deleted file mode 100644
index 656ad42..0000000
--- a/docs/chat-sender-keys.md
+++ /dev/null
@@ -1,859 +0,0 @@
-# Chat encryption — review of Phase 15 and an implementation plan
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the chat encryption design; its design
-> content now lives in §4.5 (encryption and epochs), §3.3 (sender authentication), §13.6.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **Design A decided and BUILT (2026-09-07). MNP 2.0 — a break, and
-> deliberately not an option.** There is no switch: chat is encrypted, and a 1.x
-> peer is refused at the handshake with `version_too_old` rather than connecting
-> and then failing to speak. Existing node data is migrated by
-> `QE/migration/migrate_chat_encryption.py`.
->
-> §10 records what each stage actually shipped and the three places the plan
-> below was wrong. §13 records the one part deliberately not built.
-> Written 2026-09-07 against the tree at `8883d60`. Supersedes the Phase 15 text
-> in `devel-phases-next.md` where the two disagree; that document's milestone
-> table is wrong in three places and this says why.
->
-> The decision is recorded in §4. It reverses the "Sender Keys" framing of Phase
-> 15 and of `meshbay-draft-v6.md` §5 — §9 lists every document that must be
-> corrected, and §12 lists the four policy questions that are still open and that
-> Stage 3 needs answered.
->
-> Written under the v5/v6 convention that is not negotiable: **a claim here must
-> name the adversary it holds against.** Everything below that reads as a
-> security property is written that way, and the sections that say "this buys
-> nothing" are as load-bearing as the ones that say it buys something.
->
-> **Placed in `~/meshbay/docs/` rather than `~/docs/`**, which does not exist —
-> every other design document lives here.
-
----
-
-## 0. Reading order
-
-| Read | For |
-|---|---|
-| **this document** | what is actually true of the chat path today, and what to build |
-| `devel-phases-next.md` §15 | the roadmap entry this replaces |
-| `meshbay-draft-v6.md` §5 | the "still open" row for chat — **now stale**, see §9 |
-| `desktop-client-v1.md` §4.8 | authorship: Tiers 1–3, the no-new-code-exchanges constraint |
-| `per-node-identity-v1.md` | why there is no key directory, and what a device key is |
-| `first-review.md` C1 | why a shared chain in a group is the original sin here |
-
----
-
-## 1. What exists today, verified
-
-Not read from the roadmap — read from the tree, and where a claim below is
-behavioural it was produced by running the code.
-
-**The protocol module.** `packages/meshbay-common/src/meshbay_common/senderkeys.py`
-implements a Signal-style sender-key ratchet: `SenderKeyRecord` (own state),
-`SenderKeyDistribution` (chain key + iteration + a **freshly generated** Ed25519
-public key), `GroupSenderKeyStore` (received states), `encrypt_message`,
-`decrypt_message`. 211 lines of tests, all green
-(`.venv/bin/pytest packages/meshbay-common/tests/test_senderkeys.py` → 13 passed).
-**Nothing in production imports it.** `grep` finds it in its own test file and
-nowhere else.
-
-**The chat path is plaintext, end to end.**
-
-- Browser: `static/chat-app.js` calls `transport.sendChat(text, 0, null, username)`
- (`static/transport.js:1423`), which puts the message string on the wire in
- `payload`. Attachments are the same call with a JSON string.
-- Node, WebRTC: `webrtc_server.py:3819 _do_chat_message` — stores `payload` as
- received, broadcasts it to the group's peers, notifies the hub, acks.
-- Node, QUIC: `quic_server.py:477 _do_chat_message_sync` — same shape, no history.
-- Storage: `chat/store.py`, one SQLite database per group at
- `data_dir/{group_id}/chat.db`, columns `sender_id, iteration, payload,
- timestamp, thread_id, sender_name`. **`iteration` is already there and is
- always 0.**
-- History: `webrtc_server.py:3902 _send_chat_history` → `get_recent` /
- `get_before` / `has_before`, with `has_more` driving the "load older" control.
-
-**What the node decides today.** `sender_id` is taken from the authenticated
-session, never the wire (NS6). The peer registry and the chat store are per group
-(H1). Both are held by `test_security_regressions.py`.
-
-**What the client already has to work with.** The renderer holds the per-node
-identity private keys (`transport.js:_sessionKeys.skEdB64` / `skXB64`, recovered
-from the keypair bundle) and can sign arbitrary bytes through
-`window.MeshBayKeys.signBytes`; `_pkEdFromSk` derives the matching public key.
-The GEK is in memory for the life of the connection. `groupbox.py` +
-`sealGroup`/`openGroup` in `crypto.js` are a working, parity-tested AEAD envelope
-under a GEK-derived subkey. IndexedDB and `sessionStorage` helpers exist in
-`hub-client.js`.
-
-**What the node holds.** The node is a group member. Its copy of the GEK is
-ECIES-wrapped to its own X25519 key in `bundles.db`; that private key lives in
-the Argon2id-encrypted keystore (`keystore.py`). That is the only reason "someone
-who images the disk" is a different adversary from "the operator" — and it is the
-whole basis of the threat model below.
-
----
-
-## 2. Seven findings
-
-The first four were reproduced by running the module; the script is quoted so the
-result can be re-derived rather than believed.
-
-### F1 — Any group member can impersonate any other member, silently
-
-`GroupSenderKeyStore.add_sender` (`senderkeys.py:178`) is
-`self._states[dist.sender_id] = SenderKeyState.from_distribution(dist)`. It
-accepts any distribution, for any `sender_id`, at any time, and **overwrites**
-what is there. The `signing_pk` inside a distribution is generated fresh in
-`SenderKeyRecord.create` — it is bound to nothing: not to a pinned device key,
-not to the roster, not to the account.
-
-Under the 2026-09-03 decision that distribution is GEK-wrapped, *every member can
-produce a valid distribution*. So every member can replace another member's chain
-with one they hold the signing key for, and every subsequent forged message
-verifies:
-
-```
-real: b'real alice'
-forged: b'forged as alice' # sent by a second record created as "alice"
-```
-
-This is not a subtlety of the ratchet; it is the direct consequence of pairing an
-unauthenticated distribution format with a distribution channel every member can
-write to. **Encrypting chat this way would make impersonation *worse* than today**,
-where the node at least enforces `sender_id` from the session (NS6). Any design
-that ships must bind the distribution — or the message — to a key the node pinned
-for that account.
-
-### F2 — A second device silently destroys the first device's chain
-
-Already recorded in `devel-phases-next.md` §15.0b and in v6 §5. Confirmed:
-
-```
-senders after two devices: 1
-device1 FAILED: InvalidSignature
-```
-
-Two `SenderKeyRecord.create("alice")` registered in one store leave one state, and
-the earlier device's messages then fail signature verification rather than failing
-visibly at registration. The fix recorded in the roadmap — make `sender_id` a
-device identifier — is necessary and **not sufficient**: it does nothing about F1,
-which is the finding that decides the design.
-
-### F3 — The skipped-key cache is unbounded
-
-`SenderKeyState.advance_to` caches every skipped message key and nothing ever
-trims `_skipped_keys`. `MAX_SKIP` bounds one jump at 256, not the total:
-
-```
-cached skipped keys after 10 jumps: 2000
-```
-
-A member who can write distributions (F1) or simply send messages with a high
-`iteration` grows every other member's memory 32 bytes at a time, for free, and
-those keys are exactly the material forward secrecy is supposed to have destroyed.
-Any receiver state that ships needs a cap and an eviction rule.
-
-### F4 — GEK rotation would destroy the entire chat history
-
-`ops.set_gek(..., rotate=True)` (`ops.py:267`) generates a fresh key, wraps it for
-the node, and drops the old one. Nothing keeps it. That is correct for everything
-encrypted **on the fly** — files are plaintext on disk and chunk-encrypted per
-transfer, the index is sealed at send time — but chat would be the first thing in
-the system encrypted **at rest** under something derived from the GEK.
-
-Rotation is not an edge case: it is the documented, required step after removing a
-member ("still rotate the GEK, the ex-member holds the current one"). So the
-sequence "remove a member → rotate → every message anyone ever sent is
-permanently unreadable, for everybody, including the operator" is the normal
-operating procedure. Nothing in Phase 15 mentions it.
-
-**This alone rules out "seal chat under a GEK subkey and be done".** Whatever key
-protects the archive must survive rotation as a re-wrap, not as a re-encryption.
-
-### F5 — History is served to devices that were not there, which is incompatible with a ratchet
-
-`chat_hist` serves any authorized member the newest page and pages backwards with
-no filter on when they joined. That is the behaviour today and users rely on it —
-`hasMore`, `loadOlder`, the day separators, the unread marker.
-
-A ratchet cannot serve that. A device that arrives at iteration 900 can derive
-message keys for 900 onwards and nothing before. §15.0b's answer is that "the node
-replays the latest distribution message for each active chain" — which gives that
-device the *newest* page and nothing older, so "load older" returns rows that
-render as garbage. To keep history working, the node must retain and hand out the
-**earliest** distribution for every chain, at which point see §3.
-
-### F6 — Ciphertext would be corrupted on the history path
-
-`webrtc_server.py:3920`:
-
-```python
-"payload": m.payload.decode("utf-8", errors="replace") if isinstance(...)
-```
-
-`errors="replace"` silently substitutes U+FFFD for every byte that is not valid
-UTF-8, which is most of a ciphertext. History would come back mangled while live
-messages worked, i.e. it would look like an intermittent decryption bug. The wire
-must carry msgpack `bin` (both codecs support it — Python's `use_bin_type=True`,
-and `transport.js` encodes `Uint8Array` as 0xc4/0xc5/0xc6 and decodes the same at
-`transport.js:2884`), and this line must go.
-
-### F7 — One account cannot hold two connected devices
-
-`webrtc_server.py:746` is `self._peer_registry()[self._user_id] = self`, and
-teardown pops the same key. So a person's second device **evicts the first from
-the registry**, and when either disconnects the other stops receiving broadcasts.
-The broadcast loop then excludes the sender by account
-(`webrtc_server.py:3849`, `quic_server.py:511`), so a person's own other devices
-would never see their own messages live even if the registry held them.
-
-This is the same shape as `pin_identity`'s old `INSERT OR REPLACE` and as F2 — the
-third instance of "keyed by account where it should be keyed by device". It is a
-**live defect today**, independent of encryption, and it must be fixed first: a
-per-device chat design built on a registry that cannot hold two devices of one
-account is untestable.
-
-Related, same class: `_load_pinned_pk` (`webrtc_server.py:4286`) calls
-`roster.get_identity()`, documented as "this account's **oldest** live device", and
-assigns it to `self._pinned_pk` — which is then recorded as `entry.uploader_pk` on
-every upload (`webrtc_server.py:4213`). With device linking live, uploads are
-attributed to the wrong device, and `desktop-client-v1.md` §4.8 A (authorize
-deletion against any non-revoked device of `uploader_id`) is **still not done**.
-Out of scope here, but it is the same root cause and should be fixed in the same
-sweep.
-
----
-
-## 3. The load-bearing analysis: what the ratchet is actually buying
-
-The 2026-09-03 decision (GEK-wrapped distribution) and the requirement that
-history keep working (F5) interact, and the interaction is not recorded anywhere.
-It is worth stating in full because it decides everything downstream.
-
-Take the design exactly as §15.0/§15.0b specify it:
-
-1. Distribution is GEK-wrapped, so every member can unwrap every sender key.
-2. History must be readable by devices that were not present, so the node must
- retain distributions and hand them out.
-3. To make *all* history readable — which is what "load older" means — the
- retained distribution must be the one at the chain's **earliest** iteration.
-4. A chain key at iteration *i* deterministically yields every message key from
- *i* onward: `_ratchet_chain` is pure HKDF.
-
-Therefore: **anyone who obtains the GEK at any moment can decrypt the entire chat
-archive, past and future.** There is no forward secrecy and no post-compromise
-security. The ratchet is computing `HKDF^n` over a value every member already
-holds and the node stores forever.
-
-Which means the honest comparison is:
-
-| | Sender keys as specified | One AEAD under a GEK-delivered archive key |
-|---|---|---|
-| Readable by a disk image without the keystore password | no | no |
-| Readable by any member / the operator | yes | yes |
-| Readable by an ex-member who kept a GEK | yes, for the epoch they had | yes, for the epoch they had |
-| Forward secrecy | **none** (see above) | none |
-| Post-compromise security | **none** | none |
-| Per-device state to persist, migrate, lose | one chain per device per group, in IndexedDB | none |
-| Silent failure modes | F1, F2, F3, F5 | — |
-| Lines of new client-side crypto | a ratchet, in JavaScript, from scratch | one `seal`/`open` pair that already exists |
-
-The two columns deliver the *same* security property against the *same*
-adversary. That is not an argument that sender keys are a bad protocol; it is that
-**GEK-wrapped distribution plus server-side history removes every property that
-distinguishes them**, and what is left is a large amount of stateful client code
-whose failure modes are silent.
-
-The property that is genuinely wanted and that neither column provides is **sender
-authentication** — F1 is the acute version of it, and `desktop-client-v1.md` §4.8
-already decided the answer: sign each message with the sender's device key
-(Tier 1), pin `account → device keys` client-side (Tier 2). That is independent of
-encryption and can land first.
-
-One clarification, because the roadmap gets it backwards: §15.0b says a per-device
-chain is required because a shared per-person chain reuses keys and nonces. True,
-and it is C1 one level down. But the reuse hazard comes from *shared mutable
-sending state*, and a design with no sending state at all does not have it — which
-is a stronger guarantee than per-device chains, not a weaker one.
-
----
-
-## 4. The decision — Design A, settled 2026-09-07
-
-Two coherent designs were put to the operator. They are not a spectrum; picking
-"sender keys, but history works" is picking A with extra machinery. **Design A is
-the decision.** Design B is kept below as the road not taken, because the reason
-it was refused is the same reason it must not be reintroduced later by someone
-reading the phase title.
-
-### Design A — sealed chat archive, per-device keys, signed messages ✅ **DECIDED**
-
-Chat is encrypted at rest and on the wire with AES-256-GCM under a key derived
-from a **group chat epoch key** the node generates and delivers to members wrapped
-under the current GEK. Each message is signed by the sending device's pinned
-Ed25519 identity key. No ratchet, no per-device chain state, no client-side
-persistence.
-
-- Keeps history for everyone, including new members and new devices (F5).
-- Survives GEK rotation, because the epoch key is delivered wrapped, not stored
- wrapped (F4).
-- Has no mutable sending state, so C1-class reuse is impossible by construction
- (F2, F3 cannot exist).
-- Fixes impersonation properly, with roster-rooted keys rather than a distribution
- format anyone can write (F1).
-- Reuses `groupbox.py` / `crypto.js`, which are already parity-tested.
-
-Costs: no forward secrecy — which, per §3, is what the specified design delivers
-anyway. It must be *stated*, not quietly inherited.
-
-### Design B — real sender keys, with the forward secrecy that justifies them ❌ **refused**
-
-Keep the ratchet, and accept its consequences honestly: distributions are **not**
-retained by the node, a device reads only what was sent after it joined, and
-"load older" stops at the device's own horizon. Distribution must then be pairwise
-to device X25519 keys (the pre-2026-09-03 position), because GEK-wrapping a
-distribution the node stores is what destroys the property.
-
-Costs: the chat history feature is materially reduced — a new phone shows an empty
-conversation, and a member who reinstalls loses everything. Under "I don't want
-regressions", this is a regression, and a visible one. It also reinstates the
-O(devices × members) fan-out that the 2026-09-03 decision removed for good
-reasons.
-
-### Why A, recorded so it does not have to be re-argued
-
-**Design A delivers the stated threat model (§8) exactly**, keeps every current
-chat behaviour, removes four classes of silent failure (F1–F3, F5) and is
-substantially less code. Design B was refused because losing the history — a new
-phone opening on an empty conversation, a reinstall losing everything — is a
-visible regression, and because the forward secrecy that would justify paying for
-it is not obtainable while the node serves history to devices that were not
-present.
-
-Forward secrecy is therefore **given up deliberately and on the record**, not
-inherited by accident. Per §3, the design as specified in Phase 15 did not provide
-it either; the difference is that this says so. If it ever becomes a real
-requirement, it belongs in 1:1 DM with `ratchet.py`, where there is no
-server-side history to contradict it — not in group chat.
-
-The consequence of the decision is that **`senderkeys.py` is not the module this
-feature is built on.** It joins `ratchet.py` as "kept for a possible future 1:1
-DM"; see §9. The phase is renamed *Chat encryption*, because "Sender Keys" is the
-name that led the plan to a protocol that does not fit the deployment.
-
----
-
-## 5. Design A in detail
-
-### 5.1 Keys
-
-```
-epoch_key 32 random bytes, generated BY THE NODE, per group, per epoch
- (C5b: no key material arrives from outside)
-
-device_key(d) = HKDF-SHA256(epoch_key,
- info = "meshbay:chat:dev:v1|" + group_id + "|" + d,
- salt = none, len = 32)
- where d = base64(device pk_ed25519), the roster's own identifier
-```
-
-Every member derives `device_key(d)` for every device from the epoch key, so there
-is nothing to distribute per device and nothing to store. Two *keys* never share a
-subkey, and — the part that carries the weight — **there is no mutable sending
-state at all**, so nothing can be advanced twice. That is the hazard §15.0b wanted
-per-device chains for, removed rather than partitioned.
-
-**Corrected 2026-09-07, and the correction matters.** An earlier draft of this
-section said "two devices never share an AEAD key". That is false in the
-deployment that exists, and stating it would have hidden the reason the design is
-safe. Two clients of one account on one node normally hold the **same** identity
-key: a second browser fetches the keypair bundle from the node and recovers the
-existing key rather than minting a new one (`transport.js`,
-`keypair_bundle_fetch`), and so does a fresh Electron install. Device *linking* —
-a distinct key, countersigned — is the exception, not the rule, which is why an
-operator adding a second browser is never asked to pin anything.
-
-So two clients routinely share a device key and therefore this subkey. What makes
-that safe is the nonce, not the derivation: **96 random bits, never a counter.**
-Two independent senders under one key collide only on the birthday bound, which at
-chat volume is unreachable; two independent senders advancing one *counter*
-collide immediately, which is precisely what C1 and §15.0b are about. The design
-degrades correctly into the deployment as it is; a chain-based one would have
-failed in it, silently, on the day someone opened a second tab.
-
-Nonces are 96-bit random per message. The NIST SP 800-38D ceiling of 2^32
-invocations under a random 96-bit nonce is a per-key budget now shared by however
-many clients an account runs at once — still unreachable by people typing;
-`groupbox.py` already makes and documents this argument.
-
-### 5.2 The message
-
-Plaintext, msgpack:
-
-```
-{ text, thread_id, sender_name, attachment?, sent_at }
-```
-
-`sender_name` moves **inside** the envelope. Today it is a wire field any peer can
-set to anything and the node caches it in `_user_names()` for rendering history —
-i.e. display-name spoofing is free today. Inside the sealed, signed payload it is
-authenticated.
-
-Wire (`chat_msg`, and each row of `chat_hist_resp`):
-
-```
-epoch uint which epoch key this is under
-device bin sender's pinned pk_ed25519, raw
-nonce bin(12)
-ct bin AES-256-GCM(device_key(device), nonce, msgpack(plaintext),
- aad = "chat_msg|" + group_id + "|" + epoch)
-sig bin(64) Ed25519 over "meshbay:chat:v1" ‖ group_id ‖ epoch
- ‖ device ‖ nonce ‖ ct
-```
-
-The AAD binds the group and the epoch, as `groupbox.associated_data` binds type
-and group — a ciphertext cannot be replayed into another group or attributed to
-another epoch. The signature covers the ciphertext, not the plaintext, so it is
-verifiable before decryption and by anyone holding the roster.
-
-`sender_id` stays a clear field **set by the node from the authenticated session**,
-exactly as NS6 requires. It is what the store keys on and what the UI groups by;
-it is not what authenticates the message.
-
-### 5.3 Storage on the node
-
-`chat.db` gains three columns, all with defaults so an existing database opens
-unchanged (`store.py` already does additive `ALTER TABLE` this way):
-
-```
-format INTEGER NOT NULL DEFAULT 0 -- 0 = legacy plaintext, 1 = sealed v1
-epoch INTEGER NOT NULL DEFAULT 0
-device BLOB DEFAULT NULL
-```
-
-`payload` holds the ciphertext; `nonce` and `sig` join it (either two more BLOB
-columns or one msgpack envelope in `payload` — prefer columns, they are greppable
-and the migration is the same).
-
-**Epoch keys are never stored in the clear.** They go where the node's own GEK
-copy goes: ECIES-wrapped to the node's X25519 key via `wrap_gek_aes`, in
-`bundles.db` under a new table, unlockable only through the Argon2id keystore. A
-plaintext `chat_keys.db` sitting next to `chat.db` would collapse the entire
-threat model into nothing, silently, and it is the obvious thing to write. It is
-worth a test that reads the file and asserts no 32-byte value from the live epoch
-appears in it.
-
-### 5.4 Delivery
-
-New MNP pair, additive (MNP → **1.2**):
-
-```
-chat_keys_req client → node { group_id }
-chat_keys_resp node → client sealed under groupbox purpose "chat_keys":
- { epochs: [ {epoch, key}, ... ], current: n }
-```
-
-Sealed with the existing `groupbox` envelope under a new purpose (`PURPOSE_CHAT`,
-info `b"meshbay:chat:keys:v1"`) — one more entry in `_INFO` and in
-`GROUPBOX_INFO`, held by the existing parity test. Delivered on request after the
-handshake rather than on the ack, so the ack does not grow for groups that do not
-use chat.
-
-Which epochs a member receives is the node's decision, from the roster:
-
-- **all live epochs** — the default, and what preserves today's behaviour: a new
- member sees the history, exactly as they do now;
-- **current epoch only** — an operator setting for groups where joining should not
- hand over the back catalogue. Not built in the first pass; the shape must be
- there so it can be, without a wire change.
-
-A member whose access was revoked gets nothing, because the connection does not
-complete.
-
-### 5.5 Epochs and rotation — the part that must not be got wrong
-
-A new epoch is opened when, and only when, the set of devices that may read
-*future* messages shrinks:
-
-- `member unpin`, `member revoke`, `device revoke`
-- `gek_rotate` (the operator is rotating precisely because someone left)
-- an explicit `chat rotate`
-
-Opening an epoch is: generate 32 bytes, store wrapped, increment `current`, push
-`chat_keys_resp` to connected members. Old epochs are **kept and still delivered**
-to current members, so history stays readable. That is the whole answer to F4:
-the archive is not re-encrypted, and the GEK is not what the archive is encrypted
-under — it is only what the epoch keys are wrapped with in transit, so a rotation
-is a re-wrap on the next connection and costs nothing.
-
-Note the property this gives and the one it does not: after an epoch change, an
-ex-member holding the old GEK **and** an old `chat_keys_resp` still reads the
-history they could already read, and reads nothing new. That is the same boundary
-as files, which is the point.
-
-### 5.6 There is no switch — MNP 2.0, and a flag day
-
-**Revised 2026-09-07, operator decision.** The plan above proposed a per-group
-`chat_encrypted` setting, off by default, so a node upgraded into a running group
-would refuse nobody. That was refused, and the reasoning is worth keeping:
-
-> **Every node in existence is a test node.** There is no installed base to
-> protect, so an opt-in flag buys nothing and costs a compatibility path that has
-> to be written, tested, and eventually removed. What it *would* buy is a
-> plaintext branch that stays reachable — which is the bypass C6 is the standing
-> lesson about.
-
-So: **chat is encrypted, and there is nothing to turn off.** The break is
-expressed where it belongs, in the protocol version:
-
-- `MNP_VERSION` is **2.0** and `MNP_MIN_SUPPORTED` moves with it. A 1.x peer is
- refused **at the handshake**, with `version_too_old` and a sentence saying so —
- not admitted and then left unable to send or read anything. A stated refusal is
- a bug report; a chat that quietly does not work is a support case.
-- The node refuses any `chat_msg` that is not sealed. `FORMAT_PLAIN` still exists
- as a *storage* state, because rows written before 2.0 are still in `chat.db`
- and still served; it is never accepted from the wire.
-- Nothing reads a `chat_encrypted` setting anywhere, and a test asserts that by
- reading the source. A default that can be wrong is a bypass with a name.
-
-The cost is a coordinated deployment: hub, nodes and clients move together. That
-is what MNP 1.0 already paid for once (the sealed index), and `check_version` is
-what makes it cost a refusal message rather than a mystery.
-
-**Existing node data** is migrated by `QE/migration/migrate_chat_encryption.py`,
-run with the node stopped. It opens epoch 1 for every group that has none and
-re-encrypts what is already in `chat.db` — the one step nothing can do later,
-because after it the node holds no plaintext chat to convert. It backs the
-database up first, commits once per group, and is idempotent.
-
-### 5.7 Existing plaintext history
-
-Rows written before 2.0 keep `format = 0` and render as they always did; the
-client reads both. **Nothing is rewritten automatically**, at start-up or
-otherwise: a one-shot rewrite of the only copy of a conversation is not something
-a daemon should do to a machine while nobody is looking.
-
-Two ways to convert them, the same operation behind both:
-
-- `QE/migration/migrate_chat_encryption.py`, with the node **stopped** — the
- upgrade path, and the one to use on an existing test node.
-- `meshbay-node chat encrypt-history --group <g>`, with the node running, for a
- group attached later or a database restored from a backup.
-
-Both copy `chat.db` to `chat.db.bak-<timestamp>` before touching a row and commit
-once, so an interrupted run leaves the database exactly as it was. Both seal the
-converted messages under a **synthetic device belonging to the node**, marked
-`migrated: true` in the payload: the node holds nobody's signing key and must not
-pretend to, and those messages only ever carried its word for who wrote them.
-
-Until one of them has run, `meshbay-node chat status` reports the plaintext
-count — those rows are the ones still readable off a stolen disk, and an operator
-who thinks the feature is finished needs to be told otherwise.
-
-### 5.8 What is deliberately not encrypted
-
-Stated here so nobody later reads more into the feature than it does:
-
-- **`sender_id`, `timestamp`, message sizes, and the fact of a message** are in
- the clear to the node. It is the relay; it cannot route otherwise.
-- **The hub learns, per message, the group, the time, and today the sender's name
- and account id** (`chat_notify` → `revocation.py:136`, which renders
- "*Name* posted in *Group*"). Encrypting the body while shipping that is worth
- being explicit about: the hub keeps a full social graph with timings. Dropping
- `sender_name` from the notification costs one string ("New message in *Group*")
- and should be done in the same change. `sender_user_id` is needed to skip the
- sender and stays.
-- **Link previews still send the URL to the node** (`link_preview_req`), by
- design — the client asks, the node fetches. The node therefore learns the links
- posted in an encrypted chat. This is already true and already documented as an
- SSRF surface; it now also belongs in the user-facing text.
-- **Attachments are ordinary files on a root and stay plaintext on disk** (15.7).
- The *reference* to an attachment is inside the sealed payload, but the file and
- its name are in the index. Encrypting them is a different feature with a
- different blast radius; the asymmetry gets documented, not hidden.
-
----
-
-## 6. Sender authentication — common to both designs, ships first
-
-`desktop-client-v1.md` §4.8 decided this; none of it is built. It is independent
-of encryption, it is what actually closes F1, and it is worth shipping on its own.
-
-**Tier 1 — sign every message with the sender's device key.** The signature field
-in §5.2, over the plaintext bytes while chat is still plaintext, over the
-ciphertext once it is not. The client already holds `skEdB64` and can sign. The
-node stores the signature and serves it back with history; it does not need to
-verify (it can, cheaply, and refusing an unverifiable message is a good cheap
-gate — but the authority is the receiver's check, not the node's).
-
-**Tier 2 — clients pin `account → device keys`.** Needs something that does not
-exist: a member-visible roster. `roster_read` is operator-only. Add
-`group_roster_req` / `group_roster_resp` (sealed under the group key), returning
-for each member of *this group*: `user_id`, `username`, and their live devices'
-`pk_ed25519` with the `added_by_pk` countersignature that admitted each one. The
-client pins on first sight, verifies later devices against the countersignature
-chain, and shows the one notice §4.8 budgets for: "this account's key changed".
-
-Two things to get right:
-
-- This publishes each member's device count and device keys to every other member
- of the group. That is a metadata change and it is the price of Tier 2; it stays
- inside the group, and the hub is not involved. Say it in the doc.
-- **The countersignature is verified and thrown away.** `_do_device_add`
- (`webrtc_server.py:1320`) checks the signature and stores only `added_by_pk` —
- *which* key approved, not the proof. Worse, `device_add_transcript`
- (`device.py:102`) binds `nonce_node`, a per-connection nonce, and `ts`, so even
- a stored signature is unverifiable by a third party unless the nonce and
- timestamp are stored with it. Tier 2 therefore needs `device_add` to persist
- `(sig, nonce_node, ts)` alongside the pin — a small additive change, but it must
- land *before* Tier 2 is useful. Devices pinned before that change carry no
- evidence and are trust-on-first-use only; that is acceptable and must be visible
- in the UI, not papered over.
-
-**Tier 3 (operator roster attestation) stays deferred**, per §4.8. Nothing here
-depends on it.
-
----
-
-## 7. Regression register
-
-The instruction driving this document is "no regressions". Each row is a concrete
-way this feature breaks something that works today, with what stops it.
-
-| # | Regression | Guard |
-|---|---|---|
-| R1 | GEK rotation makes all history unreadable (F4) | epoch keys wrapped at delivery, never at rest; test: rotate the GEK, reconnect, read the oldest page |
-| R2 | A new member or new device sees a wall of garbage instead of history (F5) | all live epochs delivered by default (§5.4); test with a device pinned after the messages were sent |
-| R3 | Ciphertext mangled by `errors="replace"` (F6) | payload becomes msgpack `bin` on both codecs; delete `webrtc_server.py:3920`'s decode; test asserts a non-UTF-8 payload survives a round trip through `chat_hist` |
-| R4 | Old plaintext rows stop rendering | `format` column, both paths in the client, `chat.db` opens unchanged; test opens a pre-change database fixture |
-| R5 | An older client shows gibberish or fails silently | per-group switch + explicit refusal with a stated reason (§5.6); test drives an MNP 1.1 client at a switched-on group and asserts the refusal, not a crash |
-| R6 | Two devices of one account cannot both be connected (F7) | peer registry keyed by device, broadcast excludes the sending *session* rather than the account; test connects two sessions for one `user_id` and asserts both receive |
-| R7 | A person's own second device does not see their messages | same fix; the test above asserts the sending account's *other* session receives |
-| R8 | QUIC path corrupts or leaks payloads | it is a relay; assert it stores and forwards bytes unchanged and never decodes them |
-| R9 | Chat attachments stop working | the attachment reference moves inside the envelope; `attachRoot`/`attachDir` and the upload path are untouched; existing attachment tests must stay green |
-| R10 | Link previews stop working | `firstUrl` runs on decrypted text client-side, before the request; unchanged. Test the panel end to end |
-| R11 | `isOwn` breaks — `chat-app.js` compares `m.sender_name === username` today | own-ness comes from the account id, decided in one place; local echo stops inventing `sender_id: username` |
-| R12 | Display names spoofable (already true) | `sender_name` inside the signed envelope; a message whose signature does not verify is rendered as unverified, never as someone |
-| R13 | Hub notifications break | `chat_notify` is unchanged apart from dropping `sender_name`; test asserts a notification is still created |
-| R14 | Unbounded receiver state (F3) | Not reachable: Design A holds no per-sender receiver state at all. The guard is that no such state is introduced |
-| R15 | Epoch keys land in a plaintext SQLite beside `chat.db` | test reads the file and asserts the live epoch key's bytes do not appear in it |
-| R16 | Chat retention (15.5) deletes rows an epoch still needs | retention deletes messages, never epoch keys; an epoch with no messages is harmless |
-
----
-
-## 8. The threat delta, in the words the user-facing docs should use
-
-Chat encryption protects a conversation against **someone who obtains the node's
-storage without the keystore password** — a hosting provider imaging the machine,
-a leaked backup, a seizure where the passphrase is not surrendered. Before it,
-that person reads every message; after it, they read ciphertext.
-
-It does **not** protect chat from:
-
-- **the node operator, or any current member** — they hold the group key, and the
- chat key is delivered under it. This is the same boundary as file access, by
- design: the group key is the group secret.
-- **anyone holding any one device of any member.** With several devices per
- person, that surface is larger than it was.
-- **a former member**, for the messages sent before the epoch changed. Rotation
- stops them reading what comes next; it cannot unsend what they already had.
-- **the hub**, as regards *metadata*: it learns which group has a conversation and
- when, from the notification path.
-- **the node**, as regards *links posted*, which it fetches to unfurl them.
-- **an account that also signs in from a browser**, to the extent C4 is open: the
- keypair bundle on the node yields the identity key, hence the group key, hence
- the chat key. Chat encryption is worth measurably less to a browser-using
- account than to a native one — the same asymmetry as everywhere else.
-
-And under Design A it does **not** provide forward secrecy or post-compromise
-security. Under the design as specified in Phase 15 it would not have provided
-them either (§3); the difference is that this says so.
-
-What sender *authentication* adds is separate and real: once a member's client has
-pinned a device key for an account, no other member — and no operator who turns
-malicious later — can forge a message from that account to that member. Forgery
-is limited to accounts the reader has never seen.
-
----
-
-## 9. Documents that are stale and must be corrected
-
-- `meshbay-draft-v6.md` §5, the "Chat encryption (Sender Keys)" row, still says
- "**Pairwise to identity keys, never GEK-derived**". That was reversed on
- 2026-09-03 and CLAUDE.md records the reversal; v6 does not. It is wrong as it
- stands, whichever design is chosen.
-- `devel-phases-next.md` §15 milestones 15.1–15.3 say "**Node**: sender key init /
- encrypt on send / decrypt on receive". The node is a relay and a store; messages
- are composed and read in the browser and the desktop client. Encryption belongs
- in `static/` and `senderkeys`-equivalent JS, with the node handling only key
- delivery, storage and refusal. Built as written, the node would hold the
- plaintext and the feature would protect nothing it claims to.
-- `senderkeys.py`'s module docstring says "one chain per member" and "on join:
- admin wraps each sender's SenderKeyDistribution with GEK". Both are wrong per
- §15.0b and §3.
-- `protocol.py:41` labels `CHAT_MESSAGE` "Double Ratchet message". It is not, has
- never been, and the comment predates the C1 finding that rejected exactly that.
-
-Per the §4 decision, `senderkeys.py` joins `ratchet.py` as "kept for a possible
-future 1:1 DM". The CLAUDE.md key-modules row must be amended to say so — today it
-reads "Sender Keys (group chat) | `senderkeys.py` (Phase 7.5)", which will send
-the next reader to the wrong module. The phase is renamed "Chat encryption", not
-"Sender Keys".
-
----
-
-## 10. Milestones
-
-Design A is built. This was the build order, and the "What shipped" column is
-what each stage turned out to be. **Stages 0–2
-carry no chat crypto at all**: they fix live defects, move the payload to bytes
-and add message signing, each shippable and verifiable on its own with chat still
-in plaintext. Stage 3 is the encryption itself and is the first stage that can
-break a running group, which is why the switch (3.3) exists. Do not reorder 3.1
-before 0.1 — a per-device design on a registry that cannot hold two devices of one
-account is untestable.
-
-| # | Stage | What shipped | Notes |
-|---|---|---|---|
-| 0.1 | **Per-connection peer registry** ✅ | `_registry_key` (a uuid per connection), `_register_peer` / `_unregister_peer` / `_sessions_of`; the chat broadcast excludes the sending *session*, on both transports | F7. `test_chat_multidevice.py` — three of its four tests fail with the old keying |
-| 0.2 | **`device_hello`** ✅ | Additive MNP 1.2 message, signed over a transcript naming the node, the group and this connection's nonce. Refused unless the key is a live device *of this account in the node's own roster*; refused again if the connection later claims a different one; `_load_pinned_pk` no longer overwrites a confirmed device | Chosen over changing the handshake, which would have been a breaking protocol change for something additive. `test_device_on_connection.py` |
-| 0.3 | **Delete by account** ✅ | `_verify_uploader_sig` tries every non-revoked device of `entry.uploader_id`. `uploader_pk` stops being the authorization key and becomes the audit record; it is still the fallback for an index written before `uploader_id` existed | `desktop-client-v1.md` §4.8 A — a regression device linking had already introduced |
-| 1.1 | **Payload shape** ✅ | `store.py` gains `format` / `epoch` / `device` / `nonce` / `sig`, all with defaults, plus a unique `(device, nonce)`. Ciphertext travels in its own `ct` field | **The plan said "payload becomes bytes end to end", and that was wrong.** `payload` reaches older clients, and the UI ships inside the desktop package now, so they would have rendered bytes where they expect text. Plaintext keeps exactly the shape it has always had |
-| 1.2 | **Message envelope** ✅ | `sender_name` moves inside the sealed payload; own-ness decided from `userId`, with an explicit `own` flag on the optimistic echo; `sender_name` dropped from the hub notification | R11, R12, and §5.8's metadata point |
-| 2.1 | **Signatures** ✅ | The client signs over the *ciphertext* with its pinned device key; the node checks the `device` claim against this connection before storing; receivers verify before decrypting | Shipped with 3.2 rather than before it, because one envelope carries both |
-| 2.2 | **Tier 2 — group roster** ✅ | `device_add` persists `(sig, nonce_node, ts)` beside the pin — it used to verify the countersignature and throw it away, which is what blocked this. `group_roster_req`/`resp`, sealed under a new groupbox purpose and answered to **any member**, relays each device with the evidence that admitted it. The client walks the chain itself (`_verifyRoster`) and keeps its own pins | §6, §13 |
-| 3.1 | **Epoch keys** ✅ | `ops.open_chat_epoch` / `ensure_chat_epoch` / `chat_epoch_keys`; wrapped to the node's own X25519 key in `bundles.db`; `chat_keys_req`/`resp` sealed under the new groupbox purpose `chat_keys` | §5.1, §5.4, R15 |
-| 3.2 | **Encrypt / decrypt** ✅ | `chatbox.py`, mirrored by `sealChat` / `openChat` / `verifyChatSignature` in `crypto.js` and held byte-identical by six vectors in `test_js_python_parity.py` — including a `\|` inside a group id, which is the separator both the AAD and the HKDF info string use | §5.2 |
-| 3.3 | **No switch** ✅ | **The plan's third mistake.** It proposed `chat_encrypted` per group, off by default. Refused by the operator: every node is a test node, so an opt-in flag buys nothing and leaves a plaintext branch reachable. MNP is **2.0**, `MNP_MIN_SUPPORTED` moves with it, a 1.x peer is refused at the handshake, and a test reads the source to assert nothing consults a `chat_encrypted` setting | §5.6, R5 |
-| 3.4 | **Epoch rotation** ✅ | A new epoch on member revoke, member unpin, device revoke and `gek_rotate`. Old epochs kept and still delivered; pushed to everyone connected | §5.5, R1 |
-| 3.5 | **History migration** ✅ | `QE/migration/migrate_chat_encryption.py` (node stopped — the upgrade path) and `meshbay-node chat encrypt-history` (node running). Both back `chat.db` up first and commit once; migrated messages are sealed under a synthetic device of the node's and carry `migrated: true`, because the node holds nobody's signing key and must not pretend to. Verified end to end against a seeded pre-2.0 data directory: 3 messages converted, decrypted back, plaintext absent from `chat.db` and present in the backup, second run a no-op | §5.7 |
-| 4.1 | **Retention** ✅ | `meshbay-node chat prune <days>` / `ops.prune_chat`. Messages only — never an epoch key | R16 |
-| 4.2 | **Docs** ✅ | v6 §5, `devel-phases-next.md` §15, CLAUDE.md's corrections and key-modules table, `senderkeys.py`'s docstring, and `protocol.py`'s "Double Ratchet message" comment | §9 |
-
-MNP goes to **1.2** at 3.1 — additive, so 1.1 peers keep working in groups where
-the switch is off, and are refused with a stated reason where it is on.
-
----
-
-## 11. Tests
-
-The standing rule here is that a test which models the fix agrees with it by
-construction, and that source-reading tests are weak evidence. So:
-
-- `test_senderkeys.py` — the module is now unused by production. Leave the suite
- green and add a one-line note at the top of the file saying it covers a
- protocol deferred to a possible 1:1 DM, so nobody reads its green tick as
- evidence that group chat is encrypted. Do **not** delete it, and do not spend
- effort fixing F1–F3 there: they are findings about a module nothing calls.
-- `test_chat_encryption.py` (new, common) — round trip a message the node cannot
- read; assert the node's stored bytes contain neither the plaintext nor the
- sender's display name.
-- `test_chat_history_binary.py` (new) — a payload that is not valid UTF-8 survives
- `chat_hist`. Fails today against `webrtc_server.py:3920`.
-- `test_chat_multidevice.py` (new) — two sessions, one `user_id`, both in the
- registry, both receive a third party's message, and the second receives the
- first's. Fails today against `webrtc_server.py:746`.
-- `test_chat_rotation.py` (new) — send, `gek_rotate`, reconnect, read the oldest
- page. This is R1 and it is the test most likely to be missing.
-- `test_chat_key_storage.py` (new) — the live epoch key's bytes appear in no file
- under `data_dir` that is not the keystore-protected store. R15.
-- `test_chat_downgrade.py` (new) — with the switch on, a plaintext `chat_msg` is
- refused, and an MNP 1.1 handshake gets a stated refusal rather than a silent
- empty panel. R5.
-- `test_js_python_parity.py` — extend for the new groupbox purpose and for
- `sealChat`/`openChat`, the way the index seal is already held.
-- `tests/harness/chat_send_probe.py` — already mounts the real `ChatPanel` over
- the real transport. Extend it to drive an encrypted send. The freeze this
- harness was written for lived in the seam between the panel and the transport,
- and this change adds two more layers to that seam.
-- `tests/harness/chat_scroll_probe.py` — must stay green: "load older" across an
- epoch boundary is exactly where a decryption gap would surface as a rendering
- bug.
-- `QE/deploy/e2e.py` cannot cover the client here — it is a second
- implementation, and this feature lives in `chat-app.js`. Plan for a person to
- send a message from two devices, rotate the key, and scroll up.
-
----
-
-## 12. Open questions for the operator
-
-Question 1 was **answered on 2026-09-07: Design A** (§4). The four below are
-policy rather than architecture: none of them blocks Stages 0–2, and each is
-needed by the stage named beside it.
-
-1. **Do new members see old history?** (Stage 3.1.) Design A can deliver all
- epochs — today's behaviour, and the recommended default — or only the current
- one. A per-group setting either way, but the default is a policy choice.
-2. **`chat encrypt-history`** (Stage 3.5) — build it, or leave pre-switch
- messages plaintext for ever? The second is simpler and honest; the first is what someone will ask
- for the day after they turn the switch on.
-3. **Drop `sender_name` from the hub notification?** (Stage 1.2.) It costs one string in the
- notification text and removes a per-message name from the hub's records.
-4. **Attachments** (15.7, Stage 4.2): document the asymmetry now, or encrypt them later? The
- plan above assumes documented.
-
-
----
-
-## 13. Tier 2, and what it does and does not buy
-
-**Built 2026-09-07**, after this document had recorded it as the one deliberate
-omission. What unblocked it was noticing why it was blocked: not effort, but
-that **the evidence was not being kept.** `_do_device_add` verified the
-countersignature and stored only `added_by_pk` — *which* key approved, never the
-proof — and `device_add_transcript` binds `nonce_node`, the approving
-connection's handshake nonce, so even a stored signature was unverifiable by
-anyone who had not been on that connection. Three columns fixed that.
-
-The property, stated exactly, because the temptation is to round it up:
-
-> Once a member's client has seen an account, **a node that later substitutes a
-> key for it is detected**. Nothing is gained at first sight, where the client
-> has nothing to compare against.
-
-That second sentence is not a caveat to be dropped. It is the same boundary
-`per-node-identity-v1.md` draws and this does not move it: an operator who is
-malicious *from the start*, for a member who has never seen the account, can
-still name whoever they like.
-
-**What the node decides: nothing.** It hands over evidence — for each live
-device of each active member of the group, the key, the key that countersigned
-it, the signature, the nonce and the timestamp — and the client walks the chain
-from each account's root outwards. A device the node lists but cannot evidence
-never enters the verified set, so a fabricated key is not laundered in by being
-mentioned. That is why the node is not asked to assert trust: it is the party
-the property holds *against*.
-
-Three decisions worth keeping:
-
-- **A root is a device that names no countersigner**, not one that fails to
- produce a signature. Treating "no proof" as "root" would have admitted
- anything a node chose to write, and the tests caught exactly that while this
- was being built.
-- **First sight pins everything the node says**, not the verified subset.
- Otherwise a legitimate second device whose countersignature predates this
- change raises "key changed" on every message — and an alarm that fires on
- normal events stops being read, which was §4.8's whole reason for budgeting
- exactly one notice.
-- **A device pinned before 2026-09-07 is unevidenced, and reads as such.** It
- was countersigned; the proof was not kept. Honest is better than convenient
- here, and the operator-facing consequence is small because every node is a
- test node whose devices are trivially re-paired.
-
-**The cost, stated because it is a real one:** the roster is member-visible, so
-every member of a group learns how many devices every other member holds and
-what their public keys are. It stays inside the group — the hub is not involved
-— and it is scoped to one group, so a person in two groups on one node is not
-disclosed to the second by being in the first. That is the price of the property
-and it is not avoidable: a member who cannot see the keys cannot check them.
-
-**Tier 3 stays deferred**, unchanged and with nothing depending on it: the
-operator signs a roster attestation, which would close first contact. It is
-worth doing only if a deployment appears where the operator is not the machine.
diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md
deleted file mode 100644
index 6a3ad25..0000000
--- a/docs/desktop-client-v1.md
+++ /dev/null
@@ -1,1179 +0,0 @@
-# MeshBay — Desktop Client (design)
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the desktop client design and decision record; its design
-> content now lives in §8.2 (shell), §3.3 (devices), §3.6 (accounts), §6.2 (roots), §6.5 (derived data), §10 (portability), §13.8 and §14 (decisions).
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **largely built.** Decided in discussion with the operator on
-> 2026-08-17; Stages A–D are running, and §10's table is the per-stage record.
-> Stages D5, D6, D9, D11 and D12 remain — see `MESHBAY_DESIGN.md` §15.2. This
-> header said "nothing here is implemented" long after most of it was.
-> Supersedes structural decision 18 in `devel-phases-next.md` (pywebview) and
-> rewrites Phase 13.
->
-> Read `docs/invite-pairing-v1.md` and `docs/per-node-identity-v1.md` first — this
-> document extends the roster and the pinning model, and it is wrong to read the
-> device-linking section without them.
->
-> Follows the v5 convention: **every claim names the adversary it holds against**,
-> and where a property does not hold, it says so.
-
----
-
-## 1. What is decided
-
-| # | Question | Decision |
-|---|---|---|
-| E1 | Shell | **Electron**, with an optional Python sidecar for `group://` over QUIC. Reverses structural decision 18 |
-| E2 | A second device on a node where the user is already pinned | **Device linking**: the already-pinned key countersigns, bound by a one-time code the new device generates. Neither the hub nor the node can substitute a key |
-| E3 | Account creation from the native client | **Hybrid**: register with a passphrase-derived `auth_key` (existing path, browser-compatible immediately), then authenticate day to day with a device Ed25519 key |
-| E4 | Node management | **Signed admin ops over MNP** for everything group-scoped, including GEK *rotation*. First run stays local. The loopback admin API is never exposed to the network |
-| E5 | LAN enrolment of a headless node | **Out of scope for v1**, implementable later as an isolated, self-closing enrolment door. Not needed for the desktop persona |
-| E6 | The browser SPA | **Stays.** Objective: a native client must not prevent web use — several devices, remote access |
-| E7 | A group's content | **Several named roots**, unique names, forming one virtual root. Not one directory (§6.7) |
-| E8 | Target filesystems and platform | **exFAT/NTFS and Windows are the common case**, not an edge case. Linux ships first; that is build order, not population (§6.8, §7.5) |
-| E9 | Where group-related server state lives | **On the node. Always.** Not on the hub — putting group state there breaks the model, whatever the feature |
-
-**E9 is the rule that settles future arguments**, so it is worth stating on its own:
-
-> If a feature seems to need server-side state about a group — its files, its members'
-> devices, what anyone watched, a catalogue, a search index — that state belongs **on the
-> node**. The hub holds accounts, group registry and membership, signaling, and nothing
-> else about content. It stores no file names for private groups and registers content
-> hashes for **public** groups only (H7).
-
-Everything decided in this document already obeys it: the roster, the device rows, the
-pending device requests, the invitations, the index, the per-root availability state and
-the chat all live on the node. Nothing here adds a row to the hub.
-
-Priorities carried from the operator's statement: (1) UI not served by the hub · client at
-feature parity for a standard user · confidential material stored locally · web keeps
-working · Linux (Ubuntu 24+, Fedora 44+) then Windows. (2) local node management ·
-`group://` without a hub. (3) automatic updates · Windows.
-
-**Read the build order carefully.** Linux first, Windows later — but most users are
-expected on Windows, sharing from exFAT or NTFS volumes. A design that is convenient on
-ext4 and merely *portable* to NTFS has it backwards.
-
----
-
-## 2. Electron, and why the recorded decision changes
-
-### 2.1 The measurement that drove it
-
-Phase 13 was costed in August on figures that have since moved. Measured 2026-08-17:
-
-| Asset | Doc said | Actual |
-|---|---|---|
-| `app.js` | ~2600 lines | **4586** |
-| `crypto.subtle` in `app.js` | 0 | **2** (`_pkFromSk`, `app.js:157`) |
-| `RTCPeerConnection` in `app.js` | 0 | 0 — still true |
-| `hubFetch` | 30 call sites | 43 occurrences |
-
-The seam the phase was justified on has already leaked, and the surface to rewrite grew.
-`transport.js` (1242) + `crypto.js` (362) + `keyderive.js` (303) + `downloads.js` (295) +
-`sw.js` (68), plus the MSE `VideoPlayer` and the storage glue inside `app.js`, is on the
-order of **2500–3000 lines** to reimplement under pywebview — plus a loopback media
-server, native dialogs and a SQLite index cache.
-
-**That cost is not intrinsic to being native. It is intrinsic to replacing the engine.**
-
-### 2.2 What the SPA actually depends on
-
-Not "the web" in general — Chromium-class platform APIs:
-
-| API | Chromium (Electron) | WebKitGTK (pywebview on Linux) |
-|---|---|---|
-| `RTCPeerConnection`, DataChannel, DTLS fingerprints | ✅ unchanged | ❌ reimplement in aiortc |
-| WebCrypto X25519 / Ed25519 / AES-GCM | ✅ | ❌ partial → Python |
-| MSE / `SourceBuffer` | ✅ | ⚠️ unreliable → loopback media server (old 13.6) |
-| Service Worker (streamed download) | ✅ | ❌ |
-| File System Access | ✅ + native dialog | ❌ |
-| IndexedDB, WASM (Argon2id) | ✅ | ⚠️ restricted under `file://` |
-
-Under Electron, `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js`, `sw.js` and
-the MSE player **survive unchanged**. The native work reduces to a main process and the
-part that has real value: local key storage.
-
-### 2.3 Correction to the D2 comparison table
-
-`tmp-decisions.md` counts "no browser sandbox" against native. That is true of
-pywebview/WebKitGTK. It is **false for Electron** with `sandbox: true` and
-`contextIsolation: true`, which keep the Chromium renderer sandbox — the strongest one
-available. On that criterion Electron is better than the decision it replaces.
-
-### 2.4 What Electron costs, stated plainly
-
-1. **We own Chromium's patch cadence.** This is the real cost. The renderer parses
- decrypted content from nodes — video, images, filenames — which is attacker-controlled
- input. A critical Chromium CVE every few months means a rebuild and a push. Mitigated
- by distro repositories (§7.4) and CI rebuilding on each Electron release, not
- eliminated.
-2. **npm enters the project.** Bounded: the UI has no build step (Preact + htm as native
- ESM), so the dependency tree is Electron plus a packager.
-3. **~180 MB installed**, against ~90–120 MB for a Python shell with `av`/aiortc. Real,
- not decisive.
-
-Tauri was considered and rejected: it uses the system webview, so it inherits every
-WebKitGTK problem above and adds a third language.
-
-### 2.5 The sidecar
-
-`group://` without a hub needs QUIC to a node's address. There is no usable equivalent in
-JS, and a manual SDP exchange is excluded (no copy-paste path, and the operator constraint
-forbids a browser on the node host). So hub-less access means reusing `quic_client.py` in
-a Python sidecar spoken to over loopback with a token.
-
-On Linux this is natural: the desktop package **depends on `python3-meshbay-common`**,
-which is already packaged. On Windows it means an embedded Python — consistent with
-Windows being priority 3.
-
-The sidecar is also what makes local management work on a LAN with no internet (§6.4).
-
-### 2.6 Shipping the UI introduces version skew — the tax nobody budgeted
-
-This is not an Electron property. It follows from shipping the interface at all, and it is
-the largest **recurring** cost of this phase — larger than the UI work, which is close to
-zero.
-
-Today the SPA and the hub deploy together and are always in sync: a `/v1/` response shape
-can change and `app.js` is fixed in the same commit, because the hub serves both. The
-moment the UI lives inside a package, **an old client meets a new hub** — for the first
-time in this project's life.
-
-Consequences to carry deliberately:
-
-- **`/v1/` becomes a compatibility surface.** Response shapes cannot change freely. The
- N-2 MINOR rule already stated for MNP and MHP now applies to the hub API in practice.
-- **`GET /v1/hub/version` (10.10) should carry a minimum supported client version**, and
- the client should warn below it and refuse below a hard floor. That is cheap to add now
- and awkward to retrofit once clients exist in the wild.
-- A user running the desktop client and the web SPA against the same hub is running **two
- versions of the same code at once**. They must agree on protocol, not on build.
-
-Decide this before the first public package, not after.
-
-### 2.7 One UI, one source
-
-`packages/meshbay-hub/src/meshbay_hub/static/` stays the single source of truth. The
-desktop package **copies** it at build time; it is never forked into a second tree. A
-silent fork is the only real way to end up maintaining the interface twice, so it should
-fail the build rather than be caught by discipline.
-
-Features that only make sense natively — the node management panel, local folder
-selection — live in the same codebase behind a capability check
-(`platform.capabilities.nodeAdmin`, `localFolders`). The browser simply does not render
-them. There is no second application.
-
----
-
-## 3. What the shell must do
-
-Non-negotiable, carried from structural decision 18: **UI assets ship inside the package
-and load from disk.** A shell pointing a WebView at the hub's `/app/` is a browser with a
-different icon and fixes nothing.
-
-| Requirement | Why |
-|---|---|
-| `contextIsolation: true`, `sandbox: true`, `nodeIntegration: false` | keeps the renderer sandbox; the preload exposes a narrow, enumerated API |
-| Custom `app://` protocol via `protocol.handle`, **not** `file://` | service workers, ES modules and IndexedDB misbehave under `file://`; also gives a stable origin across updates |
-| `registerSchemesAsPrivileged` with `standard`, `secure`, `supportFetchAPI` and `stream` | a scheme without these is not a secure context, so **the service worker silently refuses to register** and streamed downloads break with no error — the same failure mode as an uncontrolled page, already learned once |
-| Strict CSP in-package, **keeping `wasm-unsafe-eval` in `script-src`** | the bundle KDF is Argon2id in WebAssembly; a policy forbidding it locks every user out of their keys |
-| `will-navigate` and `setWindowOpenHandler` refuse every non-local target | the hub must never become the document origin |
-| Electron version pinned to one whose Chromium has X25519 and Ed25519 in WebCrypto | the SPA uses both; **verify on the target version, do not assume** |
-
-The single structural change to the SPA: `HUB = ''` (`app.js:12`) becomes a configurable
-absolute base URL. That is what the old 13.1 platform split existed to do, and under
-Electron it is nearly all of it.
-
-### 3.1 What running it changed
-
-Four of the statements above were wrong, and only launching the application found them.
-
-**"Nothing here needs a camera, a microphone or a location" was true, and the handler
-written from it was still wrong.** Denying every permission also denied `fullscreen`, and
-Chromium's own video controls ask for it — so a film could not be watched full-screen.
-What makes this worth recording rather than just fixing: **a denied `fullscreen` does not
-reject.** `requestFullscreen()` returns a promise that never settles. No error, no console
-message, nothing in the renderer that names a permission; the button simply does nothing,
-and the operator reported it as "impossible to go full-screen" with no lead to follow. The
-probe reported `NEVER SETTLED` while the main process logged `PERMISSION ASKED:
-fullscreen`, which is what tied the two ends together. The handler now enumerates what is
-*granted* — one entry — so anything Chromium adds later still arrives refused.
-
-**The CSP cannot live in a `<meta>` tag.** `frame-ancestors` is ignored there — Chromium
-says so in the console — so a policy carrying it has one directive that silently does
-nothing. It is sent as a header by the protocol handler instead, which is also the only
-thing that serves the interface, so there is one source rather than two.
-
-**`secure: true` is not what makes the service worker register.** Chromium refuses a
-worker on a custom scheme whatever its privileges: *"The URL protocol of the current
-origin ('app://meshbay') is not supported"*. The application therefore has **no service
-worker**, and does not need one — it saves through a native dialog, which is the better
-of the two paths anyway. `sw.js` stays in the package because the same files serve the
-browser, where it is one of only three ways to write a large file.
-
-What `secure: true` *is* for was measured at the same time: without it **the whole of
-`crypto.subtle` is undefined**. The first probe loaded a `data:` URL and every algorithm
-failed with `TypeError`, AES-GCM included. X25519 and Ed25519 are both present on
-Chromium 148, which settles the version floor that was open (O6).
-
-**The renderer cannot call the hub.** Its origin is `app://meshbay` and CORS refuses it —
-the hub has *no CORS middleware at all*, which is a posture worth keeping: its API is
-reachable from no web origin whatever. Widening it for `app://meshbay` would be worse
-than it looks, because that origin is not a credential: any Electron application on any
-machine can claim the same scheme and host name.
-
-So **every hub call leaves from the main process**, exactly as saving a file does. The
-renderer asks; the main process goes, and refuses any origin that is not the hub the user
-signed in to. `platform.apiFetch()` is `fetch` in a browser and the bridge in the
-application, so no caller has to know which one it got. Verified from inside Electron:
-the main process reaches `https://meshbay.org/v1/hub/version` (200), the renderer is
-refused by CORS, and **a script served by the hub is refused by the policy** — T3's
-mitigation demonstrated rather than asserted.
-
----
-
-## 4. Device linking
-
-### 4.1 The problem
-
-Identity keys are per node (`docs/per-node-identity-v1.md`). A browser keeps nothing
-durable, so its identity is left with the node, encrypted under the passphrase — that is
-what lets a second browser work, and it is finding **C4**.
-
-A native client that keeps its keys locally has no bundle. Today it would be refused:
-
-```python
-# webrtc_server.py:886-894
-known = await roster.get_identity(user_id)
-if known:
- if known["pk_ed25519"] != pk_ed_b64 or known["pk_x25519"] != pk_x_b64:
- self._join_refuse("key_changed", ...)
-```
-
-and `roster.py` declares `identities(user_id TEXT PRIMARY KEY)` with `pin_identity` doing
-`INSERT OR REPLACE`. **One key per person per node**, and a silent overwrite if that ever
-changed.
-
-Without a linking mechanism, a user with a browser and a native client needs an operator
-code per node per device — and, worse, an account *created* natively would need an
-operator code before its owner could ever use a browser. E6 would be dead on arrival.
-
-### 4.2 Principle
-
-The authority is **a key the node pinned itself**, exactly as with `_verify_admin_sig`.
-The hub cannot produce it: the hub has stored no user keys since 2026-08-14
-(`users.pk_ed25519` / `pk_x25519` dropped, `PUT /me/keys` gone). So device linking adds
-**no hub-reachable authority**.
-
-The approval must not rest on a human comparing digits. Phase 12.1 abandoned safety
-numbers as "correct, unusable as the default", and reintroducing them here through the
-back door would be the same mistake. Instead the approval is bound by a **one-time code
-the new device generates and displays** — the same primitive as an invitation, with the
-code produced by the joining device rather than by the operator.
-
-### 4.3 Flow
-
-```
-native generate 40 bits CSPRNG → display 7K2M-BQ84
- code_hash = sha256(code ‖ new_pk_ed25519 ‖ new_pk_x25519)
-native → device_add_request {new_pk_ed25519, new_pk_x25519, code_hash, sig_new}
- node ← pre-proof window; stored as a pending row, TTL 1 h
-browser "Add a device" → the user types 7K2M-BQ84
-browser ← the node returns pending candidates for this user_id (req_id + both keys)
- node the client recomputes sha256(code ‖ keys) and keeps the row that matches
-browser → device_add {req_id, sig_pinned}
- node
-node verify sig_new, verify sig_pinned against a live pinned key of this user,
- insert a device row, audit the event
-```
-
-Binding the keys *into* the code hash is what stops the node from substituting them: a
-node returning different keys produces no match, and the client refuses before signing.
-The fingerprint is still displayed on both ends, as a secondary check — **not**
-load-bearing.
-
-### 4.4 Transcripts
-
-```
-"meshbay:device_req:v1" ‖ len‖node_pk ‖ len‖user_id ‖ len‖new_pk_ed25519
- ‖ len‖new_pk_x25519 ‖ len‖code_hash ‖ len‖nonce_s ‖ len‖ts
-
-"meshbay:device_add:v1" ‖ len‖node_pk ‖ len‖user_id ‖ len‖new_pk_ed25519
- ‖ len‖new_pk_x25519 ‖ len‖nonce_s ‖ len‖ts
-```
-
-Length-prefixed and domain-separated per 11.5.21. `node_pk` binds an authorization to one
-node; `nonce_s` — the handshake nonce of the connection carrying the message — stops a
-replay onto another connection. The code is a bearer secret: never signed, never echoed,
-stored only as the hash.
-
-### 4.5 Schema change
-
-```sql
--- identities: PRIMARY KEY (user_id) → PRIMARY KEY (user_id, pk_ed25519)
--- + label TEXT, added_at TEXT, added_by_pk TEXT, revoked_at TEXT
-```
-
-`pin_identity` can no longer be `INSERT OR REPLACE` — today that silently overwrites, and
-with multiple devices it becomes a hole. `key_changed` becomes "this key is not among
-this user's devices".
-
-New table `device_requests(req_id, user_id, pk_ed25519, pk_x25519, code_hash, expires_at,
-created_at)`, covered by the existing `purge_expired()`.
-
-### 4.6 Bounds
-
-- **TTL 1 h**, configurable as `[node] device_request_ttl_minutes`, beside the existing
- `invite_ttl_hours` (7 d) and `pair_ttl_hours` (24 h).
-- Only a `user_id` that already has at least one pinned identity may file a request. This
- is anti-spam, **not** a security boundary — the filing key is unpinned by construction.
-- 5 attempts per connection and a node-wide lockout on failures, as for invitations.
- Guessing is 40 uniformly random bits, single use, one account.
-- Cap devices per user (5 by default). The countersigning key must not be revoked.
-- Every filing and every approval is an audit event. `member unpin <user>` removes **all**
- their devices; add `member device list|revoke` to the operator surface.
-
-### 4.7 What this does not fix
-
-**A device chain inherits the weakness of its weakest ancestor.** Whoever cracks a
-browser's keypair bundle (C4) can add a device — and that device **survives a passphrase
-change**. That is a genuinely new risk, not a relocation. It is answered by visibility,
-not by cryptography: audit events, devices listed to the operator and to the user, and
-unpin removing the lot.
-
-**Approval from a browser inherits T3.** A hub shipping malicious SPA code reads the code
-the user types into it and approves its own request. This is unchanged by anything here
-and is the reason Phase 13 exists. Its practical consequence: the **first** browser →
-native link is the moment of highest exposure for an account, and it happens once. Two
-free mitigations — the operator's invitation code remains available for anyone wanting
-out-of-band assurance, and an account created natively (§5) does the first link in the
-safe direction.
-
-### 4.8 Authorship: who said it, who uploaded it
-
-**Requirement added 2026-08-17.** Chat senders must be **cryptographically authenticated to
-each other**, and an upload must have an **identifiable owner** who can delete it, as the
-operator can. An earlier draft of this document recorded node-trusted attribution as an
-accepted limitation. It is now a requirement, and device linking both forces the work and
-supplies most of the machinery.
-
-#### A. Device linking breaks the existing owner-delete path — fix it in the same change
-
-```python
-# webrtc_server.py:1805-1817, _admin_exec_file_delete
-uploader_pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(entry.uploader_pk))
-if not (await self._verify_admin_sig(transcript, sig)
- or self._verify_sig(uploader_pk, transcript, sig)):
-```
-
-Authorization is against **the exact key that uploaded**. Alice uploads from her phone and
-cannot delete from her desktop. So:
-
-- Authorize against **any non-revoked device of `entry.uploader_id`** in the roster.
- `uploader_id` is already recorded beside `uploader_pk` (`_register_uploader`,
- `webrtc_server.py:1626`), so the data is there.
-- Keep `uploader_pk` — it stops being the authorization key and becomes the **audit**
- record of which device did it.
-- **This stays roster-rooted, not token-rooted**, which is the protection
- `per-node-identity-v1.md` added: a hub minting a token that claims to be Alice still
- holds no key the node pinned for Alice, so the signature fails. Whoever issues tokens
- still does not decide who may delete a file.
-- Ownership must survive device revocation — a revoked laptop's uploads keep their owner —
- and survive the account losing every device, where the operator remains able to delete.
-
-#### B. Make ownership provable, not merely recorded
-
-Today ownership is an assertion by the node. The uploader should **sign it**:
-
-```
-"meshbay:upload:v1" ‖ len‖node_pk ‖ len‖group_id ‖ len‖root ‖ len‖rel_path
- ‖ len‖content_hash ‖ len‖user_id ‖ len‖ts
-```
-
-signed by the uploading device's key and stored with the index entry. Then ownership is
-verifiable by any member rather than taken on the node's word, the delete authorization has
-a signed record behind it, and — combined with the no-overwrite rule — the C5a path where
-overwriting a file made the attacker its recorded uploader is closed twice over. The
-content hash already exists.
-
-Chat attachments are ordinary uploads and get the same record, which is one reason they
-land in the designated upload root (§6.7).
-
-#### C. Chat sender authentication — the constraint first
-
-**Operator constraint, 2026-08-17: no new code exchanges between people.** Verification
-rituals between users are refused in this context, and that single rule decides the shape.
-
-It costs almost nothing, because the complexity here is in the implementation, not in the
-experience:
-
-| | What the user does in addition |
-|---|---|
-| Tier 1 — sign each message with the device key | **nothing**, entirely invisible |
-| Tier 2 — pin `account → device keys` on first sight | **nothing** in normal operation; a notice only when a key changes |
-| Tier 3 — operator-signed roster attestation | **nothing**; the operator's key is already pinned |
-| ~~Safety numbers~~ | compare digits with every contact ← **the only thing that adds an exchange** |
-
-The device-linking code is not an exchange between people: it is a code between **your own
-devices**, and it was already accepted. Nothing new appears between users.
-
-**Decided:** ship Tiers 1 and 2. **Defer Tier 3** — it changes nothing a user sees and can
-land at any time; it only covers "the node is compromised but the operator's key is not",
-which is real but narrow, since in most deployments the operator *is* the machine.
-**Safety numbers are refused for this purpose**, permanently.
-
-Total user-visible cost of the whole authorship requirement: **one notice**, "this
-account's key changed". Everything else — signing, pinning, upload records, delete by
-account — is invisible. And pinning is not a new concept: it is the third application of
-the one already used for `pk_node` (11.5.8).
-
-#### C bis. The tiers themselves
-
-**Tier 1 — sign every message with the sender's device key.** Independent of Phase 15:
-signing is not encryption, so this can land first and should. It gains: no member can forge
-another member; someone holding a stolen chat database cannot *extend* it with messages
-that verify; the node can no longer silently rewrite history. Today nothing but the node's
-own enforcement (NS6) stands between a member and impersonation.
-
-**Tier 2 — clients pin `account → device keys` on first sight.** The evidence already
-exists: **device linking produces a countersignature** — a statement, signed by an
-already-pinned key, that a new key belongs to the same account. Relay those with the
-roster and a member who has pinned Alice's first device can verify her second without
-trusting the node. A node substituting a key for an account you have already seen is then
-**detected**, not merely improbable.
-
-**Tier 3 — close first sight. Deferred.** The operator signs a roster attestation
-(`account → devices`) and members pin the operator's key, which protects when the **node is
-compromised but the operator's key is not** — a seized machine, a hosting provider, a
-subverted daemon, the same threat Sender Keys was scoped for. Deferred because it changes
-nothing a user sees and can be added later without touching Tiers 1 and 2.
-
-Safety numbers were the other candidate. **Refused** under the no-new-code-exchanges
-constraint: Phase 12.1 dropped them for key *delivery* and left the door open for identity
-verification, and that door is now closed for chat.
-
-#### D. The boundary that remains, stated per the convention
-
-Against **the node operator at first contact**, attribution cannot be made unforgeable:
-they run admission, and a member who has never seen Alice has nothing to compare against.
-But the property is better than "the operator can always impersonate anyone":
-
-> Once a member's client has pinned Alice's device key, **an operator who turns malicious
-> later cannot forge Alice to that member.** Forgery is limited to accounts the victim has
-> never seen.
-
-That is a real property, it is the same shape as `pk_node` TOFU pinning (11.5.8), and it is
-worth stating precisely rather than rounding to either extreme.
-
-#### E. Where this lands
-
-A is a **regression introduced by device linking**, so it ships with Stage C, not later.
-B and Tier 1 are small and independent of Phase 15 — they should land in the same stage.
-Tier 2 belongs with Phase 15, whose threat delta it changes. Tier 3 is deferred with no
-date, and nothing depends on it.
-
----
-
-## 5. Account creation and authentication
-
-`POST /v1/users/register` (`api/users.py:100`) accepts `username` + `auth_key` **or**
-`password`, plus email; `auth_key` is PBKDF2-SHA512 600k derived client-side, so the hub
-never sees a passphrase (T1). There is **no** `POST /v1/users/auth` today — the old 13.3
-assumed one; it has to be written.
-
-**Decision E3 — hybrid:**
-
-1. Registration derives `auth_key` from a passphrase exactly as the browser does, using
- the existing endpoint. The account is browser-compatible from the first minute, and
- the passphrase is the account's **only recovery path** — a native-only account whose
- local keystore is lost would otherwise be dead.
-2. Day-to-day authentication uses a device Ed25519 key registered with the hub, on the
- pattern of `POST /v1/nodes/auth` (signed timestamp) which already exists for the
- daemon. The passphrase is entered at install and at recovery, and stored nowhere.
-3. Identity keys for nodes are generated locally and **never bundled anywhere**.
-
-`keyderive.py` already implements the browser's derivation in Python and
-`test_bundle_kdf_parity.py` holds the two byte-identical. That test now covers a third
-consumer; the standing warning applies — **never change those parameters in one place**.
-
-### 5.1 What this means for C4
-
-C4 closes for an account **only when no browser needs a bundle on that node**. Two
-consequences to state rather than blur:
-
-- The native device's private key is in no bundle anywhere, so an operator cannot attack
- it at all. That part is closed unconditionally.
-- As long as the account is also used from a browser, a bundle exists on each node and C4
- stays open for the browser identity — which is admitted to the same groups and reads the
- same content. **The native client raises the ceiling, not the floor.**
-- Closing it fully needs an opt-out: `device_policy {allow_bundle: false}`, **signed by a
- pinned key** so the decision is the user's and not the hub's. The node then deletes the
- stored bundle and refuses to store another for that user.
-
----
-
-## 6. Node management
-
-### 6.1 The observation
-
-The roster-pinned operator key is a **better authenticator than the loopback admin API's
-token**. The token is a bearer string printed at startup; the operator key is an Ed25519
-identity bound by a one-time code, signing structured transcripts with a 120 s TTL and
-channel binding. Moving operator actions from the loopback API to signed MNP ops is a
-**strengthening**, not a relaxation.
-
-Existing ops (`meshbay_common/adminop.py`): `file_delete`, `dir_delete`, `invite_create`,
-`member_revoke`.
-
-### 6.2 GEK rotation over MNP — and the line that stays
-
-Draft-v5 §5.1 says *"nothing arriving over MNP can **activate** a GEK"*. That rule targets
-C5b — **a member supplying key material** the node adopts. An operator-signed instruction
-where **the node generates the key with its own CSPRNG** is a different shape: nothing
-arrives from outside, and the node remains the sole producer of every copy of the GEK,
-which is the §5.5 property.
-
-So `gek_rotate` may be a signed MNP op. Blast radius is small: the same key already
-authorizes `member_revoke` and `dir_delete` remotely, and rotation is self-healing because
-wrapping happens per connection.
-
-**The initial `gek-init` is different** and stays local. Without a GEK, `join_result`
-answers `no_gek` and no MNP session completes — no session, no signed op. Putting it in
-the pre-proof window is possible but is the neighbourhood where C4 and C5b were born; it
-is deferred (§11), and it costs only the headless case which is deferred anyway.
-
-### 6.3 Scope
-
-| | Reachable remotely | Note |
-|---|---|---|
-| Invitations, revoke, unpin, devices, file/dir delete, roster, audit, peers | ✅ signed MNP | mostly present already |
-| **GEK rotation** | ✅ signed MNP | node generates; nobody supplies |
-| Attaching a further group, a bounded set of config keys | ✅ signed MNP, empty `group_id` | the precedent exists: `roster.get_member("", user_id)` (`webrtc_server.py:899`) already authorizes the operator node-wide |
-| **First `gek-init`** | ❌ local | §6.2 |
-| Starting/stopping the daemon | ❌ | that is systemd's job; if the daemon is down nothing is reachable anyway |
-
-The node's loopback admin API (20 endpoints in `ui/app.py`, including `groups/attach` and
-`groups/{id}/gek`) **is never exposed to the network**. C1 and C6 were both "a second path
-into the node with its own weaker handshake"; a LAN-exposed admin API with a second
-authorization model beside the roster is that shape exactly.
-
-### 6.4 The honest limit
-
-MNP between client and node is established through the **hub's WebRTC signaling**. On a
-LAN, ICE picks a local candidate so the data path stays local — but setup needs the hub.
-**A LAN with no internet cannot manage its node over MNP.** The answer is direct QUIC
-`group://` to a local address, i.e. the sidecar (§2.5). That is what makes the sidecar
-worth more than a nice-to-have: it is the hub-independent local path, and the one that
-survives a hub that is down or hostile.
-
-### 6.5 Several nodes
-
-The client keeps a local node registry — identifier, pinned `pk_node`, hosted groups,
-last seen. This is also where the 11.5.8 TOFU pin belongs, so design it once.
-
-### 6.6 One implementation, three adapters — and Phase 14 comes first
-
-**Phase 14 (node CLI) should be finished before 13.12.** Two reasons, and the second
-changes how.
-
-1. The CLI is the only interface that works in the states the desktop client cannot
- reach: daemon stopped, no GEK yet, no operator paired. Those are precisely the states
- E5 and O1 send back to the local machine. An incomplete CLI leaves bootstrap holes
- with no fallback. Remaining: 14.2 `group list`, 14.3 `group create`, 14.4 `group
- join`, 14.8 SIGHUP reload, 14.10 `denylist`, 14.11 `file rm`.
-2. **Parity is not achieved by writing the same thing twice.** The CLI calls the loopback
- admin API (`_daemon_api()` in `daemon.py`); 13.12 would add MNP handlers for the same
- operations. Two paths to one operation, with different authorization, is the shape of
- **C1 and C6**.
-
-So each operation should be factored into **one internal module** — `meshbay_node/ops.py`
-— with the CLI, the loopback API and the MNP handler as three thin adapters over it.
-Parity becomes structural rather than a discipline, authorization checks live in one
-place, tests have one target, and 13.12 becomes adapter code rather than a second
-implementation.
-
-The refactor costs less now, with six commands left, than after 13.12 exists.
-
-### 6.7 Several roots per group
-
-**Decided 2026-08-17.** A group's content is not one directory but **a set of named
-roots**, each mapping to a local directory. Names are unique within the group and a
-duplicate is refused. The group's virtual root is the union of them:
-
-```
-/ (group virtual root)
-├── Films/ → D:\Media\Films
-├── Musique/ → E:\Audio (external drive)
-└── Documents/ → C:\Users\me\Partage
-```
-
-`shared_dir` (one string per group, `config.py:103`) becomes a list of
-`{name, path, kind}` entries. A bare `shared_dir` in an existing config migrates to a
-single root.
-
-**Every index path gains a root segment**, uniformly — including in a single-root
-deployment, so there is one code path and not two. Consequences, all one-time and all
-worth accepting:
-
-- Existing deployments see every path change once (`/film.mkv` → `/Films/film.mkv`).
- Resume positions, keyed by path in `localStorage`, are invalidated. The alternative —
- leaving a lone root unprefixed — buys nothing and doubles the path logic forever.
-- **MNP messages do not change shape**, only the data they carry, so this is a MINOR bump
- (0.2 → 0.3) alongside the new `device_*` and `gek_rotate` messages, not a MAJOR one. An
- 0.2 client asking for a path that no longer exists gets a clean not-found, not a
- protocol error.
-- **The hub needs no change at all.** `SwarmSource` carries `content_hash`, `node_id` and
- `endpoint` — **no paths, no filenames** (`db/models.py:181`), and private groups
- register nothing (H7). Verified 2026-08-17. Multi-root is a node + protocol + UI change,
- end to end.
-
-**The name is the chosen directory's basename** (decided 2026-08-17), so adding a root is
-one gesture with no naming step. Four rules make that safe:
-
-- **Derived once, at add time, then stored.** Never recomputed from the path at startup —
- otherwise renaming `D:\Films` to `D:\Cinéma` on disk silently re-identifies the whole
- library and breaks every stored reference to it.
-- **A duplicate basename is refused**, compared **case-insensitively** (§6.8). Collisions
- will be common in practice: `D:\Films` and `E:\Films`, `C:\Users\me\Music` and
- `D:\Music`. Refusing is correct, but "rename your folder on disk" is a poor answer to
- give a user, so an explicit **alias** should be available as the escape hatch (O11).
-- **The basename becomes a path segment every member sees**, including Windows clients
- downloading. It must therefore pass the portability rules itself: a Linux user sharing
- `/home/me/AUX` would create a root Windows cannot write to (§6.8). Failing that check
- forces an alias.
-- **Uploads go to a root the operator designates.** Chat attachments land there too
- (draft-v5 §5.2). If that root is unavailable the upload **fails with a clear reason** and
- never silently falls back to another root; if none is designated, uploads are refused
- rather than guessed.
-
-The requirement comes from what is planned on top of the file explorer and chat — a
-video library, an audio library — which will not live in one folder on one disk. The
-unique-name rule is what keeps the union well defined: it removes the ambiguous case
-where the same relative path exists under two roots, which is the hard part of any union
-mount, and it should be enforced at config load **and** at selection time.
-
-What it costs, and none of it is optional:
-
-1. **The root name is part of a file's identity.** Every index path becomes
- `<root-name>/<relative-path>`. Renaming a root rewrites every path under it and churns
- the whole subtree of the index — so renaming is an explicit, warned operation, not a
- cosmetic setting.
-2. **No root may contain another.** `D:\Media` and `D:\Media\Films` mounted together
- would index the same bytes twice under two identities. Canonicalize each root and
- refuse a prefix relationship — **case-insensitively**, since on NTFS and exFAT
- `d:\media` and `D:\Media` are the same directory (§6.8).
-3. **Availability is per root, and this is the good news.** With one directory, an
- unplugged disk was a hazard. With named roots it becomes a supported state: `Musique/`
- goes unavailable, its subtree freezes, and `Films/` and `Documents/` carry on. The
- "unavailable, not deleted" state (§6.9) is therefore designed **per root** from the
- start, not retrofitted.
-4. **Free space, quotas and capacity are per root** — different volumes. Anything the UI
- says about space has to name which root it is talking about.
-5. **Path resolution must be per root.** A file request names `<root>/<path>`; the node
- resolves it against that root's canonical path and refuses anything escaping it. One
- implementation, in the indexer, not one per transport. The quarantine and no-overwrite
- rules (draft-v5 §5.2) are likewise per root.
-6. **Reserve `kind` on each root** (`generic` | `video` | `audio` | `photo`), unused in
- v1 — a default view hint, nothing more. See §6.10.
-
-### 6.8 Filesystem portability is a first-class constraint
-
-**Corrected 2026-08-17.** An earlier draft of this document filed exFAT and NTFS under
-"accessory". That was wrong: sharing from an external drive formatted exFAT or NTFS is
-expected to be the **common** case, not the exception, and Windows is expected to be the
-majority platform for both client and node (§7.5).
-
-So the index and the upload path must be correct on case-insensitive, permission-less
-filesystems — this is not a compatibility note, it is a correctness requirement.
-
-| Property | What it breaks | What has to be true |
-|---|---|---|
-| **Case-insensitive, case-preserving** (NTFS, exFAT) | `Film.mkv` and `film.mkv` cannot coexist; an index built on ext4 can be unrepresentable on NTFS | The index needs a defined canonical identity and a **case-folding collision check** at scan time, reported to the operator rather than resolved silently |
-| **No-overwrite on upload** (C5a) | Nothing — see the correction below | `Path.exists()` is case-insensitive on the filesystems where it matters, so the existing check already holds |
-| **Unicode normalization** | `Café.mkv` written on macOS (NFD) and on Windows (NFC) are different byte strings and compare unequal. For French filenames this is routine, not exotic | Normalize to NFC **for comparison and index identity**; preserve the original bytes for display and for opening the file |
-| **Reserved names and characters** (Windows) | `CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`, the characters `< > : " / \ | ? *`, trailing dots and spaces. A group indexed from a Linux node can contain names Windows cannot create | The client sanitizes on save and **tells the user it renamed something**; the upload allowlist should be the intersection across platforms, or some files are simply undownloadable on Windows |
-| **MAX_PATH 260** (Windows, unless long paths are enabled) | A deep media library exceeds it | Use `\\?\`-prefixed paths in the node and the client on Windows |
-| **Timestamp granularity** (FAT/exFAT: 2 s, local time, no timezone) | mtime as a change-detection fast path produces spurious rescans and, worse, missed changes across a DST shift | Do not trust mtime alone on exFAT — size + mtime with tolerance, and rehash when in doubt |
-| **No symlinks** | Nothing. This is a *simplification*: no traversal out of the root to defend against | — |
-| **No POSIX permissions or ownership** | Nothing, since the node runs as the user and access is all-or-nothing per volume | — |
-
-**Correction (2026-08-18, found while implementing).** An earlier version of this
-section called the no-overwrite check a security bug on case-insensitive
-filesystems. It is not: `_free_name` uses `Path.exists()`, and on NTFS and exFAT
-`stat()` is itself case-insensitive, so an upload of `readme.txt` beside an
-existing `README.TXT` already gets a free name rather than replacing it. C5a is
-not reachable through the filesystem. What case folding is actually needed for is
-**index identity, collision reporting and root names** — where a comparison the
-code performs itself, rather than one the OS performs, decides whether two names
-are the same file.
-
-Two watcher consequences, one per platform:
-
-- **Windows**: watchdog uses `ReadDirectoryChangesW`, whose buffer overflows under a burst
- of changes and **drops events silently**. A periodic reconciliation scan is not optional
- there; it is the only thing that recovers a missed event.
-- **Linux**: exFAT may be mounted through FUSE, where inotify does not reliably report
- changes made outside the FUSE mount. Same answer: reconcile periodically.
-
-### 6.9 A root that goes away must freeze, not empty
-
-`indexer/indexer.py` runs a watchdog `Observer` and rebuilds on any change. When a volume
-is unmounted, either watchdog emits deletions for the whole tree or the next scan sees an
-empty directory — both end with that content's index emptied and the deletions propagated
-as though the owner had erased their library.
-
-The node needs an explicit **per-root `unavailable` state** that freezes the subtree
-instead of emptying it, and surfaces it to members as "temporarily unavailable" rather
-than "gone". **This ships before root selection is offered.**
-
-Detecting it differs by platform, and neither method is exotic:
-
-- **Windows**: `WM_DEVICECHANGE` announces volume arrival and removal; the volume is
- identified by `\\?\Volume{GUID}\`, never by drive letter, which moves.
-- **Linux**: the mount table, plus `RequiresMountsFor=` on the unit so systemd orders the
- service after the mount. Note a Linux-only trap that has no Windows equivalent: a unit
- with `ProtectSystem` / `PrivateTmp` has its own mount namespace, so a volume mounted on
- the host **after** the service started is invisible inside it — the directory reads as
- empty even when everything else is configured correctly.
-
-### 6.10 The planned libraries are views, not a catalogue
-
-> **Revised 2026-08-23 for the Videos group app** — see `docs/mediacenter.md` §2.
-> The "no thumbnail store, ever" conclusion below (O12) was an extension by
-> analogy to draft-v5 §5.2, not a direct consequence of it. The Videos app
-> now caches thumbnails and TMDB metadata durably in the node's own
-> `data_dir` — still never inside a shared root, and still no per-file
-> identity beyond the file's own content hash. Everything else in this
-> section stands: views over the index, not a catalogue.
-
-**Scope settled 2026-08-17, and it is deliberately small.** The planned "video library"
-and "audio library" are **alternative views over the existing file index** — a variant of
-the explorer. Presentation rests on the filename and the directory structure, plus a
-thumbnail for video. There is **no catalogue, no metadata store, and no server-side
-database, now or later**, and nothing about them reaches the hub.
-
-An earlier draft of this section over-designed against a richer feature than the one
-wanted. Retracted: the `layout` field, the per-root scan policy, and "a file is not an
-item". The governing rule is the opposite of that last one, and simpler:
-
-> **A file stays tied to its representation on the filesystem.** The view shows files and
-> folders. Subdirectories are subdirectories — a "category", a "style", an "album" is
-> whatever the folder is called. There is no item abstraction to keep in sync, and moving
-> a file on disk makes it a different file, exactly as it does in the explorer today.
-
-#### What this means is already true
-
-| A view needs | Where it comes from | Work required |
-|---|---|---|
-| The whole group's file list, client-side | already cached in IndexedDB (10b.5), already searched client-side across groups (10b.6) | none |
-| "Is this a video / audio file" | file extension, decided in the client | none |
-| Folder hierarchy as categories or albums | the index already carries paths | none |
-| A subtitle beside a video | a same-named `.srt` is fetched like any other file; the video view can hide it from the grid and offer it as a track | presentation only |
-| A thumbnail for a video | the one thing that is not free — see below | one decision |
-
-So the answer to "is anything blocking?" is **almost nothing, and the architecture is
-already in the right shape**. Four properties have to survive, and all four hold today:
-the whole-group index is available client-side; files are identified by their path; there
-is no server-side catalogue to build; and the hub is not involved.
-
-#### The one open point: video thumbnails
-
-There will usually be no embedded cover art — these are raw files. So a thumbnail is a
-frame grab, which is genuinely derived data, and draft-v5 §5.2 forbids the node keeping a
-second copy of an image it already holds.
-
-**The resolution follows the rule already in force for chat thumbnails**, and it is the
-ridge line being looked for:
-
-> Enrichment happens **on the client**, from data it already has. Anything the client
-> cannot compute is produced **on demand by the node** and cached **on the device that
-> asked**. Neither the node nor the hub keeps durable derived state.
-
-Concretely: the node grabs a frame on request — it already decodes video for streaming, so
-this is strictly less than it does today, over the same authorized path (GEK proof, group
-membership, same transport) with no new capability and no new authorization surface. The
-client caches the result locally, per device, disposable, never leaving it.
-
-That keeps the node free of a thumbnail store, keeps §5.2 intact, and is what makes
-"no server-side state" affordable rather than merely principled.
-
-**One operational rule, and it is not optional.** A grid of fifty videos means fifty frame
-grabs. Thumbnails must run in **their own small bounded pool with a short timeout, never
-in the stream pool** (`[node] max_concurrent_streams`, default 8), where a slot is held
-for the length of a film — one grid view would otherwise exhaust every streaming slot on
-the node. And the pipe-draining rule from the streaming work applies unchanged: drain
-stdout, wait with a timeout, release the slot regardless. A thumbnail is a short ffmpeg
-run, which is exactly the shape that has deadlocked this codebase before.
-
-#### The mistake to refuse
-
-"Search across my whole media library" is the tempting one, and it would be a real
-regression: the hub stores **no file names for private groups** and registers content
-hashes for **public** groups only (H7). Cross-device resume is the same shape — CLAUDE.md
-pins it as *"nothing new learns what you watch"*. Any library feature that seems to want a
-server-side index wants the **node**, and most of the time wants nothing at all.
-
----
-
-## 7. Packaging and first run
-
-### 7.1 What already exists, and why it matters
-
-`packaging/systemd/meshbay-node.service` is a **per-user template** (`User=%i`,
-`WorkingDirectory=%h`, state under `~/.local/share/meshbay`, config under
-`~/.config/meshbay`), and `meshbay-node.spec` has **no `%post` scriptlets**.
-
-Consequence: in the standard case — client and node on the same machine — both run under
-the **same UID**. The client reads `data_dir/pair-code` and the loopback session token
-straight off the filesystem. No SSH, no polkit, no root. First `gek-init` and operator
-pairing happen from the client, locally, with no terminal.
-
-### 7.2 An ambiguity to settle
-
-As written, `User=%i` / `Group=%i` makes it a **system** template: `sudo systemctl enable
---now meshbay-node@alice`. A true user unit cannot carry `User=`. The in-file comment says
-"per-user service"; the directive says system. Ship **both**:
-
-| Unit | Enabled by | Persona |
-|---|---|---|
-| system template, `User=%i` | `sudo systemctl enable --now meshbay-node@alice` | server — an ordinary admin gesture, once |
-| user unit, no `User=` | `systemctl --user enable --now meshbay-node` — **no privilege** | desktop — the client does it itself |
-
-With `loginctl enable-linger` where the node must run with no session open.
-
-### 7.3 The firm rule: installation places files, never secrets
-
-No identity material in `%post` / `postinst` / MSI custom actions. Three reasons, the
-second being a trap:
-
-1. Fedora and Debian guidelines forbid interactivity in scriptlets and discourage state
- generation.
-2. **A package installed into a golden image or a container would give every machine the
- same key.** Known, severe, and silent.
-3. `%post` runs as root; the key would belong to root while the daemon runs as the user.
-
-Keys are generated at **first daemon start**, on that machine, under the identity running
-it.
-
-### 7.4 Sequence, with no terminal
-
-```
-install files + units. Non-interactive. No service force-started (respect presets).
-client 1st run no local node detected → "install a node here?"
- → systemctl --user enable --now meshbay-node (no privilege)
-daemon 1st run generates its Ed25519 pair into the keystore, creates data_dir,
- writes the per-run session token (11.5.3)
-wizard the client reads the token (same UID) and walks through, on loopback:
- link the node to the hub account · create or attach a group ·
- gek-init · operator pair
-thereafter everything over signed MNP, local or remote
-```
-
-A shared directory outside the user's home needs `ReadWritePaths=` extended — the unit
-already says so. The client writes a drop-in at
-`~/.config/systemd/user/meshbay-node.service.d/paths.conf` rather than weakening
-`ProtectSystem=strict`.
-
-### 7.5 Windows
-
-**Corrected 2026-08-17.** Windows ships *after* Linux, but it is expected to be where
-**most users run both the client and the node**. Build order and population are not the
-same thing: nothing in the design may assume POSIX, systemd, or a case-sensitive
-filesystem, even while Linux is the only target that has shipped. Everything in §7.1–§7.4
-is Linux-specific *hardening*; it is not the model.
-
-| | Linux | Windows |
-|---|---|---|
-| Autostart | `systemctl --user enable --now` | logon task (Task Scheduler) or a Startup entry |
-| State | `~/.local/share/meshbay` | `%LOCALAPPDATA%` |
-| Key storage | `safeStorage` → libsecret / kwallet | `safeStorage` → DPAPI, bound to the user account |
-| Install | DEB / RPM, per-user unit, no privilege | **MSI per-user, no administrator rights** |
-| Sandbox around the node | `ProtectSystem=strict`, `ReadWritePaths=`, mount namespace | **none** — the node is an ordinary user process |
-| Volume identity | filesystem UUID | `\\?\Volume{GUID}\`, never a drive letter |
-| Removable media events | mount table + `RequiresMountsFor=` | `WM_DEVICECHANGE` |
-
-The sandbox row is an asymmetry worth stating rather than papering over: on Linux the node
-is confined by the unit, on Windows it has whatever the user has. That is a real
-difference in exposure, and it is inherent to the platform, not to this design.
-
-Three Windows-specific facts that shape the product, not just the packaging:
-
-1. **A per-user node stops when the user logs off**, and there is no equivalent of
- `loginctl enable-linger`. **Decided 2026-08-17: the installer offers a service mode**,
- as a deliberate, administrator-elevated choice, alongside the per-user default. Three
- consequences:
- - **Not `LOCAL SYSTEM`.** A service running as SYSTEM would have *more* privilege than
- its owner, which inverts least privilege. Use a **dedicated low-privilege local
- account**.
- - **Granting access to each root becomes an explicit step.** The user picks a folder
- from their own session; the service account must then be granted read access to it.
- This belongs in the root-selection flow, not in a troubleshooting page.
- - **`safeStorage`/DPAPI is bound to a user account**, so the client (per-user) and a
- node running as a service cannot share a DPAPI-protected secret. This is not a
- problem: the node already has its own keystore scheme (`keystore.py`, Argon2id plus
- an unlock key) and keeps it. The separation exists; it just has to be stated.
- - In per-user mode the node is **available only while its owner is logged in**. That
- must be visible in the UI and honest in the docs — availability is the entire point
- of hosting content.
-2. **The firewall prompt.** A node listening for QUIC needs an exception. A per-user MSI
- cannot create firewall rules without elevation, so the first run raises the Windows
- Firewall dialog, and allowing it on a public network needs administrator approval. The
- first-run wizard has to expect this and explain it, rather than leaving the user with a
- node that silently accepts no connections.
-3. **SmartScreen.** An unsigned or newly-signed MSI is flagged until reputation
- accumulates. Authenticode signing is a cost item and belongs with 13.9, and its absence
- is felt on Windows far more than the absence of a GPG signature is felt on Linux.
-
-Same rule on secrets as everywhere else: **no key generation in an MSI custom action.** A
-silent or imaged install has exactly the shared-key problem `%post` has.
-
-### 7.6 Android, later
-
-Phase 16 reuses this design. One structural difference is worth knowing now, stated
-without jargon:
-
-**An Android app cannot freely read the phone's folders.** It gets its own private
-directory, and for anything else the user must point at a folder in a system dialog; the
-app then has access to that one tree, and that access can be revoked.
-
-A node has to read a directory tree continuously, watch it for changes, and serve files
-around the clock. Android is hostile to all three: there is no reliable filesystem
-watching through that mechanism, background processes are killed aggressively, and a
-long-lived listening socket is not guaranteed.
-
-So: **an Android client, yes; an Android node hosting a library, no** — and nothing in the
-root model (§6.7) should assume otherwise. This is a "do not assume", not a permanent
-prohibition.
-
-### 7.7 Updates (priority 3)
-
-Prefer the **signed apt/dnf repository** (18.5) over a bespoke updater: the OS verifies
-the signature, the key was installed out of band at install time, and `electron-updater`
-handles deb/rpm poorly anyway. Windows later: `electron-updater` plus Authenticode.
-
-An updater is an injection channel. Without a release key whose fingerprint is published
-somewhere the hub does not control, it *is* the new T3.
-
----
-
-## 8. The web tier
-
-### 8.1 URL-space contract
-
-| Space | Served by | Seen by the native client |
-|---|---|---|
-| `/`, `/about`, `/downloads`, `/news` | Caddy, `site/`, static, public | ❌ never |
-| `/app/*` | hub (SPA) | ❌ never |
-| `/v1/*`, signaling WS | hub (API) | ✅ only this |
-
-Testable, and worth a test: **the native client issues no request outside `/v1/` and the
-signaling socket**. That recovers the intent of the route-inventory blindness test dropped
-from Phase 12.
-
-### 8.2 Announcements
-
-Two channels, no third:
-
-1. **Existing notifications** (`api/notifications.py`, Phase 10.5) for anything aimed at a
- signed-in user. Already reaches web and native. Add an admin-broadcast type.
-2. **`GET /v1/hub/announcements`** — a small public JSON list (id, date, severity, title,
- body, locale) read by the landing page *and* by the native client. One source, one tab
- in the existing admin UI, no CMS.
-
-Two guards, because this lets the hub display text **inside** the native app, which is a
-phishing surface:
-
-- plain text or a very restricted markdown subset, **never HTML**, never `innerHTML`;
-- a visually distinct region labelled as a message from the hub operator, never a modal
- that can imitate app UI, no in-app link targets.
-
-### 8.3 The download page is a security page
-
-`downloads.html` is where the release key fingerprint is published — and a hostile hub
-serves that page too. Publish the fingerprint somewhere the hub does not control as well
-(repository README, a signed tag, ideally a third channel). Otherwise the relocation of
-trust described in `tmp-decisions.md` is circular.
-
----
-
-## 9. Security: what changes, what does not
-
-| Claim | Passive hub | Active hub | Malicious node operator | Malicious member |
-|---|---|---|---|---|
-| Client code integrity | ✅ ships in the package | ⚠️ **detectable, not prevented** — realised by 18.7 + published hashes, not by the packaging format | ✅ | ✅ |
-| The hub can read content by shipping malicious code (**T3**) | ✅ removed for native | ✅ removed for native; **unchanged for the browser** | — | — |
-| Device linking grants the hub authority | ✅ no | ✅ **no** — the countersignature is a key the hub has never held | ⚠️ a node can only add a device to itself, where it already reads everything | ✅ no |
-| Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ still open for any account that also uses a browser (§5.1) | — |
-| Hub knows memberships, who spoke where and when | ❌ unchanged | ❌ unchanged | — | — |
-| Hub can deny service (it relays signaling) | ❌ unchanged | ❌ unchanged; `group://` over QUIC is the way out | — | — |
-| New surface | — | update channel, Chromium CVEs, sidecar loopback, node-admin panel in-process | — | — |
-
-**The claim this design supports:** *the hub's remaining power over a native client's
-confidentiality is the artifact it ships, and an artifact can be hashed and compared.*
-
-**The claim it must not make:** that the native client makes the hub untrusted. It does
-not, and a build signed with a key the hub operator holds relocates trust rather than
-removing it. That value is realised by **18.7 reproducible builds** — for which this
-project is unusually well placed, since the UI has no bundler or minifier and Electron's
-own binaries have published upstream hashes.
-
-**And the sentence to repeat in user-facing docs:** an account used from a browser is only
-as strong as the browser path.
-
----
-
-## 10. Execution order
-
-The desktop client comes **last**, and deliberately so: everything before it is work the
-web SPA needs anyway, and shipping the client on top of a path model that is about to
-change would mean shipping the change twice, into an installed base, with version skew
-(§2.6) making it expensive.
-
-### Stage A — content model (node + protocol + UI, no client work)
-
-Everything in this stage is delivered to today's web SPA, and the hub is untouched.
-
-| # | Component | Prio |
-|---|---|---|
-| A1 | **Filesystem portability** in the indexer and upload path — case folding, NFC, reserved names, `\\?\`, reconciliation scans. **The case-folded no-overwrite check is a security fix** (§6.8) | 1 |
-| A2 | **Per-root `unavailable` state** — freeze the subtree, never empty it (§6.9) | 1 |
-| A3 | **Named roots per group** — `shared_dir` → `[{name, path, kind}]`, basename naming, no nesting, designated upload root, root segment in every path (§6.7) | 1 |
-| A4 | **MNP 0.2 → 0.3** and the SPA updated for root-aware paths | 1 |
-
-A1 before A3: the collision rules have to be right before roots multiply the paths that
-can collide. A2 before A3 is not strictly required but avoids a window where an unplugged
-disk is destructive.
-
-### Stage B — operator surface (node, no client work)
-
-| # | Component | Prio |
-|---|---|---|
-| B1 | ✅ **DONE 2026-08-18** — `group list`, `gek init\|rotate`, `reload` (SIGHUP), `denylist show\|clear`, `file list\|rm`. **No operator action requires a browser any more.** `group create` and `group join` are not built: creating a group on the hub is a browser or API action and `group add` already hosts one | 1 |
-| B2 | ✅ **DONE 2026-08-18** — `meshbay_node/ops.py`. The loopback API is one `_op(...)` line per endpoint; MNP handlers call the same functions. `test_ops.py` asserts the shape rather than trusting it | 1 |
-| B3 | ✅ **DONE 2026-08-18** for `gek_rotate` and `member_unpin`, both operator-signed. Root and device management deferred: device management needs Stage C, and root management needs writing into a hand-written TOML block, which `ops.add_root` validates and then refuses with the exact lines to paste | 1 |
-
-B2 is what stops B3 from becoming a second implementation. Doing B1 without B2 means
-refactoring the same six commands twice.
-
-### Stage C — identity, which the client cannot ship without
-
-| # | Component | Prio |
-|---|---|---|
-| C1 | ✅ **DONE 2026-08-18** — `identities` keyed by (user_id, pk_ed25519) with a rebuild migration that preserves existing pins, `device_requests`, both transcripts in `meshbay_common/device.py`, handlers and audit events | 1 |
-| C2 | ✅ **DONE 2026-08-18** — `POST /v1/users/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration. *The only hub change in the whole plan* | 1 |
-| C3 | ✅ **DONE 2026-08-18** — `client.minimum` and `client.recommended` in `GET /v1/hub/version` | 1 |
-| C4 | ✅ **DONE 2026-08-18** — the `unknown_device` refusal offers a linking code; the Members panel lists devices, approves by code and retires one | 1 |
-
-**One correction the implementation forced.** §4.3 has the approver look a request
-up by its hash — which is circular, because computing that hash needs the keys
-being asked about. What shipped: the node lists this account's pending requests
-**with their stored hashes**, and the client recomputes `sha256(code ‖ keys)` for
-each and keeps the match. The code never reaches the node, which is what makes
-substitution impossible: a node offering fabricated keys would have to produce a
-hash over a code it has never seen.
-
-C1 lands in the SPA first, where both ends of a link can be exercised without a desktop
-build existing.
-
-### Stage D — the desktop client
-
-| # | Component | Prio |
-|---|---|---|
-| D1 | ✅ **DONE 2026-08-18** — `static/platform.js`; `HUB` is `platform.hubBase()` and the transport is built with the same base. Browser behaviour identical, which was the acceptance criterion | 1 |
-| D2 | ✅ **RUNS** (2026-08-18, Electron 42 / Chromium 148 under xvfb). The packaged interface mounts over `app://`, secure context, `crypto.subtle` present, Argon2 WASM loaded, zero console errors. Three things were learned by running it — see §3.1 |
-| D3 | ✅ **DONE, verified on a real desktop** (Ubuntu 24.04 GNOME, 2026-08-18). Settings reports `gnome-libsecret`, and `secrets.bin` is written 0600 with Chromium's **`v11`** prefix — the marker for keyring-backed encryption; the fixed-key fallback writes `v10`. The key name does not appear in clear. On a headless session the same code reports `unavailable` and **refuses to store**, which is the honest outcome and is now explained in Settings rather than left as a bare word |
-| D4 | ✅ **DONE 2026-08-18, verified against a hub running this code** — first-run hub prompt (no default, on purpose), passphrase sign-in registers this device, later launches sign in with the device key and no passphrase. **The renderer never holds that key**: it is generated, stored and used entirely in the main process, which signs on request — the same rule as the save dialog, because the renderer is the part that parses hostile input. Measured: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → a stranger's key 401. **Verified end to end on a real desktop** (Ubuntu 24.04 GNOME, 2026-08-18): the application was quit and relaunched, and signed in **without asking for the passphrase**. safeStorage really uses the keyring — `secrets.bin` carries Chromium's `v11` prefix, the marker for keyring-backed encryption | 1 |
-| D5 | Node management panel over the Stage-B ops, root selection included | 2 |
-| D6 | First-run wizard — detect, enable the unit, link, group, `gek-init`, pair (§7.4) | 2 |
-| D7 | ✅ **DONE 2026-08-18, and it was not optional** — the two browser paths are both absent in the application (`showDirectoryPicker` does not exist, and Chromium refuses a service worker on a custom scheme), so the chain fell through to its floor: **the whole file collected in the page and handed over as a blob**. A gigabyte of film meant a gigabyte of RAM, and a Save As dialog at the *end* — which is how the operator noticed. The main process now streams to disk, honours "save automatically" with a remembered folder and no dialog, never overwrites, applies backpressure, and unlinks a cancelled download rather than leaving a truncated file. Measured: the file on disk grows 256 KB → 512 KB → 768 KB → 1 MB as chunks arrive |
-| D8 | ◐ **PARTIAL** — `electron-builder` configured for deb and rpm, and `meshbay-node-user.service` added. **Found doing it:** the spec installed the *system* template (`User=%i`) into the user unit directory, where systemd refuses the file — the packaged unit could never have started. Fixed, with `test_packaging_units.py`. Nothing has been built: no npm, no rpmbuild here | 1 |
-| D9 | Python sidecar — `group://` over QUIC | 2 |
-| D10 | Video thumbnails, own bounded pool (§6.10) | 2 |
-| D11 | Windows: MSI per-user, service mode offered, firewall and SmartScreen handled (§7.5) | 3 |
-| D12 | Release key, signed apt/dnf repository, updates via the OS | 3 |
-| ~~D13~~ | ~~Safety-number UI~~ — **dropped** (decision 19): no new code or fingerprint exchanges between people. Chat authentication is served by Tiers 1 and 2 (§4.8), which cost the user nothing | — |
-
-### Deleted from the old Phase 13
-
-`13.4` aiortc client transport · `13.5` SQLite index cache · `13.6` loopback media server —
-Chromium provides all three. `13.11` "retire the SPA?" is settled by E6.
-
-Deletions enabled once native is the recommended client are unchanged from
-`devel-phases-next.md`, **except** that `transport.js`, `crypto.js`, `keyderive.js`,
-`downloads.js` and `sw.js` are now *kept* — they are the client, not browser workarounds.
-
----
-
-## 10b. Group settings, and where they live
-
-The group tab that was "Members" is **"Settings"**, and it is where anything about the
-group is administered: invitations, operator pairing, your devices on this node, whether
-members may upload, leaving or deleting the group, and the roster — last, because it is
-the only part with no upper bound.
-
-Two rules came out of building it and both are easy to undo by accident:
-
-- **The tab bar does not wait for the node.** Membership is hub-side. Gating the tab on a
- live connection makes "leave this group" unreachable exactly when a node is down, which
- is when someone most wants it. Sections that do need the node say so.
-- **A setting that governs the node is stored on the node and enforced by the node.**
- `member_upload` is the worked example: signed operator instruction, row in `roster.db`,
- refusal in `_do_file_upload`. Hiding the Upload button and the chat paperclip is a
- courtesy to people who are not trying; it is not the control. See draft-v6 §2.1b.
-
-## 11. Open items, each needing its own review
-
-| # | Item | Why it is not decided here |
-|---|---|---|
-| O1 | `gek-init` in the pre-proof window | Would let a headless node be initialised remotely. It runs before the GEK proof, which is the neighbourhood C4 and C5b came from. Deferred with E5 |
-| O2 | LAN enrolment door | One endpoint, bounded window, one-time code, closes permanently on success. Small but it executes before any authentication |
-| O3 | `device_policy {allow_bundle: false}` | The mechanism that actually closes C4 (§5.1). Needs to be signed by a pinned key, never settable by the hub |
-| O4 | Node-admin panel isolation | Node-supplied strings (filenames, hub-originated usernames) rendered in a process holding the user's keys. H2 was exactly this. Separate window or partition at minimum |
-| O5 | `MESHBAY_UNLOCK_KEY` in `node.env` | Still open for the **node**. For the **client**, the OS keychain path is proven: `safeStorage` on a real GNOME desktop uses the keyring (`v11`), and refuses rather than downgrading where there is none |
-| O6 | Electron version floor | X25519 and Ed25519 in WebCrypto must be verified on the pinned version, not assumed |
-| ~~O7~~ | Several directories in one group | **Decided 2026-08-17** — named roots, unique names, union root. See §6.7 |
-| O8 | Minimum client version in `GET /v1/hub/version` | Needed before the first public package (§2.6). Trivial now, awkward once clients are in the wild |
-| ~~O9~~ | Windows node availability | **Decided 2026-08-17** — the installer offers a service mode under a dedicated low-privilege account, alongside the per-user default (§7.5) |
-| O10 | Canonical file identity across filesystems | Case folding and NFC normalization decide when two names are "the same file". It affects the index, the no-overwrite rule and cross-platform replication, so it must be defined once and shared (§6.8) |
-| O11 | Root alias when the basename cannot be used | Duplicate basenames across volumes will be common, and a basename can fail the portability rules. Refusing is correct; "rename the folder on your disk" is a poor answer, so an explicit alias is the escape hatch (§6.7) |
-| O13 | Hub identity pinning | The client points at a hub by URL and nothing pins that hub's identity. `GET /v1/hub/pubkey` exists; pinning it on first use — as `pk_node` already is (11.5.8) — would stop an account's hub being silently swapped. Bounded today, because a substituted hub cannot read content and no longer ships the code; worth doing all the same |
-| ~~O12~~ | Derived thumbnails vs draft-v5 §5.2 | **Resolved 2026-08-17, revised 2026-08-23** — frame grab produced on demand by the node, cached on the requesting device. The node keeps no thumbnail store, so §5.2 stands unchanged (§6.10). **Revision:** for the Videos group app, the node now also caches thumbnails and TMDB metadata durably in its own `data_dir` (not a shared root) — see `docs/mediacenter.md` §2 for the reasoning (TMDB quota is per-node not per-device; thin clients benefit from a node that does more) |
-
----
-
-## 12. Decisions record
-
-Settled with the operator on 2026-08-17:
-
-| # | Question | Decision |
-|---|---|---|
-| 1 | Shell | Electron + optional Python sidecar. Structural decision 18 (pywebview) is reversed |
-| 2 | Device approval | Pending request on the node, bound by a one-time code generated and displayed by the new device. No human digit comparison on the critical path |
-| 3 | Device request TTL | **1 h**, configurable. Made a comfort setting rather than a security parameter by the code binding |
-| 4 | Account creation | Hybrid: passphrase-derived `auth_key` at registration, device Ed25519 key thereafter |
-| 5 | Node management | Signed MNP ops; the loopback admin API is never exposed to the network |
-| 6 | First run and initial `gek-init` | Local, on the machine hosting the node |
-| 7 | LAN enrolment of a headless node | Out of scope for v1, kept implementable (O2) |
-| 8 | Sequencing | **Phase 14 (node CLI) before 13.12**, factored into one `ops.py` with the CLI, the loopback API and the MNP handler as adapters (§6.6) |
-| 9 | Directory selection, removable media included | Allowed, and **gated on the per-root "unavailable" state** shipping first (§6.9) |
-| 10 | One UI source | `meshbay_hub/static/` copied into the desktop package at build time, never forked; native-only features gated by capability (§2.7) |
-| 11 | **Several roots per group** | Named roots, unique names, union virtual root, no nesting, `kind` reserved for the planned libraries (§6.7) |
-| 12 | **exFAT/NTFS and Windows are the common case** | Not an edge case to accommodate later. The index and the upload path must be correct on case-insensitive, permission-less filesystems, and no design may assume POSIX or systemd (§6.8, §7.5) |
-| 13 | Root naming | The chosen directory's **basename**, derived once at add time and stored. Duplicates refused, case-insensitively. The operator designates the upload root (§6.7) |
-| 14 | Windows node availability | The installer **offers a service mode** under a dedicated low-privilege account; per-user remains the default (§7.5) |
-| 15 | Future libraries | **Views over the existing index**, not a catalogue. No metadata store, no server-side database, ever. A file stays tied to its representation on the filesystem. Reserve `kind` as a view hint; `layout` and a scan policy were over-design and are retracted (§6.10) |
-| 16 | Where enrichment happens | **On the client, from data it already has.** What it cannot compute, the node produces **on demand** and the requesting device caches. Neither node nor hub keeps durable derived state — the rule already in force for chat thumbnails (draft-v5 §5.2) |
-| 17 | **Where group-related server state lives** | **On the node, always** (E9). The hub gains no row from anything in this document. Verified for multi-root: `SwarmSource` carries hashes and endpoints, no paths |
-| 19 | **No new code exchanges between people** | Verification rituals between users are refused. Safety numbers are out for chat authentication, permanently. The device-linking code is between a user's **own** devices and is unaffected. Total user-visible cost of the authorship work: one "this account's key changed" notice (§4.8) |
-| 18 | Execution order | Content model → operator surface → identity → client (§10). The client is last because everything before it is owed to the web SPA anyway, and shipping it first would mean shipping the path change twice into an installed base |
-
-Item 3 is a default for v1, not a constraint. Items 1, 2, 4, 5, 8, 9, 11, 12, 17 and 18
-are structural and should not be revisited without revisiting this document.
diff --git a/docs/devel-phases-next.md b/docs/devel-phases-next.md
deleted file mode 100644
index c00143a..0000000
--- a/docs/devel-phases-next.md
+++ /dev/null
@@ -1,1388 +0,0 @@
-# MeshBay — Next Implementation Phases
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the implementation roadmap; its design
-> content now lives in §14.1 (structural decisions), §15 (state of the build).
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Base: Phases 1–12 complete (except 10.9 → Phase 18). Web SPA + admin panel + self-service UI + MSE video streaming live on meshbay.org. Node daemon is production-ready (WebRTC, WS, chat, HTTP, index push, swarm all wired).
-> Architecture reference: **meshbay-draft-v6.md** (2026-08-17; v5 remains
-> authoritative for everything v6 does not restate, v4 superseded 2026-08-13).
-> Desktop client, roots, device linking: **desktop-client-v1.md**
-> First security review: first-review.md (2026-08-10)
-> **Second security review: second-review.md (2026-08-13) — 6 critical, 7 high findings.**
->
-> ⛔ **Phase 11.5 is BLOCKING.** No feature phase starts until C1–C6 and H1–H7 are closed.
-> The current build must not host real private data: the node's HTTP API serves private
-> group content unauthenticated (C1), any user can hijack a node's signaling identity (C2),
-> and an active hub can obtain any group key through the key directory it controls (H3).
->
-> **Phases renumbered 2026-08-13** (old → new): 12→14, 13→15, 14→16, 15→17, 16→18, 17→19.
-> New: 11.5 (security remediation), 12 (client key verification — reworked 2026-08-13,
-> hub minimization deferred by operator decision), 13 (native desktop client).
-
----
-
-## Phase 7 — Node v2 : production, streaming, chat ✅ DONE
-
-Commit: fc56585 — 26 files, +2155/−159 lines, 109 tests.
-
-| # | Component | Status |
-|---|---|---|
-| 7.0 | JWT group claims + node authz check | ✅ |
-| 7.1 | QUIC 0-RTT session resumption | ✅ |
-| 7.2 | Signaling `client_incoming`/`punch_ready` + jti denylist push | ✅ |
-| 7.3 | Multi-group daemon (1-port multiplexing) | ✅ |
-| 7.4 | HLS streaming via QUIC | ✅ |
-| 7.5 | Chat: Sender Keys protocol + storage + MNP wire | ✅ |
-| 7.6 | Chat: local web UI + WS push to members | ✅ |
-
----
-
-## Phase 8 — Hub v2: admin, federation, security ✅ DONE
-
-Commit: 46918ec — 20 files, +508/−90 lines, 117 tests.
-Deployed to meshbay.org. Existing emails encrypted. DB schema migrated.
-
-| # | Component | Status |
-|---|---|---|
-| 8.1 | Admin roles — config-based `require_admin` | ✅ S1 resolved |
-| 8.2 | Email encrypted at rest — AES-256-GCM, HKDF | ✅ S2 resolved |
-| 8.3 | Refresh token rotation — family-based reuse detection | ✅ S5 resolved |
-| 8.4 | Federation DB persistence (HubPeer model) | ✅ |
-| 8.5 | Federation token verification async (DB-backed) | ✅ |
-| 8.6 | CSAM hash check in swarm registration | ✅ |
-| 8.7 | Rate limiting on auth endpoints (5/10/20 per min) | ✅ |
-| 8.8 | Healthcheck endpoint (GET /v1/health) | ✅ |
-| 8.9 | IP log cleanup background task (365-day retention) | ✅ |
-| 8.10 | Argon2id bumped to 256 MB (pw_version=2, rehash on login) | ✅ |
-
----
-
-## Phase 9 — Web client: WebRTC transport + core SPA ✅ DONE
-
-Commit: ab4d389 — 27 files, +3053/−330 lines, 132 tests.
-Deployed to meshbay.org + Orange node. Tested browser → node P2P through two ISP NATs.
-
-**Objective:** a web browser can connect P2P to a node behind residential NAT,
-browse files, download, stream video, and chat — with zero data through the hub.
-
-**Architecture decisions (settled 2026-08-10):**
-
-### Transport: WebRTC DataChannel for browsers
-
-Native clients (desktop, Android) use QUIC with `punch_nat()` — already validated
-in demo-v2 on SFR residential (Port-Restricted Cone NAT).
-
-Browsers cannot use QUIC for NAT traversal because WebTransport does not allow
-the browser to choose its UDP source port. Port-Restricted Cone NAT requires the
-client to connect from the exact port the node probed — impossible for browsers.
-
-**Solution:** WebRTC DataChannel with ICE/STUN. The browser's built-in WebRTC
-stack handles NAT traversal automatically. The node uses `aiortc` (same author as
-`aioquic`, already referenced in draft-v3 as [future]).
-
-ICE is strictly superior to our custom `punch_nat()` for this use case:
-- Both sides send STUN binding requests simultaneously → mutual hole-punching
-- No need for the client to pre-announce its port
-- Handles both sides behind NAT
-- Battle-tested by billions of users (Google Meet, Discord, etc.)
-
-The MNP protocol (handshake, file_request, file_chunk, chat_message, etc.) runs
-identically over WebRTC DataChannel as over QUIC streams. Same E2E encryption.
-
-**Node dual transport:**
-- QUIC (port 19000) — native clients, already in place
-- WebRTC DataChannel — browsers, using `aiortc`
-
-### Signaling: hub WebSocket relay
-
-The hub relays WebRTC signaling (SDP offer/answer, ICE candidates) between
-browser and node. This is the same role described in draft-v3 section 4.1.3:
-"NAT traversal coordination [...] stateless [...] <1 KB per message."
-
-```
-Browser → Hub (HTTPS) : POST /v1/nodes/{id}/webrtc/offer {sdp, ice_candidates}
-Hub → Node (WS) : {type: "webrtc_offer", sdp, ice_candidates, peer_id}
-Node → Hub (WS) : {type: "webrtc_answer", sdp, ice_candidates, peer_id}
-Hub → Browser (SSE) : {sdp, ice_candidates}
-```
-
-After signaling, the DataChannel is P2P. Hub is no longer involved.
-
-### UI: Preact SPA
-
-- **Framework:** Preact (~3 KB gzipped) + preact-router
-- **Build:** esbuild (single binary, no node_modules bloat) for minification
-- **Theming:** CSS `prefers-color-scheme` + localStorage toggle (dark/light)
-- **i18n:** JSON translation files loaded client-side, English default
-- **Responsive:** sidebar collapses to hamburger on mobile viewports
-- **Crypto:** existing `crypto.js` (SubtleCrypto AES-GCM) for E2E decryption
-
-### Hub role (reminder — fundamental constraint)
-
-The hub is a registrar and signaling facilitator. It stores ONLY:
-- User accounts (login, encrypted email, public keys, keypair bundle)
-- Group metadata (name, admin, members, GEK bundles — no file indexes)
-- Node registrations (endpoint hints, public keys)
-
-All data (files, streams, chat messages, directory indexes) lives on mesh nodes.
-Clients (web or native) transfer data E2E with nodes. The hub never touches
-content. This is non-negotiable.
-
-### Chat/forum storage
-
-Chat messages are stored on the node(s) hosting the group, not on the hub.
-The browser retrieves chat history from the node via DataChannel, same as files.
-If no node in the group is online, the group (including chat) is unavailable.
-This is inherent to the P2P model and acceptable.
-
-### File search
-
-Content is not indexed on the hub. Search works client-side:
-- Node provides a Mesh Group Index (file metadata: names, paths, sizes, hashes)
-- For private groups, the index is GEK-encrypted — hub stores it opaque, client decrypts
-- Browser caches decrypted indexes in IndexedDB (~50–100 MB quota, extensible)
-- Search runs locally on cached indexes — instant, no network call, no hub involvement
-
-### Milestones
-
-| # | Component | Files | Priority |
-|---|---|---|---|
-| 9.1 | **Spike: WebRTC DataChannel on node** | `aiortc` integration, 4 tests (handshake, file transfer, auth, guard) | ✅ |
-| 9.2 | WebRTC signaling endpoints on hub | `hub/api/signaling.py` — relay SDP/ICE, 2 tests | ✅ |
-| 9.3 | WebRTC→MNP transport adapter on node | `node/transport/webrtc_server.py` + hub_client WebRTC handler | ✅ |
-| 9.4 | `transport.js` — browser WebRTC client | `static/transport.js` — connect, handshake, fetch, msgpack | ✅ |
-| 9.5 | **Spike: E2E browser→NAT→node file transfer** | Mobile 4G → SFR NAT → node, IPv4 STUN + IPv6 validated | ✅ |
-| 9.6 | Preact SPA shell (login, routing, theme) | `static/app.js`, `static/style.css`, `static/vendor/htm-preact.js` | ✅ |
-| 9.7 | Group list + file explorer UI | `app.js` GroupPage, `groups.py` nodes endpoint, `revocation.py` group tracking | ✅ |
-| 9.8 | File download via DataChannel | AES-GCM chunks, GEK delivery, progress bar, browser download | ✅ |
-| 9.9 | Video streaming via DataChannel | Chunk download → Blob URL, video overlay with native controls | ✅ |
-| 9.10 | Chat/forum UI via DataChannel | ChatPanel component, chat history MNP, peer broadcast, tabs UI | ✅ |
-| 9.11 | i18n framework + English strings | `static/i18n.js` — t() lookup, ESM, localStorage lang, all strings extracted | ✅ |
-| 9.12 | Settings UI (profile, theme, language) | SettingsPage component, system theme support, sidebar link | ✅ |
-| 9.13 | Tests: unit + integration | WebRTC transport, MNP over DataChannel | ✅ |
-| 9.14 | Performance: pipelined download | sliding window (8 concurrent chunks) | ✅ |
-| 9.15 | Performance: binary wire format | raw bytes via msgpack, no base64 (+33%) | ✅ |
-| 9.16 | Performance: avoid redundant I/O | file_hash from index, not re-read per chunk | ✅ |
-| 9.17 | Large file download to disk | File System Access API (`showSaveFilePicker`) | ✅ |
-
-**Critical path validated (2026-08-10):** 9.1 → 9.5 all pass. WebRTC DataChannel
-works browser → node through two different ISP residential NATs:
-
-**SFR residential NAT** (mobile 4G → node behind SFR Port-Restricted Cone + CGNAT):
-
-| Test | ICE path | Result |
-|---|---|---|
-| WiFi LAN (same network) | IPv6 direct | OK, ~100ms |
-| Mobile 4G SFR + IPv6 | IPv6 inter-network | OK, ~600ms |
-| Mobile 4G SFR + IPv4 only | STUN hole-punch IPv4 | OK, ~650ms |
-
-**Orange Livebox NAT** (laptop browser → node behind Orange residential NAT, cross-site):
-
-| Test | ICE path | Result |
-|---|---|---|
-| Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms |
-| Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms |
-| Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms |
-
-Two ISPs validated, both Chrome and Firefox. No TURN relay needed.
-ICE/STUN handles all tested NAT types automatically.
-
-**Performance optimizations (2026-08-11):**
-- Initial transfer speed: ~2 MB/s (sequential, base64, redundant I/O)
-- After file_hash fix (9.16): ~3 MB/s (eliminated 78 GB redundant reads on 279 MB file)
-- After pipelining (9.14): ~5 MB/s (8-chunk sliding window, concurrent requests)
-- After binary wire format (9.15): eliminated 33% base64 inflation + removed
- redundant per-chunk fields (sig, hashes, pk_node) — AES-GCM tag already
- authenticates ciphertext, DTLS authenticates transport
-- Large file support (9.17): `showSaveFilePicker` (Chrome/Edge) streams decrypted
- chunks directly to disk — flat ~8 MB RAM regardless of file size. Firefox/Safari
- fall back to Blob-in-RAM approach.
-
-**Indexer debounce (2026-08-11):**
-- File copy triggers multiple watchdog events at different file sizes → duplicate
- index entries with different blake3 hashes. Fixed with 2-second debounce +
- path-based dedup (remove old entry before adding new).
-
-**Known remaining items for future phases:**
-- ~~True video streaming (MSE or Service Worker)~~ → Phase 10c (2026-08-11)
-- Multiple shared directories per node (UI + config)
-- Multi-node per user support
-
-**Dependencies added:**
-- `aiortc>=1.9` in `meshbay-node/pyproject.toml` ✅
-- `esbuild` as a dev tool (single binary, not npm) — needed for 9.6+
-- `preact` + `preact-router` (ESM imports, no npm needed — CDN or vendored)
-
----
-
-## Phase 10 — meshbay.org site + admin/moderation UI
-
-Commit: 8fa298e (10.1–10.4), 022da76 (10.5–10.10) — 155 tests.
-
-**Objective:** meshbay.org becomes both a production hub and the project's public
-website, with admin/moderation interfaces and user-facing features.
-
-### Site architecture
-
-Two layers, cleanly separated:
-- **Generic hub** (API + web app) — reusable by any hub operator
-- **Site overlay** — meshbay.org-specific pages (landing, /downloads, /about)
-
-The site overlay is served by Caddy (static files) with priority over the hub.
-The hub serves the SPA for authenticated users at `/app/`.
-
-```
-site/ # meshbay.org-specific (not in generic hub package)
-├── index.html # Landing page — project promotion
-├── downloads.html # Package repos (placeholder, Phase 13)
-├── about.html # Project info, GitHub link, contact
-└── assets/
- └── site.css # Landing page styles (dark/light aware)
-```
-
-### User roles
-
-| Role | Capabilities |
-|---|---|
-| `user` | Standard user — browse, download, chat, manage own profile |
-| `moderator` | Review reports, suspend content/groups/users |
-| `admin` | All moderator rights + hub management (same as moderator for now, distinction reserved for future federation/mirror) |
-
-Role stored as `role` column on User model (`user` | `moderator` | `admin`).
-`require_moderator` dependency (checks role ≥ moderator OR config allowlist).
-`require_admin` checks role = admin OR config allowlist (backward compat).
-Config-listed admin usernames are synced to `role = "admin"` in DB at startup.
-
-### Milestones
-
-| # | Component | Status |
-|---|---|---|
-| 10.1 | Landing page + /downloads + /about | ✅ |
-| 10.2 | Moderator role + `require_moderator` dependency + admin API | ✅ |
-| 10.3 | Moderation UI (user/group suspend, blocklist management) | ✅ |
-| 10.4 | Admin UI (stats, user list, group list, audit logs viewer, blocklist) | ✅ |
-| 10.5 | Notification system (invitations, role changes, account status) | ✅ |
-| 10.6 | User settings (profile, role display, per-group notification mute) | ✅ |
-| 10.7 | Public group search (name keyword filtering) | ✅ |
-| 10.8 | Front page (notification feed with unread badge) | ✅ |
-| 10.9 | Package repositories (APT/DNF) | Deferred to Phase 13 |
-| 10.10 | Auto-update check endpoint (`GET /v1/hub/version`) | ✅ |
-
-### API endpoints (10.2, 10.5, 10.7, 10.10)
-
-| Method | Path | Auth | Description |
-|---|---|---|---|
-| GET | `/v1/users/me` | Access token | Current user info (id, username, role, status) |
-| GET | `/v1/admin/stats` | Moderator+ | Hub stats (user/group/node counts, online nodes) |
-| GET | `/v1/admin/users` | Moderator+ | List users (paginated, searchable) |
-| GET | `/v1/admin/users/{id}` | Moderator+ | User detail (email decrypted, group count) |
-| PATCH | `/v1/admin/users/{id}` | Moderator+ | Update role or status (triggers notification) |
-| GET | `/v1/admin/groups` | Moderator+ | List groups (with member count) |
-| PATCH | `/v1/admin/groups/{id}` | Moderator+ | Update group status |
-| GET | `/v1/admin/logs` | Moderator+ | IP audit logs (filterable by event, user) |
-| GET | `/v1/notifications` | Access token | List notifications (unread_only, paginated) |
-| POST | `/v1/notifications/{id}/read` | Access token | Mark single notification read |
-| POST | `/v1/notifications/read-all` | Access token | Mark all notifications read |
-| GET | `/v1/groups?q=` | None | Search public groups by name |
-| GET | `/v1/hub/version` | None | Version check (hub, MNP, MHP versions) |
-
-### Admin UI (10.3–10.4)
-
-Admin page at `#/admin` in SPA, accessible to moderators and admins.
-Five tabs: Stats, Users, Groups, Logs, Blocklist.
-
-- **Stats:** card grid (users, groups, nodes, online nodes)
-- **Users:** searchable table, inline role dropdown, suspend/unsuspend buttons, detail overlay
-- **Groups:** table with member count, suspend/unsuspend
-- **Logs:** filterable IP audit log table, paginated (50/page, load more)
-- **Blocklist:** existing `/v1/admin/blocklist` endpoints, add/remove hashes
-
-### SPA route change
-
-SPA now also served at `/app/` and `/app/{path}` (in addition to `/`).
-With Caddy site overlay, Caddy serves `site/index.html` at `/`,
-and requests to `/app/` fall through to the hub.
-
-### Caddy integration
-
-**The real configuration lives at `packaging/caddy/meshbay.org.Caddyfile`** (added
-2026-08-17). Use it, not the snippet this section used to carry.
-
-The snippet that was here served `site/` from the root with `try_files` and proxied
-`/v1/*`, `/app*`, `/style.css` and `/*.js` to the hub. It predates asset versioning and
-**would have broken the SPA**: the module graph is served under `/a/<hash>/`, which
-`handle /*.js` does not match, and neither does `/locales/*.js`. Worse, `/sw.js` would
-have 404ed — the service worker has to stay at the root or its scope stops covering the
-pages it intercepts downloads for, which breaks streamed downloads on Firefox and Safari
-without any visible error.
-
-The rule is inverted: an **allowlist** of site paths served statically, everything else
-proxied to the hub. The hub mounts its whole static directory at `/` (`app.py`), so it
-owns the root namespace by default and the site takes only what it names.
-
-**Consequence to be aware of:** with the site overlay in front, `/` is the landing page,
-so `webapp.py`'s `GET /` (which returns the SPA shell) is unreachable on meshbay.org. That
-route stays — a **generic** hub with no site overlay should serve the application at its
-root. The overlay is meshbay.org-specific by design.
-
-`site/` is **not** pushed by the hub deploy procedure; it syncs separately to
-`/srv/meshbay/site`.
-
-### Hub mirror (design only — implementation deferred)
-
-A mirror hub is a complete replica of the primary hub (same user DB, same groups,
-same GEK bundles, same storage). Purpose: load distribution via DNS round-robin.
-
-**Design constraints:**
-- Shared Ed25519 private key (transferred once at setup, securely)
-- PostgreSQL logical replication for active-active read/write on both mirrors
-- Both mirrors can issue JWTs (same signing key)
-- DNS round-robin (2+ A records on meshbay.org)
-- If one mirror goes down, the other continues serving
-
-**Not implemented now.** The design must not prevent future implementation:
-- Hub config and private key paths must be externalizable
-- No hub-specific state that can't be replicated
-- JWT verification must not depend on hub-local state
-
----
-
-## Phase 10b — Self-service UI + client-side features
-
-Pending commit — 166 tests.
-
-**Objective:** make the web SPA fully self-service — users can create groups,
-manage members, join open groups, upload files, and search across all cached
-group file indexes. No admin intervention needed for basic operations.
-
-### Self-service features
-
-| # | Component | Status |
-|---|---|---|
-| 10b.1 | Group creation UI (CreateGroupPage) | ✅ |
-| 10b.2 | Member management + invite (MembersPanel) | ✅ |
-| 10b.3 | Group join flow (open groups self-join) | ✅ |
-| 10b.4 | File upload (client → node via MNP FILE_UPLOAD) | ✅ |
-| 10b.5 | IndexedDB caching (group file indexes cached locally) | ✅ |
-| 10b.6 | Cross-group file search (SearchPage — client-side, no hub) | ✅ |
-
-### New API endpoints (10b.1–10b.3)
-
-| Method | Path | Auth | Description |
-|---|---|---|---|
-| POST | `/v1/groups` | Access token | Create a new group (name, visibility, join_policy) |
-| GET | `/v1/groups/{id}/members` | Access token | List group members (requires membership) |
-| POST | `/v1/groups/{id}/join` | Access token | Self-join open group (checks join_policy) |
-| POST | `/v1/groups/{id}/members/{username}/gek` | Access token | Store GEK bundle for invitee |
-| GET | `/v1/groups/{id}/gek` | Access token | Get own GEK bundle (for wrapping) |
-
-### New MNP message types (10b.4)
-
-| Type | Direction | Description |
-|---|---|---|
-| `file_upload` | client → node | Push encrypted file chunk (filename, chunk_index, total_chunks, data) |
-| `file_upload_ack` | node → client | Acknowledge chunk receipt |
-
-Node stores uploads in `shared_root/.uploads/` as `.part` files during transfer,
-renames to final location on last chunk. Filename sanitized (no path traversal).
-
-### Browser crypto additions (10b.2)
-
-AES-256-GCM ECIES variant for GEK wrapping in browsers. WebCrypto does not
-support ChaCha20-Poly1305, so a parallel ECIES scheme uses AES-256-GCM with
-a distinct HKDF info string (`meshbay:gek_wrap:v1:aes` vs `meshbay:gek_wrap:v1`).
-Both Python and browser implement the AES variant for interop.
-
-Functions added to `crypto.js`: `generateGEK()`, `wrapGEK()`, `unwrapGEK()`,
-`encryptChunk()`, `b64encode()`.
-
-Functions added to `crypto.py`: `wrap_gek_aes()`, `unwrap_gek_aes()`.
-
-### IndexedDB caching (10b.5)
-
-When a group's file index is fetched from a node, it is cached in IndexedDB
-(`meshbay` database, `group_indexes` store). On subsequent visits, cached
-entries are shown immediately while the live connection is established. This
-gives instant file list display even before WebRTC connects.
-
-Cache key: `groupId`. Stored: `{ groupId, groupName, entries[], cachedAt }`.
-Best-effort — failures are silently ignored.
-
-### Cross-group file search (10b.6)
-
-SearchPage component at `#/search`. Searches file names and paths across ALL
-cached group indexes in IndexedDB. Pure client-side — no hub involvement.
-Results link back to the group page. Accessible from sidebar.
-
-### Tests added
-
-- 8 tests: group self-service (create, join open, join invite rejected, join already member, members list, non-member denied, search, join triggers notification)
-- 3 tests: AES GEK wrap/unwrap (round-trip, wrong key rejected, differs from ChaCha20 wrap)
-
----
-
-## Phase 10c — MSE video streaming (real-time playback)
-
-Pending commit — 167 tests.
-
-**Objective:** replace the download-then-play video player with real-time
-MSE (MediaSource Extensions) streaming. Playback starts within seconds
-instead of waiting for the full file download.
-
-### Architecture
-
-```
-Browser Node
- │ │
- ├── stream_req {file_id} ──────►│
- │ ├── ffprobe → codec info
- │◄──── stream_init {codec,dur} ──┤
- │ ├── ffmpeg -c copy → fMP4 pipe
- │◄──── stream_data {seg 0, ct} ──┤ (256 KB encrypted segments)
- │◄──── stream_data {seg 1, ct} ──┤
- │ ... │
- │◄──── stream_end ───────────────┤
- │ │
- MediaSource → SourceBuffer │
- ├── appendBuffer(decrypted) │
- ├── video.play() after ~2-3s │
-```
-
-**Key design decisions:**
-
-1. **Node-side remux via ffmpeg** — `ffmpeg -c copy -movflags frag_keyframe+empty_moov+default_base_moof -f mp4 pipe:1` remuxes any video format (MP4, MKV, AVI, WebM, MOV) into fragmented MP4 (fMP4) that MSE can consume. No transcoding — just remuxing. Near-zero CPU overhead.
-
-2. **Codec detection via ffprobe** — the node probes the video to determine the exact codec string for MSE SourceBuffer creation (e.g., `avc1.640028,mp4a.40.2` for H.264 High@4.0 + AAC-LC). This ensures the browser creates the correct decoder.
-
-3. **Same encryption model** — each 256 KB fMP4 segment is encrypted with AES-256-GCM using the same key derivation as file downloads (GEK + file_hash + segment_index → HKDF → chunk_key). E2E encryption is maintained.
-
-4. **Progressive SourceBuffer append** — the browser creates a MediaSource, opens a SourceBuffer with the probed codec, and appends decrypted segments as they arrive. SourceBuffer handles partial MP4 boxes internally. Playback starts after ~2-3 segments (~512 KB buffered).
-
-### Supported codecs
-
-| Codec | MSE string | Browser support |
-|---|---|---|
-| H.264 (AVC) | `avc1.PPCCLL` | Chrome, Firefox, Safari, Edge |
-| H.265 (HEVC) | `hev1.1.6.L93.B0` | Safari, Chrome (partial) |
-| VP9 | `vp09.00.10.08` | Chrome, Firefox |
-| AV1 | `av01.0.01M.08` | Chrome, Firefox |
-| AAC | `mp4a.40.2` | All |
-| MP3 | `mp4a.6b` | All |
-| Opus | `opus` | Chrome, Firefox |
-| AC-3 | `ac-3` | Safari, Chrome |
-
-### New MNP message types
-
-| Type | Direction | Description |
-|---|---|---|
-| `stream_req` | client → node | Request MSE video stream for file_id |
-| `stream_init` | node → client | Codec string + duration (probed via ffprobe) |
-| `stream_data` | node → client | Encrypted fMP4 segment (256 KB, AES-GCM) |
-| `stream_end` | node → client | End of stream signal |
-
-### Milestones
-
-| # | Component | Status |
-|---|---|---|
-| 10c.1 | MNP protocol: STREAM_REQUEST/INIT/DATA/END message types | ✅ |
-| 10c.2 | Node: ffprobe codec detection + MSE codec string derivation | ✅ |
-| 10c.3 | Node: ffmpeg fMP4 remux + encrypted segment streaming | ✅ |
-| 10c.4 | Transport: event-based stream message dispatch | ✅ |
-| 10c.5 | Browser: MSE VideoPlayer (MediaSource + SourceBuffer) | ✅ |
-| 10c.6 | Tests: stream_request error handling | ✅ |
-
-### File changes
-
-**Modified:**
-- `packages/meshbay-common/src/meshbay_common/protocol.py` — STREAM_REQUEST/INIT/DATA/END
-- `packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py` — `_probe_video()`, `_stream_video()` handler
-- `packages/meshbay-hub/src/meshbay_hub/static/transport.js` — `requestStream()`, stream event handlers
-- `packages/meshbay-hub/src/meshbay_hub/static/app.js` — MSE-based VideoPlayer component
-- `packages/meshbay-hub/src/meshbay_hub/static/style.css` — streaming progress bar
-- `packages/meshbay-hub/src/meshbay_hub/static/i18n.js` — buffering/MSE error strings
-- `packages/meshbay-node/tests/test_webrtc_transport.py` — stream_request error test
-
-### Known limitations (future work)
-
-- No seeking beyond buffered range (user must wait for data to arrive)
-- No adaptive bitrate (single quality stream)
-- Requires ffmpeg/ffprobe on the node (already a dependency for the live streaming path)
-
----
-
-## Phase 11 — Node daemon: production-ready ✅ DONE
-
-Pending commit — 171 tests.
-
-**Objective:** the node daemon (`meshbay-node`) runs as a complete, self-contained
-service. Previously the daemon only started QUIC/TCP servers and the local web UI;
-everything browser-facing (WebRTC, hub WS, chat store, HTTP API) was only wired
-in QE demo scripts. This phase moved all that logic into the daemon.
-
-### What changed
-
-**`daemon.py` — complete rewrite.** The daemon now starts all transports and
-services in a single process:
-
-1. Keystore + hub login (unchanged)
-2. Per-group directory indexers (unchanged)
-3. **ChatStore** per group (new) — SQLite DB in `~/.local/share/meshbay/{group_id}/chat.db`
-4. **WebRTC transport** (new) — browser clients via DataChannel, wired as
- `on_webrtc_offer` callback on the hub WS
-5. QUIC + TCP servers (unchanged)
-6. **Hub WebSocket** (new) — `maintain_ws()` as asyncio task, receives signaling,
- revocation tokens, WebRTC offers. Auto-reconnect on disconnect.
-7. **HTTP file API** (new) — one `create_http_app()` per group on configured port
-8. Local web UI (unchanged)
-9. **Graceful shutdown** (enhanced) — cancels WS task, closes WebRTC peers, closes
- chat stores, stops HTTP/QUIC/TCP servers, stops indexers
-
-**`hub_client.py`** — added `_ws` tracking, `send_ws()` for chat notifications,
-and `register_swarm()` for file hash registration with the hub.
-
-**`config.py`** — added `data_dir` field (default `~/.local/share/meshbay/`)
-for chat DBs and other persistent state.
-
-**`meshbay-node.service`** — updated systemd unit with `StateDirectory=meshbay`,
-`ProtectSystem=strict`, `ReadWritePaths` for config and data directories.
-
-**Index push (11.5):** when watchdog detects file changes, the debounced
-`on_change` callback fires `_on_index_change` on the daemon, which pushes a
-full `INDEX_SYNC` to all WebRTC peers in that group. Only peers whose
-`_group_id` matches receive the push.
-
-**Swarm registration (11.9):** on startup and on each index change, the daemon
-registers all file hashes with the hub's `/v1/swarm/register` endpoint. This
-allows other nodes/clients to discover which nodes host which content.
-
-### Milestones
-
-| # | Component | Status |
-|---|---|---|
-| 11.1 | Daemon: hub WS integration | ✅ |
-| 11.2 | Daemon: WebRTC transport | ✅ |
-| 11.3 | Daemon: chat store | ✅ |
-| 11.4 | Daemon: HTTP file API | ✅ |
-| 11.5 | Daemon: index push on change | ✅ |
-| 11.6 | Daemon: node_user_id + hub_ws context | ✅ |
-| 11.7 | Daemon: graceful shutdown | ✅ |
-| 11.8 | Systemd unit file | ✅ |
-| 11.9 | Swarm registration | ✅ |
-| 11.10 | Integration test | ✅ (4 tests: lifecycle, no-groups, index push, group filtering) |
-
-### File changes
-
-**Modified:**
-- `packages/meshbay-node/src/meshbay_node/daemon.py` — complete rewrite
-- `packages/meshbay-node/src/meshbay_node/hub_client.py` — `_ws` tracking, `send_ws()`
-- `packages/meshbay-node/src/meshbay_node/config.py` — `data_dir` field
-- `packaging/systemd/meshbay-node.service` — hardening, StateDirectory
-
-**Added:**
-- `packages/meshbay-node/tests/test_daemon.py` — 2 integration tests
-
----
-
-## Phase 11.5 — Security remediation ⛔ BLOCKING
-
-> Source: `second-review.md` (2026-08-13). Finding IDs in brackets.
-> **No other phase starts until section J acceptance criteria pass.**
-
-**Objective:** close the gap between what the documents describe and what the code
-enforces. The Phase 12 sovereignty work (GEK-HMAC proof, DTLS channel binding, Ed25519
-admin challenge) is sound but was implemented on one of four paths into the node. This
-phase reduces the node to two paths and brings both to the same standard.
-
-### Transport decision (settled 2026-08-13)
-
-| Listener | Fate | Reason |
-|---|---|---|
-| WebRTC DataChannel (aiortc) | **Primary** — browser + native | ICE/STUN is the only NAT traversal validated here (2 ISPs, 2 browsers, IPv4 STUN + IPv6, 4G CGNAT) |
-| QUIC 19000 | **Kept, brought to parity** | LAN, port-forwarded, and hub-less `group://` direct access |
-| TCP+TLS 18001 | **Removed** | Superseded; no GEK proof; nothing uses it |
-| HTTP 19001 | **Removed** | Source of C1; duplicates MNP without any of its controls |
-
-> `punch_nat()` is a single UDP probe (`quic_server.py:446`) with no STUN client, no
-> candidate gathering and no dual-stack fallback — `aioice` is pulled in by `aiortc` only.
-> It is a direct-connection helper, **not** a traversal stack. ICE remains the primary path.
-
-### A — Reduce the node's exposed surface
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.1 | Delete `transport/http_server.py` + daemon wiring (`daemon.py:341-366`) | **C1** | No listener on `0.0.0.0` other than QUIC; no endpoint serves file bytes or an index without a completed handshake |
-| 11.5.2 | Delete `transport/server.py` + `transport/client.py` (TCP+TLS) | C6 scope | `ChunkServer` gone from `daemon.py`; port 18001 unbound |
-| 11.5.3 | Node admin UI stays loopback + gains a session token in the URL | H2 | UI unreachable without the token printed at daemon startup |
-
-### B — One handshake, two transports
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.4 | Extract `meshbay_common/handshake.py`: JWT verify → `scope == "user"` → denylist → **mandatory** `group_id` in claims → group hosted → GEK challenge → proof verify → ack | **C6**, M1, M9 | Single implementation; `webrtc_server.py` and `quic_server.py` contain no JWT logic of their own |
-| 11.5.5 | Both transports call it; test parametrized over `[webrtc, quic]` | C6 | A test that adds a step to the handshake fails for any transport that skips it |
-| 11.5.6 | **Spike DONE 2026-08-13 — see findings below.** Channel binding for QUIC | C6/NS5 | QUIC handshake proof is bound to the connection, not replayable across connections |
-
-#### 11.5.6 spike results (aioquic 1.3.0)
-
-**No RFC 5705 exporter.** `aioquic.tls.Context` has no `export_keying_material`, so the
-preferred anchor is unavailable.
-
-**Certificate access is asymmetric and partly private:**
-
-| Side | Path to the server certificate | API status |
-|---|---|---|
-| Server | `tls.certificate` | public attribute |
-| Client | `tls._peer_certificate` | **private** — set by `_set_peer_certificate()` |
-
-`QuicConnection` exposes no `tls`/`cert` attribute either, so the client's route is
-`protocol._quic.tls._peer_certificate` — two levels of private API.
-
-**The risk this creates.** Binding a security check to a private attribute means an
-aioquic upgrade can remove it silently. A channel binding that silently becomes
-unavailable is the worst failure mode: 11.5.21 already established that the handshake
-must *refuse* rather than degrade, so a rename would turn every QUIC connection into a
-hard failure — noisy, but only if the refusal path is right. If it were ever made
-tolerant, it would turn into a silent loss of MitM detection.
-
-**Options for the implementer, in order of preference:**
-
-1. **Certificate hash via the private attribute, guarded.** Pin `aioquic` in
- `pyproject.toml`, and add a test that asserts `_peer_certificate` is reachable and
- non-None on a live connection — so an upgrade fails CI rather than production. Keep
- `make_proof()` refusing an empty binding.
-2. **Bind to `pk_node` instead of the channel.** For QUIC the MitM story differs from
- WebRTC: signaling is not hub-relayed, and the client already learns `pk_node` from the
- hub. The C3 mutual proof (node signs the transcript with `sk_node`) may be sufficient
- connection authentication on its own — but note the QUIC client currently does **not**
- verify the TLS certificate (`verify_mode` disabled, identity checked at the MNP layer),
- so this option must be paired with pinning, or the TLS layer authenticates nobody.
-3. **Upstream an exporter.** Correct long-term answer, wrong timescale for 11.5.
-
-Recommendation: option 1 with the guard test, and open option 3 upstream.
-
-### C — Mutual authentication
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.7 | Node proves GEK possession over a client nonce **and** signs the transcript with `sk_node`: `Ed25519(sk_node, "meshbay:node_proof:v1" ‖ nonce_c ‖ binding)` | **C3** | Client rejects a peer that cannot produce both |
-| 11.5.8 | Client pins `pk_node` (TOFU on first connect, persisted); key change raises a blocking warning | C3 | Swapping the node's key surfaces to the user instead of silently succeeding |
-| 11.5.9 | Node WS registration: require `scope == "node"`, verify `Node.user_id == payload["sub"]`, derive `group_ids` **from the DB**, refuse to overwrite a live registration | **C2** | A user-scoped token, or a mismatched `node_id`, is rejected at `/v1/nodes/ws` |
-| 11.5.10 | `POST /v1/nodes/announce` requires proof of possession of `sk_node`; one active record per user | M8 | Announcing someone else's `pk_node` fails |
-
-### D — MNP authorization
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.11 | `gek_bundle_store` requires an Ed25519 admin challenge; **delete `_try_activate_gek`** — GEK activation is local-UI/CLI only | **C5b** | A member cannot change the group's active GEK |
-| 11.5.12 | Upload: per-user quarantine `.uploads/{user_id}/`, refuse to overwrite an existing index entry, size cap + per-user quota, filename allowlist (`[A-Za-z0-9._-]`) | **C5a**, H2 | A member cannot replace another member's file, and cannot inject markup via a filename |
-| 11.5.13 | Admin challenge becomes a structured transcript: `"meshbay:file_delete:v1" ‖ node_pk ‖ group_id ‖ file_id ‖ nonce ‖ ts`; client displays what it signs | **H5** | No path exists where a peer obtains a signature over bytes it fully chose |
-| 11.5.14 | `gek_bundle_fetch` / `keypair_bundle_fetch` move **after** proof verification; interim rate-limit + audit on the pre-proof window | C4 (partial) | Pre-proof window serves nothing; full fix lands in 13.3 |
-
-### E — Isolation
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.15 | `chat_store` and `_peers` resolve from `_group_ctx()`, one peer registry per group (`daemon.py:249`, `webrtc_server.py:602,617,650`) | **H1** | Two-group / two-user test proves neither history nor broadcast crosses groups |
-
-### F — Node admin UI
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.16 | `html.escape()` on every interpolated value (`ui/app.py:363`), `textContent` in the audit page (`:632`), CSP header | **H2** | A file named `<img src=x onerror=...>` renders as text |
-
-### G — Revocation
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.17 | Handle `target == "group"` on the node; persist the denylist to `data_dir`; check group status in `webrtc_offer` | **H4** | Revoking a group drops live sessions and blocks new signaling |
-
-### H — Privacy
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.18 | Swarm registers hashes for `visibility == "public"` groups only; fix the mis-mounted route (`/v1/groups/v1/swarm/...`); authenticate the lookup | **H7** | No private-group content hash ever reaches the hub |
-
-### I — Resource limits
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.19 | Pre-handshake buffer cap (a few KB, not 64 MB); `asyncio.Semaphore` around ffmpeg; delete the synchronous `subprocess.run` in `_do_stream_segment`; per-user signaling rate limit + membership check before relaying an offer; validate `peer_ip` against the request source | **H6** | One client cannot stall the daemon's event loop or exhaust its memory/CPU |
-
-### J — Crypto hygiene, hub fixes, acceptance
-
-| # | Component | Finding | Done when |
-|---|---|---|---|
-| 11.5.20 | Keystore Argon2id → 256 MB, parameters stored per-node in `node.toml` (not a `meshbay_common` constant); raise the password minimum | M2 | `calibrate-argon2` writes usable config; `crypto.py:173` no longer hardcodes 64 MB |
-| 11.5.21 | Length-prefix every field in the HMAC transcript; **reject** empty DTLS fingerprints instead of proceeding | L4 | A missing fingerprint fails the handshake rather than degrading it to nonce-only |
-| 11.5.22 | Hub: fix IPLog backfill (`users.py:118-122`), trusted-proxy XFF, scrub `str(e)` from peer-visible errors, drop `GEK_REQUEST`/`GEK_RESPONSE` constants, validate email | M6, M7, L3, L1, L6 | Compliance log attributes each row to the right account |
-| 11.5.23 | Regression suite | all | See below |
-
-**Required regression tests (all must exist and fail on reintroduction):**
-
-```
-test_no_unauthenticated_content — every node listener refuses index/chunks pre-handshake
-test_handshake_parity[webrtc,quic] — identical checks on both transports
-test_group_isolation — 2 groups × 2 users: chat + peers never cross
-test_upload_cannot_overwrite — member B cannot replace member A's file
-test_gek_store_requires_admin — member cannot store/activate a GEK
-test_ws_node_identity — user token / foreign node_id rejected
-test_node_proof_required — client aborts when the node cannot prove GEK + sk_node
-test_ui_escapes_filenames — markup in a filename renders inert
-test_swarm_public_only — private hashes never registered
-```
-
-**Acceptance criteria for the phase:** with a hub whose signing key is in the attacker's
-hands, an attacker who is not a group member obtains **no** index entry, **no** file byte,
-**no** chat message, and cannot write to any node. A member who is not the node operator
-cannot delete or overwrite another member's file, and cannot change the group key.
-
----
-
-## Phase 12 — Client key verification + served-SPA integrity
-
-> **Reworked 2026-08-13 by operator decision.** This phase was "Hub minimization:
-> registrar and nothing more". That work is **deferred and may be dropped** — see
-> decisions D1/D2 in `tmp-decisions.md`. The hub will keep serving the web UI, and a
-> native client will be offered *in addition to* it, not as a replacement.
->
-> Two items are kept here because the decision makes them *more* relevant, not less:
-> the hub stays in the trusted path, so what it can substitute and what code it serves
-> both still matter. Everything else from the old Phase 12 (route blindness test,
-> opaque private-group metadata, chat_notify minimization, schema cleanup) is dropped
-> from the plan; the swarm item already shipped in 11.5.18.
-
-**Objective:** make the hub's remaining power over confidentiality *detectable*, given
-that it stays in the trusted path by choice.
-
-### 12.1 is DONE — and not as it was written
-
-**H3 is closed (2026-08-14), by removing the lookup rather than by verifying it.** The
-plan here was key transparency and safety numbers: keep fetching the invitee's key from
-the hub, and give humans a way to notice a substitution. What shipped instead is the
-invite redesign in `invite-pairing-v1.md` — the node holds the GEK and wraps it
-itself, for a key the recipient proves possession of, and identities are bound to
-accounts by one-time codes the hub never sees.
-
-Why that is better than what was planned: safety numbers make a substitution *detectable
-by a human who bothers to check*, at the single worst moment (first contact, when there
-is no previous key to compare against). Removing the directory read from the invite path
-makes the substitution impossible instead, and costs the user one code to pass along
-rather than a fingerprint comparison ritual.
-
-It also closed **M3** as a side effect, and absorbed **14.3/14.4** (CLI invite, member
-management), which had to exist for a headless operator to admit anyone.
-
-Safety numbers may still return later as defence in depth for *identity* verification —
-"is this really Bob's account" — which is a different question from "which key gets the
-group key". They are no longer load-bearing.
-
-### Milestones
-
-| # | Component | Description |
-|---|---|---|
-| 12.1 | ~~Key transparency + safety numbers~~ [H3] | ✅ **DONE 2026-08-14**, by a different design — see above and `invite-pairing-v1.md` |
-| 12.2 | Served-SPA integrity | Strict CSP, Subresource Integrity on the bundle, and a signed digest of the served bundle published by the hub so a native client or extension can verify what the browser was given. **Now the highest-value item here**: T3 is the only remaining way an active hub reads content, and it can also lift a pairing code out of the page it served. **The CSP must keep `wasm-unsafe-eval` in `script-src`** — the bundle KDF is Argon2id in WebAssembly since 2026-08-14, and a policy that forbids it locks every user out of their keys |
-| 12.3 | Honest labelling | `/app/` states plainly that the hub serves this code and what that implies. Docs stop claiming end-to-end integrity for the hub-served path — the claim that holds is "the hub cannot read your content unless it ships you malicious client code" |
-| 12.4 | Written threat model | One page: passive hub, active hub, malicious node operator, malicious member, network attacker, local attacker — and for each claim, which adversary it holds against. This is what stops the overclaiming pattern the second review kept finding |
-
-**Dropped from the old Phase 12** (recorded so the intent is not lost if it returns):
-route-inventory blindness test, opaque private-group name/description, chat_notify
-metadata minimization, residual schema cleanup.
-
----
-
-## Phase 13 — Native desktop client (Electron + optional Python sidecar)
-
-> **Reworked 2026-08-17 by operator decision. `desktop-client-v1.md` is
-> authoritative for this phase** — shell, device linking, account creation, node
-> management, packaging and the open items. The milestone table below is the summary;
-> the design and its rationale are there.
->
-> **The shell changed: pywebview → Electron.** Structural decision 18 is reversed. The
-> reason is measured, not aesthetic: the SPA depends on Chromium-class APIs (WebRTC,
-> WebCrypto X25519/Ed25519, MSE, Service Workers, File System Access), so keeping
-> Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js` and `sw.js`
-> unchanged, while a system webview meant reimplementing ~2500–3000 lines plus a loopback
-> media server and native dialogs. The old "69 % reused" figure was measured against an
-> `app.js` of ~2600 lines; it is **4586** as of 2026-08-17, and `app.js` now holds 2 direct
-> `crypto.subtle` calls — the three-globals seam had already leaked.
->
-> **One blocking addition: device linking (13.3b).** Identity keys are per node, so a
-> native client holding its own keys is refused by `webrtc_server.py:886-894` where a
-> browser is not. Without linking, an account created natively could never be opened in a
-> browser without an operator code per node — the "native must not prevent web use"
-> objective fails outright. See `desktop-client-v1.md` §4.
-
-**Objective:** ship a desktop application whose UI is not served by the hub, with durable
-local key storage, at feature parity with the web for a standard user, that does not
-prevent web use, and that can manage locally installed nodes.
-
-> ⚠️ **Do not justify this phase as "the fix for T3".** An earlier draft of
-> `second-review.md` claimed a native client makes code integrity independent of the hub.
-> That was wrong: a binary downloaded from `meshbay.org` and signed with a key the hub
-> operator holds relocates the trust rather than removing it. What native actually changes is
-> **detectability** — an attack must ship as an artifact that can be hashed and compared
-> instead of a one-off HTTP response — and that value is realised only by **18.7 reproducible
-> builds** plus published hashes.
->
-> **Cost line revised 2026-08-17.** Two of the three costs recorded here were pywebview's,
-> not native's. Electron with `sandbox` and `contextIsolation` **keeps** the Chromium
-> renderer sandbox — the strongest available — so "native costs the browser sandbox" is
-> false for this shell, and the D2 table in `tmp-decisions.md` is wrong on that row. The
-> loopback media server is deleted (13.6). What remains, and is real: **we own Chromium's
-> patch cadence**, the renderer parses attacker-controlled content from nodes, npm enters
-> the supply chain, and the updater is new surface.
->
-> The security-per-effort ranking is: **11.5 ≫ 12 ≫ 14 (CLI) ≫ 13.** This phase is justified
-> on product grounds. It closes **C4** for native devices, but **not for accounts that also
-> use a browser** — see `desktop-client-v1.md` §5.1. Reproducible builds are unusually
-> tractable here: the UI has no bundler or minifier, and Electron's own binaries have
-> published upstream hashes.
-
-### Why this is cheap (re-measured 2026-08-17)
-
-| Asset | Lines | Under Electron | Under a system webview |
-|---|---|---|---|
-| `style.css`, `i18n.js`, `vendor/`, `zipstream.js`, `transfers.js` | ~4200 | reuse | reuse |
-| `app.js` | 4586 | reuse, minus the hub base URL | reuse, minus storage glue + MSE player |
-| `transport.js`, `crypto.js`, `keyderive.js` | 1907 | **reuse** | delete and reimplement |
-| `downloads.js`, `sw.js` | 363 | **reuse** | delete |
-
-`HUB = ''` (`app.js:12`) becoming a configurable absolute base URL is the one structural
-change. That is what 13.1 exists for, and under Electron it is nearly all of it.
-
-The old plan also budgeted a loopback media server (WebKitGTK MSE is unreliable), a SQLite
-index cache (IndexedDB is restricted under `file://`) and native file dialogs. Chromium
-provides all three, so those milestones are deleted rather than rewritten.
-
-### Non-negotiable
-
-**UI assets ship inside the package and load from disk.** A shell that points its WebView at
-`https://meshbay.org/app/` is a browser with a different icon and fixes nothing. The hub is
-used for the API only, and the bundle is covered by 13.9 signing.
-
-Shell hardening is part of that and is not optional: `contextIsolation` and `sandbox` on,
-`nodeIntegration` off, a custom `app://` protocol rather than `file://`, navigation to
-non-local targets refused, and a strict in-package CSP that **keeps `wasm-unsafe-eval`** —
-the bundle KDF is Argon2id in WebAssembly, and a policy forbidding it locks every user out
-of their keys.
-
-### Milestones
-
-| # | Component | Prio | Description |
-|---|---|---|---|
-| 13.1 | Hub base URL + storage/save-file adapter | 1 | `HUB = ''` becomes configurable; a thin adapter for storage and saving. **Acceptance: the browser SPA behaves identically.** Much smaller than the old platform split — Chromium stays Chromium |
-| 13.2 | Electron shell | 1 | `app://` via `protocol.handle`, CSP, preload with an enumerated API, sandbox, single instance, tray, window state |
-| 13.3 | Local key storage + device auth | 1 | `safeStorage` (OS keychain) with an explicit fallback where no keyring exists; device Ed25519 auth on the `POST /v1/nodes/auth` pattern. **`POST /v1/users/auth` does not exist yet and must be written** |
-| **13.3b** | **Device linking** | 1 | **Blocking.** One-time code generated by the new device, countersigned by an already-pinned key. `identities` gains a device dimension; `pin_identity`'s `INSERT OR REPLACE` must go. `desktop-client-v1.md` §4 |
-| 13.3c | Hybrid registration | 1 | Register with a passphrase-derived `auth_key` (existing endpoint, browser-compatible at once, and the only account recovery path), device key thereafter |
-| ~~13.4~~ | aiortc client transport | — | **Deleted** — Chromium provides WebRTC. `transport.js` is kept as the client |
-| ~~13.5~~ | SQLite index cache | — | **Deleted** — IndexedDB works under `app://` |
-| ~~13.6~~ | Loopback media server | — | **Deleted** — Chromium MSE works. Removes the C1-shaped surface this milestone would have added |
-| 13.7 | Native save dialog | 2 | `dialog.showSaveDialog` + streamed write from the main process; the service-worker path already works, so this is an improvement, not a prerequisite |
-| 13.8 | Safety-number UI | 3 | Consumes 12.2. **No longer load-bearing for device linking** — the code binding replaced digit comparison |
-| 13.9 | Release key + verified updates | 3 | Prefer the signed apt/dnf repo (18.5) over a bespoke updater. **The key must exist before the first public package**; without it the update channel is the new T3 |
-| 13.10 | Packaging | 1 / 3 | `.deb` (Ubuntu 24+) and `.rpm` (Fedora 44+) first; MSI per-user (Windows) later; AppImage/Flatpak optional |
-| ~~13.11~~ | "Retire the SPA?" | — | **Settled**: the web stays. A native client must not prevent web use |
-| 13.11b | Per-root `unavailable` index state | 2 | Freeze the subtree instead of emptying it when a volume goes away. **Prerequisite for root selection** — otherwise unplugging a USB drive propagates deletions for a whole library |
-| 13.11c | Named roots per group | 2 | `shared_dir` (one string) → a list of `{name, path, kind}`; the name is the directory's basename, derived once and stored. Unique names (case-insensitive), no nesting, per-root availability, operator-designated upload target |
-| 13.11d | Filesystem portability | 1 | Case folding, NFC normalization, Windows reserved names, `\\?\` paths, reconciliation scans. Case folding is for index identity, collision reporting and root names. **Not** the no-overwrite check: `Path.exists()` is already case-insensitive on NTFS/exFAT, so C5a is not reachable that way (verified 2026-08-18) |
-| 13.12 | Node management over signed MNP ops | 2 | Invitations, revoke, unpin, devices, file/dir delete, roster, audit, peers, root selection, **`gek_rotate`**. The loopback admin API is never exposed to the network |
-| 13.13 | First-run wizard | 2 | Detect a local node, `systemctl --user enable --now` (no privilege), link to the hub account, create/attach a group, `gek-init`, `operator pair` — all on loopback, no terminal |
-| 13.14 | Python sidecar — `group://` over QUIC | 2 | Reuses `quic_client.py`. Also the only local management path on a LAN with no internet, since MNP setup needs the hub's signaling |
-
-### Deletions enabled once native is the recommended client
-
-`webcrypto.py` + the `:aes` HKDF variant · `deriveAuthKey`/`deriveEncryptionKey` +
-`pw_version` 3 + legacy migration · keypair bundle MNP messages + `keypair_bundles` table ·
-`_bundleKey` in IndexedDB + `_sessionKeys` in sessionStorage + `_pkFromSk`.
-
-**Revised 2026-08-17:** `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js`, `sw.js`
-and the MSE path are **no longer on this list** — under Electron they are the client, not
-browser workarounds. The keypair-bundle deletions still apply, but only for accounts that
-opt out of browser use (`desktop-client-v1.md` §5.1); the browser path needs them.
-
-**Kept regardless:** WebRTC transport, hub signaling relay, DTLS channel binding.
-These carry NAT traversal and are not browser workarounds.
-
----
-
-## Phase 14 — Node CLI + management
-
-> Was Phase 12 before the 2026-08-13 renumbering.
-
-**Objective:** `meshbay-node` CLI becomes a full management tool, not just a
-daemon launcher.
-
-**Partially delivered early (2026-08-13), forced by the first real deployment.**
-Every operator action lived behind a web UI on the node's own loopback interface,
-so a node on a server reached over SSH could not be operated at all without
-port-forwarding a browser session — and 11.5.3 added a token that had to be
-copied out of a log to get in. `status`, `ui` and `gek-init` shipped to unblock
-that.
-
-**Member management landed 2026-08-14** with the invite redesign, for the same
-reason: a node admits people from its own roster, and a headless operator had no
-way to put anyone on it. `operator pair`, `member list|invite|revoke|unpin` all
-work over SSH. **Deleting a file is now the only operator action that still needs
-a browser.**
-
-### Milestones
-
-| # | Component | Description |
-|---|---|---|
-| 14.1 | `meshbay-node status` | ✅ DONE — hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is stopped |
-| 14.1b | ~~`meshbay-node ui`~~ | **Removed 2026-09-01** (`refactor-node-ui.md` phase 5). The server-rendered admin page it opened is gone; the CLI and the desktop client's Node page use the loopback control API directly |
-| 14.1c | `meshbay-node gek-init` | ✅ DONE — initialises a group key via the daemon's loopback API. Was previously only possible by clicking a button in a browser on the node's own machine |
-| 14.2 | `meshbay-node group list` | ✅ **DONE 2026-08-18** — groups with roots, key state, file and peer counts |
-| 14.3 | `meshbay-node group create` | Create group on hub, add to config, generate GEK |
-| 14.4 | `meshbay-node group join` | Join existing group, fetch GEK from local BundleStore, add to config |
-| 14.5 | `meshbay-node member invite` | ✅ **DONE 2026-08-14** — issues a one-time code; the node wraps the GEK itself when the invitee connects. The original description ("wrap GEK for new member, store bundle") describes the design the invite redesign replaced |
-| 14.6 | `meshbay-node member revoke` | ✅ **DONE** — stops the node serving the key, and tells the operator to rotate it, since the ex-member still holds the current one |
-| 14.6b | `meshbay-node member unpin` | ✅ **DONE** — forget a pinned identity so someone can pair again after a key reset |
-| 14.7 | `meshbay-node member list` | ✅ **DONE** — roster: who is admitted, with what role, pinned when and how. Online status still to add |
-| 14.8 | Config reload (SIGHUP) | ✅ **DONE 2026-08-18** — `meshbay-node reload`. Deliberately narrow: it re-roots **groups already hosted**, which is what an operator adjusts day to day, and reports a changed group *set* as needing a restart. Adding a group live means new indexers, chat stores, GEK loads and transport contexts, and that is how a half-built group ends up serving content. No connection is dropped |
-| 14.9 | ~~`meshbay-node admin-key`~~ | ✅ **Superseded by `operator pair`** — pairing binds the operator's browser key with a one-time code instead of pasting a base64 key, and the auto-pin that made M3 possible is deleted |
-| 14.10 | `meshbay-node denylist` | ✅ **DONE 2026-08-18** — `denylist show|clear [identifier]`. Clearing asks for confirmation and reports the count, because it re-admits whoever it was keeping out |
-| 14.11 | `meshbay-node file rm` | ✅ **DONE 2026-08-18** — `file list|rm <id>`. **No operator action now requires a browser.** Refuses a file whose root is unavailable: it is frozen, not gone |
-
-### Sequencing and factoring (added 2026-08-17)
-
-**Phase 14 finishes before 13.12** (node management from the desktop client), and the
-remaining commands are written against a single internal module rather than beside one.
-
-- The CLI is the only interface that works with the daemon stopped, with no GEK, or with
- no operator paired — exactly the states the desktop client cannot reach, and the ones
- decision E5 sends back to the local machine. Holes here have no fallback.
-- 13.12 would add MNP handlers for operations the CLI already performs through the
- loopback API. **Two paths to one operation with different authorization is the shape of
- C1 and C6.** Factor each operation into `meshbay_node/ops.py`, with the CLI, the
- loopback API and the MNP handler as three thin adapters. Parity becomes structural,
- authorization lives in one place, and 13.12 is adapter code.
-
-The refactor is cheaper now, with six commands left, than after 13.12 exists.
-See `desktop-client-v1.md` §6.6.
-
-**Done 2026-08-18.** `meshbay_node/ops.py` holds each operation once; the loopback API is
-a one-line adapter per endpoint (`_op()` translates `OpError` into a JSON response) and the
-MNP handlers call the same functions through `_run_op`. `test_ops.py` asserts the shape
-rather than trusting it: every operation takes `state` first, `ops` imports nothing
-web-shaped, and no loopback handler performs an operation itself.
-
-**Signed MNP ops shipped with it:** `gek_rotate` and `member_unpin`, both operator-signed
-over a structured transcript like every other destructive operation. Rotation is the half
-of revocation that revocation cannot do — the ex-member holds the current key — and the
-node generates the replacement with its own CSPRNG, so no key material crosses the wire.
-
-### Architecture
-
-CLI commands talk to the running daemon over its **loopback admin API**, authenticated
-with the per-run session token (11.5.3) — `_daemon_api()` in `daemon.py`. The Unix-socket
-design below was the original plan; the loopback API already existed for the admin UI,
-carries the same authorization, and avoided a second control plane. A socket would still
-be an improvement (no port, file permissions instead of a token file) if the admin UI
-ever goes away.
-
-`status` deliberately does *not* use it: it reads the keystore, the config and the roster
-directly, so it works while the daemon is stopped — which is when an operator most needs
-to know why.
-
----
-
-## Phase 15 — Chat encryption + retention
-
-> **Superseded 2026-09-07 by `docs/chat-sender-keys.md`, which is the
-> specification and the decision record. Built.** Read that document before this
-> section: the milestone table below is kept for the history of the decision and
-> is wrong in three places, each marked. In particular 15.1–15.3 put encryption
-> *in the node* — the node is a relay and an archive, and messages are composed
-> and read in the client, so built as written the feature would have protected
-> nothing it claimed.
->
-> The protocol is no longer Sender Keys. `senderkeys.py` is unused and kept for a
-> possible future 1:1 DM, alongside `ratchet.py`.
-
-> Was Phase 13 before the 2026-08-13 renumbering.
-
-**Objective:** implement spec section 6.6 — group chat messages are encrypted
-with the Sender Keys protocol. Currently, chat messages are stored and
-transmitted as plaintext payloads (relying on transport encryption only).
-
-### Background
-
-`meshbay_common.senderkeys` (Phase 7.5) implements the Sender Keys protocol, but nothing
-in production imports it — `grep` finds it only in its own tests. The node chat flow
-(`_do_chat_message`) stores raw payloads. The module provides per-sender chain key
-derivation, symmetric message encryption, and a distribution format.
-
-### 15.0 — Distribution channel ✅ DECIDED 2026-09-03
-
-`draft-v4` §6.6 says sender keys are distributed "via pairwise channels (GEK-wrapped or
-direct)". An earlier version of this document rejected GEK-wrapped distribution on the
-grounds that anyone holding the GEK recovers every sender key.
-
-**Revised 2026-09-03 by operator decision: GEK-wrapped distribution is the right choice
-for this platform.** The reasoning that led to pairwise was sound in isolation but wrong
-for the actual threat model:
-
-- The GEK already gives access to **all files** in the group. Wrapping sender keys
- under it means "anyone who can read the files can read the chat" — which is exactly
- the semantics of a group chat. There is no scenario where a member should read files
- but not chat, or vice versa.
-- The node operator is always a group member and therefore a legitimate sender-key
- recipient. Sender Keys does not protect chat from the operator regardless of the
- distribution channel (see threat delta below).
-- H3 (key substitution at invite) is **closed** (2026-08-14, `invite-pairing-v1.md`).
- The GEK is no longer obtainable through the hub. A former member who kept the old
- GEK is handled by GEK rotation on removal, which is already implemented.
-- Pairwise distribution would add O(devices × members) ECIES wraps per sender key
- change, for a marginal security gain: separating "file access" from "chat access"
- on a platform where both are gated by the same group membership.
-
-**Distribution is GEK-wrapped:** each sender key distribution message is encrypted
-with `wrap_gek_aes` under the group's current GEK. Every member who has the GEK can
-unwrap it. Simple, no fan-out, no new crypto primitive.
-
-### 15.0b — A sender key is per DEVICE, never per person (added 2026-08-17)
-
-**This phase predates device linking (`desktop-client-v1.md` §4) and is wrong as
-written.** One person now holds several identity keys on one node — a browser and a
-desktop client, up to the device cap. Two consequences, and the first is the whole
-decision:
-
-**A shared per-person chain reintroduces C1, one level down.** If Alice's two devices share
-one sending chain, both advance it, and concurrent sends produce **key and nonce reuse** —
-which is precisely why `first-review.md` C1 rejected a shared Double Ratchet for groups.
-Per-device chains have no shared mutable state and no reuse. There is no third option worth
-weighing.
-
-**The code already fails this, silently.** `senderkeys.py` keys everything by
-`sender_id: str`, and `GroupSenderKeyStore.add_sender` does
-`self._states[dist.sender_id] = ...` — so a second device registering under the same
-`sender_id` **overwrites the first, dropping its chain**. Same shape as `pin_identity`'s
-`INSERT OR REPLACE`, same fix: `sender_id` becomes a **device** identifier (account plus
-device key fingerprint), not a `user_id`. The module needs its identifier redefined, not
-restructuring — and the class docstring, which says "one chain per member", needs to say
-per device.
-
-**What follows from per-device chains:**
-
-- **A new device receives all current sender keys via the GEK it already holds** (revised
- 2026-09-03). Since distribution is GEK-wrapped, a device that has completed the
- handshake and received the GEK can unwrap every sender key distribution message. No
- redistribution by every sender is needed; the node replays the latest distribution
- message for each active chain. History encrypted under older chain keys remains
- unreadable only if the chain has ratcheted forward since — which is the expected
- forward-secrecy property, not a gap.
-- **Revoking a device must rotate**, exactly like revoking a member: a lost laptop holds
- every sender key it ever received. 15.4 only knows about members today and must cover
- `device revoke` and `member unpin`.
-
-### Honest threat delta (state this in the docs, not just here)
-
-Sender Keys protects chat against **someone who holds the node's disk but not the GEK** —
-a hosting provider imaging the machine, a backup that leaks, a law-enforcement seizure
-where the keystore password is not surrendered. It does **not** protect chat from anyone
-who holds the GEK, which includes every current group member and the node operator.
-This is the same boundary as file access, by design (operator decision 2026-09-03):
-the GEK is the group secret, and both files and chat are gated by it.
-
-Claiming more than that would repeat the overstatement pattern `second-review.md` §7
-flags. Additions that belong in the user-facing docs:
-
-- **It does not protect against anyone holding any one device of any member.** With
- several devices per person, that surface is larger than it was.
-- **It does not protect against a former member who kept the GEK before rotation.**
- GEK rotation on member removal is implemented, but messages encrypted under the
- old GEK remain readable to anyone who held it. This is the same property as files.
-- **C4's blast radius reaches chat history.** A browser recovers its identity key from the
- keypair bundle on the node; cracking that bundle yields the GEK, and therefore every
- sender key distributed under it. Not a regression — chat is plaintext at rest today —
- but it means Sender Keys is worth measurably less to a browser-using account than to a
- native one, which is the same asymmetry as everywhere else in `desktop-client-v1.md`
- §5.1.
-- **Sender authentication is now a requirement, not an accepted limitation**
- (operator decision, 2026-08-17). A sender key proves *a device*; it does not prove which
- account that device belongs to, and NS6's enforcement of `sender_id` from the session is
- the node's word. The design is in `desktop-client-v1.md` §4.8: **sign every message
- with the sender's device key** (independent of encryption, so it can land before this
- phase), **pin `account → device keys` client-side** using the device-add
- countersignatures as evidence, and optionally have the **operator sign a roster
- attestation** to close first contact. What survives: an operator who turns malicious
- *later* cannot forge an account a member has already seen — forgery is limited to
- accounts the victim has never encountered.
-
-### Milestones
-
-| # | Component | Description |
-|---|---|---|
-| 15.0 | **Distribution decision** | ✅ DECIDED 2026-09-03 (GEK-wrapped), then **overtaken 2026-09-07**: the epoch key is delivered wrapped under the group key, but nothing is *stored* under it — which is what makes a group-key rotation a re-wrap instead of the destruction of the archive |
-| 15.0b | **Per-device chains** | ✅ **Obtained without chains.** One key per device, derived by name from the epoch key, so there is no shared mutable sending state to reuse a nonce and nothing to persist per device |
-| 15.1 | ~~Node: sender key init~~ | ❌ **Wrong as written** — the node is a relay and an archive. It generates and delivers the epoch key (`ops.open_chat_epoch`, `chat_keys_req`); the client seals |
-| 15.2 | ~~Node: encrypt chat on send~~ | ❌ **Wrong as written.** Encryption is in `static/crypto.js`; the node stores what it cannot read |
-| 15.3 | ~~Node: decrypt chat on receive~~ | ❌ **Wrong as written.** Only clients decrypt. Out-of-order does not arise: there is no chain to advance |
-| 15.4 | Key rotation on removal | ✅ Member revoked, unpinned, device revoked, or `gek_rotate` → a new epoch, pushed to everyone connected. Old epochs kept, or the removal would take the history with it |
-| 15.5 | Chat retention config | ✅ `meshbay-node chat prune <days>` / `ops.prune_chat`. Deletes messages, never epoch keys |
-| 15.6 | MNP version negotiation | ✅ **DONE 2026-09-03**, and not here: it shipped with **MNP 1.0** (the sealed index and ack), which forced a coordinated deployment anyway. `handshake` and `handshake_challenge` each carry `v` and `v_min`; `check_version` refuses with `version_too_old` / `version_too_new` / `version_unreadable`, shaped like `not_a_member`. The flag day was already being paid for, so the next breaking change costs a refusal message instead of a second one. See `MESHBAY_NODE_PROTOCOL.md` §13.1 |
-| 15.7 | Chat attachments | **Documented, not encrypted.** Attachments are ordinary files on a shared root and stay plaintext on disk; the *reference* to one is inside the sealed payload, but the file and its name are in the index. Encrypting them is a different feature with a different blast radius — `docs/chat-sender-keys.md` §5.8 states the asymmetry rather than hiding it |
-| 15.8 | **The switch** | ✅ Per group, operator-signed (`OP_CHAT_ENCRYPTED`), reported inside the sealed handshake ack. Off by default — a node upgraded into a running group must refuse nobody. On, the node refuses plaintext outright |
-| 15.9 | **`chat encrypt-history`** | ✅ Explicit CLI command, backs `chat.db` up first, one transaction. Deliberately not done by the switch: it rewrites the only copy of a conversation, and a toggle that does that is one somebody flips twice |
-
----
-
-## Phase 16 — Android client MVP
-
-> Was Phase 14 before the 2026-08-13 renumbering.
->
-> **Rewritten 2026-08-17.** The previous text described an architecture that no longer
-> exists and, in two places, one that was deliberately dismantled. Corrections are listed
-> below rather than silently applied, because the same mistakes are easy to make twice.
->
-> **Shares the desktop design** (`desktop-client-v1.md`): keys generated and kept
-> locally, device Ed25519 authentication, no keypair bundles, and **an Android client is
-> simply another device** under device linking. Do not re-derive a second crypto, auth or
-> admission model here.
-
-**Objective:** Android app for account creation, group browsing, file download, streaming
-and chat. **Client only — no node functionality on mobile**, and that is structural: an
-app cannot freely read the phone's folders (the user grants access to one tree at a time,
-revocably), background processes are killed, and a long-lived listening socket is not
-guaranteed. All three are things a node must have.
-
-### What the previous text got wrong
-
-| It said | Reality |
-|---|---|
-| "The `keypair_bundle` (encrypted, stored on hub) enables cross-device" | **The hub has stored no keypair bundle since 2026-08-12**, and since 2026-08-14 identity keys are **per node** — there is no single identity to carry between platforms. Cross-device is **device linking**, not a shared bundle |
-| "Notification state and read markers sync via hub (small encrypted blob per user)" | Violates the rule that **group-related server state lives on the node** (draft-v6 §2.5). Even encrypted, a per-user blob the hub stores gives it update timing and frequency — who reads which group, when. Node-side or not synced |
-| "Hub client (auth, groups, **GEK**)" | The hub does not serve GEKs. `GET /gek` and the `gek_bundles` table were removed in the T3 work; the node wraps the key on every connection |
-| "NAT traversal (`punch_nat` + QUIC)" | `punch_nat()` is **not** a traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated (structural decision 17). **ICE/STUN is the traversal path**, and Android has a native WebRTC stack |
-| "MNP extended with an `upload` message type" | Already shipped — `FILE_UPLOAD`, Phase 10b.4 |
-| "Account creation … + keypair bundle" | Hybrid registration (draft-v6 §1 item 7): passphrase-derived `auth_key`, then a device Ed25519 key. No bundle anywhere |
-| Milestones numbered 14.x inside Phase 16 | Leftover from the renumbering; they are 16.x below |
-
-**Stack:** Kotlin + Jetpack Compose. **WebRTC via Android's native stack** — the traversal
-path, same as every other client. Crypto via Bouncy Castle JVM.
-
-**QUIC is deferred.** It exists for LAN and hub-less `group://`, which is marginal on a
-phone, and it would drag a Rust JNI dependency (`quiche`) into an MVP. Add it if a real
-use case appears.
-
-### Milestones
-
-| # | Component | Priority |
-|---|---|---|
-| 16.1 | Hub client — auth, groups, notifications (Retrofit) | High |
-| 16.2 | Crypto — Ed25519, X25519, ChaCha20, and **`auth_key` derivation byte-identical to `keyderive.js`/`keyderive.py`** | High |
-| 16.3 | WebRTC DataChannel transport + the unified handshake (11.5.4) | High |
-| 16.4 | **Device linking** — the app generates its own keys and is approved by an already-paired device (§4 of the desktop-client doc) | High |
-| 16.5 | Hybrid registration from the app | High |
-| 16.6 | File browser + download, **root-aware paths**, per-root "unavailable" state | High |
-| 16.7 | Upload from mobile via the existing `FILE_UPLOAD` handler; photo picker, no broad storage permission | Medium |
-| 16.8 | Chat UI | Medium |
-| 16.9 | Video streaming (native player, MSE not required) | Medium |
-| 16.10 | Contact list integration (permission-gated) | Low |
-
-### Consequences carried from the other phases
-
-- **Device linking is a prerequisite** (Stage C), exactly as for the desktop client. Without
- it, installing the app on a phone would need an operator code per node.
-- **A fourth consumer of the KDF parity test.** `auth_key` is PBKDF2-SHA512 600 000 in
- `keyderive.js`, `keyderive.py`, the QE harness and now Kotlin. The standing warning
- applies and matters more each time: **never change those parameters in one place** — a
- mismatch does not look like an error, it looks like an account nobody can open.
-- **Sender Keys**: a phone is a device, so it gets its own chain (§15.0b). The "no history
- until every sender redistributes" property is **most visible here** — people install an
- app and expect their backlog — which argues for the sealed state handover rather than
- the accept-and-explain option.
-- **Version skew is worse than on desktop.** An installed client meets a newer hub
- (`desktop-client-v1.md` §2.6), and store review latency means a fix cannot be
- pushed quickly. The minimum-client-version check is not optional here.
-- **Multi-root** falls out for free if the app is built after Stage A; it must not assume a
- group is one directory.
-
-### Open, and worth deciding before 16.8
-
-**Chat delivery on a phone has no answer today.** Android will not let an app hold a
-WebRTC DataChannel open in the background, so a message arriving while the app is closed
-reaches nobody. The obvious mechanism is a push service, and the obvious push service is
-FCM — which would mean **Google learning the timing of your group activity, and the hub
-sending it**, against the whole metadata posture (H7, draft-v6 §2.5). Alternatives
-(a self-hosted UnifiedPush distributor, a foreground service the user opts into, polling
-on open) each cost something different. **Decide it explicitly; do not let FCM arrive as
-an implementation detail.**
-
-**Out of scope:** node functionality on mobile, Mac and iPhone support.
-
----
-
-## Phase 17 — Network resilience (optional, low priority)
-
-> Was Phase 15 before the 2026-08-13 renumbering.
-
-**Objective:** handle edge cases — symmetric NAT (CGNAT mobile), TURN relay,
-0-RTT reconnection. Not needed for typical residential users.
-
-| # | Component | Priority |
-|---|---|---|
-| 15.1 | Mesh Relay TURN server | Low |
-| 15.2 | Relay registration via MHP | Low |
-| 15.3 | Node fallback to relay after ICE failure | Low |
-| 15.4 | QUIC 0-RTT (session tickets) | Medium |
-| 15.5 | Connection pool (1 QUIC conn = N requests) | Medium |
-| 15.6 | Test CGNAT mobile 4G | Low |
-
-**Note:** enterprise users behind restrictive firewalls can configure port
-forwarding themselves. This phase targets the ~15% of residential connections
-where even ICE/STUN fails (symmetric NAT behind CGNAT). Not a priority —
-the user explicitly deprioritized this.
-
----
-
-## Phase 18 — Packaging, repositories, CI, supply chain
-
-> Was Phase 16 before the 2026-08-13 renumbering.
-> Release **signing** is not here — it moved into 13.9, because a desktop application
-> cannot ship without a verified update channel. This phase covers distro packaging and CI.
-
-| # | Component |
-|---|---|
-| 18.1 | RPM build pipeline (Fedora, RHEL) |
-| 18.2 | DEB build pipeline (Ubuntu, Debian) |
-| 18.3 | GitHub Actions CI (pytest + ruff on PR) |
-| 18.4 | **Security CI**: the 11.5.23 regression suite + the 12.1 hub-blindness test run on every PR; dependency audit (`pip-audit`); static analysis (`bandit`/`semgrep`) |
-| 18.5 | Repo apt/dnf on meshbay.org/packages/, signed with the 13.9 key |
-| 18.6 | Android APK distribution on meshbay.org/downloads/ |
-| 18.7 | Reproducible builds for the desktop client (stretch) — lets third parties verify the shipped bundle matches the source, the last piece of the T3 answer |
-
----
-
-## Phase 19 — Extension module sandbox (future)
-
-> Was Phase 17 before the 2026-08-13 renumbering.
-> Adds a large new attack surface (arbitrary code near group data). Requires its own
-> security review before any code is written. Must stay last.
-
-**Objective:** implement spec section 12 — Python extension modules that can
-react to group events, access the file index, and send messages, running in
-a sandboxed subprocess with limited permissions.
-
-| # | Component | Description |
-|---|---|---|
-| 17.1 | Module manifest loader | Parse `module.toml`, validate permissions |
-| 17.2 | Sandboxed subprocess | `read_index()`, `send_message()`, `receive_events()` API |
-| 17.3 | Permission enforcement | No filesystem/network beyond group context |
-| 17.4 | Module marketplace on hub | List/install/rate extension modules |
-
-**Low priority.** This is an extensibility feature for power users and
-community developers. Core functionality must be complete and stable first.
-
----
-
-## Recommended order
-
-```
-Phase 11.5 (Security remediation) ⛔ BLOCKING — nothing else starts
-Phase 13.1 (Hub base URL + adapters)← free refactor, prerequisite for the desktop client
-Phase 12 (Key verification) ← H3 safety numbers + served-SPA integrity
-Phase 14 (Node CLI) ← best security-per-effort answer to T3
-Phase 15 (Sender Keys) ← chat encryption; 15.0 decision first
-Phase 13.2+ (Desktop client) ← Electron; offered alongside the browser SPA
-Phase 16 (Android) ← reuses the Phase 13 design
-Phase 17 (Resilience) ← optional, edge cases only
-Phase 18 (Packaging + CI) ← distro repos; 18.7 gates 13's security argument
-Phase 19 (Extensions) ← last, needs its own security review
-```
-
-**Reordered 2026-08-13.** The desktop client was originally placed third on the strength of
-"it removes T3". That claim was corrected (see the Phase 13 banner), so the client is now
-sequenced after the work that closes actual findings, and behind decision D2 in
-`tmp-decisions.md`. Security-per-effort: **11.5 ≫ 12 ≫ 14 ≫ 13**.
-
-Phase 14 (CLI) moved ahead of the client work for a specific reason: the node operator holds
-the GEK and is the content authority, yet today must use hub-served JS to initialize GEKs and
-invite members. The CLI removes that dependency for the highest-value target at a fraction of
-any client's cost.
-
-**Phase 11.5 is blocking and not negotiable.** The current build serves private group
-content over an unauthenticated HTTP port (C1), lets any user hijack a node's signaling
-identity (C2), and lets any member seize the group key (C5b). No feature work lands on top
-of that.
-
-**One task can run in parallel:** 13.1 (hub base URL + storage/save-file adapter) is pure
-refactoring with the acceptance criterion "the browser SPA is unchanged in behaviour". It
-de-risks Phase 13 and touches none of the security surface.
-
-**One task must not be deferred inside Phase 13:** 13.3b (device linking). It is a protocol
-and schema change, it gates the "native must not prevent web use" objective, and the
-roster's `pin_identity` currently does `INSERT OR REPLACE` on a `user_id` primary key — a
-silent overwrite that becomes a hole the moment more than one key per person is legitimate.
-
-**Renumbering map (2026-08-13):**
-
-| Old | New | Phase |
-|---|---|---|
-| — | 11.5 | Security remediation (new) |
-| — | 12 | Hub minimization (new) |
-| — | 13 | Native desktop client (new) |
-| 12 | 14 | Node CLI + management |
-| 13 | 15 | Chat encryption (Sender Keys) |
-| 14 | 16 | Android client |
-| 15 | 17 | Network resilience |
-| 16 | 18 | Packaging, repos, CI |
-| 17 | 19 | Extension module sandbox |
-
----
-
-## Structural decisions (all resolved)
-
-1. Multi-group on a single QUIC port ✅ (Phase 7)
-2. Signaling punch/connect via hub WS ✅ (Phase 7)
-3. Chat is a core feature, not a module ✅ (draft v3)
-4. Chat encryption: Sender Keys ✅ (security review)
-5. JWT group claims required ✅ (security review)
-6. Admin model: config-based ✅ (Phase 8)
-7. Refresh token rotation: family-based ✅ (Phase 8)
-8. Email encrypted at rest: AES-256-GCM ✅ (Phase 8)
-9. Argon2id params: 256 MB, pw_version for migration ✅ (Phase 8)
-10. **Browser transport: WebRTC DataChannel + ICE/STUN** ✅ (decided 2026-08-10)
-11. **Hub role: registrar + signaling ONLY, never in data path** ✅ (reinforced 2026-08-10)
-12. **Chat stored on nodes, not hub** ✅ (decided 2026-08-10)
-13. **Web UI: Preact SPA, dark/light, responsive, i18n** ✅ (decided 2026-08-10)
-14. **Site overlay: meshbay.org-specific pages separate from generic hub** ✅ (decided 2026-08-10)
-15. **MSE streaming: ffmpeg fMP4 remux on node, SourceBuffer on browser** ✅ (Phase 10c)
-16. **Transport: aiortc/ICE is primary for browser AND native. QUIC kept at parity for LAN,
- port-forwarded and hub-less `group://` access. TCP+TLS and the node HTTP API are
- removed.** ✅ (decided 2026-08-13, second review)
-17. **`punch_nat()` is a direct-connection helper, not a NAT traversal stack** — no STUN, no
- candidate gathering, no dual-stack fallback, validated on one ISP. ICE/STUN (validated on
- two ISPs, two browsers, IPv4 + IPv6 + 4G CGNAT) is the traversal path. ✅ (2026-08-13)
-18. ~~**Native desktop shell: pywebview**~~ → **Electron**, with an optional Python sidecar
- for hub-less `group://` over QUIC. **Reversed 2026-08-17** — the SPA depends on
- Chromium-class APIs, so a system webview meant reimplementing ~2500–3000 lines and
- losing the renderer sandbox. What is unchanged and non-negotiable: **UI assets ship
- inside the package and load from disk**, never fetched from the hub, or T3 is not
- fixed. See `desktop-client-v1.md` §2.
-18b. **A second device is admitted by device linking, not by an operator code.** The
- already-pinned key countersigns; the binding is a one-time code the new device
- generates and displays, never a human comparing digits. The hub cannot produce that
- countersignature. ✅ (2026-08-17)
-19. **Private keys never leave the device on native clients.** Keypair bundles are retired
- rather than relocated; Phase 12's move of bundles from hub to node was the wrong
- destination (C4). ✅ (2026-08-13). **Qualified 2026-08-17:** this holds for native
- devices. A browser has no durable storage of its own and still needs a bundle on each
- node, so C4 closes for an account only when it opts out of browser use.
-20. ~~Sender keys are distributed pairwise to identity keys, never derived from or wrapped
- under the GEK.~~ **Reversed 2026-09-03:** sender keys are distributed **GEK-wrapped**.
- The GEK is the group secret; both files and chat are gated by it. Pairwise distribution
- would add complexity for a separation (files vs chat) that has no meaning in this
- platform's group model. Per-device chains (15.0b) remain required for correctness
-21. **Hub minimization is enforced by an acceptance test (12.1), not by policy.** The hub
- must be *unable* to see keys, content, or file listings. ✅ (2026-08-13)
diff --git a/docs/first-review.md b/docs/first-review.md
deleted file mode 100644
index 17d5b36..0000000
--- a/docs/first-review.md
+++ /dev/null
@@ -1,382 +0,0 @@
-# MeshBay — First Architecture & Security Review
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the first security review; its design
-> content now lives in §13.1, and the invariants each finding names throughout.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Date: 2026-08-10
-> Scope: design-level review of the cryptographic architecture, trust model, and
-> security properties as specified in draft v3 (archived in `old-draft.md`) and implemented
-> through Phases 1-6 (81 tests, demo-v2 validated).
->
-> This review does NOT assess the security of the demo/test deployment. It evaluates
-> whether the architecture, as designed, provides a robust foundation for a secure
-> decentralized platform.
-
----
-
-## Executive Summary
-
-The cryptographic architecture is **strong and well-designed**. The algorithm
-choices are modern and correct, the trust model is sound, and the key hierarchy
-is properly separated. The six POC spikes were genuinely useful — the jti fix
-(Spike 3), the Argon2id recalibration (Spike 1), and the GEK wrapping protocol
-confirmation (Spike 6) are exactly the kind of findings that save projects from
-shipping real vulnerabilities.
-
-There are **no fatal design flaws**. The issues found are fixable before Phase 7,
-and the most important one (Double Ratchet group model) should be resolved
-before writing production chat code.
-
-Classification: **Critical** (must fix before production), **Significant** (design
-gap, fix before Phase 8), **Minor** (improvement, can schedule), **Note** (observation,
-no action required).
-
----
-
-## What Is Solid
-
-These design decisions are correct and represent genuine security engineering:
-
-**1. Hub-blind GEK wrapping (ECIES-like)**
-The wrapping protocol (ephemeral X25519 + HKDF + ChaCha20-Poly1305 with AAD) is
-textbook ECIES done right. The hub stores opaque blobs, the ephemeral keypair
-ensures each wrapping produces different ciphertext, and the AAD binding to
-`pk_recipient` prevents bundle swapping attacks. This is the most important
-crypto decision in the system and it's correct.
-
-**2. Ed25519 identity verification independent of TLS**
-Nodes use self-signed TLS certs for transport confidentiality only. Client
-verifies the node's Ed25519 public key (from hub) at the MNP handshake layer.
-This decouples transport security from identity — the right design for a system
-where nodes can't get CA-signed certificates.
-
-**3. Mandatory jti in JWT**
-The Spike 3 finding was critical. Ed25519 is deterministic — without jti, two
-tokens issued in the same second are bit-identical. Adding UUID4 jti to every
-token was the correct fix. The architecture now enables per-token revocation.
-
-**4. On-the-fly encryption model**
-Files stored in plaintext on the node, encrypted at read time with per-chunk keys
-derived from the GEK via HKDF. This avoids the double-storage problem
-(encrypted + plaintext) and makes GEK rotation feasible without re-encrypting
-terabytes on disk.
-
-**5. Domain separation in HKDF**
-Every key derivation uses a distinct `info` string (`meshbay:gek_wrap:v1`,
-`meshbay:ratchet:root:v1`, etc.). The AES variant adds `:aes` suffix to chunk
-key derivation. This is a small detail that prevents cross-protocol key reuse
-and shows mature crypto engineering.
-
-**6. Transport abstraction layer**
-The `Transport` interface allowing TCP+TLS v1 → QUIC v2 migration without
-protocol changes was a good architectural decision confirmed by the successful
-demo-v2 QUIC validation.
-
-**7. Refresh token stored as blake3 hash**
-Server never stores the raw refresh token — only its hash. Correct pattern.
-Database breach doesn't leak usable refresh tokens.
-
----
-
-## Critical Issues
-
-### C1 — Double Ratchet is not suitable for group chat as described
-
-**Location:** `meshbay_common/ratchet.py`, draft v3 section 6.6
-
-**Problem:** The draft states "all members share the same ratchet state seeded
-from the group GEK." The Signal Double Ratchet is designed for **pairwise**
-(1:1) communication. It fundamentally cannot work as a shared group state:
-
-- If all N members share a single ratchet state, each member advancing the
- sending chain desynchronizes all other members. Message 5 from Alice and
- message 5 from Bob would use the same chain key, producing a nonce/key reuse
- — a catastrophic failure for ChaCha20-Poly1305 and AES-GCM.
-- The current `RatchetState` class has one `CKs` (sending chain) and one `CKr`
- (receiving chain), confirming it's a pairwise protocol.
-
-**What Signal actually does for groups:** Signal uses a different protocol called
-**Sender Keys** (described in their "Group Protocol" specification). Each member
-has their own symmetric sending chain key. When a member joins a group, all
-existing members send their current sender key to the new member via pairwise
-Double Ratchet channels. This gives forward secrecy per member, not per message.
-
-**Impact:** If implemented as described, group chat will either:
-- Silently corrupt messages (if state is truly shared), or
-- Require N*(N-1)/2 pairwise ratchet sessions (O(N^2) state, impractical for
- groups > 10 members)
-
-**Recommendation:** Before Phase 7.5 (chat), decide between:
-1. **Sender Keys** (Signal Groups approach): each member maintains one symmetric
- sending chain. Forward secrecy at member rotation granularity. O(N) state.
- Simpler to implement, good enough for most threat models.
-2. **Pairwise Double Ratchet**: keep the current implementation but use it for
- 1:1 messages only. Group messages would be encrypted N-1 times. O(N^2) cost
- per message — only feasible for small groups (<20).
-3. **MLS (Message Layer Security, RFC 9420)**: the modern standard for group
- messaging. Tree-based ratcheting, O(log N) state and messages. More complex
- but future-proof. Python implementations exist (`openmls` bindings, or
- `mls-protocol`).
-
-Recommendation: **Sender Keys** for v1 (pragmatic, Signal-proven), with the
-option to migrate to MLS later if group sizes grow.
-
-### C2 — No group membership verification in MNP handshake
-
-**Location:** `quic_server.py:125-146`, `server.py:130-151`
-
-**Problem:** The MNP handshake verifies the JWT signature and expiration, but does
-NOT check whether the authenticated user is a member of the group being accessed.
-Any valid JWT holder can request any file from any group served by the node.
-
-The draft says the JWT carries "hub-signed groups membership claim" (section 4.1.4),
-but the actual `issue_access_token()` in `auth.py:104-125` does not include any
-group membership claims. The JWT contains only `sub`, `pk_user`, `hub_id`, `jti`,
-`iat`, `exp`.
-
-**Impact in Phase 7 (multi-group):** A user authenticated for group A can request
-files from group B on the same node. Since all groups share one QUIC port, this
-becomes an authorization bypass.
-
-**Recommendation:**
-- Add group membership claims to the JWT: `"groups": ["group_id_1", "group_id_2"]`
-- Node verifies the requested group_id is in the JWT's groups claim
-- This is a simple change to `issue_access_token()` + handshake verification
-- The JWT is already verified offline with the hub's Ed25519 key — adding claims
- doesn't change the verification flow
-
----
-
-## Significant Issues
-
-### S1 — Admin revocation endpoint has no authorization check
-
-**Location:** `revocation.py:149-194`
-
-**Problem:** The `admin_revoke` endpoint requires authentication (`get_current_user`)
-but does NOT verify that the current user is a hub admin. Any authenticated user
-can revoke any other user or any group. The docstring says "Admin only (user must
-be hub admin — user_id in config)" but no such check is implemented.
-
-**Impact:** Any registered user can revoke any other user or group on the hub.
-This is a privilege escalation vulnerability.
-
-**Recommendation:** Phase 8 plans admin roles (8.1: `hub_admin` flag on User). This
-check must be added before the revocation endpoint is used in any non-demo context.
-For now, the endpoint exists but is only callable by someone who knows the API —
-acceptable for a test deployment, not for production.
-
-### S2 — Email stored in plaintext in the database
-
-**Location:** `models.py:42`, draft v3 section 4.1.1
-
-**Problem:** The spec says "Email and phone are stored encrypted at rest in the
-database." The actual `User` model stores email as `String(256)` — plaintext.
-A database breach would expose all user emails.
-
-**Recommendation:** Encrypt email (and future phone field) with a server-side key
-derived from a secret not stored in the database (e.g., from the hub config file).
-Use AES-256-GCM with a deterministic IV derived from user_id (for lookups) or
-accept that encrypted email cannot be searched by value.
-
-### S3 — No jti denylist distribution to nodes
-
-**Location:** draft v3 section 4.1.4, open question #9
-
-**Problem:** The architecture describes a jti denylist for immediate token revocation,
-but:
-- The hub has no `GET /v1/revoke/denylist` endpoint (marked [TBD])
-- Nodes don't check any denylist during JWT verification
-- The revocation WebSocket pushes revocation tokens to nodes, but nodes don't
- persist or check them during MNP handshake
-
-**Impact:** A revoked user's JWT remains valid for up to 1 hour (until natural
-expiration). The revocation WebSocket can close active connections, but new
-connections with the same JWT will succeed.
-
-**Recommendation:** Two options:
-1. **Push + local cache** (recommended): when the node receives a revocation via
- WebSocket, it adds the jti to an in-memory set. MNP handshake checks this set.
- Simple, real-time, no polling.
-2. **Pull**: node periodically fetches the denylist from the hub. Adds latency
- between revocation and enforcement.
-
-Option 1 is simpler and already half-built (the WebSocket channel exists).
-
-### S4 — AES-GCM keystore uses non-standard 128-bit IV
-
-**Location:** `crypto.py:148` — `iv = os.urandom(16)`
-
-**Problem:** AES-GCM is specified for 96-bit (12-byte) nonces (NIST SP 800-38D).
-The keystore encryption uses a 128-bit (16-byte) IV. The `cryptography` library
-accepts this and processes it through GHASH to derive the internal counter, which
-is secure — but it's a deviation from the standard.
-
-**Impact:** No direct vulnerability. AES-GCM with >96-bit IVs has a slightly
-different security proof (birthday bound applies to the GHASH reduction). For a
-keystore that's encrypted once and rarely re-encrypted, the practical risk is zero.
-
-**Recommendation:** Change to `os.urandom(12)` for standard compliance. Simple
-one-line fix. The existing keystore files would need re-encryption on next save
-(which happens naturally when the user updates their keystore).
-
-### S5 — Refresh token not rotated on use
-
-**Location:** `users.py:158-179`
-
-**Problem:** When a refresh token is used to obtain a new access token, the same
-refresh token remains valid. If an attacker intercepts a refresh token, they can
-use it repeatedly alongside the legitimate user, and neither party detects the
-theft.
-
-**Recommendation:** Implement refresh token rotation: each use of a refresh token
-issues a new refresh token and invalidates the old one. If the old token is used
-again (by the attacker), the hub detects the reuse and revokes all tokens for
-that user (indicating theft). This is the OAuth 2.0 Security BCP recommendation
-(RFC 6819, section 5.2.2.3).
-
----
-
-## Minor Issues
-
-### M1 — Username enumeration via registration and pubkeys endpoints
-
-The registration endpoint returns "Username already taken" (409), and
-`GET /v1/users/{username}/pubkeys` returns 404 vs a valid response. Both allow
-enumerating valid usernames. For a decentralized platform where users have public
-identities, this may be acceptable by design, but it should be a conscious
-decision.
-
-### M2 — TLS self-signed certificate uses RSA-2048
-
-**Location:** `tls_cert.py:36`
-
-The TLS cert uses RSA-2048 while the rest of the system uses Ed25519. Since the
-cert is only for transport confidentiality (identity is verified via Ed25519),
-this is acceptable. However, using an Ed25519 TLS certificate would be more
-consistent and is supported by modern TLS 1.3 stacks. RSA-2048 is ~112-bit
-security; Ed25519 is ~128-bit.
-
-### M3 — No rate limiting on GEK retrieval and pubkeys endpoints
-
-Only `/register` and `/login` have rate limiting. An attacker could enumerate
-pubkeys or attempt to retrieve GEK bundles at high frequency. While GEK bundles
-are opaque (no direct attack), rate limiting on all authenticated endpoints is
-good hygiene.
-
-### M4 — Single admin per group with no delegation or recovery
-
-If the admin's node goes offline, the group becomes inaccessible: no new members
-can be added, no GEK rotation, no moderation. There's no mechanism for admin
-delegation or recovery. For a personal file-sharing platform this may be
-acceptable, but for any group with more than a few members, this is a
-single-point-of-failure.
-
-### M5 — Chunk key derivation uses HKDF salt=None
-
-**Location:** `crypto.py:46-51`
-
-The code uses `salt=None` and puts the file context in `info`. This is actually
-correct HKDF usage (salt is for randomizing extraction when IKM might be
-non-uniform; GEK is from CSPRNG so salt isn't needed; info is for domain
-separation). However, the draft v3 spec describes it as using `salt`, which
-creates a spec/code discrepancy. Update the spec to match the code, since the
-code is correct.
-
-### M6 — Argon2id production parameters not yet applied
-
-**Location:** `crypto.py:131-133`, `auth.py:24-26`, `keyderive.py:33-35`
-
-All three Argon2id usage sites still use the dev parameters (iterations=3,
-memory=64MB, ~78ms). Production target is iterations=4, memory=256MB, ~500ms.
-Phase 7.7 plans a calibration CLI command. This must be done before any
-real-world deployment. The comments document this correctly.
-
----
-
-## Notes (No Action Required)
-
-### N1 — Forward secrecy model is appropriate
-
-File encryption uses GEK-derived symmetric keys — no forward secrecy at the
-application layer. If GEK is compromised, past files are decryptable. This is
-documented and accepted: the alternative (per-session file encryption keys)
-would break seeking, caching, and multi-source download. The transport layer
-(TLS 1.3 / QUIC) provides forward secrecy for data in transit.
-
-### N2 — Error messages in login are correct
-
-`login()` returns the same "Invalid credentials" for both user-not-found and
-wrong-password. This is the correct behavior to prevent user enumeration through
-the login flow (even though registration and pubkeys endpoints allow it — see M1).
-
-### N3 — Hub legal exposure model is well-positioned
-
-The hub stores no content, no metadata, no node IPs (beyond ephemeral signaling).
-GEK bundles are opaque. The hub's legal exposure is analogous to a domain
-registrar or email provider — it knows who registered but not what they share.
-LCEN/DSA compliance is addressed through IP logging with 1-year retention.
-
-### N4 — QUIC NAT probe content is fine
-
-`punch_nat()` sends `b'MESHBAY:NAT:PUNCH'` as a fixed probe. Some NAT
-implementations might filter constant payloads, but in practice this works
-(demo-v2 confirmed). The content of the probe packet doesn't matter for NAT
-entry creation — only the 5-tuple (src_ip, src_port, dst_ip, dst_port, proto)
-matters.
-
-### N5 — Web/CLI key derivation mismatch is by design
-
-Strategy A (Argon2id) and Strategy B (PBKDF2-SHA512 in browser) produce
-different keys from the same password. The code and docs correctly explain this:
-users pick one registration path. The web client uses random keypairs stored
-encrypted on the hub, not password-derived keys. This avoids the mismatch
-entirely.
-
----
-
-## Prioritized Action Plan
-
-| # | Issue | Severity | When to fix |
-|---|---|---|---|
-| C1 | Double Ratchet group model | Critical | Before Phase 7.5 (chat) |
-| C2 | No group membership in JWT/handshake | Critical | Phase 7.3 (multi-group) |
-| S1 | Admin revocation has no authz check | Significant | Phase 8.1 (admin roles) |
-| S2 | Email stored in plaintext | Significant | Phase 8 |
-| S3 | No jti denylist on nodes | Significant | Phase 7.2 (signaling) |
-| S4 | AES-GCM 128-bit IV | Significant | Any time (1 line) |
-| S5 | Refresh token rotation | Significant | Phase 8 |
-| M1 | Username enumeration | Minor | Accept or Phase 8 |
-| M2 | RSA-2048 TLS cert | Minor | Phase 7 or later |
-| M3 | Rate limiting gaps | Minor | Phase 8.6 |
-| M4 | Single admin SPOF | Minor | Phase 8+ |
-| M5 | Spec/code HKDF discrepancy | Minor | Update spec |
-| M6 | Argon2id prod params | Minor | Phase 7.7 |
-
----
-
-## Conclusion
-
-MeshBay's security architecture is built on solid foundations. The cryptographic
-primitive choices are modern and correct. The trust model (hub-blind, node-hosted,
-E2E encrypted) is well-designed and consistently applied. The POC spikes caught
-real issues (jti, Argon2id calibration, NAT behavior) that would have been
-difficult to fix post-deployment.
-
-The two critical issues (C1: group ratchet model, C2: group membership
-authorization) are both design decisions that need to be made before Phase 7
-produces production chat and multi-group code. They are not retroactive problems
-— they are forward-looking decisions that the architecture leaves room for.
-
-The significant issues (S1-S5) are implementation gaps that should be addressed
-during Phases 7-8, in the natural course of hardening the hub and node.
-
-Overall assessment: **good foundations, ready for Phase 7** after deciding the
-group chat encryption model (C1) and adding group claims to the JWT (C2).
diff --git a/docs/indexing-v2.md b/docs/indexing-v2.md
deleted file mode 100644
index a3522de..0000000
--- a/docs/indexing-v2.md
+++ /dev/null
@@ -1,324 +0,0 @@
-# Indexing v2 — Partial-read hashing for large files
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the partial-read hashing; its design
-> content now lives in §6.3.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **built.** `hash_version` and partial-read hashing are in the indexer,
-> the protocol and the hash cache. Kept as the decision record and for the
-> migration notes; this header said "not built" long after it shipped.
-
----
-
-## 0. Problem
-
-The current indexer reads every file in full to compute its blake3 content hash (`id`).
-For a large media library (multi-terabyte, thousands of files), this means:
-
-- **Time**: initial indexing takes tens of minutes to hours.
-- **Disk I/O**: every byte of every file is read, which wears SSDs and saturates spinning
- drives for the entire duration. A USB hard drive serving a 4 TB library is pegged for
- over an hour.
-- **Blocking**: no connected peer receives a usable index until the full scan finishes.
-
-The hash exists for **content identity** (deduplication, cross-group search, file
-requests). A 4 GB film does not need 4 GB of I/O to be identified with overwhelming
-probability — 45 MB of well-chosen samples suffice.
-
----
-
-## 1. Design
-
-### 1.1 Hashing rules
-
-| File size | Method | `hash_version` |
-|--------------------|-----------------------------------------------------|-----------------|
-| <= 40 MB | Full read, blake3 of entire content (unchanged) | `1` |
-| > 40 MB | Partial read, blake3 of 45 MB sampled (see below) | `2` |
-
-**Partial-read algorithm (hash_version 2):**
-
-Given a file of `S` bytes where `S > 40 MB`:
-
-1. Read the first **20 MB** (bytes `[0, 20 MB)`).
-2. Append the last **20 MB** (bytes `[S - 20 MB, S)`).
-3. Append **5 MB** starting at **50% of the file** (bytes `[S // 2, S // 2 + 5 MB)`).
-4. Compute `blake3(concatenation of the three regions)`.
-
-The three regions may overlap for files just above 40 MB. This is fine — the concatenation
-is deterministic for a given file, which is the only property that matters.
-
-**Why these offsets.** Head and tail catch container headers, trailers, and the common case
-of files that differ only at one end (re-encoded, re-muxed, appended). The mid-sample
-catches files that share a header and trailer but differ in content (same container,
-different media stream).
-
-**Why 40 MB threshold.** Below 40 MB the partial read would sample the entire file anyway
-(head + tail >= file size), so the full-read path is both simpler and produces the same
-result. The boundary is inclusive: a 40 MB file is read in full.
-
-### 1.2 `hash_version` field
-
-A new field on `IndexEntry`:
-
-```
-hash_version: int = 1
-```
-
-- `1` — the `id` is blake3 of the full file content. This is the only value any existing
- node has ever produced.
-- `2` — the `id` is blake3 of the 45 MB partial sample described above.
-
-**For files <= 40 MB on a v2 node, `hash_version` stays `1`.** The hash is identical to
-what a v1 node produces, because both read the file in full. This preserves cross-group
-search compatibility for small files across v1 and v2 nodes.
-
-**For files > 40 MB on a v2 node, `hash_version` is `2`.** The hash is different from
-what a v1 node would produce for the same file. This is the accepted side effect.
-
-### 1.3 Backward compatibility
-
-| Scenario | Behaviour |
-|---|---|
-| v2 node sends `hash_version` to v1 client | Client ignores unknown field (JS objects are open) |
-| v1 node sends entries without `hash_version` | Client/consumer treats it as `1` (dataclass default) |
-| v2 `IndexEntry(**e)` where `e` lacks `hash_version` | Uses default `1` — existing serialized indexes deserialize correctly |
-| Cross-group search: same file, one node v1, one node v2 | Different `id` for files > 40 MB — not merged. Accepted |
-| Cross-group search: same small file, mixed nodes | Same `id` (both `hash_version=1`) — merged correctly |
-| Hub tables (`swarm_sources`, `content_blocklist`, `content_reports`) | Store `content_hash` as an opaque string. No change needed |
-| `GroupIndex.serialize()` / `deserialize()` | `asdict(e)` includes `hash_version`; `IndexEntry(**e)` with default handles missing field |
-
-**Nothing breaks.** A v1 node's data remains valid. A v2 node produces correct new hashes.
-Mixed v1/v2 environments work, with the documented search side effect.
-
----
-
-## 2. Affected components
-
-### 2.1 `meshbay_common` — `protocol.py`
-
-| Change | Detail |
-|---|---|
-| `IndexEntry` dataclass | Add `hash_version: int = 1` field |
-| `index_entry_wire()` | Add `"hash_version": e.hash_version` to the wire dict |
-
-### 2.2 `meshbay_node` — `indexer/indexer.py`
-
-| Change | Detail |
-|---|---|
-| Constants | `_PARTIAL_THRESHOLD = 40 * 1024 * 1024`, `_PARTIAL_HEAD = 20 * 1024 * 1024`, `_PARTIAL_TAIL = 20 * 1024 * 1024`, `_PARTIAL_MID = 5 * 1024 * 1024` |
-| `_scan_file()` | After stat, if `size > _PARTIAL_THRESHOLD`: use partial-read blake3. Set `hash_version=2` on the returned `IndexEntry`. Otherwise: unchanged (full read, `hash_version=1`) |
-| `_hash_or_cached()` | Compute expected `hash_version` from file size. Pass it to cache `lookup()`. Store it in cache `put()`. Set it on the returned `IndexEntry` |
-
-**`_scan_file` partial-read implementation:**
-
-```python
-def _partial_hash(file_path: Path, size: int) -> str:
- hasher = blake3.blake3()
- with open(long_path(file_path), "rb") as f:
- _feed(hasher, f, _PARTIAL_HEAD)
- f.seek(size - _PARTIAL_TAIL)
- _feed(hasher, f, _PARTIAL_TAIL)
- f.seek(size // 2)
- _feed(hasher, f, _PARTIAL_MID)
- return hasher.hexdigest()
-
-def _feed(hasher, f, nbytes: int) -> None:
- remaining = nbytes
- while remaining > 0:
- chunk = f.read(min(_HASH_CHUNK, remaining))
- if not chunk:
- break
- hasher.update(chunk)
- remaining -= len(chunk)
-```
-
-### 2.3 `meshbay_node` — `indexer/cache.py`
-
-| Change | Detail |
-|---|---|
-| Schema | Add `hash_version INTEGER NOT NULL DEFAULT 1` column to `files` table |
-| `_SCHEMA` | New databases get the column via `CREATE TABLE` |
-| `_MIGRATE_V2` | `ALTER TABLE files ADD COLUMN hash_version INTEGER NOT NULL DEFAULT 1` — applied on `open()` if the column does not exist |
-| `lookup()` | Add `hash_version` parameter. WHERE clause becomes `path = ? AND size = ? AND mtime = ? AND hash_version = ?` |
-| `put()` | Add `hash_version` parameter. INSERT includes `hash_version` |
-| `CachedEntry` | Add `hash_version: int` field |
-
-**Auto-migration on open:** `IndexCache.open()` runs the ALTER TABLE inside a try/except
-(column already exists → no-op). This way a node upgrade just works — no manual step
-needed for the cache.
-
-### 2.4 `meshbay_node` — `indexer/group_index.py`
-
-No code change needed. `serialize()` calls `asdict(e)` which includes `hash_version`.
-`deserialize()` calls `IndexEntry(**e)` which uses the default `1` for entries written
-before v2.
-
-### 2.5 `meshbay_node` — `transport/wire.py`
-
-No code change needed. `index_sync_message()` and `index_delta_message()` call
-`index_entry_wire()` which is updated in §2.1.
-
-### 2.6 Client side (browser / desktop)
-
-**No code changes required.** Entries are JavaScript objects; the extra `hash_version`
-field is carried through without needing explicit handling:
-
-- `transport.js` — `_applyIndexMessage()` passes the opened payload through. `hash_version`
- rides along on each entry object.
-- `group-page.js` — `applyIndex()` / `applyIndexDelta()` store entries as-is in state
- and in IndexedDB.
-- `hub-client.js` — IndexedDB cache stores entry objects verbatim.
-- `source-merge.js` — merges by `id`. Different hashes naturally don't merge.
-- `search-page.js` — aggregates entries from cached indexes. No change.
-- `files-app.js` — renders entries. `hash_version` is ignored.
-
-### 2.7 Hub side
-
-**No code changes required.** `swarm_sources.content_hash`, `content_reports.content_hash`,
-`content_blocklist.content_hash` are opaque `String(64)` columns. They store whatever
-blake3 hex the node provides. No hub migration needed.
-
-### 2.8 Tests
-
-| Test | What it verifies |
-|---|---|
-| `test_indexer.py` — new cases | Partial hash for file > 40 MB produces `hash_version=2`. File <= 40 MB produces `hash_version=1`. Partial hash is deterministic. Partial hash differs from full hash for the same large file |
-| `test_indexer.py` — existing cases | All existing tests still pass (small files, type detection, enrichment, etc.) |
-| `test_index_cache.py` — new cases | Cache lookup with `hash_version` match. Cache miss when `hash_version` differs. Schema migration from v1 cache |
-| `test_index_cache.py` — existing cases | Unchanged behaviour for v1 entries |
-| `test_indexer.py` — round-trip | `GroupIndex.serialize()` → `deserialize()` preserves `hash_version` on entries |
-| `test_index_seal_client.py` | Wire format includes `hash_version`, old entries without it deserialize as v1 |
-
----
-
-## 3. Migration
-
-### 3.1 What actually needs migrating
-
-The node has two relevant stores:
-
-| Store | Location | Content | Migration |
-|---|---|---|---|
-| `index_cache.db` | `data_dir/index_cache.db` | `(path, size, mtime) → hash` accelerator | Add `hash_version` column |
-| In-memory `GroupIndex` | rebuilt from disk on every startup | Current file listing | No migration — rebuilt on next scan |
-
-**The IndexCache is the only persistent store that needs a schema change.** The GroupIndex
-is rebuilt by scanning the filesystem on every daemon start. Once the code uses v2 hashing,
-the next startup produces v2 hashes for large files automatically.
-
-**The cache auto-migrates.** `IndexCache.open()` adds the `hash_version` column if missing.
-Existing rows get `DEFAULT 1`. When the v2 indexer looks up a large file with
-`hash_version=2`, the cached v1 entry won't match (different hash_version in WHERE), so
-the file is re-hashed with the partial algorithm and the new entry is written with
-`hash_version=2`.
-
-This means: **large files are re-hashed lazily on first scan after upgrade.** The first
-scan after upgrading to v2 re-reads 45 MB per large file instead of the full content —
-already much faster than v1's full read.
-
-### 3.2 Migration script — `QE/migration/migrate_index_v2.sh`
-
-A standalone bash script (not in git — QE/ is gitignored) that:
-
-1. Detects the node's `data_dir` from `node.toml` (default `~/.local/share/meshbay-node/`)
-2. Checks that `index_cache.db` exists
-3. Runs `ALTER TABLE files ADD COLUMN hash_version INTEGER NOT NULL DEFAULT 1`
-4. Optionally (`--purge-large`) deletes cache entries for files > 40 MB, forcing immediate
- re-hash on next scan instead of lazy migration
-5. Reports what it did
-
-**Works on Ubuntu and Fedora** — uses only `sqlite3` (present by default on both) and
-standard bash.
-
-**Not strictly required** if the code's auto-migration in `IndexCache.open()` is
-implemented. The script exists for operators who want to:
-- Verify the schema change before restarting
-- Force a clean re-hash of all large files in one pass
-- Run the migration on a machine where the node isn't installed yet (preparing a data dir)
-
-### 3.3 What the operator does
-
-1. Update the node package (or `pip install -e` in dev)
-2. (Optional) Run `QE/migration/migrate_index_v2.sh` to preview or force the migration
-3. Restart the node daemon
-4. The first scan re-hashes files > 40 MB with partial reads — much faster than before
-
-No client-side action needed. No hub-side action needed.
-
----
-
-## 4. What does NOT change
-
-- **Files <= 40 MB** — identical hashing, identical `id`, `hash_version=1`.
-- **Enrichment** (thumbnails, duration, metadata) — unchanged, still runs after hashing.
-- **File downloads** — `file_req` uses the current `id` from the index. After re-indexing,
- clients get the new index with new hashes and request accordingly.
-- **Watchdog / reconciliation** — filesystem events trigger the same code paths. New or
- modified files are hashed with the appropriate method based on size.
-- **Index encryption / signing** — `GroupIndex.serialize()` and the GEK-sealed wire
- messages are unchanged. `hash_version` rides inside each entry via `asdict()`.
-- **Index delta computation** — `GroupIndex.diff()` compares entries by all fields
- (dataclass `__eq__`). A re-indexed file whose hash changed (v1 → v2) appears as a
- deletion of the old id + addition of the new id, which is correct.
-- **Hub tables** — opaque content_hash storage, untouched.
-- **MNP version** — this is an additive field on index entries. No protocol version bump
- needed. An older node that doesn't send `hash_version` is handled by the default.
-
----
-
-## 5. Side effects — documented and accepted
-
-1. **Cross-group search**: a file > 40 MB indexed on a v1 node and a v2 node produces
- different `id`s. The search page will not merge them as the same file. The user sees
- two entries instead of one, each from its own group. This resolves itself when both
- nodes upgrade.
-
-2. **First scan after upgrade**: files > 40 MB are re-hashed. With v2 this reads 45 MB
- per file (not the full content), so the re-index is fast — but it is not instant. A
- 4 TB library with 1000 large files reads ~44 GB instead of 4 TB.
-
-3. **`content_hash` drift on hub tables**: if a public group's node upgrades, the hashes
- it registers in `swarm_sources` change for large files. Old entries with v1 hashes
- become stale. The swarm registration mechanism's `last_seen` update handles this — stale
- entries age out. No explicit cleanup needed.
-
-4. **Index version bump**: re-indexing sets `GroupIndex.version = int(time.time())`,
- which triggers a full `index_sync` to all connected peers. This is the normal path for
- any index change — it is not new load.
-
----
-
-## 6. Indexing trigger points — verified safe
-
-| Trigger | Location | Impact of v2 |
-|---|---|---|
-| Daemon startup — initial scan | `daemon.py:648-674` | Uses `_hash_or_cached()` which applies v2 rules. Safe |
-| Create Group wizard — Step 3 | `create-group-page.js:244-248` polls `index-status` | No change — polls progress, doesn't control hashing |
-| Settings — add root | `group-settings.js:909-927` | Triggers `retarget()` → scan → `_hash_or_cached()`. Safe |
-| Watchdog — file created/modified | `indexer.py:797-816` | Calls `_update_entry()` → `_hash_or_cached()`. Safe |
-| Reconciliation loop | `indexer.py:513-650` | Calls `_sweep_available_roots()` → `_hash_or_cached()`. Safe |
-| Hot reload — `_reload_config()` | `daemon.py:790-829` | Creates new `DirectoryIndexer` with v2 code. Safe |
-
----
-
-## 7. Implementation order
-
-1. **`protocol.py`** — add `hash_version` field to `IndexEntry` and `index_entry_wire()`
-2. **`cache.py`** — add `hash_version` to schema, auto-migrate on open, update lookup/put
-3. **`indexer.py`** — implement `_partial_hash()`, update `_scan_file()` and
- `_hash_or_cached()`
-4. **Tests** — new test cases for partial hashing, cache versioning, wire round-trip
-5. **`QE/migration/migrate_index_v2.sh`** — standalone migration script
-6. **Manual test** — run a node with a mixed library (small + large files), verify:
- - Small files: same hash as before, `hash_version=1`
- - Large files: different hash, `hash_version=2`, 45 MB read
- - Cross-group search: small files merge, large files don't (across v1/v2 nodes)
- - Cache hit on second scan: no re-read
- - Index sync to connected peers: entries carry `hash_version`
diff --git a/docs/invite-pairing-v1.md b/docs/invite-pairing-v1.md
deleted file mode 100644
index 8eb378f..0000000
--- a/docs/invite-pairing-v1.md
+++ /dev/null
@@ -1,559 +0,0 @@
-# MeshBay — Invitation and Pairing (design)
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the admission and pairing design; its design
-> content now lives in §3.4 and §3.5.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **implemented and deployed** (2026-08-14). Written 2026-08-13 as a proposal;
-> §9bis records what shipped, what departed from the plan, and what a browser found
-> that the automated run could not.
-> Supersedes the invite flow described in `meshbay-draft-v5.md` §5.1 and the
-> Phase 12.1 milestone in `devel-phases-next.md`, if adopted.
->
-> Closes **H3** for the path where it is actually exploitable, and dissolves **M3**
-> rather than patching it. Follows the v5 convention: every claim names the
-> adversary it holds against.
-
----
-
-## 1. What is wrong today
-
-Two problems, one visible to users and one invisible.
-
-### 1.1 The workflow problem
-
-An invite runs entirely inside the inviter's browser (`app.js:1391-1420`):
-
-1. fetch the invitee's `pk_x25519` from the hub — `GET /v1/users/{name}/pubkeys`
-2. take the raw GEK out of the live transport connection
-3. wrap the GEK for that key and push the bundle to the node over MNP
-4. add the member on the hub
-
-Step 2 requires the inviter to be **connected to the node with the group key in
-hand**. Step 3 requires the node to accept the bundle, which since C5b means the
-signature must be the node operator's (`webrtc_server.py:1023-1035`). Together:
-
-- only the node operator can invite — v5 §5.1 records this as deliberate
-- the operator must be at a browser, connected, at the moment of the invite
-- a group admin who does not run the node cannot add anyone, ever
-
-That is not a workflow. It is the reason the demo needs `demo.py set-admin-pk`.
-
-### 1.2 The security problem (H3)
-
-Step 1 asks the **hub** which key belongs to `bob`. The hub stores those keys as
-mutable columns on the user row (`api/users.py:352-354`) and serves them with no
-signature and no history. A hub that answers with a key it holds is handed the GEK
-by an honest inviter following the protocol exactly. Nothing forged, nothing
-injected, and nothing in the client notices.
-
-The account is unique and the account is right. The **key attached to the account**
-is what the hub controls.
-
-### 1.3 M3, underneath both
-
-The daemon auto-pins its own keystore key as the admin key (`daemon.py:470-474`);
-the browser signs with the user identity key (`app.js:1416`). They differ, so
-invites and deletions fail closed with a signature error that looks like a bug
-elsewhere. The demo works only because a deploy script writes the browser key into
-`node.toml`.
-
-The tempting fix — have the daemon fetch the operator's key from the hub — turns M3
-into a second H3: the hub would then be able to install itself as node
-administrator. **The node must never learn authority from the hub.**
-
----
-
-## 2. Principle
-
-Three rules. Everything below follows from them.
-
-1. **The node wraps the group key.** The node already holds the GEK — it encrypts
- and serves the content. So it, not the inviter's browser, produces each member's
- bundle. No member ever handles another member's key material.
-2. **A key is bound to an identity by a one-time pairing code, then pinned.** The
- code travels out of band (the inviter sends it to the invitee the way they
- already talk). The hub never sees it and therefore cannot claim to be the
- invitee.
-3. **The node keeps its own roster.** Hub membership is an input, not an
- authorization. Otherwise a hub that invents an account and mints a token for it
- collects the GEK on connect.
-
-Human cost of the whole scheme: **one code per person, once per node**, plus one
-code for the operator at install. No fingerprint comparison, no per-member
-signature ceremony, no operator required to be online when someone joins.
-
----
-
-## 3. Flows
-
-### 3.1 Operator pairing (once per node, replaces M3)
-
-```
-operator (SSH) meshbay-node operator pair
-node prints PAIR-CODE: K7M2-QX4P (also written to data_dir/pair-code)
-operator (SPA) group → Members tab → "Pair this browser" → types the code
-SPA → node join_request {role_hint: operator, code, pk_ed25519, pk_x25519, sig}
-node code valid, unused, unexpired → pins the keys, role = operator
-node writes the pin to its roster DB, prints it in `status`
-```
-
-The operator types 8 characters into their own browser. Nothing is pasted, nothing
-is copied out of a terminal, no browser is needed on the node host, and the hub is
-not involved at any point. `admin_pk_ed25519` in `node.toml` became a legacy
-fallback, and was removed on 2026-08-15: the roster is the only source of operator
-authority. A config that still names the key is warned about at startup, so a
-deployment relying on it learns why its invites stopped rather than discovering a
-signature error.
-
-### 3.2 Invite (one click, operator or delegate)
-
-```
-grenet (SPA) Groups → Members → "Invite bob"
-SPA → node admin_request {op: invite_create, group_id, invitee: bob, ttl}
-node → SPA admin_challenge (structured transcript, H5 — the SPA shows one
- line: "authorize bob to join <group>")
-SPA → node admin_response {sig} ← signed silently with grenet's key
-node creates invite: code, group, invitee, expiry; status = pending
-node → SPA invite_created {code: "R3H8-TB6V", expires_at}
-grenet sends the code to bob however they already talk
-SPA → hub POST /v1/groups/{id}/members/bob (membership, unchanged)
-```
-
-Grenet's browser signs, but grenet does not *inspect* a signature — one click, one
-confirmation line, one code to pass on. Same gesture as any invite link on any
-platform.
-
-### 3.3 Join (fully automatic, operator may be asleep)
-
-```
-bob (SPA) opens the group; client has no GEK for it
-bob → node handshake {token, group_id, nonce_c} (pre-proof window)
-node → bob handshake_challenge {nonce_s, node_pk} ← bob signs over both
-bob → node join_request {group_id, pk_ed25519, pk_x25519, code, sig}
-node 1. rate-limit + attempt count on this connection
- 2. code matches a pending invite for this user_id and group
- 3. identity not already pinned to a different key
- 4. pin (user_id → pk_ed25519, pk_x25519), mark invite used,
- member status = active
- 5. wrap the ACTIVE GEK for pk_x25519 (ECIES, as today)
-node → bob join_result {ok, pk_eph_b64, nonce_b64, wrapped_b64}
-bob unwraps the GEK, completes the normal GEK proof, session proceeds
-```
-
-On every later connection bob sends `join_request` **without** a code; the node
-recognises the pinned identity, re-wraps the current GEK and answers. So GEK
-rotation propagates by itself, and a revoked member simply stops being served.
-
-### 3.4 Open-join groups: no code (decided 2026-08-13)
-
-A group whose hub-side `join_policy` is `"open"` (`groups.py:227-228`) admits anyone
-who asks. A pairing code there protects nothing — the hub can create an account,
-join through the front door and be a legitimate member — so it is pure friction.
-For those groups the node pins on first contact (TOFU, `pinned_via = 'tofu'`) and
-wraps the GEK immediately.
-
-Note the axis. `visibility` (public/private) controls **discoverability** and swarm
-hash registration (H7); `join_policy` (open/request/invite) controls **admission**.
-Only the second one decides whether a code is required. A `visibility = "public"`
-group with `join_policy = "invite"` keeps the code — being findable is not being
-open.
-
-**Stated plainly, per the v5 convention:** in an open-join group the hub can obtain
-the GEK, because it can become a member legitimately. That is a property of open
-joining, not of this design — it is equally true today. Content in such a group is
-protected from the network and from non-members, and from nobody else. The docs
-must say so, and the SPA should say so when someone sets `join_policy = "open"` on
-a group that already holds content.
-
----
-
-## 4. Why a pairing code, and not something lighter
-
-The choice is forced by one question: when bob connects for the first time, what
-stops the hub from being bob?
-
-| Option | What the hub can do | Verdict |
-|---|---|---|
-| Node wraps for the key the peer presents, no binding | Forge a JWT for bob, present its own key, receive the GEK | **Worse than today** — today a forged JWT yields a bundle wrapped to bob's real key, which is useless |
-| Bind to the key the inviter fetched from the hub | Substitute at invite time — H3 unchanged, just relocated into the node | No |
-| TOFU: first connection wins | Race the real bob with a forged token; small window, total consequence | No |
-| Safety-number comparison at invite | Nothing — but it needs two humans reading digits at the worst moment | Correct, unusable as the default |
-| **One-time pairing code** | Nothing: the code never reaches the hub | **Adopted** |
-
-The code is the cheapest thing that binds an identity to a key without the
-directory. It is also the familiar shape — invite links work this way everywhere.
-
-**Boundary, stated honestly:** for a browser client the SPA is served by the hub, so
-a hub that ships malicious client code can read the code out of the page. That is
-**T3, accepted permanently** by decision D1 and unchanged by this design. The code
-defeats a hub that *lies in its directory* — a silent, undetectable, per-request
-attack — not one that *rewrites the client*, which is an artifact and is what the
-native client (Phase 13) removes. Do not blur the two in the docs.
-
----
-
-## 5. Protocol
-
-### 5.1 New MNP messages (`meshbay_common/protocol.py`)
-
-```
-JOIN_REQUEST = "join_request" # client → node, served in the pre-proof window
-JOIN_RESULT = "join_result" # node → client
-INVITE_CREATE = "invite_create" # inviter → node (admin op, see 5.3)
-INVITE_RESULT = "invite_result" # node → inviter, carries the code once
-```
-
-`join_request`:
-
-| Field | Meaning |
-|---|---|
-| `group_id` | mandatory, as everywhere since 11.5.4 |
-| `pk_ed25519`, `pk_x25519` | the caller's own keys, base64 raw |
-| `code` | first pairing with this node only; omitted when `join_policy == "open"` (§3.4) |
-| `role_hint` | `operator` or absent; the node trusts the *code*, not the hint |
-| `sig` | Ed25519 over the transcript below |
-
-### 5.2 Join transcript
-
-```
-"meshbay:join:v1" ‖ len‖node_pk ‖ len‖group_id ‖ len‖user_id
- ‖ len‖pk_ed25519 ‖ len‖pk_x25519 ‖ len‖nonce_s ‖ len‖ts
-```
-
-Length-prefixed and domain-separated per 11.5.21. `nonce_s` is the handshake nonce
-the node just issued, so a `join_request` cannot be replayed onto another
-connection. `pk_x25519` is inside the signature, so the Ed25519 key vouches for the
-X25519 key it is paired with — this is what makes "wrap for the presented key" safe.
-
-The **code is never signed and never echoed** — it is a bearer secret, compared
-against a stored hash and destroyed on use.
-
-### 5.3 Invite creation reuses the existing admin machinery
-
-New op in `meshbay_common/adminop.py`, alongside `OP_FILE_DELETE` and
-`OP_GEK_BUNDLE_STORE`:
-
-```
-OP_INVITE_CREATE = "invite_create"
-"meshbay:admin:v1" ‖ len‖op ‖ len‖node_pk ‖ len‖group_id ‖ len‖subject ‖ len‖nonce ‖ len‖ts
- subject = invitee user_id
-```
-
-Authorized by the pinned **operator** role, or by a **delegate** (§6.2). TTL 120 s,
-same as every other admin op. The client rebuilds the transcript and refuses to
-sign if the subject is not the person the user typed — H5's rule, unchanged.
-
-### 5.4 Code format
-
-8 characters, Crockford base32 (no `I`, `L`, `O`, `U`), rendered `XXXX-XXXX` — 40
-bits. Single use, default TTL 24 h, stored only as `sha256(code)` — a password KDF
-would be pointless over 40 uniformly random bits, and `blake3` is not a node
-dependency. Guessing is
-bounded by: 5 attempts per connection, a node-wide limiter on failed
-`join_request`s, and the fact that a code is valid for exactly one `user_id` in one
-group. A brute-force attempt is an audit-log event, not a silent grind.
-
----
-
-## 6. Node state
-
-### 6.1 Schema (new tables, `roster.py`, same SQLite file style as `bundle_store.py`)
-
-```sql
-CREATE TABLE identities ( -- one row per person, not per group
- user_id TEXT PRIMARY KEY,
- username TEXT NOT NULL,
- pk_ed25519 TEXT NOT NULL,
- pk_x25519 TEXT NOT NULL,
- pinned_at TEXT NOT NULL,
- pinned_via TEXT NOT NULL -- 'code' | 'tofu' | 'legacy-config' | 'operator-reset'
-);
-
-CREATE TABLE members (
- group_id TEXT NOT NULL,
- user_id TEXT NOT NULL,
- role TEXT NOT NULL, -- 'operator' | 'delegate' | 'member'
- status TEXT NOT NULL, -- 'pending' | 'active' | 'revoked'
- approved_by TEXT NOT NULL, -- user_id whose signature created the invite
- approved_at TEXT NOT NULL,
- PRIMARY KEY (group_id, user_id)
-);
-
-CREATE TABLE invites (
- code_hash TEXT PRIMARY KEY,
- group_id TEXT NOT NULL,
- user_id TEXT NOT NULL,
- role TEXT NOT NULL,
- created_by TEXT NOT NULL,
- expires_at TEXT NOT NULL,
- used_at TEXT
-);
-```
-
-Identity is pinned **per node, not per group**: someone already paired for one group
-needs no code for the next one. The operator's pairing is the same mechanism with
-`role = 'operator'` and no group.
-
-### 6.2 Delegation — **deferred** (decided 2026-08-13)
-
-A `delegate` row would let a group admin who is not the node operator create invites
-without the operator being involved again. Not needed while the operator is the
-inviter (the demo, and every single-operator deployment), so it is **not built in
-v1**.
-
-The `role` column reserves the value and `invite_create` authorization is written as
-a role check rather than an equality test against the operator, so adding it later
-is a roster row and a CLI command — no protocol change, no migration.
-
-### 6.3 What the node stops doing
-
-- `gek_bundle_store` **no longer accepts member-supplied bundles at all.** Nothing
- arriving over MNP contributes key material. C5b's rule is not merely preserved,
- it becomes structural — the message can be deleted from the client path entirely.
-- Per-member rows in `gek_bundles` are no longer written. The node wraps on demand.
- The node's own `_node_{user_id}` bundle stays: that is how the daemon reloads its
- GEK across restarts.
-- **Consequence worth having:** revocation starts working for key delivery. A
- stored bundle today survives revocation; on-demand wrapping does not. (Rotating
- the GEK after a revocation is still required — the ex-member has the old key.)
-
----
-
-## 7. Security analysis
-
-### 7.1 Against each adversary
-
-| Attack | Today | With this design |
-|---|---|---|
-| Hub substitutes the invitee's key at invite time (**H3**) | Succeeds silently, hub gets the GEK | **Fails** — no key is ever fetched from the directory |
-| Hub forges a JWT for a real member | Gets a bundle wrapped to the member's real key: useless | Unchanged: no code, no pin match → refused |
-| Hub invents an account and adds it to the group | Blocked only accidentally, by the bundle requirement | **Blocked by the roster** — no invite, no code, no GEK |
-| Hub substitutes the *operator's* key (M3's tempting fix) | n/a | **Impossible** — the node pins by code, never asks the hub |
-| Member wraps a GEK of their choosing for the operator (**C5b**) | Blocked by operator signature | **Impossible** — the message no longer exists |
-| Member replays a `join_request` from another connection | n/a | Bound to `nonce_s` |
-| Member presents someone else's `pk_x25519` | n/a | Signed by the paired `pk_ed25519`, mismatch refused |
-| Ex-member reconnects after revocation | Stored bundle still unwraps | Not served; GEK rotation still needed |
-| Third party guesses a code | n/a | 40 bits, single use, per-user, rate-limited, audited |
-| Hub joins an **open-join** group and collects the GEK | Succeeds | Still succeeds — inherent to open joining (§3.4), must be documented, not hidden |
-
-### 7.2 What this does **not** fix
-
-- **T3** — the hub serves the SPA and can read the code out of the page. Accepted
- (D1); removed only by the native client plus reproducible builds.
-- **C4** — the keypair-bundle pre-proof window is untouched. A first-time joiner
- still needs their own identity keys before they can sign anything; that material
- belongs on the user's device (Phase 13.3).
-- **The node operator reads everything.** Inherent to the model.
-- **The hub still knows who is in which group.** Membership is hub-side; the roster
- only decides who receives the key.
-- **A member can still leak the GEK out of band.** Nothing prevents that, and
- nothing in the current design pretends to.
-
----
-
-## 8. Failure modes and edge cases
-
-| Case | Behaviour |
-|---|---|
-| Code lost or expired | Inviter clicks "Invite" again; the old invite is superseded and its hash deleted |
-| Bob pairs, then loses his keys and runs `regenerateKeys` | Pin mismatch → join refused with a clear message; needs a fresh invite (operator or delegate re-issues). This is the intended blocking warning, moved to the moment it matters |
-| Bob is in two groups on the same node | One pin, one code, ever |
-| Node reinstalled / roster lost | Everyone re-pairs. Same class of event as losing the keystore; `status` must say so plainly |
-| Operator pairs a second browser | New `operator pair` code; both browsers valid, both listed in `status` |
-| Two people race one code | Single-use row, `used_at` set under a transaction; the loser gets a plain refusal |
-| Invite created while the node is offline | Not possible — invites are created on the node. The SPA must say "node offline, cannot invite" instead of failing obscurely |
-| Member connects while the group has no active GEK | `join_result {ok: false, reason: "no_gek"}`; the operator runs `gek-init` |
-| Legacy deployment with `admin_pk_ed25519` set | Ignored since 2026-08-15, with a warning at startup naming the config file. The operator pairs a browser; there is no second path |
-
----
-
-## 9. Operator surface
-
-```
-meshbay-node operator pair # print a pairing code for a browser
-meshbay-node member list [--group G] # roster: who is pinned, role, status
-meshbay-node member invite bob [--group G] # same as the SPA button, from SSH
-meshbay-node member revoke bob [--group G] # stop serving the GEK to bob
-meshbay-node member unpin bob # force re-pairing after a key rotation
-```
-
-`--group` is optional whenever the node hosts exactly one group.
-
-`meshbay-node status` gains a line per group: pinned identities, pending invites,
-and — when nothing is paired — the exact command to fix it. Everything an operator
-needs is reachable over SSH with no browser on the host, per the standing
-constraint. This absorbs milestones 14.3 and 14.4.
-
----
-
-## 9bis. Implementation status
-
-**Slices 1 and 2 landed 2026-08-13/14.** Not yet exercised against a live
-deployment — the operator tests after slice 3, so the slices are written to be
-coherent with each other rather than individually demo-able.
-
-### Slice 1 — roster and operator pairing (M3)
-
-| Shipped | Where |
-|---|---|
-| `identities` / `members` / `invites`, codes, single-use redemption | `meshbay_node/roster.py` |
-| `join_transcript` — both public keys signed together | `meshbay_common/join.py` |
-| `join_request` / `join_result` handler, valid pre-proof and post-handshake | `transport/webrtc_server.py` |
-| Admin authority read from the roster on every check, and from nowhere else | `webrtc_server._verify_admin_sig` |
-| **Auto-pin of the keystore key deleted** (M3) | `daemon._legacy_admin_pk` |
-| `meshbay-node operator pair`, roster in `status` | `daemon.main`, `ui/app.py` |
-| Pairing form in the group's Members tab | `app.js`, `transport.js` |
-
-### Slice 2 — the node wraps the key (H3)
-
-| Shipped | Where |
-|---|---|
-| Node wraps the GEK for the key the joiner proved, on every connection | `webrtc_server._join_ok` |
-| Roster decides who may receive it — hub membership alone does not | `Roster.is_authorized` |
-| `invite_create` admin op; the SPA shows a code instead of handling keys | `adminop.py`, `app.js` |
-| **`gek_bundle_store` deleted** — no member ever hands the node key material | `protocol.py`, `webrtc_server.py` |
-| **`gek-init` no longer fetches member keys from the hub** — it was H3 with the node as victim | `ui/app.py` |
-| Open-join groups admit without a code; policy read from `node.toml` | `config.py`, `_group_join_policy` |
-| Client asks for the key when it has none; prompts for a code when required | `transport.js`, `app.js` |
-
-**Tests: 148 node, 174 hub/common** (from 121/168 before this work). The end-to-end
-one worth knowing about is `test_invite_then_join_delivers_the_gek`: over a real
-DataChannel, the operator gets a code, and a member who has never held the group
-key redeems it in the pre-proof window and receives the key wrapped for a key only
-they can open.
-
-### What a browser found that the harness could not
-
-The design was exercised end to end by `QE/deploy/e2e.py` and passed. It was then tried in
-a browser and failed four times, each for a different reason, none of them the pairing code:
-
-| Symptom | Cause |
-|---|---|
-| No code ever requested, "no GEK available" | the browser held no identity keys — session restore never re-read the encrypted bundle sitting in its own localStorage |
-| "No nodes available" | the node had gone deaf: its hub socket sat in CLOSE-WAIT because the WebRTC offer handler was awaited inside the read loop (a Phase 11 bug) |
-| "Not a member of this group" | the token predated being added to the group; `groups` is baked in at sign-in, and nothing refreshed it |
-| "Handshake incomplete — reconnect and retry" | `joinGroup()` read `node_pk` and `nonce_node` before they were assigned |
-
-The common thread is worth keeping: **e2e.py is a second implementation of the client**,
-written in the right order by construction, so it proves the protocol and nothing about
-`app.js`. Three of those four were ordering or plumbing mistakes it could never see. Hence
-`packages/meshbay-hub/tests/test_spa_ordering.py`, which asserts on source order — inelegant,
-and the only thing that catches them.
-
-### Deliberate departures from this document
-
-| Written | Built | Why |
-|---|---|---|
-| `blake3(code)` | `sha256(code)` | 40 uniformly random bits; blake3 is not a node dependency |
-| Pairing in Settings | Pairing in the group's Members tab | that is where a live node connection exists |
-| — | `gek-init` rewritten | not in the plan: it wrapped the GEK for keys fetched from the hub, which is the same substitution the design closes |
-
-Two C5b tests were rewritten rather than kept: they asserted that
-`gek_bundle_store` demanded an operator signature, and that message no longer
-exists. They now assert the stronger property — that no member can hand the node
-key material at all, and that the retired message reaches no handler.
-
-### Slice 3 — the operator surface
-
-| Shipped | Where |
-|---|---|
-| `member list` / `invite` / `revoke` / `unpin`, all over SSH, no browser | `daemon.main` |
-| Roster endpoints behind the per-run session token (11.5.3) | `ui/app.py` |
-| Roster view in the desktop client's Node page (Preact escapes by default; H2) | `static/node-page.js` |
-| `_daemon_api` / `_resolve_group` — one loopback call path for every command | `daemon.py` |
-| Codes written to `data_dir/invite-code` and `data_dir/pair-code` | `roster.write_code_file` |
-
-Revocation tells the operator what it does **not** do: the ex-member stops
-receiving the key on their next connection, but they still hold the current one,
-so the message ends with the `gek-init` command that rotates it.
-
-`member revoke`/`unpin` resolve a username against the roster and refuse an
-unknown one rather than acting on nobody — a typo must not look like success.
-
-**Tests: 158 node, 174 hub/common** (from 121/168 before this work).
-
-### Code lifetimes (settled 2026-08-14)
-
-| Code | Default | Configurable via |
-|---|---|---|
-| Member invitation | **7 days** | `[node] invite_ttl_hours` |
-| Operator pairing | 24 h | `[node] pair_ttl_hours` |
-
-They differ because the acts differ: an invitation waits for someone to read their
-messages, a pairing code is typed during the SSH session that printed it. The
-longer window costs little — single use, one account, never seen by the hub, and
-40 bits do not fall to guessing in a week against the node-wide lockout.
-
----
-
-## 10. Work to do
-
-### Node
-- `roster.py` — new module, the three tables and their queries
-- `transport/webrtc_server.py` — `join_request` / `join_result` in the pre-proof
- window (beside the existing bundle fetches, `:243-258`); delete member-supplied
- `gek_bundle_store`; `OP_INVITE_CREATE` in the admin-op dispatch
-- `transport/quic_server.py` — same handler via the shared path (11.5.4 parity test
- must cover `join_request`)
-- `daemon.py` — `_resolve_admin_pk` → roster lookup with the legacy config fallback;
- new CLI commands; `status` output
-- `ui/app.py` — roster and invites over the token-gated loopback API (JSON)
-
-### Common
-- `protocol.py` — four message constants
-- `adminop.py` — `OP_INVITE_CREATE`
-- `handshake.py` — expose `nonce_s` to the join transcript builder
-
-### Hub
-- **No change.** Membership endpoints stay as they are. Worth stating in the commit
- message: the fix for H3 removes a hub responsibility rather than adding one.
-
-### SPA
-- Invite dialog shows the code and a copy button, instead of doing crypto
-- Settings gains "Pair this browser with my node" (code entry)
-- `join_request` on connect when no GEK is held; drop the wrap-and-store path
-- Delete the `pubkeys` fetch from the invite flow — the line that is H3
-
-### Tests (negative assertions, per §10 of v5)
-```
-test_join_requires_code_first_time — unpinned identity without a code is refused
-test_join_rejects_key_swap — pinned user presenting a new key is refused
-test_join_replay_across_connections — join_request bound to nonce_s
-test_invite_requires_operator_role
-test_gek_never_wrapped_for_hub_supplied_key — the H3 regression test
-test_revoked_member_gets_no_gek
-test_code_bruteforce_bounded
-test_open_join_group_pins_on_first_contact — no code required, TOFU pin recorded
-test_invite_group_still_requires_code — public visibility does not skip it
-```
-
-### Docs to rewrite
-- `meshbay-draft-v5.md` §2 (H3 row in the claims table), §5.1 (the "a group admin
- who does not run the node can no longer invite" consequence is reversed), §9
- (H3 moves to closed; note what remains open — T3, C4)
-- `devel-phases-next.md` — Phase 12.1 becomes this; 14.3/14.4 absorbed
-- `second-review.md` — H3 and M3 marked closed by this design
-- `QE/deploy/README.md` — `demo.py set-admin-pk` retired in favour of `operator pair`
-
----
-
-## 11. Decisions
-
-Settled 2026-08-13 with the operator:
-
-| # | Question | Decision |
-|---|---|---|
-| 1 | Groups that skip the pairing code | **`join_policy == "open"` only** — asked as "public groups"; corrected to the admission axis, since `visibility` governs discoverability, not entry (§3.4) |
-| 2 | Delegation (group admin ≠ node operator) | **Deferred.** Role value reserved, authorization written as a role check so it drops in later (§6.2) |
-| 3 | Code TTL and length | 24 h, 40 bits, `XXXX-XXXX`. Default unless the first real use says otherwise |
-| 4 | `gek_bundle_store` from members | Deleted, not disabled — it is the C5b surface and keeping it dead-but-present invites its return |
-
-Items 3 and 4 are defaults chosen for v1, not constraints; both are one-line changes
-if the deployment argues against them.
diff --git a/docs/mediacenter.md b/docs/mediacenter.md
deleted file mode 100644
index a1dbe2f..0000000
--- a/docs/mediacenter.md
+++ /dev/null
@@ -1,1035 +0,0 @@
-# MeshBay — Videos application (design)
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the Videos application design; its design
-> content now lives in §9.7, with the node-side caching decision in §6.5.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **built** — `video-app.js`, the node-side matching, the metadata and
-> thumbnail caches all shipped, and §10.1–§10.7 record fixes made against a real
-> library since. This header said "not implemented" long after it was.
-> Read `docs/apps.md`
-> first — Videos is a new group application built on the plug-in mechanism
-> described there, and this document assumes that mechanism rather than
-> re-explaining it. Read `docs/desktop-client-v1.md` §6.10 before touching
-> thumbnails or metadata — this document **revises decision O12** from that
-> file, with the reasoning recorded in §2 below.
->
-> Follows the project convention: every claim names the adversary it holds
-> against (§8).
-
----
-
-## 0. What was asked, in one paragraph
-
-A streaming-service-style poster browser for a shared directory (or a subtree
-of one), with two operator-selectable modes: (a) **poster-grid**, metadata
-and artwork from TMDB, falling back to a thumbnail when TMDB match fails; (b)
-**flat**, alphabetically sorted, thumbnail-based, folders as
-categories/seasons, no TMDB. Both need a filename parser, evaluated in §3
-against a real local media library (~1950 files across three collections —
-standalone movies, TV shows with nested season folders, and a small
-single-franchise set) against a >95% bar. A default TMDB read token is
-provided; an operator may supply their own.
-
----
-
-## 1. What this design does not reopen
-
-Everything already settled stands, and this plan is written to fit inside it,
-not around it:
-
-- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10,
- draft-v6 §2.7). A file stays tied to its filesystem representation; nothing
- here adds a second identity for a file, and nothing reaches the hub.
-- **The apps plug-in mechanism** (`apps.md`): a new `video-app.js`, one
- registry entry, one node-side `ALLOWED_APPS` entry, i18n keys, the asset
- list, the two file-set tests. Enablement is a per-group,
- operator-signed setting, same shape as `member_upload`.
-- **Group-related server state lives on the node** (E9). Nothing in this
- design puts a row on the hub. TMDB and thumbnail caches are node-local,
- disposable, and rebuildable from the shared files plus TMDB — the same
- status the file index's own hash/size already have, not the kind of
- authority state E9 protects (membership, roster, uploads).
-- **Filesystem portability** (§6.8): nothing here writes into a shared root.
- See §2.
-
-## 2. Revision to decision O12 — where the cache lives, and why
-
-`desktop-client-v1.md` §6.10 (2026-08-17) decided thumbnails are computed
-**on demand by the node** and cached **on the requesting device**, never
-durably on the node. The stated reason was an analogy to draft-v5 §5.2's
-chat-thumbnail rule ("the node stores no second copy of an image it already
-holds"). Re-reading §5.2's actual text: it is about the **upload directory**,
-and the chat case it names is one the **browser computes itself for free**
-(resizing an image it already decrypted) — the node has no work to do at all.
-A video thumbnail is not that: it needs `ffmpeg` to decode a frame, work only
-the node can do, and §6.10 already conceded this ("a frame grab is strictly
-less than the decoding the node already does for streaming"). So the
-per-device-cache-only conclusion was an **extension by analogy** to a general
-project preference for keeping the node free of derived state — a real,
-deliberate stance (E9, "no metadata store, ever"), but a policy call, not a
-consequence of §5.2 itself.
-
-**Revised here, on two concrete grounds raised by the operator:**
-
-1. **TMDB quota is per API token, not per device.** A token shipped inside
- every install and called directly from every browser/Electron client
- scales with the number of *devices* across every MeshBay deployment in
- existence, hitting TMDB's rate limits far sooner than if each **node**
- makes the calls on behalf of all its own members — the number of nodes is
- the relevant denominator, and each node's own cache means one lookup per
- unique title serves every member, indefinitely.
-2. **Thin/future clients benefit from a node that does more, not less.**
- `desktop-client-v1.md` §7.6 already flags Android as hostile to *hosting*
- a node but fine as a *client*; a phone with limited storage and battery
- should receive a small ready-made JPEG and a JSON blob, not decode video
- or hold its own multi-gigabyte thumbnail cache.
-
-**What is decided:**
-
-- **TMDB metadata and posters/backdrops are fetched, cached and served by the
- node.** Clients never call TMDB directly, from any platform. This also
- removes a question this design would otherwise raise: the native client's
- tested invariant is *"issues no request outside `/v1/` and the signaling
- socket"* (`desktop-client-v1.md` §8.1) — if the client never talks to TMDB,
- that invariant is untouched, no CSP change, no new egress from the
- renderer.
-- **Video thumbnails are generated by the node (unchanged) and now cached by
- the node too**, not only handed over and forgotten.
-- **The cache lives in the node's own `data_dir`** — a SQLite store or blob
- files beside `chat.db`/`audit.db`/`bundle_store.db` — **never inside a
- shared root**. This is not a compromise; it is strictly better than the
- operator's original `.thumbs`-in-the-media-folder idea, and for reasons
- independent of the sovereignty argument (§9): the node's own `data_dir` is
- always writable by the process regardless of platform, whereas a shared
- root is routinely a read-only backup mount, a NAS share the node has no
- write access to, or an external exFAT/NTFS drive where a stray `.thumbs`
- folder is not hidden by default on Windows and would appear as an ordinary
- visible folder full of preview images of a private group's content to
- anyone who plugs that drive into a different machine. Keeping the cache
- entirely off the shared volume avoids that whole class of problem for
- free, with no loss of the benefit the operator asked for (one computation,
- reused by every member, every platform).
-- **A visible, node-wide toggle exists to turn TMDB calls off entirely**
- (`tmdb_enabled`, default on) for an operator who wants zero third-party
- network traffic. This is new "outbound to a third party" behaviour the
- node did not have before (§8), and it is disabled independently of the
- Videos app itself being enabled.
-- **Cache lifecycle is tied to the index.** A file's thumbnail cache entry
- and its file→TMDB-id mapping are pruned when the indexer removes that
- file's `IndexEntry` (the same event that already prunes the index itself).
- TMDB metadata JSON is cached per TMDB id with its own refresh window (30
- days is a reasonable default — TMDB ratings/overviews do change) rather
- than tied to any one file, since several files (episodes of a show) share
- one show-level fetch.
-
-**Security read of this reversal (per adversary, full table in §8):** against
-the node's own operator, nothing changes — they already hold every file in
-plaintext and could compute all of this themselves; against another member or
-an outside attacker, the same GEK-proofed MNP channel that already serves
-files and streams now also serves metadata and thumbnails, no new
-authorization surface. Keeping the TMDB token server-side is a **strict
-improvement** over shipping it to every renderer: one place holds the
-credential, not every device.
-
-`desktop-client-v1.md` should get a one-line pointer added at O12 when this is
-implemented, noting the revision and pointing here — not done in this
-document, which only adds the new design.
-
----
-
-## 3. Filename parsing — empirical study
-
-### 3.1 Method
-
-The operator's own library was used as the test corpus, listed on
-2026-08-23:
-
-| Set | Files | Notes |
-|---|---|---|
-| movies collection | 261 | flat directory, mixed naming conventions, ~15 years of scene/tracker releases |
-| TV shows collection | 1677 | 21 shows, nested up to `Show/SxxSeasonFolder/episode.mkv`, several non-standard layouts |
-| single-franchise collection | 9 | small, single-franchise sanity check |
-
-`guessit` 4.4.0 (pure Python, MIT, pip-installable, actively maintained —
-the de facto reference implementation for this exact problem, used by
-Sonarr/Radarr-adjacent tooling) was installed in a scratch venv and run
-against every bare filename. Its output was then validated for real against
-the live TMDB API using the token the operator provided, with a small
-Python harness: search by parsed title (+ year for movies), score the result
-against the query with `difflib.SequenceMatcher`, and — this matters, see
-§3.3 — retry with a more naively cleaned title before giving up.
-
-The harness and raw output are not committed; the corpus itself is a local
-media library external to the repo. Re-running this validation against
-the production parser, on the same corpus, is listed as an acceptance step
-in §6.
-
-### 3.2 Results
-
-| Metric | Movies (261) | TV shows, show-level (21 shows / 1677 episodes) |
-|---|---|---|
-| `guessit` extracts a non-empty title from the bare filename | **100%** | 98.7% raw; the 1.3% gap is entirely episode files with no show name in the filename itself (e.g. `S08E02.SUBFRENCH.720p.mkv`) — recovered to 100% once the parent folder supplies the title (§3.4) |
-| `guessit` extracts season+episode | — | 98.6% raw (same gap, same fix) |
-| **Confident TMDB match, real network call** | **92.3%** (241/261), first pass, before the fixes in §3.3 | **100%** (21/21 shows, 1677/1677 episodes weighted) |
-
-Series clears the 95% bar outright. Films does not on the raw first pass —
-every miss has an identified, generic (not per-title) cause, listed next.
-
-### 3.3 What the film misses were, and the fix for each
-
-| Cause | Example pattern | Fix |
-|---|---|---|
-| Real title lands in `guessit`'s `alternative_title` field, not `title` | A "Franchise Name + numeric code" filename (e.g. `Franchise.007.-.1999.-.The.Real.Subtitle...`) parses to `title="Franchise 007"`, `alternative_title="The Real Subtitle"` | **Search both fields**; prefer whichever gets a confident TMDB result |
-| Hyphenated proper noun split at the hyphen | A hyphenated title immediately followed by a parenthesized year (e.g. `Hero-Name.(2002)...`) parses to `release_group="Hero"`, `title="Name"` (a known `guessit` tokenizer quirk before a parenthesized year) | Fallback tier: retry with **all** of `.`, `_`, `-` normalized to spaces (the naive fallback used in this test only normalized `.`/`_`, which is why this specific case still missed even with a fallback in place — noted as a bug in the test harness itself, not the parser design) |
-| French edition vocabulary not in `guessit`'s (English-centric) edition list | Titles carrying `Version.Longue`, `remasterise`, `Original.Version` as a release tag | Extend the edition/tag stripping list with `version longue`, `version intégrale`, `remasterisé`/`remasterise`, `non censuré`, `original version` |
-| Trailing sequel digit kept, real TMDB title has none or uses Roman numerals | A sequel filename ending in a bare `1`/`2` where the real TMDB title has no trailing digit, or uses a Roman numeral instead | Additional retry: same title **without** the trailing digit, and with it converted to a Roman numeral |
-| The test's own scoring picked the wrong result | A show referenced by its acronym in the folder name: TMDB's own search genuinely returns the right show first for that acronym — confirmed by a direct re-query — but the harness re-ranked TMDB's top-5 by local string similarity and preferred a coincidentally closer-looking unrelated show | **Trust TMDB's own top-ranked result** rather than re-ranking locally; use local similarity only as a confidence signal for a "not the right one? search again" affordance, never to override the choice |
-| No TMDB entry exists under any reasonable query | An obscure abbreviated title, unresolved even manually | Inherent, expected. Falls to the mandated fallback: thumbnail + minimally cleaned filename (§4.3) |
-
-None of these are per-title hacks; each is a generic rule. Applying them was
-not re-run against the full corpus for this planning pass (that means writing
-the production-quality parser, which is implementation, not planning) but
-every miss in the 261-file set is accounted for by one of the six rows above,
-which gives good confidence the >95% bar holds once built — to be confirmed
-empirically in §6's acceptance step, on the same corpus, before shipping.
-
-### 3.4 Directory context — the one thing bare-filename parsing cannot give you
-
-Two real findings from the series corpus, both because a bare filename does
-not carry the folder it lives in:
-
-- **Abbreviated folder names need corroboration from a file inside them.**
- A show folder named after its acronym rather than its full name (with
- episode files inside following the usual `Show.Name.SxxExx...` pattern) —
- a design that only trusts a strict "does this look like the real title"
- gate on the folder name alone is fragile the moment the query is
- ambiguous, even when TMDB's own search actually does resolve the acronym
- correctly (verified directly). **Rule:** derive the show title primarily
- from `guessit` run on a representative **episode filename** inside the
- folder (which reliably contains the full show name, per the table in
- §3.2), and use the folder name only as a grouping key and a
- manual-override anchor — never as the sole source of the TMDB query.
-- **Non-standard season folders are real, and TMDB already models them.**
- One show in the corpus numbers its episodes with a bare leading integer
- and no `SxxExx` token anywhere, uses a French word (not "season") plus a
- Roman numeral as its season-folder name, and keeps a sibling folder for
- specials. Querying TMDB directly confirmed this is not an obstacle to
- work around — **TMDB's own season list for that show uses the identical
- non-English season-naming scheme**, with a `season_number: 0` entry for
- specials that the sibling folder maps onto exactly. **Rule, kept generic:**
- when no `SxxExx` token is found, look for a season-like ancestor folder
- matching `season|saison|<local-language season word>\s*(\d+|[ivx]+)`
- (Roman numerals included, vocabulary configurable per locale), and treat a
- folder matching `bonus|extras|specials?` as season 0. Match that folder's
- name against the show's own TMDB season list rather than assuming a
- numeric convention everywhere.
-
-### 3.5 What the parser does **not** need to guess
-
-The node already probes real technical facts from the file itself via
-`ffprobe` for streaming (`_probe_video()`,
-`meshbay_node/transport/webrtc_server.py`) — resolution, codec, duration.
-**None of that should come from the filename.** A file mislabeled `1080p`
-that is actually 720p is a real, observed failure mode of filename-derived
-metadata; ffprobe is authoritative and already runs on the node. The parser's
-job is narrowed to exactly what TMDB search and the flat view need: title,
-year, season, episode — a materially smaller surface than `guessit`'s full
-scope (it also parses codecs, languages, release groups — useful for
-debugging, not needed by either view).
-
-### 3.6 Where the parser runs
-
-**On the node, in Python, using `guessit` as a real dependency** (pure
-Python, MIT license, no native extensions — trivially addable to
-`packages/meshbay-node/pyproject.toml`), wrapped by a small
-`meshbay_node.indexer.title_parse` module implementing the rules in §3.3–3.4.
-This follows directly from §2: since the node already owns the TMDB call, it
-is the natural place to own the parse step too — no filename-parsing code is
-needed in the browser/Electron client at all. The parsed `(title, year,
-season, episode)` and a best-effort `display_title` (the flat-mode fallback:
-extension stripped, `_`/`.` replaced by spaces, used when parsing confidence
-is low) travel to clients as ordinary index fields (§5), computed once by the
-indexer and cached the same way size/hash already are.
-
----
-
-## 4. The two view modes
-
-### 4.1 Mode A — poster grid (TMDB), default
-
-Browse by poster, click through to a detail view (poster,
-year, resolution + duration from the real probe, overview, cast, director,
-a **Play** button that hands off to the existing `video-player.js`/
-`stream_req` path unchanged). Falls back to a thumbnail-only card, with just
-the cleaned filename, when the node's TMDB lookup returns nothing or a
-low-confidence match (§3.3's last row) — exactly the operator's own fallback
-rule.
-
-Grouping for the poster grid: one card per **movie**, and one card per
-**show** (not per episode) that expands to a season/episode picker on click,
-using TMDB's own season list (§3.4) — the standard model for this kind of
-browser, and requiring no new grouping logic beyond what the folder structure
-and the node's per-show TMDB fetch already give.
-
-### 4.2 Mode B — flat, thumbnail-based, no TMDB
-
-Alphabetical, folder-driven, exactly as specified: a folder of movies is a
-flat list of thumbnail cards; a folder that turns out to be a show is a list
-of season subfolders (or files directly, if there's no season layer), each
-expandable, never TMDB-enriched. Per-card info comes entirely from the
-(now-populated, §5) index entry: `display_title`, `duration`, resolution
-(from the technical probe fields), file `size` — no network call, no TMDB
-dependency, and it keeps working with TMDB disabled node-wide.
-
-### 4.3 The toggle between modes
-
-**Per-device, `localStorage`**, same mechanism already used for the video
-player's resume position and the i18n language choice — a display
-preference with no security or authority implication, so it does not need
-the `roster.db` + signed-op treatment `member_upload`/`apps_enabled` get.
-Default: Mode A, per the operator's own spec.
-
-### 4.4 Scoping to a folder — added 2026-08-24, this was in §0 and got missed
-
-The operator's original ask was explicit: pick an already-shared directory,
-possibly a subdirectory of one already exposed — not necessarily the whole
-group. This did not make it into the plan when the rest of this document was
-written; caught only once the feature was in real use against an existing
-group.
-
-**Unlike the mode toggle above, this is not a per-device display
-preference** — it decides what *every* member's Videos tab shows, the same
-class of decision `apps_enabled`/`member_upload` already are. So it gets the
-same treatment: a per-group setting in `roster.db` (`video_root`, empty
-string meaning "the whole group index" — today's behaviour, unchanged),
-changed by a new signed op (`OP_VIDEO_ROOT`, `video_root`/`video_root_ack`
-on the wire) that any already-connected member is told about without
-reconnecting, exactly like `apps_enabled_ack`.
-
-**Where the operator sets it: Files, not Settings.** The folder being
-scoped is something the operator is already looking at while browsing —
-Files gets a toolbar action that toggles between "Set as Videos root" (any
-other folder) and "Clear Videos root" (the folder that already is one, or
-the top level when one is set deeper in the tree). No separate path-picker
-UI was built; browsing *to* the folder in the browser used for browsing
-everything else in the group is the picker.
-
-**Validation happens before a signature is ever asked for**, the same
-principle as apps_enabled's "empty set refused up front": a candidate path
-is resolved against the group's actual `RootSet` (the same traversal-safe
-resolution `entry_abs_path`/directory creation already use) and must name a
-real, currently-readable directory, or the request is refused immediately
-— a stale or mistyped path never reaches the operator's browser as a
-signing prompt.
-
-The client-side filter is a plain path-prefix test against `IndexEntry.path`
-(already root-relative, e.g. `"Series/SomeShow/S1/ep01.mkv"`) — no new index
-field, no server-side filtering; the node still sends the whole index, and
-narrowing to the configured folder is exactly the same kind of view-over-
-the-index work Files' own current-folder filter already does.
-
----
-
-## 5. Protocol and index changes
-
-### 5.1 `IndexEntry` already reserves two of the three fields this needs
-
-`meshbay_common/protocol.py:133` — checked directly, and both fields are
-declared but **never populated by the indexer nor consumed by any client
-today**:
-
-```python
-duration: int | None = None # seconds, for media
-thumb_hash: str | None = None # blake3 of thumbnail
-```
-
-This is smaller than a fresh addition: `duration` needs the indexer to run
-(the existing) `ffprobe`-based probe at index time and store the result;
-`thumb_hash` needs a thumbnail to have been generated and cached, addressed
-by its own blake3 the same way a file is addressed by its `id` — which
-opens a clean reuse (§5.3). **New**, not yet present: `width`/`height`
-(resolution) and the parsed `display_title`/`season`/`episode` fields from
-§3. All are additive fields on the same dataclass, so this is a MNP
-**MINOR** bump (0.4 → 0.5, same class of change as the multi-root 0.2→0.3
-bump), not a breaking one — an older client simply doesn't render the new
-fields.
-
-### 5.2 Index-time cost, bounded the same way thumbnails already are
-
-Probing every video file with `ffprobe` at index time is real cost on a
-large library. `desktop-client-v1.md` §6.10 already established the pattern
-for exactly this shape of problem (thumbnails): **a small, bounded worker
-pool, its own short timeout, never sharing the streaming pool**
-(`max_concurrent_streams`), draining stdout and releasing the slot
-regardless — the same lesson `CLAUDE.md` records repeatedly for anything
-that shells out to `ffmpeg`/`ffprobe`. The probe (and the `guessit` parse,
-which is cheap by comparison) runs asynchronously after a file is first
-seen; the file appears in the index immediately with size/hash only, and an
-`index_sync` delta fills in the technical/parsed fields once ready — no
-scan is blocked waiting for it.
-
-**Bug found live, 2026-08-24**: an operator moved a whole show's season into
-an already-watched shared folder and saw no scanning indicator and no
-progress bar at all — files just appeared one at a time, exactly as if
-nothing were tracking the add. Root cause: `IndexProgress` (`scanning`/
-`scanned_bytes`/`total_bytes`) was only ever touched by the two *bulk* scan
-paths — the initial walk and the periodic reconcile backstop, both of which
-know their file list upfront. The real-time watchdog path
-(`_schedule_update`/`_debounce`/`_update_entry`, one file at a time, each
-independently debounced) never touched it at all — an omission, not a race:
-dropping in ten files this way always gave zero feedback, indexing or not.
-Fixed by accounting a "burst" the same real-time path creates: a file's size
-is added to `total_bytes` the moment its debounce timer is first scheduled
-(not on every re-trigger of the same path — a cancelled-and-rescheduled
-timer must not double-count), and to `scanned_bytes` once its hash actually
-finishes; `scanning` flips back off only once no debounce timers are pending
-*and* no hash is still running, not just when the last timer fires — the
-in-flight hash of a large file is the entire reason to show progress in the
-first place.
-
-### 5.3 Thumbnail delivery — reuse the chunk path
-
-Since `thumb_hash` is a blake3 exactly like a file's own `id`, the cleanest
-delivery is to **serve a thumbnail through the existing chunked
-`file_req`/`ChunkRequest` path**, with the node's chunk handler resolving a
-requested id against either a real file or the thumbnail cache. This reuses
-transport, GEK-scoped encryption, and backpressure handling that already
-exist and are already tested, rather than inventing a parallel mechanism. If
-that reuse proves awkward against the concrete dispatch code once someone
-is in it, a dedicated `thumb_req`/`thumb_resp` pair is a perfectly fine
-fallback — noted as an implementation choice, not a decision this document
-needs to force.
-
-### 5.4 New request/response: TMDB metadata
-
-A JSON blob, not chunked file content, so a plain request/response pair
-fits better than reusing the chunk path:
-
-```
-media_meta_req { path } # client → node, root+relpath already known from the index
-media_meta_resp { tmdb_id, title, original_title, overview, poster_thumb_hash,
- backdrop_thumb_hash, release_date | first_air_date,
- genres[], vote_average, runtime,
- cast[] (name, character), director,
- season?, episode? (for a TV request), confidence }
-```
-
-`poster_thumb_hash`/`backdrop_thumb_hash` point back at §5.3's mechanism —
-the node fetches the image from `image.tmdb.org` once, caches it under its
-own blake3 in `data_dir`, and clients pull it exactly like a video
-thumbnail. No client ever contacts `image.tmdb.org` or `api.themoviedb.org`
-directly (§2).
-
-**Bug found live, 2026-08-24**: `transport.js`'s generic reply routing
-resolves an untyped response against whichever request is *oldest pending*
-— correct for every request the client only ever has one of in flight, but
-`media_meta_req` is fired once per visible poster-grid tile, several at a
-time (apps.md §5's virtualization), exactly the shape `file_req` already
-special-cases with a `chunk:{file_id}:{chunk_index}` key. Without the same
-treatment, two concurrent lookups could resolve to each other's answer
-whenever their responses arrived out of order — reproduced live as two
-shows' posters/season lists swapping across reloads. Fixed by keying
-`media_meta_req`/`media_meta_resp` by `path`, the same way `file_req` is
-keyed by id+chunk.
-
-**Bug found live, 2026-08-24**: a poster-grid tile used to render its raw
-per-file frame immediately (already known from the index, no network round
-trip needed), then swap it for the TMDB poster once `media_meta_resp`
-arrived — and, for a §V6 merge candidate, briefly render as its own
-standalone card before collapsing into its sibling once both resolved to
-the same `tmdb_id`. Individually harmless, but visibly distracting in
-sequence (reported as "hurts the eyes"). `video-app.js`'s `PosterCard` now
-withholds a tile's content entirely — title, subtitle, and image alike —
-until both the metadata lookup and the chosen image (poster or fallback
-frame) have actually settled, showing a spinner in the meantime; a slow
-lookup (a very large, freshly-scanned library) just means the spinner
-stays a little longer. This does not (and structurally cannot, without
-giving up per-tile virtualization, apps.md §5) suppress every trace of a
-§V6 merge: a rare split-release show can still show two fully-resolved
-cards for an instant before they collapse into one, since the merge is
-only known once both siblings' independent lookups return.
-
-**Bug found live, 2026-08-24**: an operator reported the poster grid
-hanging on spinners forever on a *second* visit to the same group's Videos
-tab within one page session (revisit the tab, or a full reload — either
-way, reproducibly). Two real, separate causes:
-
-1. `_fetch_and_cache_poster` (`webrtc_server.py`) downloaded a TMDB poster/
- backdrop from `image.tmdb.org` on *every* `media_meta_req`, even for a
- file whose TMDB match was already cached — nothing had ever checked "is
- this poster_path already fetched" before making the network call, since
- the content-addressed `thumb_hash` isn't known until the bytes already
- are. Fixed by keying the `thumbs` cache on a synthetic `tmdb:{poster_path}`
- id, checked before the request (`media_cache.get_thumb_hash_by_file_id`).
-2. §V6's merge used to mint a brand-new key (every constituent title,
- joined) the instant two raw groups turned out to be the same show —
- destroying an already-visible, already-resolved card and rebuilding one
- from scratch. Fixed by keying the merged entry as `groups[0].title`
- instead: the overwhelmingly common (unmerged) case is unaffected, and a
- merge now updates an existing card's props rather than replacing it.
-
-Neither of those was the actual hang, though both were real, worth fixing,
-and made it worse. The hang itself was a race in `PosterCard`'s own
-"nothing shown until ready" gate (§ above): `imageReady` was a `useState`
-reset to `false` by its *own* `useEffect` keyed on `posterHash`, and set to
-`true` by `MediaThumb`'s `onReady` callback. The instant `metaReady` flips
-true, `posterHash` jumps from the raw fallback frame to the resolved
-poster **in the same commit** that mounts `MediaThumb` for it. If that
-poster's bytes are already in `MediaThumb`'s session-lifetime blob cache —
-exactly true on a same-tab revisit, since that cache is never cleared —
-its `onReady` fires *synchronously* from that mount effect. Effects run
-children-first, so `PosterCard`'s own "reset on posterHash change" effect
-fired *after* it in that same commit, unconditionally overwriting the flag
-back to `false` — with nothing left to ever set it `true` again, since
-`posterHash` doesn't change a second time. The card was stuck as a
-spinner forever despite the image already being fully loaded and cached.
-Fixed by deriving `imageReady` from a direct comparison —
-`readyHash === posterHash`, where `onReady` sets `readyHash` — rather than
-a second, separately-timed writer of the same boolean: whichever of
-"meta resolves" and "image confirms ready" happens first, in whichever
-order effects run, the next render sees the same, correct answer. A cold
-fetch (no reload, or a genuinely new poster) never hit this at all,
-because `onReady` there fires asynchronously, in a later commit, well
-after the reset had already settled — which is exactly why it only ever
-showed up on a revisit, and only after the two fixes above had already
-made a revisit's fetch fast enough to *be* synchronous.
-
-### 5.5 Node-wide config, not per-group
-
-The TMDB credential and query language are one operator's budget and one
-shared cache, not a per-group concern, so they do not belong in
-`group_settings` keyed by a real `group_id`. The existing precedent for
-node-wide operator state stored in that same table is already established
-(`desktop-client-v1.md` §6.3: *"the precedent exists:
-`roster.get_member("", user_id)` already authorizes the operator
-node-wide"*) — so an optional `tmdb_api_token` and `tmdb_language` land in
-`group_settings` under the sentinel `group_id=""`, changed by a signed
-operator op (`OP_TMDB_CONFIG`, same shape as `OP_MEMBER_UPLOAD`/
-`OP_APPS_ENABLED`), and broadcast in `node_status` (the node-wide channel,
-as opposed to the per-group `handshake_ack`) so every connected client,
-across every group, sees the current token/language state without
-reconnecting. Absent means the shipped default token, present with a token
-means "use this one instead" — the same "absent means the old behaviour"
-discipline `member_upload`/`apps_enabled` already follow. (Whether TMDB is
-used *at all* used to live here too — moved per-group, below.)
-
-**Added 2026-08-24**: a third field, `tmdb_language` (e.g. `"fr-FR"`),
-travels the same way — one node-wide setting, not a per-viewer request.
-Live against a real French-language show, the node's TMDB matches came back
-in English by default (an English title for a show whose real title and audience
-are French) purely because no language was ever requested; TMDB honors a
-`language` parameter on every search/details call, so `tmdb.py`'s `_get()`
-now attaches whichever language is configured (or none, TMDB's own English
-default, if unset) to every request transparently — no change needed at
-any call site. The Settings UI pre-fills this field from the operator's own
-current browser language (a sensible default, not an attempt to read their
-hub-account profile, which the node has no way to learn), and the operator
-can still change it. This does not by itself fix an outright wrong match
-(a real, separate accuracy question, §3) — only which language a *correct*
-match's title/overview/poster come back in.
-
-**Correction, same day**: it turned out to fix more than that. Live, the
-French query didn't just translate the operator's example show — it
-resolved season 1's match correctly for the first time (previously a wrong,
-unrelated film in the same fallback slot, §3's kind of miss). A likely
-explanation, not a guaranteed one: TMDB's own search ranking can differ by
-language, and the French title happened to disambiguate what the English
-query could not. Not a substitute for §3's accuracy work — a French-only
-title with no ambiguity would not be helped this way — but a real,
-observed improvement worth having independent of translation.
-
-**Per-field English fallback, added same day**: TMDB does not fall back to
-English server-side for a field with no translation in the requested
-language — it returns `""` (or `[]`) for that one field, confirmed live
-(a French query left `overview` empty for a title TMDB has no French copy
-of, despite a good poster and cast). The TMDB *website* papers over exactly
-this gap client-side; `_tmdb_build_meta` (webrtc_server.py) now does the
-same — a second, English-forced details fetch only when a checked field
-(`overview`, `poster_path`, `genres`) comes back empty, merged field by
-field so a good localized field is never overwritten by the fallback.
-
-**Whether TMDB is used at all, moved per-group, 2026-08-24**: an operator
-running one real media-library group alongside several test/demo groups on
-the same node found every group sharing one on/off switch meant there was
-no way to spend TMDB quota and make outbound requests for the group that
-actually needed it without doing the same for groups that didn't. Split
-into its own signed op, `OP_TMDB_ENABLED`/`tmdb_enabled`/`tmdb_enabled_ack`,
-scoped to `self._group_id` exactly like `OP_VIDEO_ROOT` — stored in
-`group_settings` under the real `group_id` this time, not the `""`
-sentinel, and surfaced in the per-group `handshake_ack` (`tmdb_enabled`,
-already there — it simply reads a per-group value now) rather than
-`node_status`. `tmdb_config`/`OP_TMDB_CONFIG` keep their name and shape for
-the token/language, which stay node-wide for the reasoning above; only the
-on/off switch moved. `TmdbClient` itself lost its own notion of "enabled"
-entirely — every call site in `webrtc_server.py` (`_do_media_meta_request`,
-`_do_season_meta_request`, `_do_tmdb_search_request`) now checks
-`self._group_ctx().get("tmdb_enabled", True)` before ever calling it, and a
-group with it off degrades exactly like "no client configured" already
-did — zero confidence, empty search results, never an error, since a
-member's Videos tab already has to handle "no TMDB match" as the ordinary
-case (§4.1).
-
-### 5.6 `ALLOWED_APPS`
-
-`webrtc_server.py:1629` — add `"video"` to the frozenset. `DEFAULT_APPS`
-(`roster.py`) is deliberately **left unchanged** (`("chat", "files")`): a
-brand-new group does not get Videos for free, since it is the first app that
-makes outbound third-party network calls (once TMDB is on) — the operator
-opts a group into it explicitly via the existing Settings checklist, same
-gesture as any other app.
-
-### 5.7 Season-specific overview, and correcting a wrong automatic match
-
-Found live, 2026-08-24, on a real 3-season show: TMDB's own search
-consistently resolved every season's folder to the same *season-3-specific*
-promotional entry — a wrong `tmdb_id`, not a MeshBay grouping bug (§3.3's
-`_best_match` deliberately trusts TMDB's own top result, per that section's
-own postmortem). Two independent problems, two independent fixes:
-
-**A show's own `overview` (§5.4) is one static field that does not
-necessarily describe every season alike.** New request/response pair, read
-lazily per selected tab (same virtualization discipline as `media_meta_req`,
-never fetched for a season the operator hasn't clicked):
-
-```
-season_meta_req { tmdb_id, season } # tmdb_id is whatever media_meta_resp
- # already resolved — never re-searched here
-season_meta_resp { tmdb_id, season, confidence, name, overview, air_date,
- poster_thumb_hash }
-```
-
-`video-app.js`'s `VideoDetailModal` shows a season picker
-(`Specials` / `Season 1` / `Season 2` / …) whenever a show has more than one
-season, defaulting to the lowest-numbered season present (`defaultSeason`;
-specials only when there is nothing else). It defaulted to the representative
-episode's season until §10.5, which is not the same thing at all. Selecting a season both filters the episode list to it and swaps in that
-season's own `overview`/`air_date` — falling back to the show-level
-`overview` when a season's own comes back empty (TMDB has no season-level
-text for every show), the same per-field fallback shape §5.4's English
-fallback already established, just one level further down when there is
-nothing at all to show otherwise. It was a row of tabs until §10.4; it is a
-menu now, for the reasons given there.
-
-**An operator needs a way to correct a wrong match** when TMDB's own
-top-ranked result is simply wrong — no amount of local re-ranking fixes
-this (§3.3's last row is exactly the mistake that would repeat). Two more
-message pairs, the second an admin op:
-
-```
-tmdb_search_req { query, media_type } # media_type: "movie" | "tv"
-tmdb_search_resp { query, media_type, results: [{ tmdb_id, title, year,
- poster_thumb_hash }] }
-
-tmdb_override { path, tmdb_id, media_type } # admin op, subject =
- # "path={path},tmdb_id={tmdb_id},media_type={media_type}"
-tmdb_override_ack { path, tmdb_id, media_type } # broadcast to every connected peer
-```
-
-`tmdb_search_req` is deliberately **not** admin-gated — it is read-only (the
-same TMDB lookup the automatic matcher already performs on everyone's
-behalf) and returns nothing that isn't already visible in the search
-results a browser could get by hand. `media_type` is echoed back in the
-response, not only the query: a client that fires a movie search and a tv
-search for the same title in close succession needs it to tell the two
-responses apart for keyed matching (`transport.js`), the same reordering
-hazard §5.4's first postmortem already covers for `media_meta_req`.
-
-`tmdb_override`, once signed, is applied to **every index entry sharing the
-resolved file's `display_title`** (`webrtc_server.py`'s
-`_admin_exec_tmdb_override`) — the same grouping the poster grid itself uses
-(§3.4/V6) — not just the one file the operator happened to right-click,
-so the correction actually sticks for every episode of the show, and
-broadcasts a `tmdb_override_ack` to every connected peer so an already-open
-grid/modal picks up the change without a reconnect (`video-app.js`'s
-`useMediaMeta` subscribes to a module-level generation counter, bumped on a
-successful override, that forces every mounted tile/modal to refetch).
-
-New adminop: `OP_TMDB_OVERRIDE = "tmdb_override"` (`adminop.py`), following
-`OP_VIDEO_ROOT`/`OP_TMDB_CONFIG`'s exact shape — signed for the same reason:
-`media_cache` is shared node-wide, not per-viewer, so an unsigned override
-would let any member vandalize another show's metadata for everyone.
-
-**Bug found live, 2026-08-24**: the search overlay (a second, later
-`.video-overlay` sibling, opened on top of the detail modal) rendered
-completely invisibly the first time it shipped — present in the DOM
-(confirmed via the accessibility tree), inert on screen. Cause: its own
-`z-index: 1` lost to the base `.video-overlay` class's `z-index: 200` that
-the detail modal underneath it already used; both are `position: fixed`,
-so an explicit z-index always wins over DOM order regardless of which
-element mounted later. Fixed by giving `.video-search-overlay` an explicit
-`z-index: 210`.
-
----
-
-## 6. Node-side implementation, concretely
-
-| Piece | Where | What |
-|---|---|---|
-| Title/season/episode parse | new `meshbay_node/indexer/title_parse.py` | Wraps `guessit`, applies §3.3–3.4's rules, returns `(display_title, year, season, episode, confidence)` |
-| Technical probe at index time | `meshbay_node/indexer/indexer.py`, reusing `_probe_video`'s ffprobe call from `webrtc_server.py` (extend `-show_entries` with `width,height`) | Populates `IndexEntry.duration`, new `width`/`height` |
-| Bounded probe/thumbnail pool | `meshbay_node/transport/webrtc_server.py`, sibling to the streaming pool | Small worker count, short timeout, drains stdout, never touches `max_concurrent_streams` |
-| TMDB client | new `meshbay_node/tmdb.py` | `httpx` (already a dependency) against `api.themoviedb.org`; resolves token via §5.5, falls back to the shipped default |
-| Caches | `data_dir/media_cache.db` (SQLite, alongside `chat.db`/`audit.db`/`bundle_store.db`) | `(content_hash) → tmdb_id` mapping; `(tmdb_id) → metadata json`, TTL 30 days; `(thumb_hash) → jpeg bytes`, pruned when the owning file leaves the index |
-| Operator config | `roster.py` `group_settings`, `group_id=""` | `tmdb_enabled`, `tmdb_api_token`; `ops.py` gains `set_tmdb_config(state, enabled, token=None)`, one `_op(...)` line, same adapters (loopback/CLI/MNP) as everything else in `ops.py` |
-| `pyproject.toml` | `packages/meshbay-node/pyproject.toml` | add `guessit>=4.4` |
-
-## 7. Client-side, per `apps.md`'s checklist
-
-1. `video-app.js` — receives the standard props (§2 of `apps.md`); internally
- two presentational sub-views (poster grid / flat), switched by the
- `localStorage` toggle (§4.3). Requests `media_meta_req` lazily, only for
- currently-visible tiles in the poster grid (same virtualization discipline
- `apps.md` §5 already calls out as mandatory for a grid of many tiles), and
- thumbnails the same way.
-2. Register `{ key: "video", icon, labelKey: "group.tab_video", Component: VideoApp }` in `apps.js`.
-3. `ALLOWED_APPS` (§5.6).
-4. `group.tab_video` in all ten `static/locales/*.js`.
-5. `webapp.py`'s `_ASSETS` tuple — add `video-app.js`.
-6. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py`
- (`SPLIT_FILES`) — add the new file to both.
-7. `npm run sync-ui` in `meshbay-client`.
-
-No hub change, no protocol change beyond §5's additive fields and two new
-message pairs — consistent with every other app added under this mechanism.
-
----
-
-## 8. Security — per adversary
-
-| Claim | Passive hub | Active hub | Malicious node operator | Another member |
-|---|---|---|---|---|
-| TMDB token confidentiality | ✅ unreachable | ✅ unreachable | held by the operator, who could always query TMDB manually anyway — no new exposure | ✅ never transmitted to members |
-| Metadata/thumbnail delivery | ✅ unchanged transport | ✅ unchanged transport | sees it already (holds the plaintext file) | same GEK-proofed MNP channel as files/streaming — no new authorization surface |
-| New outbound traffic (node → TMDB) | — | — | the operator already controls their own node's egress | — |
-| Stale cache after file deletion | — | — | pruned on the index's own deletion event (§2); until built, a deleted file's thumbnail/metadata could persist on the node's disk after removal from the share — a real, addressable gap, not a design flaw, and called out here so it is not forgotten | — |
-
-**The claim this design supports:** moving TMDB/thumbnail work to the node
-adds no new authorization boundary — everything travels over the channel
-that already proves group membership and GEK possession — and *removes* a
-credential (the TMDB token) from every client that would otherwise hold it.
-
-**The claim it must not make:** that this is free. It is new node behaviour
-(egress to a third party, a new disk-resident cache with a real deletion
-obligation) that did not exist before, and both are called out explicitly
-rather than left implicit.
-
-## 9. Filesystem/Windows — why §2's choice avoids the trap
-
-`desktop-client-v1.md` §6.8/§7.5 already established exFAT/NTFS/Windows as
-the **common** case, not an edge case. Had the cache lived inside a shared
-root (the operator's original `.thumbs` idea), it would have inherited every
-one of those constraints for no benefit:
-
-- A read-only backup mount or a NAS share the node has no write access to
- would refuse the write outright — not a Windows quirk, a real failure on
- any platform.
-- Windows does not hide a leading-dot folder the way Unix does; `.thumbs`
- would need an explicit "hidden" attribute set (and kept set across
- recreation), or it shows up as an ordinary visible folder in Explorer —
- previews of a private group's content, visible to anyone who plugs that
- drive into a different machine.
-- It would need to be filtered out of every directory listing path
- consistently (indexer, root-name collision checks, watcher
- reconciliation) — one more place a future change could forget it.
-
-Keeping the cache in the node's own `data_dir` (§2) — the same location
-`chat.db`/`audit.db`/`bundle_store.db` already use on every supported
-platform — sidesteps all three for free, with no loss of the benefit the
-centralization was for (one computation, reused by every member).
-
----
-
-## 10. Open items
-
-| # | Item | Why it is not decided here |
-|---|---|---|
-| V1 | Exact wording/UX of the "TMDB disabled" state in Mode A (Mode B unaffected) | Product/UI choice, not architectural |
-| V2 | Whether `poster_thumb_hash`/`backdrop_thumb_hash` reuse the chunk path (§5.3) or get a dedicated pair | Depends on how the concrete `file_req` dispatch code reads once someone is implementing against it |
-| V3 | TMDB metadata cache TTL (30 days proposed) | Needs no more than a config constant; not worth deciding without seeing real staleness complaints |
-| V4 | Multi-audio-track / subtitle-track surfacing in the detail view | Out of scope for this pass — `video-player.js`'s existing track handling is unchanged; Videos only adds discovery and metadata |
-| V5 | `Music`/`Photos` apps | Explicitly out of scope, per `apps.md` — this document only builds `video-app.js` and the shared node-side machinery (TMDB client, thumbnail cache, title parser) that a future audio/photo app could also reuse |
-| ~~V6~~ | ~~Two folders of the same show, named by different release groups, can produce two separate poster-grid cards~~ | **Closed, 2026-08-24.** Rather than fuzzy title matching (real design decision, still deferred), `PosterGrid` now merges raw show-groups client-side once each group's TMDB lookup resolves to the same confident `tmdb_id` (`onMetaResolved` reports each `PosterCard`'s resolved meta upward; a `useMemo` groups by id, combining episodes/seasons into one card). This only merges what TMDB already agrees is one show — a genuinely unmatched show still gets its own card, which is correct. Confirmed live: two release-group spellings of one show's season folders now render as a single card with both seasons |
-| ~~V7~~ | ~~A show's automatic TMDB match can be wrong at the show level (not just mis-ranked locally), and a wrong match's `overview` can read as scoped to one season~~ | **Closed, 2026-08-24 — §5.7.** Per-season tabs (own `overview`/`air_date`, falling back to the show-level text when empty) plus an operator-only "Fix match…" search-and-correct affordance, applied to every file sharing the resolved `display_title`. Confirmed live on a real 3-season show, itself matched to a wrong season-specific promotional TMDB entry: season tabs correctly filtered episodes and swapped in each season's own air_date, and the search overlay returned real TMDB candidates for a manual correction |
-
-### 10.1 Movie-matching bugs — a batch found live (2026-08-29)
-
-Several wrong poster-grid matches on a real library: a two-volume film's second part
-matched the first; a numbered sequel matched a same-year making-of documentary (TMDB's
-real entry uses a Roman numeral); two entries of one franchise matched a single early
-entry whose *localized* TMDB title is the franchise name, and a third matched nothing.
-One mechanism: `_tmdb_search` returned the first candidate query whose title-similarity
-ratio merely cleared 0.6, before `alternative_title` / the Roman-numeral variant was
-tried. **Fixed** (branch `fix/videos-tmdb-matching`): scored retry ladder + year-exact
-rescue for a sub-0.6 top hit (§3.3's "trust TMDB's own ranking" still holds per query —
-no local re-rank of any one result list); `volume`/`part` folded into `display_title`;
-movie "Fix match" applied to the one file, not the whole `display_title` group;
-`ops.rematch_video` + a rename now drop stale cached matches (`media_cache.tmdb_override`
-shields manual corrections); the movie detail modal shows the source filename + resolved
-TMDB id, and an unmatched poster gets a badge.
-
-**V8–V13 — the follow-ups, now done** (branch `feat/videos-matching-v8-v13`):
-
-| # | Item | Done |
-|---|---|---|
-| ~~V8~~ | The TV/show branch of `_tmdb_search` used the old "first over 0.6 wins" shape | Both branches share one `_tmdb_ladder` — score every candidate, keep the best, fast-path a confident primary hit. `title_parse.year_in` lifts a year off a show folder name; `title_parse.clean_query` de-dots a folder-derived title without `naive_title`'s extension-strip |
-| ~~V9~~ | `_best_match` trusted `results[0]` per query unconditionally | Optional `year`: when the top result is not a confident textual hit (< 0.6) and a year was requested, a different result of that exact release year is preferred. A confident top hit is never overridden |
-| ~~V10~~ | `sequel_variants` narrow (trailing digit 2–9, arabic→roman only) | Widened: digit↔Roman both ways, spelled-out indices (one/two…, un/deux…, ordinals), a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word that is not an index or a 4-digit year |
-| ~~V11~~ | Extra TMDB calls in the 0.6–0.85 band | When the primary hit is decent (≥ 0.6) and there is nothing more specific to try (no `alternative_title`, no sequel variant), the ladder returns without the extra requests |
-| V12 | Movies not merged in the poster grid | Built (`mergedMovies` by resolved `tmdb_id`) then **reverted** (`revert/video-movie-merge`): with a poor matcher it *fuses distinct films* — every numbered entry of a saga that TMDB resolves to the same base id collapses into one card (seen live: a 9-film saga, two unrelated same-title movies). A merge that trusts `tmdb_id` needs the match to be right first; that is the V8–V11 / V9 work, still not fully there. Movies now render one card per file again; the detail modal has a single Play button; `video.versions` key removed. |
-| ~~V13~~ | Per-card "re-match this one file" button | `OP_TMDB_REMATCH` / `MNP.TMDB_REMATCH` (MNP 0.13, additive) → `media_cache.drop_tmdb_match` (forgets the match *and* the override marker). Signed like `tmdb_override`. Button next to "Fix match" in the detail modal; `transport.rematchTmdbMatch`; `video.rematch_one` key ×10 |
-
-### 10.2 A movie mis-shelved as a series (2026-08-29)
-
-`Some.Film.2017.MULTI.108.grp.mkv` — a movie whose `1080p` tag was truncated to `108` —
-made guessit invent `S01E08`, so `enrich.py`'s flat-library branch filed a standalone film
-as a nonexistent series, and "Fix match" (TV-only for a "show") could only offer other
-series. **Fixed** (`fix/movie-misclassified-as-show`): that branch now requires
-`ep.season` *and* `ep.episode`, plus either an explicit marker
-(`title_parse.has_episode_marker` — `SxxExx` / `1x08` / `Episode N` / `Season N`) or the
-absence of a `(2019)`-style year; otherwise it falls through to the movie branch. A real
-flat-dumped episode always carries a marker, so nothing regresses.
-
-**Still open:** there is no operator control over the movie/show classification itself —
-"Fix match" only changes the TMDB id, not the kind. A genuinely ambiguous name
-(`Show.2019.308.mkv`) that guessit slots wrongly has no in-UI remedy short of renaming the
-file. A "this is a movie / a show" toggle would close that.
-
-### 10.3 A whole saga matched to its first film (2026-08-30)
-
-Every `<Saga> Episode <N> - <subtitle>` file in a numbered franchise resolved to the
-series' first entry. Cause: `sequel_variants` stripped `Episode <N>` and offered the bare
-`<Saga>` as a candidate query — which matches the first film's `original_title` at ratio
-1.0, beating PASS 1's correct (but lower-ratio) hit. A franchise's bare name is very often
-a real, different film. **Fixed** (`fix/saga-match`): when a `Part`/`Episode`/…
-keyword is what carried the index, `sequel_variants` no longer emits the bare base — only
-`<base> <digit>` and `<base> <roman>`. Without a keyword (`<Franchise> 3`) the bare base is
-still offered. Verified live against a numbered franchise's episodes plus the earlier
-numbered-sequel / two-part-film / franchise-subtitle regressions.
-
-### 10.4 The detail modal jumped from one season to the next (2026-09-02)
-
-Three faults, one complaint: opening a different season of the same show moved
-everything under the synopsis, which is where the season control and the
-episode list are, so the thing just clicked was no longer under the pointer.
-
-- **The synopsis is however many lines TMDB wrote.** A twelve-line season
- summary and a two-line one put the season control ten lines apart, and
- resized the modal with it. It is now **exactly three lines** with a "read
- more" link — clamped from above and, for a multi-season show only (the one
- case where a season can change underneath it), pinned from below to the same
- number. A constant, not a range: a two-line band still reads as a jump, and
- the point is that the picker is at the same pixel from one season to the
- next. Five lines was built first and is why the number is three — at five, a
- short synopsis sat over three blank lines. `-webkit-line-clamp` cannot do
- the clamp half of this: it only ever puts its ellipsis at the end of the
- last line and leaves no room for anything after it. The link is floated
- into the third line box instead, which is why
- `OverviewText` writes the button *before* the text (a float only pushes the
- line boxes that follow it) and the CSS reserves the two lines above with a
- zero-width float. That spacer is scoped to `.has-more`: `overflow: hidden`
- makes the paragraph a block formatting context, so it *contains* the float
- and would otherwise stand two lines tall for a one-line movie synopsis.
- Whether three lines is all of it depends on the modal's width, so it is
- measured in the browser, not counted, and re-measured on a resize.
-- **The cast line was however long the cast was.** Clamped to two lines,
- ellipsised at the end of the second. Nothing to expand to here, so this one
- is a plain `-webkit-line-clamp`.
-- **The episode list is however many episodes the season has**, and that is
- the half neither clamp can reach. The body scrolled as a whole, so a
- thirteen-episode season pushed the modal to its `max-height` where a
- six-episode one had not — and `.video-overlay` centres its child, so the
- taller modal *started higher up the screen*: title bar, close button and all.
- `.video-detail-steady` (a multi-season show only) gives the modal a `height`
- rather than a `max-height`, makes the body a flex column, and hands the
- leftover to `.video-season-list` as the one scrolling part. A constant-height
- box is centred in the same place every time, so both halves settle at once.
- A season that scrolls also draws a scrollbar where one that does not draws
- none, which is a scrollbar's width of content and re-wrapped the file path
- above it; `scrollbar-gutter: stable` reserves it either way.
-- **The season menu's panel is `position: fixed`.** As an absolutely
- positioned child it was clipped by the modal's own `overflow: hidden`
- whenever the seasons outran the room under the picker — measured at a 740 px
- viewport, a thirteen-season panel wanted 320 px and had 288, and the rest sat
- outside the modal where no scroll could reach it. `placeSeasonPanel()` takes
- the trigger's rect and the window height, picks whichever side has more room,
- and caps the panel to it. It is the only part of this menu the stylesheet
- does not decide, so it has its own test.
-- **The season tab bar scrolled sideways.** A show with a dozen seasons hid
- most of them behind a horizontal scrollbar — unusable on a phone, and ugly
- everywhere. `SeasonMenu` replaces `SeasonTabs`: one trigger reading
- `Season 5 · 1997` and a menu of every season with its episode count, one row
- high whatever the season count. The selected season's air year moved onto
- that trigger, where it names what it belongs to; the *show's* year moved
- into the facts line next to the director. Per-season air dates are still
- fetched lazily for the selected season only (§5.7), so the menu lists
- episode counts — which are local — rather than a year per row.
-
-A series also had no director: `media_meta_resp.director` was filled from the
-credits crew's `job == "Director"`, a movie shape. TMDB's aggregate
-`tv_credits` crew is routinely empty and never carries that job, so every show
-answered `null` and the modal dropped the line. It now comes from `created_by`
-on the show details, which is what TMDB's own page shows. Cached show metadata
-keeps its `null` until `TMDB_META_TTL_SECS` expires or an operator re-matches.
-
-Measured, not read out of the stylesheet (`test_video_detail_measured.py`,
-via the `layout_probe.py` harness): the season picker's offset inside its own
-modal body is the same pixel for a one-line and a twelve-line synopsis *and*
-for a six- and a twenty-four-episode season, at every width from 320 px up;
-the modal's own top and height are the same for all three; the episode list
-never runs past the bottom of its modal; the clamped synopsis is three
-line-heights either way and the cast at most two; the read-more link lands on
-the third line; and a movie gets neither the floor nor the fixed height. Each
-measured block sits inside a real `.video-overlay` — the centring is half the
-defect — in a whole-pixel-height container, since stacked in flow they start at
-fractional offsets and two identical layouts an eighth of a pixel apart round
-to tops one pixel apart, which reads as a defect in the thing being measured
-and is not one. `test_season_panel_placement.py` runs `placeSeasonPanel()` in
-node over a rect and a window height: down, flipped up, capped to the room
-available, floored when cramped. Two things there are declarations rather than
-rectangles, and say so in their own docstrings: `scrollbar-gutter`, because
-headless Chrome gives the probe zero-width overlay scrollbars.
-`test_tmdb_show_director.py` covers the credit.
-
-### 10.5 A show that opened on season 6 (2026-09-02)
-
-Every season was in the picker and none was missing; the *default* was wrong.
-`VideoDetailModal` took it from `repEntry.season`, and `repEntry` is the show's
-"representative entry", which the poster grid picks as
-`episodes.find((e) => e.thumb_hash) || episodes[0]` — the first episode that
-has a thumbnail, so the card has a fallback frame when TMDB has no poster.
-That choice is from the original Videos commit; the season tabs came later and
-read the same entry as "the episode the reader is looking at", which it never
-was on that path. Episodes are sorted by (season, episode), so a show whose
-first five seasons had no thumbnail yet — a partial enrichment pass, or ffmpeg
-failing on those particular files — hands back a season-6 episode.
-
-Two meanings of "representative" that were never the same thing, and only one
-of them is about what the reader is looking at. `defaultSeason(show)` now reads
-the season list and nothing else: the lowest-numbered season present, specials
-only when there is nothing else, and the lowest *number* rather than the first
-entry so it does not quietly depend on `buildSeasons` keeping its sort.
-`test_video_default_season.py` — no input it takes can carry a thumbnail.
-
-### 10.6 The same film twice in the cross-group Search view (2026-09-02)
-
-An operator hosting two groups gave both the *same* video directory — which is
-the point of having two groups: different people are invited to different
-libraries, and one library may be in several of them. **Search files** then
-showed every film as two poster cards and every episode twice in the season
-list, one copy badged per group. Flat list too.
-
-Not a Videos bug. Inside a group it cannot happen: `GroupIndex` is keyed by
-blake3, so the same bytes at two paths are already one entry. `search-page.js`
-concatenates *N* independently keyed indexes into one list, and that is where
-the duplication is born.
-
-`source-merge.js` folds entries on the content hash and resolves **one source
-per unit** — a film, a whole show — rather than per file: a season split across
-two nodes would open two connections and two metadata lookups for one show. A
-group hosted by the reader's own node wins (read from `handshake_ack`'s
-`is_node_admin`, which the node computes from its own record of who it belongs
-to, never a hub claim); failing that the pick is `hash(unitKey + userId)`,
-stable for one reader across renders and reloads — a source that changed
-mid-stream would tear down the connection under a film that is playing — and
-spread across readers.
-
-The units come from **`groupVideoEntries` itself**, called on the un-merged
-list purely to learn them, never a second copy of its keys in the Search page.
-A copy would keep agreeing until one of them changed, and the symptom would be
-a show whose episodes stream from two different nodes.
-
-Two consequences worth knowing:
-
-- **An operator's "Fix match" and "Rematch" go to the chosen source's node.**
- On the operator's own libraries that is their node, which is what they mean.
- On a merged entry they do not host, the override lands on whichever group was
- picked — and the other source keeps its own match.
-- **`PosterGrid.mergedShows` still merges two differently-parsed titles once
- both resolve to the same TMDB id.** Those were two units when the source was
- picked, so a merged card can hold two sources. Left alone deliberately:
- re-picking under a card the reader is looking at is worse than a mixed one.
-
-Full design, the adversary this names, and what must not change:
-`docs/refactoring-search.md`. `test_search_source_merge.py` holds the rules,
-`test_search_media_merge.py` holds this symptom end to end.
-
-### 10.7 Every card badged "unmatched" when TMDB is off (2026-09-08)
-
-§10.1 gave an unmatched poster a badge: a dashed warning outline on the card
-plus a `?` next to the title, with `video.no_match` ("no confident TMDB match
-— showing the filename") as its tooltip, and the same sentence again at the
-top of the detail modal. That is a useful signal — it is how an operator finds
-the titles worth a "Fix match".
-
-It says the wrong thing when the group's operator has turned TMDB **off**
-(§5.5's per-group switch). No lookup was made, so nothing failed to match;
-`media_meta_req` answers `confidence: 0` for the whole library by design
-(`webrtc_server._do_media_meta_request`, the same silent degradation as "no
-client configured"). The poster grid therefore drew *every* card as a failure
-of the very thing the operator chose, and no poster was ever going to arrive
-to clear it.
-
-Poster mode now presents the file's own thumbnail plainly in that case —
-what Mode B (§4.2) does — and keeps the badge for what it was built for, a
-lookup that ran and came back with nothing:
-
-- `PosterCard` takes `tmdbEnabled` and derives `flagUnmatched = tmdbEnabled &&
- !confident`, which gates both `video-card-unmatched` and the `?` span. The
- title still falls back to the parsed filename exactly as before.
-- `VideoDetailModal` takes it too and gates its `video-detail-nomatch`
- paragraph. A **show** still opens that modal with TMDB off — it is where the
- season list lives, unlike a movie, which §V12's `onOpen` already sends
- straight to the player — so suppressing it on the card alone would have left
- the same claim one click deeper.
-- Both default the prop to `true`, so a call site that forgets it keeps the
- badge rather than silently losing it.
-- The modal's operator-only **"Fix match" / "Rematch"** buttons are gated on
- `isNodeAdmin && tmdbEnabled` for the same reason one step further on: both
- act on a TMDB match that, with the switch off, does not exist. "Fix match"
- opened a search the node answers with an empty result list (§5.7's silent
- degradation) and "Rematch" dropped a cached match that was never made — two
- dead buttons offered to the one person who already knows why. Nothing else
- reaches `TmdbSearchOverlay` or `doRematch`; those buttons are their only
- entry points, so gating them here is the whole change.
-
-Deliberately unchanged: `search-page.js` passes `tmdbConfig={{ enabled: true }}`
-because a cross-group view has no single group's switch to read, so a merged
-card there still badges — and, being `readOnly`/`isNodeAdmin={false}`, it never
-showed the operator buttons in the first place.
-
-
-## 11. Acceptance before shipping
-
-1. Re-run the §3 validation (real TMDB calls, same corpus, same script
- methodology) against the actual production parser and `tmdb.py`, not the
- scratch harness used for this planning pass. Keep the script in `QE/`
- (not versioned, per repo convention) as a regression check for future
- parser changes.
-2. Confirm the >95% bar on `Films/` with the §3.3 fixes applied for real,
- not just argued.
-3. Verify cache pruning on file deletion actually fires (§2, §8) before
- calling the durable-cache reversal complete — an untested claim here
- would repeat the exact "documented but not built" pattern this project's
- own history (CLAUDE.md) warns about repeatedly. Covered by an automated
- test (`test_media_cache.py`), not just argued.
-
-**Done, 2026-08-24**: the full pipeline ran live against the operator's real
-node and library (a real show, real TMDB token) — probe/parse/thumbnail
-enrichment, TMDB search/match/credits, poster fetch-and-cache, chunk-path
-delivery, and the poster-grid/flat-list/detail-modal UI, end to end, no
-errors. One real bug was found and fixed this way that no unit test would
-have caught: `DirectoryIndexer.initial_scan()` (run once at startup) never
-itself triggers the `on_change` callback enrichment hangs off — daemon.py's
-`_bg_scan` now explicitly calls `_on_index_change` after the startup scan
-completes, or every file already on disk at boot (the common case) would
-never get enriched, only files added later while the node runs. Covered by
-`test_startup_scan_enrichment.py`. The exhaustive §3 corpus re-run (item 1
-above, item 2's >95% bar) is still open — this was a live smoke test of the
-mechanism, not the accuracy study.
diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md
deleted file mode 100644
index 49aef75..0000000
--- a/docs/meshbay-draft-v5.md
+++ /dev/null
@@ -1,622 +0,0 @@
-# MeshBay — Architecture Draft v5
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the architecture draft v5; its design
-> content now lives in §2 (claims), §5 (protocol), §6 (node), §7 (hub), §4 (cryptography), §12 (testing).
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> **SUPERSEDED 2026-08-17 by `docs/meshbay-draft-v6.md`.** v6 restates only what
-> changed; everything below that v6 does not touch is still authoritative — the
-> handshake (§4), node authority (§5), the hub's role (§6), cryptography (§7) and
-> the testing posture (§10). Read v6 first, then this.
->
-> Status: **Phase 11.5 (security remediation) complete. Invite redesign complete
-> (2026-08-14) — H3 and M3 closed.** See `devel-phases-next.md` and, for the invite
-> and pairing design, `docs/invite-pairing-v1.md`. For the desktop client — shell,
-> device linking, account creation, node management — `docs/desktop-client-v1.md`
-> (2026-08-17) is authoritative and supersedes §8.2 here.
-> Supersedes draft v4 (archived in `old-draft.md`). Sections not restated here are unchanged from v4.
->
-> v5 exists because the second security review (`second-review.md`, 2026-08-13) found
-> that v4 described a system the code did not implement, and because several v4 claims
-> were simply wrong. This document states what is true, what is chosen, and what is
-> knowingly accepted. **Where a property does not hold, it says so.**
-
----
-
-## 0. How to read this document
-
-v4 described intent. Several of its statements were contradicted by the implementation:
-
-| v4 said | Reality found in the code |
-|---|---|
-| "ALL operations require passing the GEK proof first" (§4.2.x) | True on WebRTC only. QUIC, TCP and the node HTTP API each had their own weaker handshake |
-| "The node NEVER serves the GEK in plaintext" | True, but the node's HTTP API served the *decrypted files themselves* with no authentication at all |
-| "Argon2id parameters updated: memory_cost 262144" | Applied to the hub's password verifier only; the node keystore was still at 64 MB |
-| "GEK bundles: opaque blobs; hub cannot decrypt them" | True, and irrelevant — the hub was the key directory, so it could substitute a key at invite time and be handed the GEK legitimately *(fixed 2026-08-14; the invite path no longer reads the directory — §5.5)* |
-| "hub stores no content metadata" | The node registered blake3 hashes of **private** files with the hub |
-
-So v5 carries an explicit rule: **a claim in this document must name the adversary it
-holds against.** A property that holds against a passive hub and not an active one is
-written that way.
-
----
-
-## 1. Changes from v4
-
-| # | Category | Change | Source |
-|---|---|---|---|
-| 1 | Transport | TCP+TLS transport and the node HTTP file API **removed**. Two listeners remain: WebRTC DataChannel (primary) and QUIC (LAN / port-forwarded / hub-less) | 11.5.1–2, findings C1/C6 |
-| 2 | NAT traversal | **ICE/STUN is the traversal mechanism**, for native clients too. `punch_nat()` is a direct-connection helper, not a traversal stack | Decision D3 |
-| 3 | Handshake | One implementation in `meshbay_common.handshake`, shared by every transport | 11.5.4, finding C6 |
-| 4 | Handshake | **Mutual authentication**: the node proves GEK possession and signs the transcript. Authentication used to run one way only | 11.5.7, finding C3 |
-| 5 | Handshake | Transcripts are length-prefixed and domain-separated; `group_id` is mandatory; missing channel binding is refused | L4, M1 |
-| 6 | Admin ops | Destructive operations sign a structured transcript naming operation, node, group, subject, nonce and time — not 32 opaque random bytes | Finding H5 |
-| 7 | Node authority | **Nothing arriving over MNP can activate a GEK.** Member-supplied bundles are not gated but *gone*: `gek_bundle_store` was deleted when the node took over wrapping | Finding C5b |
-| 7b | Admission | **The node wraps the group key itself**, for a key the recipient proved possession of, and admits people from its own roster using one-time codes the hub never sees (§5.5) | Findings **H3**, **M3** |
-| 8 | Uploads | Confined to a per-user quarantine directory; no overwrite | Finding C5a |
-| 9 | Node identity | Hub verifies node ownership on WebSocket registration; group claims are derived from the database | Finding C2 |
-| 10 | Revocation | Group revocations are honoured by nodes; denylist persists across restarts | Finding H4 |
-| 11 | Privacy | Only **public** groups register content hashes with the hub | Finding H7 |
-| 12 | Client architecture | The hub **keeps serving the web UI**. A native desktop client is offered **alongside** it | Decision D1/D2 |
-| 13 | Hub role | "Hub minimization" is **deferred and may be dropped**. The hub stays in the trusted path by choice | Decision D4 |
-
----
-
-## 2. Security claims — what holds, against whom
-
-This replaces the informal assurances scattered through v4 §4.2.x and §13.
-
-| Claim | Passive hub | **Active hub** | Malicious node operator | Malicious group member | Network attacker |
-|---|---|---|---|---|---|
-| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the GEK | ✅ |
-| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ plaintext at rest until Phase 15 | ❌ | ✅ |
-| File index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ |
-| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ |
-| Node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ |
-| Client code integrity | ❌ **T3, accepted** | ❌ T3 | ✅ | ✅ | ✅ |
-| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ |
-| Hub cannot obtain the group key | ✅ | ✅ **since H3 closed** — except in `join_policy = "open"` groups, where it can join legitimately | — | — | ✅ |
-| Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ **files, pinned identity and bundle stay on the node** — §6.1 | — | — |
-| Your identity keys stay yours | ✅ | ✅ | ⚠️ **offline attack on the bundle they hold** — see §7.1. Succeeds against a weak passphrase, and yields the identity used **on that node only**: nothing on anyone else's | ✅ | ✅ |
-
-**The claim this project can make:** *the hub cannot read your content unless it ships you
-malicious client code.* Since H3 closed (2026-08-14) that is the **only** remaining path,
-it is not silent, and it does not exist for a native client.
-
-**What changed.** The hub used to be the key directory on the invite path: the inviter
-fetched the invitee's `pk_x25519` from the hub and wrapped the GEK for whatever came back,
-so a hub answering with its own key was handed the group key by an honest member following
-the protocol exactly. That path is gone. The node holds the GEK and wraps it itself, for a
-key the recipient proved possession of over an authenticated channel, bound to an identity
-the operator admitted with a one-time code the hub never sees (§5.5).
-
-**The claim it must not make:** *"everything is encrypted and unreadable by other parties,
-even the hub."* Three reasons, all deliberate:
-
-- **T3 — the hub serves the SPA.** Accepted permanently for browser users (decision D1). A
- hub that ships the code can lift keys from the page regardless of protocol design — and
- can read a pairing code out of it. This is an artifact-level attack, not a silent
- directory lie: the native client (Phase 13) plus reproducible builds (18.7) remove it.
-- **Open-join groups.** Where `join_policy = "open"`, anyone the hub calls a member is
- served the key, so the hub can join through the front door. That is a property of open
- joining, not a defect, and the setting is read from `node.toml` — the hub cannot declare
- a group open.
-- **Members and the operator read everything.** Inherent: "end-to-end" here describes
- *client ↔ node*, never *client ↔ client*.
-
-One boundary deserves naming: an operator hosts your content by design, but they should not
-be able to become *you*. They can still try — a bundle sits on their disk and a weak
-passphrase gives it up — but since 2026-08-14 what it gives up is **the identity you use
-with them**, which unlocks nothing they did not already have. Reading what they host is by
-design; reading what *other* operators host is not, and no longer follows.
-
----
-
-## 3. Transport (replaces v4 §6.4, §6.5, §7.1)
-
-### 3.1 Listeners
-
-| Listener | Role | Status |
-|---|---|---|
-| **WebRTC DataChannel** (aiortc) | Primary, browser **and** native clients | Unified handshake enforced |
-| **QUIC** (aioquic) | LAN, port-forwarded, hub-less `group://` | Unified handshake enforced |
-| ~~TCP + TLS 1.3~~ | — | **Removed** (C6) |
-| ~~HTTP file API~~ | — | **Removed** (C1) |
-
-The HTTP API bound `0.0.0.0` for every group, private ones included, and served the
-Mesh Group Index and raw plaintext files with no authentication. It was deleted rather
-than repaired: it duplicated MNP without any of its controls.
-
-### 3.2 NAT traversal — correction
-
-v4 presented `punch_nat()` as the native-client traversal mechanism. It is not one. It
-is a single UDP probe to one address (`quic_server.py`), with:
-
-- no STUN client (the `aioice` dependency is pulled in by `aiortc` only)
-- no candidate gathering, no candidate pairs, no dual-stack fallback
-- a requirement that the client already know its own external IP:port and connect from a
- fixed source port
-
-It was validated on one ISP and one NAT type. **ICE/STUN is the traversal path** —
-validated across two ISPs, two browsers, IPv4 STUN + IPv6 direct, and 4G CGNAT.
-
-Native clients therefore run `aiortc` in Python rather than reimplementing traversal.
-Every native benefit (local keystore, ChaCha20, Argon2id, Ed25519 auth) comes from being
-native, not from the transport. QUIC is retained for LAN, port-forwarded and hub-less
-access, where a signaling channel is unavailable or unwanted.
-
----
-
-## 4. MNP handshake (replaces v4 §4.2.x handshake description)
-
-One implementation: `meshbay_common/handshake.py`.
-
-```
-client → node handshake {token, group_id, nonce_c}
-node authorize_token() JWT · scope · denylist · group_id · membership · hosting
-node → client handshake_challenge {nonce_s, node_pk}
- ── pre-proof window: keypair/GEK bundle fetch, join ──
-client → node handshake_response {proof}
-node verify HMAC(GEK, client transcript)
-node → client handshake_ack {proof, sig, node_pk, is_node_admin}
-client verify HMAC(GEK, node transcript) + Ed25519(node_pk)
- and that ack.node_pk is the key announced above
-```
-
-`node_pk` is announced in the challenge because joining needs it before the ack: a
-first-time member signs a transcript naming this node (§5.5) and has no GEK to complete a
-handshake with. It is unverified at that point and is never a substitute for the ack — the
-ack proves possession and signs the transcript, and the client refuses a peer whose key
-changed between the two. A wrong value there only makes the node's own verification fail.
-
-**Refusals carry a code**, not only a sentence. `not_a_member` in particular is usually a
-token issued before the person was added to the group — `groups` is baked in at sign-in and
-the hub pushes no updates — so the client refreshes once and retries rather than telling
-someone who was invited a minute ago that they are not a member.
-
-### 4.1 Transcript
-
-```
-"meshbay:mnp:handshake:v1" ‖ len‖role ‖ len‖group_id ‖ len‖nonce_c ‖ len‖nonce_s ‖ len‖binding
-```
-
-Every field length-prefixed; the role bound in so a client proof cannot be replayed as a
-node proof. v4's transcript was `nonce ‖ offer_fp ‖ answer_fp` — bare concatenation, and
-a missing fingerprint silently degraded it to nonce-only, dropping MitM detection.
-**An absent channel binding is now refused.**
-
-### 4.2 Channel binding
-
-| Transport | Anchor |
-|---|---|
-| WebRTC | both DTLS certificate fingerprints |
-| QUIC | SHA-256 of the server certificate. aioquic exposes no RFC 5705 exporter; the client's access to the peer certificate is a guarded private attribute, and on a **resumed** session — which carries no certificate — the anchor travels with the session ticket |
-
-### 4.3 Authorization rules
-
-- `group_id` is **mandatory**. Omitting it previously skipped the membership check and
- fell back to the node's first group.
-- Node-scoped daemon tokens are **refused** on the client path.
-- The denylist is consulted for user, `jti` **and** group.
-
-### 4.4 Mutual authentication
-
-The node proves GEK possession over a client-chosen nonce **and** signs the transcript
-with its long-term key. Previously the client received an unverifiable `node_pk` and
-trusted `is_node_admin` from whoever answered — so a peer that had hijacked signaling
-could serve a forged index, forged chat history and forged permissions.
-
-The client also **pins** `pk_node` per node on first use (11.5.8) and refuses a changed
-key outright, with a deliberate reset path in Settings for a legitimate rotation.
-
----
-
-## 5. Node authority (replaces v4 §4.2.x)
-
-### 5.1 Privileged operations
-
-Destructive and privileged operations require an Ed25519 signature over a structured
-transcript, never a JWT — the hub controls JWT issuance and can therefore never
-establish node-level authority.
-
-```
-"meshbay:admin:v1" ‖ len‖op ‖ len‖node_pk ‖ len‖group_id ‖ len‖subject ‖ len‖nonce ‖ len‖ts
-```
-
-TTL 120 s. The client reconstructs the transcript from announced fields and **refuses to
-sign** if the operation or subject does not match what the user requested. v4's challenge
-was 32 opaque random bytes signed blind — an unbound signing oracle.
-
-| Operation | Authority |
-|---|---|
-| `file_delete` | Node operator, or the user who uploaded the file (verified by the key recorded at upload) |
-| `invite_create` | **Node operator only** (delegation designed, deferred) |
-| GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK. The rule targets *key material arriving from outside* (C5b), not the instruction: a future operator-signed `gek_rotate` where **the node generates the key itself** is a different shape and is allowed (`docs/desktop-client-v1.md` §6.2). The initial `gek-init` stays local — with no GEK there is no completed session to carry a signed op |
-| ~~`gek_bundle_store`~~ | **Message deleted.** No member ever hands the node key material |
-
-`gek_bundle_store` previously accepted any member's bundle and auto-activated one
-addressed to the operator. Since the operator's X25519 public key is published in
-`handshake_ack`, any member could wrap a key of their choosing for it and take over the
-group. It was first gated behind an operator signature, then removed outright when the
-node took over wrapping (§5.5) — the message has no remaining purpose, and a path that
-does not exist cannot be mis-authorized.
-
-**Who can invite.** Still the node operator only: `invite_create` is verified against the
-operator keys in the node's roster. A group admin who does not run the node cannot admit
-anyone until delegation ships (§6.2 of `invite-pairing-v1.md`, deferred by decision
-2026-08-13). What *did* change is the timing: the operator issues a code and is then out
-of the loop, so nobody has to be online when the invitee actually joins.
-
-### 5.2 Uploads
-
-Confined to `shared_root/uploads/`, filename allowlist, no overwrite (a colliding name
-gets a suffix), chunk ordering enforced, size capped. Chat attachments land there too, so
-there is one directory an operator can look at, empty, or back up. Nothing derived is
-written beside them: a chat thumbnail is the decrypted file scaled by the browser, so the
-node stores no second copy of an image it already holds.
-
-The destination has moved twice — `shared_root/` → `.uploads/{user_id}/` → `uploads/` —
-and the four protections above were carried across each move deliberately. The original
-version accepted a client-chosen path and overwrote whatever was there, which defeated
-deletion authorization as well: overwriting a file made the attacker its recorded
-uploader, and therefore the party allowed to delete it (C5a).
-
-### 5.2b Removing a directory
-
-Creating a directory is not privileged (§5.2); removing one is, and it is refused
-unless the directory is empty. The emptiness rule is the safety property: whatever
-the caller intended, and whatever the browser sent, this operation cannot destroy
-content. It is checked twice — once before the challenge is issued, and again
-after the signature comes back, because a file can land during the round trip to
-the operator's browser.
-
-Authorized by `_verify_admin_sig` alone. A file also accepts its uploader's key,
-since the node recorded who put it there; a directory has no uploader, so there is
-no second key to accept.
-
-Archives are not the node's business: a member downloading a folder as a zip
-fetches the same encrypted chunks as any other download and assembles the archive
-in the browser. The node serves no bundles, holds no temporary files, and cannot
-be asked to compress anything — one fewer place where a request turns into work
-on someone else's disk.
-
-### 5.3 Operator interface
-
-Two personas, and they need different tools:
-
-| Operator | Reaches the node via |
-|---|---|
-| Desktop | the local admin UI in their own browser |
-| **Headless / SSH** | the CLI — `status`, `ui`, `gek-init`, `operator pair`, `member list\|invite\|revoke\|unpin` |
-
-The CLI is the primary interface for servers, which is the normal deployment.
-`status` deliberately reads the keystore and config directly so it works while
-the daemon is stopped — the state an operator is most often in, since the daemon
-will not stay up before its key is linked or before a group exists. Anything the
-UI can do should eventually have a CLI equivalent (Phase 14). Inviting, revoking and
-pairing now have one; deleting a file is the remaining browser-only operation.
-
-### 5.4 Local admin UI
-
-Loopback plus a **per-run session token** (`?t=` or `X-MeshBay-Token`), printed at
-startup. "Localhost only" is not authentication: any local process can reach it, as can
-a page in the operator's browser via DNS rebinding — and this API re-initialises group
-keys, issues invitations and reads the audit log. All interpolated values are
-HTML-escaped — filenames chosen by members, and usernames that originate at the hub;
-CSP contains exfiltration but cannot prevent injected inline script, so escaping is the
-actual fix.
-
-### 5.5 Admission and key delivery (closes H3)
-
-The node decides who receives the group key, and produces every copy of it itself.
-
-```
-grenet (SSH) meshbay-node member invite bob → CODE R3H8-TB6V
- (or the same from the SPA's Members tab, signed by the paired browser)
-grenet sends the code to bob out of band
-bob opens the group; the client holds no GEK
-bob → node join_request {pk_ed25519, pk_x25519, code, sig} ← pre-proof window
-node code valid for this account → pin the identity, admit to the group
-node → bob the GEK, wrapped for the X25519 key bob just proved he holds
-```
-
-Four properties, each load-bearing:
-
-1. **No public key is ever fetched from the directory.** The invitee's key arrives from
- the invitee, signed by their identity key in the same transcript (`meshbay:join:v1`),
- so the identity key vouches for the encryption key. This is the H3 fix.
-2. **The code binds the key to the account**, and the hub never sees it. 40 bits,
- single-use, one account, node-wide lockout on repeated failures. Codes live 7 days
- for invitations and 24 h for operator pairing, both configurable.
-3. **The node's roster is the authority**, not hub membership. A hub that invents an
- account, adds it to a group and mints it a token gets `not_authorized_for_group`.
- Exception by configuration: `join_policy = "open"` pins the first comer (TOFU) and
- serves them — read from `node.toml`, never from the hub.
-4. **Wrapping happens on every connection**, so key rotation propagates by itself and
- revocation actually takes effect. No member bundle is stored; nothing outlives the
- decision to stop serving someone. (Rotating the GEK after a revocation is still
- required — the ex-member holds the old one.)
-
-`gek-init` no longer pre-wraps the key for anyone but the node's own keystore. It used to
-fetch every member's public key from the hub and wrap for what came back, which was H3
-with the node as the victim rather than the inviter.
-
-Node authority is established the same way, once per node: `meshbay-node operator pair`
-prints a code, the operator types it into their browser, and the node pins that identity
-key. It is never learned from the hub — a hub able to name the operator's key could
-install itself as node administrator (finding M3, which the old keystore auto-pin left
-inert and failing closed).
-
----
-
-## 6. Hub (replaces v4 §4.1)
-
-### 6.1 Role — chosen, not minimal
-
-Hub minimization was considered and **deferred** (decision D4). The hub keeps serving
-the web UI and remains in the trusted path by choice. This section describes what it
-*does*, not an aspiration.
-
-**Stores:** accounts (username, encrypted email, status, role), group registry and
-membership, IP logs (1 year, legal), node registrations, refresh tokens, notifications,
-moderation blocklist.
-
-**No longer stores user identity keys** (2026-08-14). `users.pk_ed25519` and
-`users.pk_x25519` are dropped, `PUT /me/keys` is gone, and `/pubkeys` returns an account id
-and a node's linking key — nothing to wrap for. Tokens carry no `pk_user` claim either: the
-node used to record it as the uploader's identity, which let whoever issued tokens decide
-who could delete a file.
-
-**Does not store:** file content, file names, private-group indexes, message content,
-private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling.
-
-**Knows, unavoidably:** who is a member of what, when nodes connect, when a chat message
-was posted and by whom, and content hashes for **public** groups only.
-
-The "by whom" is a `user_id`, not a display name, since 2026-08-15. The node has to name
-the author for the hub to skip them when creating notifications — it used to name nobody,
-so the hub substituted the node's own account and told every author about their own
-message. The exchange is `chat_notify` over the node's hub socket and it carries no
-content, but it is a stable identifier: the hub can build who-talks-in-which-group-when
-without reading a word. Minimizing it was in the dropped phase, and it stays a known
-metadata leak rather than a solved problem.
-
-**Erases on request, and what survives.** A user can delete their own account from
-Settings (passphrase re-entered — a live token may be a borrowed laptop), and a hub admin
-can delete one. The row is tombstoned rather than dropped: username released, email and
-password hash cleared, node linking key dropped, memberships, notifications and refresh
-tokens removed, active access tokens refused at once by status check rather than left to
-expire. Two things survive on purpose. The IP log is kept for its legal retention period
-and stays attributable, since detaching it would keep the data and lose the only thing it
-is for — the name is copied onto those rows as the account goes, since the join that used
-to supply it would answer with the tombstone. And **nothing on a node is touched**: files, the pinned identity and the keypair
-bundle live on machines the hub does not command, which is the same sovereignty that makes
-§5.5 work. Deleting the hub account is not an erasure request to the operators who host
-you — the operator interface (§5.3) is where that happens. Deletion is refused outright
-while the account still owns groups, rather than cascading into other people's data.
-
-**No longer decides:** who holds the group key. The hub still publishes public keys, and
-they are still used to look up an account by name — but nothing wraps a key for a key
-that came from there. Hub membership lets someone *reach* a node; the node's roster
-decides whether it wraps anything for them (§5.5). A pairing code never touches the hub.
-
-### 6.2 Node registration
-
-Registration on `/v1/nodes/ws` requires a node-scoped token, verifies `Node.user_id`
-against the token subject, and derives group claims from the database — a node may
-narrow the set to what it hosts but cannot widen it. It previously took `node_id` and
-`group_ids` verbatim from the client's first message, letting any user hijack a victim
-node's signaling and impersonate it to browsers.
-
-### 6.3 Signaling
-
-Rate-limited, SDP size bounded, capped per user, and the caller must share an **active**
-group with the target node. Previously reachable by any authenticated user for any node,
-making a third party's machine allocate peer connections on demand. `peer_ip` in the NAT
-punch request must match the caller's source address.
-
-### 6.4 Client addresses
-
-`X-Forwarded-For` is honoured only from a trusted proxy, rightmost hop. It was trusted
-unconditionally, so anyone could forge the IP written to the compliance log.
-
----
-
-## 7. Cryptography (amends v4 §6)
-
-Unchanged: GEK wrapping (ECIES, X25519 + HKDF + AEAD), per-chunk key derivation, domain
-separation, on-the-fly encryption.
-
-| Parameter | Value |
-|---|---|
-| Node keystore KDF | Argon2id **256 MB**, iterations 3, lanes 4 — parameters recorded per envelope so they can be raised without orphaning existing keystores |
-| Hub password verifier | Argon2id 256 MB over a PBKDF2-derived `auth_key` |
-| Browser bundle key | **Argon2id 128 MB, t=3, p=1** (was PBKDF2-SHA512 600k until 2026-08-14) |
-| Browser `auth_key` | PBKDF2-SHA512, 600 000 iterations — the hub's Argon2id verifier is what protects it at rest |
-
-### 7.1 The keypair bundle, and what it is worth to an attacker (C4)
-
-A bundle carries **one node's** identity keys, encrypted under the owner's passphrase, and
-is stored on that node. It is what lets a second browser open the same account there — the
-ordinary expectation, and the only mechanism available to a browser that keeps nothing
-durable of its own.
-
-The adversary is concrete: an operator holding their own node's disk, attacking offline at
-their leisure.
-
-**What cracking one yields.** The identity that person uses **on that node** — where the
-operator already holds the content, the index and every byte they serve. It is not a key
-anywhere else: each node gets its own, generated the first time its owner joins it, and a
-key one node pinned is a stranger to the next (which asks for a code, like any first
-contact). Until 2026-08-14 a single identity was copied to every node joined, so one crack
-yielded content on *other* operators' nodes and the ability to sign as that user anywhere —
-that was the part worth attacking, and it is gone.
-
-Two smaller consequences fall out. Two operators can no longer tell they host the same
-person by comparing keys. And the hub publishes no user keys at all now (§6.1), so there is
-no directory left to substitute from.
-
-**Why Argon2id.** PBKDF2 is compute-only, which is exactly what a GPU is good at. Measured
-on the development machine: PBKDF2-SHA512 600k costs 241 ms per guess on one core,
-Argon2id 128 MB/t=3 costs 88 ms — the defender pays *less* — but only one of them forces
-an attacker to find 128 MB per guess.
-
-The honest size of the gain: on a single high-end card the ceiling moves from roughly 8k
-guesses/s to roughly 2k, a factor of four, not a thousand. What it really buys is the cost
-of scale — 128 MB per lane caps a 24 GB card near 187 concurrent guesses, and makes custom
-hardware unattractive, where SHA-512 silicon is cheap.
-
-**The passphrase decides this, not the KDF.** At ~2k guesses/s, a dictionary-and-rules run
-of 10⁹ candidates — which covers a large share of human-chosen passwords — takes about six
-days on one card. Four random words (~52 bits) outlasts the sun. No parameter choice saves
-a weak passphrase; it only moves it from hours to days. The client therefore refuses
-passphrases below 12 characters and ~60 estimated bits, and that floor can only live in the
-client: with the password split (T1) the hub never sees a passphrase and cannot enforce
-anything about one.
-
-**Operational notes.**
-
-- Argon2id runs in WebAssembly, vendored under `static/vendor/` with its provenance —
- the CSP forbids external hosts, and 12.2 must keep `wasm-unsafe-eval` in `script-src`.
-- Two implementations exist (the browser's WASM, `argon2-cffi` in QE). A parity test holds
- them byte-identical, because a disagreement would not look like an error — it would look
- like an account nobody can open.
-- Bundles written under the old KDF carry no marker, are still readable, and are
- re-encrypted the first time their owner's browser backs them up.
-- Cost is paid **once per sign-in**: 650 ms for the bundle key, plus 239 ms for `auth_key`.
- Opening a group, downloading, streaming and reloading the page all reuse it — the key
- lives in IndexedDB for the session.
-- The pre-proof window that serves bundles is still bounded (4 fetches) and audited.
-
-C4 is **reduced, not closed**: bundles still sit on disks their owner does not control, and
-a weak passphrase still gives up the key for that node. It closes when the native client
-stops storing them remotely (Phase 13.3) — the material belongs on the user's own device,
-not on the hub *or* on other people's nodes.
-
----
-
-## 8. Clients (replaces v4 §4.3)
-
-### 8.1 Two clients, deliberately
-
-| | Hub-served web SPA | Native desktop client |
-|---|---|---|
-| Distribution | Served by the hub | Installed, signed release |
-| Code integrity | **T3 accepted** — the hub can inject | Detectable *if* reproducible builds ship |
-| Key storage | IndexedDB / sessionStorage | OS-protected local keystore |
-| Crypto | WebCrypto (AES-GCM) | Full (ChaCha20, Argon2id) |
-| Transport | WebRTC | WebRTC **+ QUIC** |
-| Positioning | **Convenience tier** — zero install | Recommended for sensitive use |
-
-**Several browsers, one identity per node.** A browser keeps nothing durable the user
-controls, so the identity it creates for a node is left with that node, encrypted under the
-passphrase. Any other browser recovers it there with the passphrase alone: same identity on
-that node, same pin, no second code. Joining a *different* node creates a different key and
-needs that operator's code, which is the first contact it has always needed.
-
-This is what makes the product behave the way people expect, and it is also finding C4
-(§7.1) — with a blast radius of one node. The native client removes the need for it
-entirely, which is a large part of why it exists.
-
-The SPA is not deprecated. It is the zero-install path and it stays. It must be labelled
-honestly: `/app/` states that the hub serves this code, and the docs never claim
-end-to-end *integrity* for that path.
-
-### 8.2 Native client
-
-**Electron** (decided 2026-08-17 — see `docs/desktop-client-v1.md`, which is authoritative
-for the desktop client and supersedes this section's earlier pywebview form), with an
-optional Python sidecar reusing `quic_client.py` for hub-less `group://`.
-
-Assets ship **inside the package** and load from disk — a shell pointing its WebView at the
-hub's `/app/` would be a browser with a different icon and would fix nothing. That
-constraint is unchanged and is the whole point.
-
-What changed is the engine. The SPA does not depend on "the web" in general but on
-Chromium-class APIs: `RTCPeerConnection`, WebCrypto X25519/Ed25519, MSE, Service Workers,
-File System Access. Keeping Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`,
-`downloads.js`, `sw.js` and the MSE player **unchanged**; replacing it with a system webview
-meant reimplementing ~2500–3000 lines plus a loopback media server and native dialogs. The
-"~69 % reused" figure quoted here previously was measured in August against an `app.js` of
-~2600 lines; it is 4586 lines as of 2026-08-17, and `app.js` now contains 2 direct
-`crypto.subtle` calls (`_pkFromSk`), so the three-globals seam had already leaked.
-
-Native does **not** remove trust in the hub operator; it converts an undetectable,
-per-request, per-user attack into a persistent artifact that can be hashed and compared.
-That value is realised by reproducible builds and published hashes, not by the packaging
-format.
-
-Two properties of the desktop client bear on this document directly:
-
-- **Identity keys are generated and kept locally**, never bundled anywhere. C4 closes for a
- native device unconditionally — but **not for an account that also uses a browser**, which
- still needs a bundle on each node (§7.1). An account is only as strong as its weakest
- client, and the docs must say so.
-- **A second device is admitted by device linking**, not by an operator code: the
- already-pinned key countersigns, bound by a one-time code the new device generates. The
- hub cannot produce that countersignature — it has stored no user keys since 2026-08-14
- (§6.1) — so this adds no hub-reachable authority. `identities` gains a device dimension;
- see `docs/desktop-client-v1.md` §4 before touching the roster.
-
----
-
-## 9. Open items
-
-**Phase 11.5 is complete.** All six critical and all seven high findings from
-`second-review.md` are closed, bounded, or deferred by explicit decision.
-
-### Closed since this document was drafted
-
-| # | Item | Closed by |
-|---|---|---|
-| **C6** | GEK proof on the QUIC path | QUIC now runs the same challenge/response as WebRTC — client nonce, role-bound transcript, GEK proof, and the node proving itself with a GEK proof plus an Ed25519 signature |
-| 11.5.6 | QUIC channel-binding anchor | Certificate hash. Server reads its own certificate from disk; the client's access to the peer certificate is guarded and fails loudly if aioquic moves it. **A resumed TLS session carries no certificate**, so the anchor travels with the session ticket — sound, because the ticket is derived from the handshake where the certificate was presented |
-| 11.5.8 | `pk_node` TOFU pinning | Pinned per `node_id` on first handshake; a change is **refused**, with a deliberate reset in Settings |
-| **M8** | Node announce proof-of-possession | Announcer signs `meshbay:node_announce:{user_id}:{pk_node}:{timestamp}`; re-announcing updates in place |
-| **H3** | Hub key substitution | **Closed 2026-08-14** by the invite redesign (§5.5), not by safety numbers: no public key is fetched from the directory at all, so there is nothing to substitute. See `docs/invite-pairing-v1.md` |
-| **M3** | Node authority inert as shipped | Closed with it — authority comes from the roster, established locally by pairing |
-
-### Still open
-
-| # | Item | Status |
-|---|---|---|
-| C4 | Remote keypair bundles | Bounded (4 pre-proof fetches, audited). **Closes for a native device unconditionally** (its keys are never bundled), but **stays open for any account that also uses a browser** — a browser has no durable storage of its own and needs a bundle on each node. Full closure needs the opt-out in `docs/desktop-client-v1.md` §5.1, signed by a pinned key so the hub cannot set it |
-| — | Chat encryption (Sender Keys) | Phase 15. Distribution must be **pairwise to identity keys**, never GEK-derived |
-| — | Chat attachments | Plaintext on the node; decide in Phase 15.7 |
-| — | Delegation | Designed, deferred: a group admin who does not run the node still cannot invite |
-| **T3** | Hub serves the SPA | **Accepted permanently** (D1). Now the *only* way an active hub reads content — and it can also read a pairing code out of the page it served. Removed for native clients by Phase 13 + reproducible builds (18.7) |
-
-### Scope note on pinning
-
-With C6 closed, `pk_node` pinning is defence in depth rather than the primary control: a
-substituted node already fails the GEK proof. Pinning covers the case where an attacker
-*holds* the group key — an ex-member, or a leaked GEK — and swaps the node underneath,
-which the proof alone cannot distinguish from the genuine node.
-
-### Sender Keys — scope correction
-
-Sender Keys protects chat from someone holding the node's disk who is **not** a group
-member — a seized machine, a hosting provider, a compromised node. It does **not**
-protect chat from the node operator, who is a group member and therefore a legitimate
-recipient. Distributing sender keys under the GEK would make the encryption decorative,
-since anyone with the GEK would recover every sender key.
-
----
-
-## 10. Testing posture
-
-The pre-remediation suite passed 209 tests while every finding in the review was live. It
-tested that features work, and in doing so **pinned four vulnerabilities in place** as
-expected behaviour: GEK auto-activation, the transport-wide chat store, the blind admin
-challenge, and private-group hashes registered with the hub. A refactor that accidentally
-fixed one would have been reported as a regression.
-
-Security tests are therefore written as **negative assertions** — "this attack does not
-work" — and verified to fail against the pre-fix source before being trusted.
-`test_security_regressions.py`, `test_node_ws_auth.py` and `test_handshake.py` hold them.
diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md
deleted file mode 100644
index 1105d0e..0000000
--- a/docs/meshbay-draft-v6.md
+++ /dev/null
@@ -1,620 +0,0 @@
-# MeshBay — Architecture Draft v6
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the architecture draft v6; its design
-> content now lives in §6.2 (roots), §3.3 (devices), §8.2 (client), §9 (applications), §7.4–§7.5 (hub policy), §5.1 (transport), §10 (portability).
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **superseded** (it was the current specification until this document
-> was written). Supersedes `meshbay-draft-v5.md` in its turn.
-> **Sections not restated here are unchanged from v5**, which remains the reference for
-> everything v6 does not touch — the handshake (§4), node authority (§5), the hub's role
-> (§6), cryptography (§7) and the testing posture (§10) are all still v5's.
->
-> v6 exists because a design discussion on 2026-08-17 settled the desktop client and, in
-> doing so, changed four things v5 states: what a group's content *is*, how a person's
-> devices are admitted, how authorship is established, and which shell the native client
-> uses. It also records one rule v5 assumed without writing down.
->
-> **Amended 2026-08-28** with three changes that post-date that discussion: the hub gains a
-> runtime instance-policy store (public groups can be switched off hub-wide), group names
-> become unique per owner account (`name@owner`), and chat gains link previews. See §1
-> rows 11–13 and §§2.8–2.10. Wire versions at that point: MNP 0.12, MHP 0.1; packages 0.9.0.
->
-> **Amended 2026-08-30** with WebRTC transport resilience: STUN server fallbacks on both
-> browser and node, configurable on the node side (Node page, CLI, `node.toml`); ICE
-> interface filtering exposed on the Node page. See §1 row 15 and §2.12.
->
-> **Amended 2026-09-01**: §2.11/§2.12 corrected to match what was built — the Node page
-> reaches the daemon over its token-gated loopback HTTP API, not MNP. The MNP `node_*`
-> message types remain, unused, for a possible future browser-only path. See
-> `docs/refactor-node-ui.md`.
->
-> The v5 convention is carried forward and is not negotiable: **a claim in this document
-> must name the adversary it holds against.** A property that holds against a passive hub
-> and not an active one is written that way.
-
----
-
-## 0. Reading order
-
-| Read | For |
-|---|---|
-| **this document** | what is true now, and what changed from v5 |
-| `meshbay-draft-v5.md` | everything v6 does not restate — still authoritative there |
-| `second-review.md` | the findings (C1–C6, H1–H7, M*, L*) referenced throughout the code |
-| `docs/invite-pairing-v1.md` | invitations, pairing codes, the node roster — **as built** |
-| `docs/per-node-identity-v1.md` | identity keys are per node; the hub stores none |
-| `docs/desktop-client-v1.md` | the desktop client in full — shell, device linking, roots, packaging, execution order |
-| `docs/apps.md` | the group UI's plug-in architecture — as built, and how to add an application |
-| `devel-phases-next.md` | the roadmap |
-
----
-
-## 1. Changes from v5
-
-| # | Category | Change | Source |
-|---|---|---|---|
-| 1 | Content model | A group's content is **several named roots**, not one directory. Names are unique, derived from the directory's basename, and form a union virtual root | E7 / decision 11, 13 |
-| 2 | Identity | **Device linking**: one person may hold several devices on a node, admitted by a key the node already pinned, bound by a one-time code the new device generates | E2 / decision 2 |
-| 3 | Client | The native client is **Electron**, not pywebview. Structural decision 18 reversed | E1 / decision 1 |
-| 4 | Node authority | `gek_rotate` may be a signed MNP op — the C5b rule forbids *key material arriving from outside*, not an operator-signed instruction where the node generates the key | §2.3 below |
-| 5 | Server state | **Group-related server state lives on the node, always.** Written down because v5 assumed it | E9 / decision 17 |
-| 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 |
-| 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 |
-| 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b |
-| 9 | Node authority | The operator decides **which directories accept uploads**, per root. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control. **Superseded 2026-09-06** by `docs/refactor-groups.md` §1.1: the group-wide `member_upload` switch this section described is replaced by RO/RW per root, and the "everyone but the operator" carve-out is gone | §2.1b |
-| 10 | Client | A group's UI is a **set of pluggable applications** (Chat, Files today), not one monolithic page. Which are shown is a per-group, operator-signed setting on the same pattern as change 9 | §2.7 |
-| 11 | Hub role | The hub gains a **runtime instance-policy store** (`hub_settings`). First policy: an admin switches **public groups off** hub-wide, enforced server-side on every hub-mediated path. `suspend` vs `revoke` on a group are now written down as the distinct things they are | §2.8 |
-| 12 | Group registry | A group name is **unique per owner account**, not globally; the group's identity is still its UUID. Listed everywhere as `name@owner` | §2.9 |
-| 13 | Enrichment | **Chat link previews** — the node unfurls a pasted URL into an OpenGraph card. A new instance of the §2.7 "node on demand, asking device caches, nothing durable" rule; new SSRF surface, gated. MNP 0.12 | §2.10 |
-| 14 | Node page | The Node page (D5) **exposes five `[node]` settings** — TTLs for invitations, pairing and device requests, the concurrent stream cap, and the transcode toggle. Editable from the panel, persisted in both `roster.db` and `node.toml` | §2.11 |
-| 15 | Transport | WebRTC ICE gathering uses **several public STUN servers** (three as of 2026-09-04) instead of one, configurable on the node side. ICE interface filtering is exposed on the Node page. Both follow the §2.11 persistence pattern | reliability — single-server STUN was a silent SPOF, §2.12 |
-
----
-
-## 2. What v6 changes in v5's text
-
-### 2.1 §5.2 Uploads — now per root
-
-v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite,
-chunk ordering and a size cap. All four protections stand. Two amendments:
-
-- There is no single `shared_root`. **Each root is read-only or read-write**, and an
- upload goes to the folder the sender is looking at, inside a writable root. If that
- root is unavailable the upload fails with a stated reason and never falls back to
- another; if the group has no writable root, uploads are refused rather than guessed.
- (Amended 2026-09-06 — the original text designated *one* root as the upload
- destination, and the client named none. See `docs/refactor-groups.md` §1.1.)
-- **There is no `uploads/` quarantine directory any more** (2026-09-06). It was the
- last of v5's, the per-user layer having gone on 2026-08-14, and it went for the same
- reason: a folder appearing beside the operator's library because somebody sent a
- file is the node deciding how their disk is arranged. **What made the quarantine
- worth having was never the subdirectory** — it is the filename allowlist, the size
- cap, the chunk ordering and the no-overwrite rule, and all four are unchanged.
- The client now names the destination folder, which is safe for one reason and only
- one: it is resolved through `RootSet.resolve()`, which refuses `..`, absolute
- segments and anything escaping its root, symlinks included. A member answers "which
- of this group's folders", never "which path on the operator's disk".
-- **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier
- draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does
- not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those
- filesystems, so the upload already gets a free name. C5a is not reachable through the
- filesystem. Case folding is needed for index identity, collision reporting and root
- names — comparisons the code makes itself.
-
-v5's rule that nothing derived is written beside the originals is **unchanged**, and it
-decides the video-thumbnail question: a frame grab is produced on demand and cached on the
-device that asked, so the node keeps no thumbnail store.
-
-### 2.1b §5.2 Uploads — the operator may close them
-
-> **Superseded 2026-09-06.** `member_upload` is gone; the mechanism is `writable` on
-> each root. What the three load-bearing properties below say is *unchanged* — read
-> "the root's `writable` flag" for "`member_upload`" and every word of them still
-> holds, which is why they are kept rather than deleted. What did change:
->
-> - **It is per root, not per group.** A group can publish one library read-only and
-> accept uploads into another, which the single switch could not express.
-> - **There is no carve-out for the operator.** Read-only means read-only for
-> everyone, because a published library that quietly accepts writes from whoever
-> holds admin authority is not one. The paragraph below justifying the setting by
-> "the only way to get a curated library was to designate no upload root at all,
-> which refuses the operator too" is therefore the reasoning that was reversed: that
-> *is* the model now, and refusing the operator is the point rather than the defect.
-> - **The client names the destination root.** With several writable roots the node
-> cannot choose without guessing, and a guess sends a member's file to a disk the
-> operator did not intend. It names a root, never a path; everything below the root
-> is still decided by the node.
-> - The signed op is `OP_ROOT_UPDATE` (plus `OP_ROOT_EJECT` / `OP_ROOT_PLUG`) rather
-> than `OP_MEMBER_UPLOAD`, and the flags live in `node.toml` — they are
-> configuration — while the *ejected* runtime state lives in `roster.db`.
-> `member_upload` survives on the handshake ack alone, computed as "any root is
-> writable", for MNP 1.0 clients that read no other field.
->
-> See `docs/refactor-groups.md` §1.1 and §1.5b.
-
-New. A group where every member may add files is the default and stays the default;
-some groups want a library the operator curates, and until now the only way to get one
-was to designate no upload root at all, which refuses the operator too.
-
-`member_upload` is a per-group setting, and three things about it are load-bearing:
-
-- **It lives on the node**, in `roster.db`, not in `node.toml` and not on the hub. Not
- the hub because a hub that decides who may write to someone else's disk has authority
- over that node, which is the arrangement this design exists to avoid (change 5). Not
- `node.toml` because that file is hand-written, full of comments recording decisions,
- and `ops.py` deliberately appends to it rather than round-tripping it through a
- writer — a setting changed from a panel must not rewrite the operator's file, and must
- not need a restart.
-- **Changing it is a signed operator instruction** (`OP_MEMBER_UPLOAD`, MNP
- `member_upload`), on the same path as removing a member. An unsigned one would let any
- member turn it back on, which makes the control a suggestion. The transcript's subject
- is `on` or `off` — what the operator is shown before signing has to name the outcome,
- not the operation.
-- **The node enforces it**; the interface merely stops offering it. `handshake_ack`
- carries `member_upload` so a client knows whether to draw the Upload button and the
- chat paperclip, and the node broadcasts `member_upload_ack` to everyone connected when
- it changes. None of that is the control: a member on an old tab, or one speaking MNP
- directly, is refused by the node with `member_upload_off`.
-
-**Absent means allowed**, at every layer — no row in `group_settings`, no key in the
-group context, no field in the ack. A node or client that predates the setting behaves
-exactly as it did, and an upgrade never silently closes a group.
-
-The operator is always exempt. Turning it off otherwise locks them out of their own
-node, with a config file and a restart as the only way back.
-
-### 2.2 §5.5 Admission — devices, not one key per person
-
-v5 and `invite-pairing-v1.md` bind **one** key pair to an account per node: `identities`
-has `user_id` as its primary key and `pin_identity` does `INSERT OR REPLACE`. A person
-with a browser and a native client needs two keys on the same node, so:
-
-- `identities` becomes keyed by `(user_id, pk_ed25519)`, with `label`, `added_at`,
- `added_by_pk` and `revoked_at`. **`INSERT OR REPLACE` must go** — today it silently
- overwrites, which becomes a hole the moment a second key is legitimate.
-- A new device is admitted when **a key the node already pinned countersigns it**, bound
- by a one-time code the new device generates and displays, hashed together with the new
- keys so the node cannot substitute them.
-- The operator's one-time code remains available and is unchanged. Device linking is an
- addition to admission, not a replacement.
-
-**Against an active hub this holds**, and for the same reason §5.5 holds: the hub has
-stored no user keys since 2026-08-14, so it cannot produce the countersignature. Against a
-malicious node operator it is not a new exposure — a node can only add a device to itself,
-where it already reads everything it serves.
-
-**Where it does not hold:** approval performed *in a browser* inherits T3, because the hub
-serves that browser its code and can read the typed code. The first browser-to-native link
-is therefore the moment of highest exposure for an account, and it happens once.
-
-Full design: `docs/desktop-client-v1.md` §4.
-
-### 2.3 §5.1 GEK activation — the rule, stated precisely
-
-v5 says *"nothing arriving over MNP can activate a GEK"*. Read precisely: the rule targets
-**key material arriving from outside** (C5b), not the instruction. An operator-signed
-`gek_rotate` where **the node generates the key with its own CSPRNG** satisfies the
-property v5 §5.5 actually establishes — the node produces every copy of the key — and is
-allowed.
-
-**The initial `gek-init` stays local.** With no GEK, `join_result` answers `no_gek` and no
-MNP session completes, so there is no authenticated session to carry a signed op. Placing
-it in the pre-proof window is possible and is deliberately deferred; that window is where
-C4 and C5b were born.
-
-### 2.4 §8.2 Native client — Electron
-
-pywebview is replaced by Electron plus an optional Python sidecar for hub-less `group://`
-over QUIC. The non-negotiable is unchanged and is the entire point: **UI assets ship
-inside the package and load from disk.** A shell pointing at the hub's `/app/` is a browser
-with a different icon.
-
-What changes is the engine, not the claim. Native still does **not** remove trust in the
-hub operator; it converts an undetectable, per-request attack into an artifact that can be
-hashed and compared, and that value is realised by reproducible builds (18.7), not by the
-packaging format.
-
-Two corrections to v5's client table:
-
-- **Key storage.** Identity keys are generated and kept locally, never bundled. C4 closes
- for a native device unconditionally — and **stays open for any account that also uses a
- browser**, which has no durable storage of its own and still needs a bundle on each
- node. An account is only as strong as its weakest client.
-- **Crypto.** The client keeps WebCrypto *and* gains local Argon2id and ChaCha20 in the
- main process. v5 implied WebCrypto is lost with the browser engine; under Electron it is
- not.
-
-### 2.4b §5.1 Authorship — authenticated, not asserted
-
-v5 §5.1 authorizes `file_delete` by "the node operator, or the user who uploaded the file
-(verified by the key recorded at upload)". Two changes:
-
-**Authorization moves from the key to the account.** With several devices per person,
-`_admin_exec_file_delete` — which verifies against `entry.uploader_pk`, the exact uploading
-key — would refuse Alice's desktop the right to delete what her phone uploaded. It becomes
-**any non-revoked device of `uploader_id` in the roster**, with `uploader_pk` kept as the
-audit record of which device acted. This remains **roster-rooted, not token-rooted**: a hub
-minting a token that claims to be Alice holds no key the node pinned for Alice, so the
-signature fails — the property `per-node-identity-v1.md` established is preserved.
-
-**Ownership becomes provable.** The uploader signs `meshbay:upload:v1` over node, group,
-root, path, content hash, account and timestamp; the node stores it with the index entry.
-Ownership is then verifiable by any member rather than asserted by the node, and the C5a
-path — overwriting a file to become its recorded uploader — is closed a second time.
-
-**Chat senders must be cryptographically authenticated to each other.** v5 relied on NS6,
-where the node enforces `sender_id` from the authenticated session; that is the node's
-word. Messages are signed with the sender's **device** key, clients pin `account → device
-keys` on first sight using the device-add countersignatures as evidence, and the operator
-may sign a roster attestation to close first contact.
-
-**Against whom this holds.** Against another member: fully — no member can forge another's
-signature. Against someone holding the node's disk: fully — a stolen chat store cannot be
-*extended* with messages that verify. Against the node operator: **partially, and the
-partial part is worth having** — once a member's client has pinned Alice's device key, an
-operator who turns malicious later cannot forge Alice to that member; forgery is limited to
-accounts the victim has never seen. Full protection at first contact requires an
-attestation rooted outside the node, which is what the operator-signed roster and safety
-numbers provide.
-
-Design: `docs/desktop-client-v1.md` §4.8.
-
-### 2.5 §6.1 Hub role — one addition, one rule
-
-The hub gains exactly one endpoint from all of this: **`POST /v1/users/auth`**, device
-Ed25519 authentication on the pattern of `POST /v1/nodes/auth`. Nothing else in the
-desktop-client design adds a row or a column to the hub.
-
-And the rule v5 assumed without writing:
-
-> **Group-related server state lives on the node.** Files, indexes, members' devices,
-> pending device requests, invitations, chat, per-root availability, and anything a future
-> feature wants to keep about a group — all on the node. The hub holds accounts, the group
-> registry and membership, signaling, notifications, the moderation surface, and
-> instance policy (`hub_settings`, §2.8) — and nothing else about content.
-
-Verified for the multi-root change: `SwarmSource` carries `content_hash`, `node_id` and
-`endpoint` — **no paths, no filenames** — and private groups register nothing (H7). The
-content model changes end to end without the hub moving.
-
-### 2.6 §7 Cryptography — unchanged, one consumer added
-
-No parameter changes. `keyderive.py` now has a third consumer: the desktop client derives
-`auth_key` exactly as the browser does at registration. `test_bundle_kdf_parity.py` covers
-it, and the standing warning is unchanged — **never change those parameters in one
-place**; a mismatch does not look like an error, it looks like an account nobody can open.
-
-### 2.7 The group UI becomes a set of applications
-
-New (2026-08-23). A group had two fixed tabs, Chat and Files, both defined inside one
-monolithic `static/app.js`. Two things motivated splitting it before adding to it: the file
-had become the thing every unrelated change touched, and the roadmap wants three more
-group-level surfaces — a Netflix-style video browser, a Spotify-style music player, a photo
-album viewer. None of the three need a protocol change: the node's indexer already
-classifies files as `video`/`audio`/`image`, and they would read the same `index_sync` /
-`file_req` / `stream_req` messages Files and the video player already use. What they need is
-somewhere to live that is not one file, and a way for an operator to turn one off.
-
-**The shape.** `group-page.js` is now the shell: the WebRTC connection, the file index, the
-tab bar, and the video/preview modals, none of which are Files- or Chat-specific. `apps.js`
-holds the registry — `[{ key, icon, labelKey, Component }]` — and every registered
-component receives the same props object from the shell, spread rather than hand-listed, so
-adding an app changes no code in the shell itself. Chat and Files each moved to their own
-file (`chat-app.js`, `files-app.js`) to prove the mechanism; nothing else exists yet.
-
-**Enablement is the same pattern as change 9, on purpose.** `apps_enabled` is a per-group
-setting: lives on the node (`roster.db`, not the hub, not `node.toml`, for the identical
-reason `member_upload` does — a hub or a config file that decided this would have authority
-over the node), changed by a signed operator instruction (`OP_APPS_ENABLED`), enforced by
-the node refusing to store an unrecognised or empty set rather than by the client's
-honesty. **Settings itself is not an app** and cannot be disabled — the one way back if
-everything else were turned off.
-
-**What this does not change.** No new server state on the hub (change 5 stands unmoved: the
-enabled-apps set is group-related state, and it lives on the node like everything else in
-that category). No new adversary or trust boundary — this is a display policy, not a key or
-a permission over content; a member whose client shows a hidden tab's data anyway would
-still be a member the node already serves that data to.
-
-Full detail — the props contract, the file layout, and a checklist for adding a new
-application — is `docs/apps.md`, on the same basis `docs/desktop-client-v1.md` holds the
-desktop client's detail: this document states what changed and what holds, not how to build
-on it.
-
-### 2.8 §6.1 Hub role — instance policy, and public groups can be switched off
-
-New (2026-08-28). The hub had no runtime-configurable setting: everything was `hub.toml`
-(read once, restart to change) or code. It now has `hub_settings`, a key/value table an
-admin edits from the panel. The first entry is `allow_public_groups`.
-
-**This does not contradict change 5 / §2.5.** `hub_settings` is *instance policy, not group
-content*: it says how this hub behaves, and holds nothing about any group's files, index,
-membership or keys. "Group-related server state lives on the node" is untouched.
-
-**What "off" does** — server-side, on every path the hub mediates, read live:
-
-- `create_group` refuses `visibility=public` — staff included; the way back is to
- re-enable it, not to slip past
-- the public directory (`GET /v1/groups`) returns nothing, local **and** federated
-- open-joining a public group is refused
-- `group_online_nodes` hands a non-member no node to connect to
-- `webrtc_offer` drops its "the node hosts an open-join group, admit anyone" fallback
-- the MHP directory export advertises nothing to peers
-
-Flipping it back restores every path. **Existing members of a group that predates the
-switch keep their membership row and their access** — this is a directory-and-brokering
-control, not a purge.
-
-**What it is not.** The node still reads `visibility` and `join_policy` from its own
-`node.toml`, never the hub (`invite-pairing-v1.md`). A node whose operator set
-`join_policy = "open"` still TOFU-pins and serves whoever reaches it directly over MNP.
-The switch removes the hub-provided ways to *find and reach* such a node; it is **not a
-remote kill**. For a hard stop on one group, `POST /v1/admin/revoke` is the tool.
-
-**`suspend` vs `revoke`, made explicit** — the two moderation verbs on a group were never
-written down as distinct, and they are:
-
-| | `suspend` (`PATCH /v1/admin/groups/{id}`) | `revoke` (`POST /v1/admin/revoke`) |
-|---|---|---|
-| Hub DB | `status = "suspended"` | `status = "revoked"` |
-| Node | nothing | signed revocation broadcast → denylist + live sessions dropped, **persisted across a node restart** |
-| Reversible from the panel | yes | no |
-
-The client shows the real state now (`Group is revoked`, not a blanket `Group is
-suspended`).
-
-### 2.9 §6.1 Hub role — group names unique per owner, shown as `name@owner`
-
-New (2026-08-28). "The name is unique" was an unenforced expectation — the `groups` table
-had only a non-unique index. Two changes:
-
-- A functional unique index `uq_groups_owner_name (admin_id, lower(name))`. Uniqueness is
- **per owner account**, case-insensitively, trimmed — two different owners may each have a
- `photos`. `create_group` returns 409 on a clash, with an `IntegrityError` backstop for
- the race. The migration pre-flights and **aborts listing the offending pairs** rather
- than renaming anyone's group.
-- **The group's identity is still its UUID.** `#/group/<uuid>`, `_node_groups`,
- `node.toml [[groups]] id`, membership — all unchanged. `name@owner` is a *label plus a
- create-time check*, not an addressing scheme.
-
-`owner_username` (derived from `admin_id`, nothing stored anew) rides `/v1/groups/mine`,
-`GET /v1/groups` (local rows), the join response and `/v1/admin/groups`. The SPA renders it
-under the name, smaller and grey, prefixed `@`. The handle is **hub-local**:
-`foofoo@grenet` on two federated hubs are different groups, and a federated row shows
-`@<source_hub>`, not an account.
-
-Design record and the decisions behind it: `~/next/groupnames.md` (kept out of the repo).
-
-### 2.10 Chat link previews
-
-New (2026-08-28). A URL pasted in a group's chat unfurls into an OpenGraph card — title,
-description, site name, image.
-
-**Whose request it is: the node's**, never the browser's or the hub's. The browser cannot
-(a strict `img-src`/`connect-src` and CORS), and a direct fetch would leak every reader's
-IP to the linked host on each render. The hub must not touch group content (§2.5). The node
-already fetches third-party metadata for the Videos and Music apps, over the same
-authorised path.
-
-This is a new instance of the §2.7 / draft-v5 §5.2 rule: **the node produces enrichment on
-demand and the asking device caches; neither node nor hub keeps durable derived state.**
-The card text lives in a bounded in-memory TTL cache on the node; the OG image rides the
-existing `media_cache` thumb store (blake3-keyed) and the browser fetches it over the
-normal `file_req` path, exactly like a poster. Nothing reaches the hub — this is node↔
-browser P2P.
-
-**New adversary surface: SSRF.** The URL is a *member's* choice and it triggers an outbound
-HTTP request from the operator's machine. `linkpreview.safe_url` is the gate — http(s)
-only, no credentials, and every resolved address must be globally routable (loopback,
-private, link-local, multicast, reserved and cloud-metadata ranges refused); redirects are
-followed by hand so each hop is re-checked. Residual, documented in the module: a name that
-resolves clean at check time and internal at connect time (rebinding), closed properly by
-pinning the checked IP — deferred.
-
-**MNP 0.12** — `link_preview_req` / `link_preview_resp`, additive: an older node logs
-"unknown type" and the client shows the bare link.
-
-### 2.11 Node page — `[node]` settings exposed to the operator
-
-New. The Node page (D5) shows groups, roots and the roster; it does not show the
-daemon's own tuning. Five `[node]` settings from `node.toml`
-are worth surfacing there — they affect what the node does, not only how it starts,
-and their current value is invisible until something goes wrong (an invitation that
-expired silently, a viewer told "server busy" with no knob to turn).
-
-| Setting | Type | Default | What it controls |
-|---|---|---|---|
-| `invite_ttl_hours` | positive int | 168 (7 days) | How long a member invitation code remains valid. An invitation travels through a human conversation and may be read days later; too short and the recipient finds a dead code with no message telling them what happened |
-| `pair_ttl_hours` | positive int | 24 | How long an operator pairing code remains valid. Shorter than an invitation because the code is typed during the SSH session that printed it |
-| `device_request_ttl_minutes` | positive int | 60 | How long a device-add request may wait for approval from an existing device. Comfort rather than security: the code is bound to the requesting keys by its hash, so a longer window widens nothing an attacker can use |
-| `max_concurrent_streams` | positive int | 8 | Maximum simultaneous video streams. One ffmpeg process runs per viewer; it remuxes rather than re-encodes so CPU cost is low, but each uses about 50 MB of memory. When reached, new viewers are told the server is busy. Override: `MESHBAY_MAX_CONCURRENT_STREAMS`. Zero or negative values are ignored with a warning |
-| `transcode_incompatible_video` | bool | true | When true, HEVC and other browser-incompatible video codecs are transcoded to H.264 during streaming so browsers can play them. Unlike remuxing, this costs real CPU per concurrent viewer. Set to false only when all viewers are known to decode the source codec natively. **Turning it off does not mean the same thing for every source** (corrected 2026-09-09, when only HEVC was in fact wired up): HEVC has an MSE codec string, so it falls back to a copy and the viewer's own decoder decides; MPEG-4 Part 2 (Xvid, DivX), MPEG-2, VC-1, WMV and Theora have none, so there is nothing to fall back to and the stream is refused naming this setting |
-
-**Editable from the panel, persisted in both `roster.db` and `node.toml`.** The panel
-writes to both stores simultaneously: `roster.db` for immediate effect (no restart needed),
-`node.toml` so the value survives a DB wipe or a fresh install. On startup, `node.toml` is
-read as today; if `roster.db` holds an override for a key, it takes precedence. A manual
-`node.toml` edit plus a reload overwrites the roster value. The TOML write is a targeted
-line replacement (the same technique `ops.py` uses for roots), not a round-trip through a
-writer — comments and formatting survive.
-
-**Transport — the loopback API, not MNP.** The Node page talks to the daemon over its
-token-gated `127.0.0.1` HTTP API (`GET`/`PUT /api/node-settings`), the same API the CLI
-uses. `GET` returns the five effective values (roster override if present, config default
-otherwise); `PUT` writes changes and returns the updated keys. Both require the per-run
-session token, which the desktop client's main process reads from the daemon's data
-directory and the renderer never sees.
-
-An earlier draft of this section specified an MNP path (`node_status_ack` carrying a
-`settings` object, `node_settings_set` / `node_settings_set_ack`). Those message types
-exist in `protocol.py` and are handled in `webrtc_server.py`, but the Node page does not
-use them: the loopback API needs no signaling round-trip, no GEK and no joined group, and
-works while the node is still starting. The MNP handlers are reserved for a possible
-future browser-side (no desktop client) path. See `docs/refactor-node-ui.md`.
-
-### 2.12 WebRTC transport — STUN fallbacks and ICE interface filtering
-
-The WebRTC transport relied on a single hardcoded Google STUN server
-(`stun:stun.l.google.com:19302`) on both sides. If that server was unreachable or slow,
-ICE gathering waited the full 4-second timeout before completing — a silent single point
-of failure that added seconds to every connection.
-
-**Three default servers, two providers deep.**
-
-```
-stun:stun.l.google.com:19302
-stun:stun1.l.google.com:19302
-stun:stun.cloudflare.com:3478
-```
-
-Both browser (`transport.js`) and node (`config.py`) carry the same three defaults.
-The two sides gather ICE candidates independently and exchange them via SDP through the
-hub — they do not need to use the same STUN server, and neither learns which server the
-other used.
-
-> **Amended 2026-09-04:** a fourth default, `stun:stun.services.mozilla.com:3478`,
-> was dropped — Mozilla retired the service and the name no longer resolves, so
-> every gather waited out its DNS timeout. Google and Cloudflare still give
-> two-provider coverage.
-
-**Node-side configuration.** The node's STUN list is editable three ways: the Node page
-(a dedicated section with add, remove, reorder, save and reset-to-defaults), the CLI
-(`meshbay-node stun list|add|remove|reset`), and `node.toml` (`stun_servers` under
-`[node]`). Persistence follows the §2.11 pattern: `roster.db` for immediate effect,
-`node.toml` so the value survives a DB wipe, roster override wins on startup. The live
-transport's STUN list is hot-swapped on save — no restart. The Node page reaches these
-settings over the loopback API described in §2.11, not MNP.
-
-**Browser-side: hardcoded, not configurable.** The browser's `iceServers` array is set in
-`transport.js`, shipped by the hub. No mechanism exists for a node to push STUN
-configuration to a browser — the hub relays SDP, not ICE policy — and none is needed:
-the browser is always on a consumer connection where the four public servers will work.
-
-**ICE interface filtering** (`ice_interfaces`) was already configurable in `node.toml`
-and enforced by a monkey-patch on `aioice`'s `get_host_addresses` (`ice_filter.py`). It
-is now also editable from the Node page — same persistence, same hot-swap (the filter is
-reinstalled on save). Two modes: auto (empty list — excludes virtual and VPN adapters by
-heuristic) and manual (explicit interface whitelist). The filter is **node-side only**: it
-controls which local addresses the node offers as ICE candidates. The browser's ICE
-gathering is governed by Chromium's own WebRTC stack and is not configurable from this
-application.
-
-**What prompted this.** A VPN client (ProtonVPN) adding virtual network interfaces caused
-the browser-side ICE gathering to include those interfaces' candidates. The remote node
-then spent seconds attempting to resolve `.local` mDNS candidates for addresses it could
-not reach, inflating connection time from ~1 s to ~6 s. The STUN fallback makes the
-server-reflexive path resilient; the ICE filter lets the operator exclude problematic
-interfaces on the node side. The browser-side delay from VPN interfaces is a Chromium
-behaviour outside this application's control.
-
-**Trust note.** A STUN server learns the querier's public IP and NAT mapping — that is
-its purpose. No content, no credentials, no group metadata passes through STUN; the
-protocol is a single binding request/response. Adding a STUN server is trusting its
-operator to learn your NAT topology, nothing more.
-
----
-
-## 3. Filesystem portability as a security property
-
-New in v6, because it was treated as an edge case and is not one. Most users are expected
-to share from an external exFAT or NTFS volume, on Windows.
-
-| Property | Consequence |
-|---|---|
-| Case-insensitive, case-preserving | `Film.mkv` and `film.mkv` cannot coexist. The index needs a canonical identity and a **case-folding collision check** at scan time. The no-overwrite rule must be case-folded — **this one is a security fix** (§2.1) |
-| Unicode normalization | `Café.mkv` written on macOS (NFD) and Windows (NFC) are different byte strings. Normalize to **NFC for identity**, preserve the original bytes for display and opening |
-| Windows reserved names, `MAX_PATH` | A group indexed on Linux can hold names Windows cannot create. The client sanitizes on save **and says so**; use `\\?\` paths |
-| FAT/exFAT timestamps (2 s, local time) | mtime alone is not a change detector. Size + mtime with tolerance, rehash when in doubt |
-| Watcher reliability | `ReadDirectoryChangesW` drops events under load; inotify on a FUSE mount is unreliable. **Periodic reconciliation is mandatory on both platforms** |
-| No symlinks, no POSIX permissions | Simplifications: nothing to defend against, and the node runs as the user anyway |
-
-A volume that disappears must **freeze** the affected root's subtree, never empty it.
-Emptying propagates deletions for a whole library as though the owner had erased it.
-
----
-
-## 4. Security claims — deltas only
-
-v5 §2's table stands. Three rows change, and they are the honest version:
-
-| Claim | Passive hub | Active hub | Malicious node operator |
-|---|---|---|---|
-| Client code integrity | ✅ ships in the package (native) | ⚠️ **detectable, not prevented** — realised by 18.7, not by packaging | ✅ |
-| Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ **open for any account that also uses a browser** |
-| Devices | ✅ | ✅ the hub cannot countersign a device — it holds no user keys | ⚠️ a node adds devices only to itself, where it already reads everything |
-
-**The claim v6 supports:** *the hub cannot read your content, and against a native client
-its only remaining lever is the artifact it ships — which can be hashed and compared.*
-
-**The claim it must not make:** that a native client makes the hub untrusted, or that C4
-is closed for an account that still signs in from a browser.
-
----
-
-## 5. Still open
-
-v5 §9's list stands, with these movements:
-
-| # | Item | Status |
-|---|---|---|
-| C4 | Remote keypair bundles | **Partially closed.** Gone for native devices; open for browser-using accounts until the signed `device_policy {allow_bundle: false}` opt-out ships |
-| T3 | Hub serves the SPA | **Accepted permanently** for browser users. Removed for native clients, whose value depends on 18.7 |
-| — | Chat encryption | **Built 2026-09-07, and not as Sender Keys** — see `docs/chat-sender-keys.md`. The row that stood here ("pairwise to identity keys, never GEK-derived") was reversed on 2026-09-03 and then overtaken entirely. Per group, off by default, MNP 1.2 |
-| — | Delegation | Designed, deferred, unchanged |
-| — | Hub identity pinning | New. `GET /v1/hub/pubkey` exists and nothing pins it; bounded, because a substituted hub can neither read content nor ship the code to a native client |
-
-**Phase 15 was re-read twice and abandoned as written.** The first correction
-(2026-08-17, `devel-phases-next.md` §15.0b) said a sender key must be per device, never
-per person, because a shared chain advanced by two devices produces key and nonce reuse —
-`first-review.md` C1, one level down. That is still true, and it is why the design that
-was built has no shared mutable sending state at all.
-
-The second correction (2026-09-07) ended the protocol choice. **`docs/chat-sender-keys.md`
-is the specification; this records only what changed.** 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, so the ratchet
-bought no confidentiality over one AEAD while adding stateful client code with silent
-failure modes. Three were reproduced in that document; the worst is that under group-key
-distribution **any member could sign as any other**, because `add_sender` accepts any
-distribution and the signing key inside one is bound to nothing.
-
-What was built instead: one key per group, per epoch, per **device**, derived by name from
-an epoch key the node generates and delivers wrapped under the group key. A new epoch
-opens whenever the set of devices that may read future messages shrinks; old epochs are
-kept and still delivered, so the history stays readable to everyone who could already read
-it — and rotating the group key becomes a re-wrap rather than the destruction of the whole
-archive, which is what a group-key-derived archive key would have caused on the first
-`member unpin`. Messages are signed over the ciphertext with the device key the node
-pinned.
-
-Two properties of the old plan survive unchanged:
-
-- **Revoking a device opens a new epoch**, exactly as revoking a member does.
-- **Sender attribution is device-rooted, and the device-to-account mapping comes from the
- node's roster.** Encryption does not by itself make senders cryptographically
- authenticated to each other; the *signature* does, and only as far as the reader's
- roster is honest. The docs must not imply more.
-
-Ordering consequence, unchanged and now satisfied: **device linking (Stage C) lands
-first**, or this is built against an identity model about to change underneath it.
-
----
-
-## 6. Where the detail lives
-
-This document states what changed and what holds. It does not restate the desktop client's
-design, which is long and belongs in one place:
-**`docs/desktop-client-v1.md`** — shell requirements, device-linking protocol and schema,
-account creation, node management over signed MNP ops, several roots per group, filesystem
-portability, packaging and first run, the web tier, and the execution order for all of it.
-
-**`docs/apps.md`** — the group UI's plug-in architecture (§2.7): the props every
-application receives, the enablement mechanism end to end, and a checklist for adding one.
diff --git a/docs/musicbay.md b/docs/musicbay.md
deleted file mode 100644
index 0bcff38..0000000
--- a/docs/musicbay.md
+++ /dev/null
@@ -1,502 +0,0 @@
-# MeshBay — Music application (plan)
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the Music application design; its design
-> content now lives in §9.8.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **built** — `music-app.js`, `music-app-settings.js` and the node-side
-> enrichment all shipped; this header said "not implemented" long after they did.
-> Follows the plug-in mechanism in
-> `docs/apps.md` — read that first, and `docs/mediacenter.md` second: Music
-> reuses its node-side pattern (metadata fetched and cached by the node,
-> never by a client) wherever the same shape applies, and this document only
-> states where Music differs and why.
->
-> Follows the project convention: every claim names the adversary it holds
-> against (§7).
-
----
-
-## 0. What was asked, in one paragraph
-
-A group "application" with exactly the same principles as Videos — a view
-over the existing file index, no catalogue, node-side metadata enrichment,
-enable/disable per group on the same signed-op mechanism — but for audio,
-styled after a mainstream streaming-music player rather than a poster grid:
-album art browsing, a persistent transport (play/pause, previous/next within
-the current album or queue, shuffle, repeat), and no cast/DLNA surface (out
-of scope, unlike a possible future addition to Videos).
-
----
-
-## 1. What this design does not reopen
-
-Everything Videos already established stands, and this plan fits inside it:
-
-- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10,
- draft-v6 §2.7, `mediacenter.md` §1). A file stays tied to its filesystem
- representation; nothing here adds a second identity for a file, and
- nothing reaches the hub.
-- **The apps plug-in mechanism** (`apps.md`): a new `music-app.js`, one
- registry entry, one node-side `ALLOWED_APPS` entry, i18n keys, the asset
- list, the two file-set tests. Enablement is a per-group, operator-signed
- setting, same shape as `member_upload`/`apps_enabled`.
-- **Group-related server state lives on the node** (E9). Nothing in this
- design puts a row on the hub.
-- **Node-side derived-data caching, never in a shared root**
- (`mediacenter.md` §2, §9). Same reasoning applies unchanged: a cache
- belongs in the node's own `data_dir`, never beside the audio files.
-- **The node already classifies audio** — `indexer.py`'s `MEDIA_EXTENSIONS`
- already has an `"audio"` set (`.mp3`, `.flac`, `.ogg`, `.wav`, `.aac`,
- `.m4a`, `.opus`), and `IndexEntry.duration`/`.thumb_hash`/`.display_title`
- are already generic fields, not video-specific ones, per their own
- docstrings. No MNP change is needed to know a file is a track.
-
-## 2. Where Music genuinely differs from Videos
-
-### 2.1 Metadata mostly already exists in the files themselves
-
-A video file's name is usually all there is to go on; an MP3 collection —
-especially one assembled over many years — routinely carries **ID3 tags**
-(artist, album, title, track/disc number, year, genre) and often an
-**embedded cover image** (the `APIC` frame). Unlike Videos, where `guessit`
-had to do all the work, here the filename parser is the *fallback*, not the
-primary source. Order of trust, each one filling gaps the previous left:
-
-1. **Embedded tags** (ID3v2 for MP3, Vorbis comments for OGG/FLAC, etc.),
- read with `mutagen` (pure Python, MIT, no native extension — same
- licensing shape as `guessit`, trivially addable to
- `packages/meshbay-node/pyproject.toml`). Also the source of an embedded
- cover image when present.
-2. **Filename/folder parsing** for whatever tags don't supply — the existing
- `title_parse.py` pattern extended with a small audio-specific ruleset
- (`Track - Title.mp3`, a leading `NN - `/`NN. ` track-number prefix, the
- parent folder as a fallback album name, the grandparent as a fallback
- artist name for an `Artist/Album/track.mp3` layout).
-3. **MusicBrainz**, node-side, fetched and cached exactly like TMDB — for
- canonical spelling, a missing field, or cover art when the file has none
- embedded. Read the whole of §3 before assuming this needs the same
- credential story as TMDB: it does not.
-
-A quick, honest read of a real personal MP3 library (several thousand
-files, classic P2P-era rips, folder-per-artist/album) during planning showed
-embedded tags present and broadly usable on the large majority of files, with
-gaps concentrated in older VBR rips and remix/compilation folders — exactly
-where the folder-name fallback already has something to say (the folder is
-named after the artist or the release). The library itself is not committed
-anywhere in this repo, the same discipline `mediacenter.md` §3.1 already
-follows for its own test corpus; a real accuracy figure belongs in an
-acceptance step (§8), not asserted here from a first look.
-
-**Amendment (2026-08-24):** an early version of this plan skipped a Videos-
-style scoping root, on the reasoning that tag reads are free/local (unlike
-ffprobe+ffmpeg thumbnailing) so the cost a root protects against was
-smaller. Real use showed the actual problem a root solves isn't cost, it's
-*mixing* — a shared tree with more than one kind of thing under it (a real
-messy library rarely holds only music) puts everything into one
-undifferentiated Music view with no way to scope it down. Music now has
-`audio_root`, symmetric to `video_root` in every respect including gating
-enrichment on it — see §4.3b.
-
-### 2.2 No playback protocol change is needed at all — the big simplification
-
-Videos needed MSE, live ffmpeg remuxing, seek-by-restart, and a whole flow
-control mechanism (`video-player.js`) because a film is gigabytes and has to
-start playing before it is fully downloaded. A track is a few megabytes.
-**Music does not need any of that.** Playback reuses the download/decrypt
-pipeline `Files` and `Chat` attachments already use —
-`pipelinedDownload`/`downloadEntry` in `file-utils.js` — fetching and
-decrypting a whole track into a blob, then handing a `blob:` URL to an
-`<audio>` element. No `stream_req`, no node-side transcode pool, no
-`max_concurrent_streams` slot consumed, nothing added to
-`webrtc_server.py`'s streaming machinery. This is a real, deliberate
-simplification worth stating plainly rather than under-selling: **Music adds
-no new node-side streaming code**, only a metadata/art path shaped like
-Videos' TMDB one (§4).
-
-The one thing worth prefetching, as a UX nicety and not a protocol change:
-while a track plays, silently start the decrypt pipeline for the *next*
-queued track so pressing "next" doesn't visibly wait — client-side only, a
-small in-memory blob cache keyed by file id, evicted once it falls off the
-front of the queue.
-
-**Amendment (2026-08-24), one exception found against a real library:** WMA
-and Musepack (.mpc) tag and cover perfectly well (enrich_audio.py reads
-their real keys directly, since neither has mutagen's generic "easy"
-wrapper), but decode in **no** mainstream browser's `<audio>` element at
-all, no matter how the file is tagged — this is a codec-support gap, not a
-metadata one. For these two extensions only, the player asks the node to
-transcode to AAC/M4A first (`audio_transcode_req`/`_resp`, MNP 0.9), a
-one-shot whole-file conversion (not live-piped like video's fMP4 segments —
-an audio file is small enough that there is nothing to gain from streaming
-it), cached under its own content hash the same way a TMDB poster or a
-MusicBrainz cover is cached, and served back through the ordinary
-file_req/chunk path (`_try_serve_thumbnail`, generalized to slice a
-multi-chunk cached blob rather than assuming everything cached is
-thumbnail-sized). Every other format still plays exactly as described
-above — this is a narrow, extension-gated exception, not a reopening of the
-simplification.
-
-### 2.3 The player is persistent across tabs — a shell-level addition
-
-Closing the Music tab must not stop the music, the same way switching to
-Chat mid-film is not how Videos works today (Videos doesn't have this
-problem because playback only ever happens inside a modal the shell already
-owns). A `<audio>` element tied to the currently active *tab* would pause on
-every navigation, which is wrong for a Spotify/Deezer-style player and is
-explicitly requested (previous/next, shuffle, loop — a session, not a
-per-visit control).
-
-**Decision:** the playing state — the `<audio>` element itself, the current
-queue, track index, shuffle/repeat mode, play/pause — moves up into
-`group-page.js` (the shell), exactly the precedent already set for "the
-video/preview modal" (`apps.md` §1). `music-app.js` reads and controls this
-state through props, the same way `onPreview` already lets any app open the
-shell's preview modal without owning it. A small persistent player bar,
-rendered by the shell below the tab content (visible regardless of which tab
-is active, hidden entirely when nothing is queued), is the concrete UI for
-this — it is the one piece of Music's UI that is *not* inside
-`music-app.js` itself, and that asymmetry should be commented at the call
-site so a future reader doesn't go looking for the play bar in the wrong
-file.
-
-This is a small addition to the shell's responsibilities, not a new trust
-boundary: it is UI state, holds nothing secret, and unmounts cleanly when the
-group is left (leaving a group already tears down the shell entirely).
-
-### 2.4 Transport features requested explicitly
-
-All client-side, no signed op, no node involvement — this is exactly the
-kind of state `mediacenter.md` §4.3 already put in `localStorage` for the
-view-mode toggle, for the same reason (a display/session preference with no
-security or authority implication):
-
-| Feature | Behaviour |
-|---|---|
-| Previous / next within an album | Queue defaults to "every track in the clicked track's folder, in track-number then filename order" — the folder is the album, same principle as Videos treating the folder as the category (`desktop-client-v1.md` §6.10) |
-| Next / previous across the queue | Same queue, not bounded to the album once shuffle or a manual "play all" from an artist view has built a longer one |
-| Shuffle | Fisher-Yates over the current queue, toggled on/off; toggling off restores original (folder) order from the current track forward |
-| Repeat | Three-state cycle: off → repeat-all (queue wraps) → repeat-one (current track loops) |
-| Volume | `localStorage`, per browser, same shape as the video player's resume position |
-| Resume-on-return | Not attempted for v1 (a 3-minute track resuming mid-play across a reload is marginal value); *volume* and *shuffle/repeat mode* persisting is enough. Open item if requested later (§9) |
-
-No cast/DLNA surface — explicitly out of scope per the request, and nothing
-in Videos' cast plans (if any exist elsewhere) is reused here.
-
----
-
-## 3. MusicBrainz: what it needs, and what it does not
-
-Directly relevant since the operator now has a MusicBrainz account and asked
-whether the TMDB pattern applies. **Mostly yes, for the caching/node-side
-architecture — no, for the credential.**
-
-### 3.1 Read lookups need no API key at all
-
-TMDB requires a **Bearer token** issued to a registered application (v4
-auth) for every call, which is why `tmdb.py` resolves a secret before doing
-anything. MusicBrainz's web service (`ws/2`, documented at
-`musicbrainz.org/doc/MusicBrainz_API`) is different: **search and lookup
-calls require no key or account at all.** What it requires instead:
-
-- A **descriptive `User-Agent` header** identifying the application, a
- version, and a contact (an email or a URL) — MusicBrainz's stated policy
- for being allowed to use the service at all, not a secret.
-- **Rate limiting to roughly one request per second** for unauthenticated
- use. This is a courtesy limit enforced by convention and by MusicBrainz's
- own throttling of abusive clients, not a token-bucket the server hands
- out — so it is this node's job to self-limit, not something to "get more
- of" by having an account.
-- **Cover Art Archive** (`coverartarchive.org`), the companion service that
- hosts album art keyed by MusicBrainz release ID, is equally keyless.
-
-So the MusicBrainz account the operator created is **not what supplies the
-credential this design needs** — there isn't one to fetch. What an account
-*is* for:
-
-- **Editing MusicBrainz's own data** (submitting corrections, adding
- releases) — irrelevant here, this design only reads.
-- **AcoustID** (`acoustid.org`), a *separate* service (audio fingerprinting
- — identifying a track from its waveform when it has no useful tags or
- filename at all) that does issue a free API key from its own site, tied to
- an AcoustID account, not the MusicBrainz one. This is the feature to reach
- for if the filename+tag+MusicBrainz-search pipeline (§2.1) still can't
- identify a meaningfully large fraction of a real library once measured —
- deliberately **deferred** (§9) rather than built speculatively, the same
- discipline `mediacenter.md` applied to its own out-of-scope items.
-
-### 3.2 What to configure instead — mirrors `tmdb_config`, minus the secret
-
-Same shape as `mediacenter.md` §5.5's `OP_TMDB_CONFIG`/`tmdb_enabled`, with
-the credential field replaced by a contact string:
-
-- **`musicbrainz_contact`** (node-wide, `group_settings` under the `""`
- sentinel, same precedent as `tmdb_api_token`/`tmdb_language`) — the
- `User-Agent` contact field MusicBrainz's policy asks for. **This must not
- be a literal value committed to source** — same rule already in force for
- `MESHBAY_TMDB_DEFAULT_TOKEN`, and the reason the plan note at the top of
- this document exists: no personal contact address in git. A generic
- project-level placeholder (e.g. a `meshbay.org` contact page, once one
- exists) is the shipped default; an operator can override it with their
- own, the same "operator-supplied beats shipped default" order TMDB's
- token resolution already uses.
-- **`tmdb_language`'s twin**, if wanted — MusicBrainz search doesn't take a
- language parameter the way TMDB does (its data isn't localized per
- request the same way); skip this field rather than inventing a knob
- nothing reads.
-- **`musicbrainz_enabled`**, per group from the start (unlike TMDB, which
- started node-wide and was split per-group later, §5.6 of `mediacenter.md`
- — no reason to repeat that path here now that the lesson is already
- learned).
-- **Rate limiting is the node's own job**, not configuration: a single
- `asyncio.Semaphore(1)` plus a minimum-interval gate in the client (the
- same shape as the bounded worker pools already used for thumbnails/probes,
- just serialized to one at a time with a floor on the interval between
- calls) — enforced regardless of how many members' tiles are requesting
- metadata concurrently, because the 1 req/s budget is shared across the
- whole node, the same reasoning `mediacenter.md` §2 already used to justify
- node-side TMDB caching in the first place (one lookup per unique
- artist/release serves every member indefinitely).
-
-### 3.3 What MusicBrainz actually supplies here
-
-- Canonical artist/release/track names and a `release` MBID, searched by
- (artist, album) or (artist, track) depending on what tags/parsing already
- found.
-- Release date, and enough of a release/recording graph to disambiguate a
- reissue/compilation from the original release when useful — not pursued
- further than what the detail view needs.
-- Cover art, via Cover Art Archive keyed by the resolved release MBID — used
- **only when the file has no embedded art** (§2.1's first tier already
- covers the common case for free, no network call).
-
----
-
-## 4. Protocol and index changes
-
-### 4.1 Reuse first
-
-`IndexEntry.duration` and `.thumb_hash` are already generic fields (their
-docstrings say "for media" / "of thumbnail", not "video"); Music reuses both
-exactly as declared — `duration` from `mutagen`, `thumb_hash` pointing at
-either an embedded or MusicBrainz-fetched cover image, delivered through the
-existing chunk `file_req` path (`mediacenter.md` §5.3's reuse, unchanged).
-`display_title` is reused as the parsed track title.
-
-### 4.2 New fields on `IndexEntry` (additive — MNP MINOR bump, same class of
-change as the video fields were)
-
-```python
-artist: str | None = None # tag or parsed, Music app
-album: str | None = None # tag or parsed, Music app
-track_no: int | None = None # tag or parsed, Music app
-```
-
-`disc_no` and `year` were considered and dropped from v1: neither is needed
-by the two view modes below, and an unused field is exactly the kind of
-thing that silently rots (`desktop-client-v1.md`'s own history has more than
-one example of a declared-but-never-populated field). Add them later,
-additively, if a feature actually needs them.
-
-### 4.3 New request/response: MusicBrainz metadata
-
-Same shape as `media_meta_req`/`resp`, keyed by `path` for the same reason
-(`mediacenter.md` §5.4's concurrent-lookup bug and its fix apply identically
-here — `transport.js`'s reply routing needs the same per-path keying the
-moment more than one tile can be in flight):
-
-```
-music_meta_req { path }
-music_meta_resp { mbid, artist, album, title, release_date,
- cover_thumb_hash, confidence }
-```
-
-No season-tab, no manual-override pair (`tmdb_override`'s equivalent) in
-v1 — deferred (§9), on the reasoning that a wrong MusicBrainz match's blast
-radius is smaller than a wrong TMDB match (wrong cover art or a
-canonicalized spelling, not a wrong show entirely) and tag-derived data
-already covers most files, so building a correction UI before measuring
-whether it's needed would repeat the "designed and not needed" pattern this
-project's own history warns against.
-
-### 4.3b New request/response: audio transcode (amendment, §2.2)
-
-```
-audio_transcode_req { file_id }
-audio_transcode_resp { file_id, hash, size, mime }
-```
-
-`hash` is a cache key, not the file's own id — the client downloads it
-through the ordinary `file_req`/chunk path exactly like a TMDB poster or
-MusicBrainz cover, never a new download mechanism. Sent only for WMA and
-Musepack (`.wma`/`.mpc`); every other format still downloads its own
-`entry.id` directly, unchanged. Shares its concurrency cap with video's
-transcode pool (`MAX_CONCURRENT_TRANSCODES`) rather than getting its own —
-both are real ffmpeg processes on the same node.
-
-### 4.4 `ALLOWED_APPS`
-
-Add `"music"` to the frozenset in `webrtc_server.py`. `DEFAULT_APPS` stays
-`("chat", "files")` — same reasoning as Videos: a brand-new group doesn't
-get an app that can make outbound third-party calls (once MusicBrainz is on)
-for free; the operator opts in explicitly.
-
----
-
-## 5. The two view modes
-
-Same two-mode shape as Videos (`mediacenter.md` §4), same per-device
-`localStorage` toggle (§4.3 there), same default-to-the-richer-mode
-decision:
-
-### 5.1 Mode A — album grid (art + metadata), default
-
-Grid of album covers (one card per distinct `(artist, album)` pair found in
-the index — folder-derived when tags are absent), click through to a
-tracklist (track number, title, duration) with a **Play** / **Play all**
-action that builds the queue and hands off to the shell's player (§2.3). An
-artist-level view groups album cards under an artist heading, with its own
-"play all" that queues every album's tracks in album-then-track order.
-Falls back to a plain folder-name card with no art when there's neither an
-embedded cover nor a MusicBrainz match — the direct analogue of Videos'
-thumbnail-only fallback card.
-
-### 5.2 Mode B — flat, folder-based, no MusicBrainz
-
-Alphabetical list of folders (artists or albums, whichever level a given
-subtree sits at), each expandable to its tracks, no network call, no
-MusicBrainz dependency — keeps working with `musicbrainz_enabled` off,
-exactly like Videos' Mode B keeps working with TMDB off.
-
----
-
-## 6. Node-side implementation, concretely
-
-| Piece | Where | What |
-|---|---|---|
-| Tag/filename parse | `meshbay_node/indexer/title_parse.py`, extended | A small audio ruleset alongside the existing video one — track-number prefix, `Artist/Album/track.mp3` folder fallback |
-| Tag + embedded art extraction | new `meshbay_node/indexer/enrich_audio.py`, sibling to the existing (video) `enrich.py` | `mutagen` read is synchronous I/O — run via `asyncio.to_thread`, never blocking the event loop; through its own bounded worker pool, same discipline as the video enrichment pool (`enrich.py`'s `DEFAULT_MAX_CONCURRENT`), not because `mutagen` shells out (it doesn't — no ffmpeg-style pipe-draining hazard here) but to keep a large library's indexing burst bounded the same way everywhere |
-| MusicBrainz client | new `meshbay_node/musicbrainz.py`, shaped like `tmdb.py` | `httpx`, self-rate-limited to ~1 req/s (§3.2), no bearer token — just the configured `User-Agent` |
-| Cover Art Archive fetch | same client | Unauthenticated image fetch, same shape as `TmdbClient.fetch_image` |
-| Caches | `data_dir/media_cache.db`, same store TMDB already uses, new tables alongside the existing ones | `(content_hash) → mbid` mapping; `(mbid) → metadata json`, TTL (30 days, same default as TMDB, revisit if MusicBrainz data proves to churn less); `(thumb_hash) → jpeg bytes`, pruned on the owning file's index removal — identical lifecycle to the video thumbnail cache |
-| Operator config | `roster.py` `group_settings` | `musicbrainz_contact` (`group_id=""`, node-wide), `musicbrainz_enabled` (per real `group_id`, per §3.2's reasoning for starting there directly) |
-| New adminop | `meshbay_common/adminop.py` | `OP_MUSICBRAINZ_CONFIG` (node-wide contact string), `OP_MUSICBRAINZ_ENABLED` (per-group), same shape as `OP_TMDB_CONFIG`/`OP_TMDB_ENABLED` |
-| `pyproject.toml` | `packages/meshbay-node/pyproject.toml` | add `mutagen>=1.47` |
-
-No hub change, no change to the streaming/transcode machinery at all (§2.2).
-
-## 7. Client-side, per `apps.md`'s checklist
-
-1. `music-app.js` — the album grid / flat list, receiving the standard
- `apps.md` §2 props. Does **not** own the `<audio>` element or queue state
- (§2.3) — those live in `group-page.js` and are threaded through as
- additional props (`playerState`, `enqueue`, `playNow`, `skipNext`,
- `skipPrev`, `toggleShuffle`, `cycleRepeat`), the same "spread, not
- hand-listed" shape `commonProps` already uses.
-2. A small persistent player-bar component, rendered by `group-page.js`
- itself below the tab content — the one piece of this feature that is not
- inside `music-app.js` (§2.3).
-3. Register `{ key: "music", icon, labelKey: "group.tab_music", Component:
- MusicApp }` in `apps.js`.
-4. `ALLOWED_APPS` (§4.4).
-5. `group.tab_music` (and a handful of player-bar strings — play/pause,
- shuffle, repeat) in all ten `static/locales/*.js`; `test_locales.py`
- holds them to the same key set.
-6. `webapp.py`'s `_ASSETS` tuple — add `music-app.js`.
-7. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py`
- (`SPLIT_FILES`) — add the new file to both.
-8. `npm run sync-ui` in `meshbay-client`, confirmed reported.
-
----
-
-## 8. Security — per adversary
-
-| Claim | Passive hub | Active hub | Malicious node operator | Another member |
-|---|---|---|---|---|
-| MusicBrainz contact string confidentiality | ✅ unreachable | ✅ unreachable | not a secret — it's a courtesy header, not a credential | ✅ never transmitted to members |
-| Metadata/art delivery | ✅ unchanged transport | ✅ unchanged transport | sees it already (holds the plaintext file) | same GEK-proofed MNP channel as files/streaming — no new authorization surface |
-| New outbound traffic (node → MusicBrainz/Cover Art Archive) | — | — | the operator already controls their own node's egress | — |
-| Playback path | — | — | operator already holds the plaintext | unchanged — the same chunk `file_req` path Files already uses, now consumed by an `<audio>` element instead of a save dialog |
-| Stale cache after file deletion | — | — | pruned on the index's own deletion event, same mechanism as the video cache (§6, `mediacenter.md` §2) | — |
-
-**The claim this design supports:** identical to Videos' — no new
-authorization boundary, and (going further than TMDB) **no credential of
-any kind** leaves the node's own config, because MusicBrainz needs none.
-
-**The claim it must not make:** that self-imposed rate limiting is enforced
-by MusicBrainz on this node's behalf — it is a courtesy the node has to
-actually implement (§3.2), not a property the protocol gives for free.
-
----
-
-## 9. Open items — deliberately deferred
-
-| # | Item | Why not now |
-|---|---|---|
-| M1 | AcoustID audio-fingerprint matching for tag-less, unparseable files | Needs its own account/key (§3.1) and only matters once real coverage is measured against the library — building it speculatively repeats a pattern this project's history warns against |
-| M2 | Manual match correction (`tmdb_override`'s analogue) | Smaller blast radius than a wrong TMDB match (§4.3); build if real mismatches turn out to be common enough to need it |
-| M3 | Resume position mid-track | Marginal value for a ~3-minute file; volume/shuffle/repeat persistence (§2.4) covers the useful part of "remembering state" |
-| M4 | Playlists / favorites | A genuinely new category of per-account node state, not covered by anything E9 already enumerates — needs its own design pass (ownership, sync across devices, whether it's node-local or something else) rather than being folded in here |
-| M5 | Gapless playback, crossfade, lyrics, waveform seek preview | Nice-to-haves, no architectural prerequisite from this plan either way |
-| M6 | Photos app | Out of scope of this document, per `apps.md`'s own list — unaffected by anything here |
-
-## 9b. One album, not two, in the cross-group Search view (2026-09-02)
-
-Reported against Videos and true here by construction: an operator hosting two
-groups that share one directory saw every track listed twice inside one album.
-Inside a group it cannot happen (`GroupIndex` is keyed by blake3); the Search
-page concatenates *N* indexes, and that is where the duplication is born.
-
-The fix is `source-merge.js`, applied identically to all three media views —
-fold on the content hash, resolve **one source per unit**. For Music the unit
-is an album, and a track loose enough to carry no artist at all is a unit of
-its own (or it would be dropped from the merge entirely, since
-`groupMusicEntries` never puts it in an album bucket).
-
-**`foldKey` is exported for this**, and the reason is worth keeping. §5.1's
-grouping folds case and `&`/`and` for the *key* while keeping the first-seen
-spelling for display — and which group is seen first is whichever index
-happened to arrive first. Keying a merge unit on the display strings would
-therefore let the chosen source change between page loads. The folded key is
-the one grouping actually uses, and is stable.
-
-The units come from `groupMusicEntries` itself, called on the un-merged list
-purely to learn them — never a second copy of its keys in the Search page.
-
-**The player needed no change.** `music-player.js` resolves a connection from
-`entry.groupId` (§2.3), and a merged entry carries exactly one. The queue built
-in `search-page.js`'s `onPreview` needed none either: it filters by `groupId`
-and is reachable only from the Files explorer, which is deliberately *not*
-merged — there each group is a folder and merging would remove a track from
-one of them.
-
-Design and the adversary it names: `docs/refactoring-search.md`.
-`test_search_media_merge.py` covers the album cases, including a differently-
-cased tag not splitting the unit.
-
-## 10. Acceptance before shipping
-
-1. Tag-coverage measurement against a real local library (not committed —
- same discipline as `mediacenter.md` §3.1): what fraction of files get a
- usable artist/album/title from tags alone, from tags+filename parsing,
- and only then how much MusicBrainz enrichment actually adds — this
- number decides whether M1 (AcoustID) is worth ever building.
-2. A mocked-`httpx`-transport test suite for `musicbrainz.py`, the same
- shape `test_tmdb.py` already uses — no test hits the real service.
-3. Confirm the self-imposed rate limiter actually serializes calls to
- roughly 1/s under concurrent tile requests from several open clients at
- once (a unit test around the semaphore/interval gate, not a live
- MusicBrainz call).
-4. Confirm cache pruning on file deletion fires for the new tables, mirroring
- `test_media_cache.py`'s existing coverage.
-5. Confirm the persistent player survives a tab switch and a group-to-group
- navigation without an audible glitch, and stops cleanly when the group is
- left — this is the one behavior that can't be unit-tested meaningfully
- and needs a real browser check before calling this done, the same
- standing rule `CLAUDE.md` records for anything UI-timing-shaped.
diff --git a/docs/old-draft.md b/docs/old-draft.md
deleted file mode 100644
index f02c54f..0000000
--- a/docs/old-draft.md
+++ /dev/null
@@ -1,4497 +0,0 @@
-# MeshBay — Archived Drafts
-
-> **Status: historical archive. Nothing here is authoritative.**
->
-> This file consolidates the superseded architecture drafts (v1–v4), the original
-> POC plan, and the Phase 1–12 development log. They are kept for provenance and
-> for the section references (`draft-v3 §4.1.3`, `draft-v4 §6.6`, …) still made
-> from live documents and code comments.
->
-> For what is true now, read instead:
->
-> | Topic | Document |
-> |---|---|
-> | Current specification | `meshbay-draft-v6.md` (+ `meshbay-draft-v5.md` for what v6 does not restate) |
-> | Roadmap | `devel-phases-next.md` |
-> | Security findings | `first-review.md`, `second-review.md` |
-> | Client architecture decisions | `tmp-decisions.md`, `desktop-client-v1.md` |
->
-> Contents of this archive, in order:
-> 1. Architecture Draft v1
-> 2. Architecture Draft v2
-> 3. Architecture Draft v3
-> 4. Architecture Draft v4
-> 5. POC v1
-> 6. Development Phases (1–12)
-
-
----
-
-<!-- ============================================================ -->
-<!-- ARCHIVED: Architecture Draft v1 (was docs/meshbay-draft-v1.md) -->
-<!-- ============================================================ -->
-
-# MeshBay — Architecture Draft v1
-
-> Status: preliminary draft — many points still open, marked [TBD]
-
----
-
-## 1. Project Overview
-
-MeshBay is a decentralized, peer-to-peer platform for file sharing, video streaming, and group messaging. It combines identity federation (via Mesh Hubs) with truly peer-to-peer data exchange (via Mesh Nodes), aiming to be resilient, censorship-resistant, and user-friendly.
-
-**Core principles:**
-- Data never transits through a central server — only identity and routing do
-- End-to-end encryption for all private content (files, indexes, messages)
-- The node operator is the legal host and is fully responsible for their content
-- The hub is a lightweight registrar, not a content host or indexer
-- Open source, self-hostable at every level
-
-**Domain:** meshbay.org
-
----
-
-## 2. Terminology
-
-| Term | Role |
-|---|---|
-| **Mesh Hub** | Identity authority and group registry server |
-| **Mesh Node** | Local program on the host user's machine |
-| **Mesh Client** | Web browser or Android app (end user) |
-| **Mesh Relay** | Community-operated TURN fallback relay |
-| **MNP** | Mesh Node Protocol — P2P protocol between nodes and clients |
-| **MHP** | Mesh Bay Hub Protocol — inter-hub federation protocol |
-| **GEK** | Group Encryption Key — symmetric key for group content |
-| **Mesh Directory** | Public registry of groups (hub level) |
-| **Mesh Group Index** | Encrypted file listing for a group (node level) |
-
----
-
-## 3. System Components
-
-### 3.1 Mesh Hub
-
-A lightweight server acting as a registrar. It is intentionally kept minimal to reduce legal exposure and operational burden.
-
-**What the hub stores:**
-- User accounts: username, hashed email, `PK_user` (public key fingerprint), hub ID, status
-- Group registry: name, `PK_group`, hosting node address, visibility, member list with encrypted GEK bundles
-- Revocation lists (users and groups)
-- Registered peer hubs (explicit allowlist — no auto-discovery)
-
-**What the hub never stores:**
-- File content or metadata
-- Private group indexes
-- Message content
-- Node IP addresses (handled by ephemeral signaling service)
-
-**Hub interactions — when is it called?**
-
-| Event | Hub load | Frequency |
-|---|---|---|
-| Account creation | Hash credential, store PK | Once |
-| Login | Verify credentials, issue signed JWT | Per session (~30-day validity) |
-| Group creation | Register name, PK_group, node | Once per group |
-| Member add/remove | Store/remove encrypted GEK bundle | On admin action |
-| Group discovery | Return node address + PK_node + GEK bundle | Per initial access |
-| NAT signaling | Relay a few WebSocket messages (<1 KB) | Per new P2P connection |
-| Public search | Delegate query to nodes at request time | On demand |
-| MHP federation sync | Exchange public directory updates | Background, periodic |
-| Revocation | Issue signed revocation token | Rare |
-
-**The hub is never in the data path after initial connection setup.**
-
-**JWT as offline passport:**
-The hub issues a JWT signed with its Ed25519 private key. Nodes verify this JWT locally using the hub's known public key — no hub roundtrip required per request. JWT validity: ~30 days.
-
-**Tech stack:**
-- Language: Python
-- Framework: FastAPI + Uvicorn
-- Database: PostgreSQL + SQLAlchemy + Alembic
-- Deployment: behind Apache reverse proxy (ProxyPass)
-- Authentication: own system (JWT signed with Ed25519, no OAuth dependency)
-
-**Account creation:** [TBD] — email only at first, phone number associable later. Via Android app, both collected by default. Fusionable accounts.
-
-### 3.2 Mesh Node
-
-A local program running on the host user's machine. The node is the actual host of all content.
-
-**Responsibilities:**
-- Watch and index shared directories (Mesh Group Index)
-- Serve files and video streams to group members
-- Manage all cryptographic keys locally (keystore, password-protected)
-- Handle P2P connections and NAT traversal
-- Run the MNP protocol
-- Host the Python module sandbox
-- Serve the local web UI (localhost)
-- Receive and redistribute ephemeral video from mobile [future]
-
-**Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability.
-
-**Tech stack:**
-- Language: Python (primary), Rust extensions only if strictly necessary for hot paths
-- QUIC: `aioquic`
-- ICE/STUN: `aioice`
-- WebRTC (future): `aiortc`
-- Crypto: `cryptography` (PyCA, OpenSSL-backed, hardware-accelerated)
-- Serialization: `msgpack`
-- Compression: `zstandard` (zstd)
-- File watching: `watchdog`
-- Local DB: SQLite
-- Local web UI: served by the node on localhost (port [TBD])
-
-**Node pairing with mobile:** QR code from local web UI [future].
-
-### 3.3 Mesh Client
-
-Web browser or Android app. Consumes content from the node; manages account via the hub.
-
-**Hub-side operations (via hub):**
-- Account creation and login
-- Public group search and discovery
-- Group membership management
-
-**Node-side operations (direct P2P):**
-- File browsing (Mesh Group Index)
-- Message feed reading (with attachments, Signal-like)
-- File download
-- Video streaming (VOD)
-- [Future] Ephemeral video feed
-
-**Client modes** [to be designed]:
-- Explorer mode: browse files in a group
-- Feed mode: message thread with attachments
-- Hub/Node UI split to be defined
-
-### 3.4 Mesh Relay
-
-Community-operated TURN relay. Used only as last-resort fallback when all P2P connection methods fail. Traffic is always E2E encrypted — the relay sees only opaque QUIC packets and cannot read content.
-
-Not operated by meshbay.org. A protocol for relay registration with hubs is [TBD].
-
----
-
-## 4. Group Model
-
-Groups are the core organizational unit.
-
-| Parameter | Options |
-|---|---|
-| Visibility | Public / Private |
-| Join policy | Open / On request / By invitation only |
-| Admin | The hosting node operator (legal host) |
-
-A public group functions like a themed forum: shared files, message thread, member list. It can be open entry, request-based, or invitation-only regardless of its public visibility.
-
-A private group's content (files, index, messages) is always E2E encrypted with the GEK. Only members with the GEK can decrypt anything.
-
-**Group addressing** [TBD]:
-```
-meshbay.org/u/username/groupname — public group via hub
-meshbay.org/g/groupname — direct public group
-group://<PK_group_fingerprint>@<node_addr> — hub-less direct access
-```
-
----
-
-## 5. Cryptographic Architecture
-
-### 5.1 Key Hierarchy
-
-```
-User Identity Key Ed25519 Signing, authentication
-User Exchange Key X25519 Key agreement
-Group Identity Key Ed25519 Group metadata signing (held by admin node)
-Group Encryption Key ChaCha20 Content and index encryption (symmetric, 256-bit)
-Session Keys X25519/HKDF Perfect forward secrecy per P2P connection
-```
-
-All private keys are stored exclusively on the node (or client device), in a password-protected local keystore. The hub never sees any private key.
-
-### 5.2 GEK Management
-
-**Group creation:**
-1. Admin node generates GEK (ChaCha20-Poly1305, 256-bit, CSPRNG)
-2. GEK is encrypted for each member using X25519 key agreement + HKDF
-3. Encrypted GEK bundles stored on hub (or node — [TBD])
-
-**Member addition:**
-- GEK encrypted with new member's `PK_user` and distributed
-
-**Member revocation:**
-- Admin node generates a new GEK
-- Re-encrypts for all remaining members
-- New content encrypted with new GEK
-- Former member retains ability to decrypt previously received content (acceptable trade-off — full re-encryption not planned)
-
-### 5.3 On-the-Fly Encryption for File Transfer
-
-Files are stored in plaintext on the host's disk. The node encrypts at read time before transmission.
-
-```
-Disk (plaintext) → [Node] → zstd compress → GEK encrypt (per-chunk) → QUIC session → [Client] → QUIC decrypt → GEK decrypt → plaintext
-```
-
-**Chunking strategy:**
-- Chunk size: 1 MB (amortizes AEAD overhead, allows seeking)
-- Per-chunk key derivation:
- `chunk_key = HKDF(GEK, "file:" || blake3(file) || "chunk:" || index)`
-- Each chunk independently decryptable (enables video seeking)
-- Compress before encrypt (zstd compression is useless after encryption)
-
-**Chunk authentication:**
-Each chunk (or batch) is signed with the node's Ed25519 key. The client verifies before decryption. Prevents data injection by a compromised relay.
-
-### 5.4 Transport Security
-
-- Primary protocol: **QUIC** (TLS 1.3 integrated, UDP-based, multiplexed)
-- Per-connection session keys via X25519 ECDH + HKDF
-- The QUIC layer is independent from the GEK application layer — two independent encryption layers
-
-### 5.5 Chat Encryption
-
-Group messaging uses the **Double Ratchet algorithm** (as in Signal):
-- Forward secrecy and break-in recovery per message
-- Each message independently encrypted
-- Implementation: existing Python or Rust library [TBD]
-
----
-
-## 6. Network and Connectivity
-
-### 6.1 NAT Traversal — Attempt Order
-
-```
-1. IPv6 available on both sides → direct connection, no NAT issue
-2. UPnP / NAT-PMP on router → node opens port automatically
-3. ICE + STUN / UDP hole punching → works for ~80-85% of cases
-4. Mesh Relay (TURN fallback) → community-operated, E2E encrypted traffic
-```
-
-**Signaling** (steps 3/4): coordinated via hub WebSocket, <1 KB per attempt, stateless after connection established.
-
-**Step 4 coverage:** ~15-20% of connections (symmetric NAT on both sides, CGNAT). The relay sees only encrypted QUIC packets.
-
-### 6.2 MNP — Mesh Node Protocol
-
-Application-level protocol over QUIC. Defined blocks:
-
-- **Handshake**: key exchange, group membership verification (JWT presentation)
-- **Index sync**: encrypted delta Mesh Group Index on connection
-- **File transfer**: chunk request/response with hash verification
-- **VOD streaming**: HLS/DASH segments, encrypted per-segment with GEK-derived keys
-- **Messaging**: Double Ratchet messages encapsulated in MNP frames
-- **[Future] Ephemeral stream**: `ephemeral_stream` type with TTL metadata
-
-### 6.3 Public Content Delivery
-
-Public files are identified by their `blake3` hash. Multiple nodes can serve the same file:
-
-1. Node A has public file X (hash H)
-2. Any node that obtains X and chooses to mirror it registers with the hub: "I serve hash H"
-3. Hub maintains: `{ blake3_hash → [node_A, node_B, ...] }`
-4. Client requests X → hub returns source list → client fetches in parallel chunks from multiple nodes
-
-**Public content transport:** TLS only (no GEK). Content signed with the original node's Ed25519 key for authenticity verification by clients, even when served from a mirror. Door left open for GEK on "registered-users-only public" groups in a future revision.
-
----
-
-## 7. Indexes
-
-### 7.1 Mesh Directory (hub level)
-
-Public registry of groups. Exchanged between hubs via MHP.
-
-Format: msgpack, signed by hub's Ed25519 key.
-
-Fields per entry: group name, `PK_group`, hosting hub, description, content type tags, join policy.
-
-### 7.2 Mesh Group Index (node level)
-
-File listing for a group. Generated and maintained by the hosting node.
-
-Format: msgpack → zstd compressed → GEK encrypted (private groups) or plaintext signed (public groups).
-
-Entry structure:
-```python
-{
- "id": "<blake3_hash>",
- "name": "filename.mkv",
- "path": "Movies/2024/", # relative to shared directory
- "size": 4294967296,
- "type": "video", # video | audio | image | document | archive | other
- "duration": 7245, # seconds, for media
- "thumb_hash":"<blake3>", # thumbnail hash (thumbnail also GEK-encrypted)
- "added_at": 1720000000
-}
-```
-
-**Delta updates:** each update carries `{base_version, additions, deletions}` — no full re-encryption on every change.
-
-**Transit:** nodes push index deltas to connected members on change. Members pull full index on first connection. Hub stores no index content — only the node address for routing.
-
-### 7.3 Search
-
-**Private groups:** search is entirely local on the client device. The client maintains a local encrypted cache of all indexes for groups it belongs to. No network call, no hub involvement, instant results.
-
-**Public groups:** client queries node(s) directly at request time. Hub provides routing (which node hosts which group) but performs no content lookup itself.
-
-**Hub web UI search:** delegates query to relevant nodes at request time. Hub stores nothing from this interaction. In-memory micro-cache of results: **60-second TTL maximum, RAM only, never written to disk, public content only.** This qualifies as technical caching (EU DSA Article 13) and does not constitute indexing.
-
----
-
-## 8. Hub Federation (MHP)
-
-### 8.1 Hub Hierarchy
-
-```
-Root Hub (meshbay.org)
- ├── Full Hub (self-hosted, CA-delegated)
- │ └── issues user credentials, manages its own groups
- │ └── can federate with other Full Hubs via MHP
- └── Mirror Hub
- └── hosts public Mesh Directory only (no user accounts)
-```
-
-A Full Hub receives a certificate signed by the Root Hub (or a parent Full Hub) proving its authority. Clients verify the chain. A Mirror Hub can only replicate public directory data.
-
-### 8.2 MHP Design Principles
-
-- Explicit peer selection: each hub maintains an allowlist of trusted peer hubs
-- No automatic hub discovery
-- Exchanged data: Mesh Directory (public groups), revocation lists, cross-hub user credentials
-- Cross-hub authentication: user from Hub A presents JWT signed by Hub A; Hub B verifies using Hub A's public key (fetched once on first interaction, cached)
-
-### 8.3 Cross-Hub Client Access
-
-Client from Hub A accessing a group on Hub B:
-1. Hub A's public directory or direct link leads client to Hub B
-2. Client presents Hub A JWT to Hub B directly
-3. Hub B verifies JWT signature using Hub A's public key
-4. Hub B issues a short-lived local token for this session
-5. Client proceeds to node as normal
-
----
-
-## 9. Moderation
-
-### 9.1 Public Content
-
-```
-Report #1 → automatic suspension of public access to content
- → node operator notified
-One republication allowed
-Report #2 → escalated to hub moderators
-Confirmed → group revoked on local hub
- → revocation propagated to federated hubs via MHP
-```
-
-Mechanism: blake3 hash of content added to hub blocklist. Node receives signed revocation notice and cuts public access.
-
-### 9.2 CSAM
-
-Hash matching against NCMEC/IWF database on all public content at registration time. Participation demonstrates good faith and significantly reduces legal exposure. No scanning of private/encrypted content.
-
-### 9.3 Copyright
-
-DMCA/legal notice framework (takedown on notification). No automated technical blocking — too complex, too many false positives (fair use, regional variations). Hub can revoke on confirmed legal request.
-
-### 9.4 Private Content
-
-Not directly moderatable (E2E encrypted by design). Only action available: revoke user or group at hub level on formal legal request. Hub issues a signed revocation token that all group members' nodes can verify.
-
----
-
-## 10. Python Module System
-
-The node can load extension modules (Python) that run in a sandboxed subprocess.
-
-**Module manifest** (declared capabilities):
-```python
-{
- "name": "group-chat",
- "version": "1.0.0",
- "permissions": ["read_index", "send_message", "receive_events"]
-}
-```
-
-**Available APIs (restricted):**
-- `read_index()` — read current group index (read-only)
-- `send_message(content)` — post a message to the group thread
-- `receive_events(handler)` — subscribe to group events (new file, new message)
-
-**Not available:**
-- Arbitrary network access
-- Filesystem access outside the group context
-- System calls
-
-**First official module:** group chat thread (Signal-like, with attachments). Bundled with node.
-
----
-
-## 11. Legal Framework
-
-**Node operator:** primary legal host of content. Fully responsible for what they share. Node software clearly communicates this at setup.
-
-**Hub operator:** registrar, not content host. Stores minimal PII. Operates takedown mechanism. Participates in CSAM hash matching. Analogous to a domain registrar in legal exposure terms.
-
-**Protocol/software author:** protected by substantial non-infringing uses. No active facilitation of infringement.
-
-**Hub data minimization:**
-- Email stored hashed after verification [TBD]
-- No IP address logging (or auto-deletion after 24h)
-- No content metadata stored
-- Node current address managed by ephemeral signaling service only
-
----
-
-## 12. Future Features (noted, not designed)
-
-- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
-- **Mobile video push:** mobile films → pushes to hosting node → distributed as ephemeral stream with TTL to group members. MNP `ephemeral_stream` type reserved.
-- **Mesh Relay registration protocol:** community TURN relays registered with hubs
-- **Node mobile pairing:** QR code from local web UI
-- **Multi-source download:** parallel chunk fetching from multiple nodes for same public file (swarm)
-- **iOS client**
-- **At-rest encryption on node:** optional, for nodes deployed on remote servers
-
----
-
-## 13. Open Questions [TBD]
-
-1. **GEK bundle storage:** on hub or on node only? Hub = easier discovery; node only = more decentralized
-2. **Group address scheme:** final URL format
-3. **Hub local web UI scope for V1:** config only, or also group browsing?
-4. **Account creation:** email only to start, phone associable — confirm
-5. **Chat implementation:** bundled module or core feature?
-6. **QUIC library maturity:** `aioquic` production readiness assessment needed
-7. **Double Ratchet library:** identify best Python implementation
-8. **Relay registration protocol:** design when community relays are introduced
-9. **Cross-hub directory exchange:** frequency, conflict resolution
-10. **Node port for local web UI:** to assign
-11. **JWT expiry and refresh strategy**
-12. **Keystore format and unlock mechanism on node startup**
-
-
----
-
-<!-- ============================================================ -->
-<!-- ARCHIVED: Architecture Draft v2 (was docs/meshbay-draft-v2.md) -->
-<!-- ============================================================ -->
-
-# MeshBay — Architecture Draft v2
-
-> Status: preliminary draft — open points marked [TBD]
-> Changes from v1: IP logging (legal), protocol versioning, hardware sizing, JWT strategy, keystore proposals, chat as core, relay moved to future, hub mirror future, GEK clarified, port 18000, lazy admin keystore.
-
----
-
-## 1. Project Overview
-
-MeshBay is a decentralized, peer-to-peer platform for file sharing, video streaming, and group messaging. It combines identity federation (via Mesh Hubs) with truly peer-to-peer data exchange (via Mesh Nodes), designed to be resilient, censorship-resistant, and user-friendly.
-
-**Core principles:**
-- Data never transits through a central server — only identity and routing do
-- End-to-end encryption for all private content (files, indexes, messages)
-- The node operator is the legal host and is fully responsible for their content
-- The hub is a lightweight registrar, not a content host or indexer
-- Open source, self-hostable at every level
-
-**Domain:** meshbay.org (configurable at compile/deploy time throughout the codebase)
-
----
-
-## 2. Terminology
-
-| Term | Role |
-|---|---|
-| **Mesh Hub** | Identity authority and group registry server |
-| **Mesh Node** | Local program on the host user's machine |
-| **Mesh Client** | Web browser or Android app (end user) |
-| **Mesh Relay** | Community-operated TURN fallback relay [future] |
-| **MNP** | Mesh Node Protocol — P2P protocol between nodes and clients |
-| **MHP** | Mesh Bay Hub Protocol — inter-hub federation protocol |
-| **GEK** | Group Encryption Key — symmetric key for private group content |
-| **Mesh Directory** | Public registry of groups (hub level) |
-| **Mesh Group Index** | File listing for a group (node level, encrypted for private groups) |
-
----
-
-## 3. Protocol Versioning
-
-All protocols (MNP, MHP, hub REST API) carry explicit version information.
-
-**Format:** `MAJOR.MINOR`
-- MAJOR bump: breaking change, backward incompatible
-- MINOR bump: backward-compatible addition
-
-**Negotiation:** during handshake, both parties declare their supported version range. The highest mutually supported MINOR within the same MAJOR is used. If no common version exists, connection is refused with an explicit error.
-
-**Support policy:** a release supports the current MAJOR and at least the two previous MINOR versions (N-2).
-
-**Implementation:** a `version` field in every msgpack message header. Handshake step precedes all other exchanges.
-
----
-
-## 4. System Components
-
-### 4.1 Mesh Hub
-
-A lightweight server acting as a registrar. Intentionally minimal to limit legal exposure and operational cost.
-
-**What the hub stores:**
-- User accounts: username, email (stored for account recovery — see §4.1.1), optional phone number, `PK_user`, hub ID, status, creation timestamp
-- Group registry: name, `PK_group`, hosting node identifier, visibility, join policy, member list with encrypted GEK bundles (private groups only)
-- Mandatory connection logs (see §4.1.2)
-- Revocation lists (users and groups)
-- Registered peer hubs (explicit allowlist — no auto-discovery)
-
-**What the hub never stores:**
-- File content or metadata
-- Private group indexes
-- Message content
-- Node current IP (handled by ephemeral signaling — see §4.1.3)
-
-#### 4.1.1 Account Data
-
-Email is kept in full (not hashed) to support:
-- Account recovery (password reset)
-- Legal notifications
-- Abuse contact
-
-Phone number: optional, associable after account creation. On Android, both collected at registration. Accounts are fusionable (email + phone pointing to same account).
-
-Email and phone are stored encrypted at rest in the database.
-
-#### 4.1.2 Mandatory IP Logging (Legal Compliance)
-
-Legal frameworks (LCEN in France, EU e-Commerce Directive, DSA) require service providers to retain connection logs. The hub logs the following with timestamp and IP address:
-
-| Event | Retention |
-|---|---|
-| Account creation | 1 year minimum |
-| Login (success and failure) | 1 year minimum |
-| Group creation | 1 year minimum |
-| Group join / leave | 1 year minimum |
-| Group deletion | 1 year minimum |
-| Revocation actions | 1 year minimum |
-
-Logs are stored in a separate, access-controlled log table. They are not used for any purpose other than legal compliance and are not exposed to users or operators beyond legal requests.
-
-#### 4.1.3 Signaling Service
-
-NAT traversal coordination is handled by a lightweight WebSocket endpoint, logically separate from the main hub API. It is stateless: connection state is held in memory only and discarded after P2P connection establishment (typically within seconds). No persistent storage of node IP addresses.
-
-**Hub interaction summary:**
-
-| Event | Hub crypto load | Frequency |
-|---|---|---|
-| Account creation | Argon2 hash, store PK | Once |
-| Login | Verify password, issue JWT (Ed25519 sign) | Per session |
-| Group creation | Register metadata | Once per group |
-| Member add/remove | Store/remove GEK bundle | On admin action |
-| Group discovery | Return node address + PK_node + GEK bundle | Per initial access |
-| NAT signaling | Relay WebSocket messages (<1 KB) | Per new P2P connection |
-| Public search | Delegate to nodes, 60s in-memory cache | On demand |
-| MHP federation sync | Exchange Mesh Directory | Background, periodic |
-| Revocation | Ed25519-sign revocation token | Rare |
-
-**The hub is never in the data path after connection setup. JWT verification by nodes is local (Ed25519, no hub roundtrip).**
-
-#### 4.1.4 JWT Strategy
-
-Two tokens issued at login:
-
-**Access token** (JWT, signed Ed25519):
-- Validity: 1 hour
-- Payload: `user_id`, `PK_user`, `hub_id`, `issued_at`, `expires_at`, hub-signed groups membership claim
-- Presented to nodes for authentication and group access verification
-- Verified locally by nodes using the hub's known public key — no hub roundtrip
-- Compromise window: 1 hour maximum
-
-**Refresh token** (opaque, random 256-bit):
-- Validity: 30–90 days [TBD exact duration]
-- Stored securely on client only
-- Used exclusively with the hub to obtain a new access token
-- Revocable immediately by the hub (invalidates all future refreshes for this token)
-- Stored server-side as a hashed value
-
-**Revocation flow:** hub invalidates the refresh token → next access token renewal fails → node access expires within 1 hour at most.
-
-**Tech stack:**
-- Language: Python
-- Framework: FastAPI + Uvicorn
-- Database: PostgreSQL + SQLAlchemy + Alembic
-- Deployment: Apache reverse proxy (ProxyPass + SSL termination)
-- Authentication: own system (Ed25519 JWT, Argon2id for password hashing)
-- Hub accessible via domain and directly by IP (self-signed cert warning expected for IP access; documented)
-
-### 4.2 Mesh Node
-
-A local program running on the host user's machine. The node is the actual host of all content.
-
-**Responsibilities:**
-- Watch and index shared directories (Mesh Group Index)
-- Serve files and video streams to group members
-- Manage all cryptographic keys locally (encrypted keystore)
-- Handle P2P connections and NAT traversal
-- Run the MNP protocol
-- Host the Python extension module sandbox
-- Serve the local web UI (localhost:18000)
-- Host the group chat (core feature)
-
-**Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability.
-
-#### 4.2.1 Keystore and Unlock
-
-Private keys (user identity, group identity, GEK copies) are stored in a local encrypted keystore file.
-
-**Format:** msgpack container encrypted with AES-256-GCM, key derived from master password using Argon2id (tuned for ~1s derivation on target hardware).
-
-**Three unlock modes:**
-
-| Mode | How it works | Security level |
-|---|---|---|
-| **Secure (default)** | Password prompted at startup via terminal or local web UI | High |
-| **Lazy file** | Password or derived key stored in `~/.config/meshbay/unlock.key` (chmod 600), read automatically at startup | Medium — acceptable for physically secure home machines. Risk documented at setup. |
-| **Service (headless)** | `MESHBAY_UNLOCK_KEY` environment variable, set via systemd `EnvironmentFile=` pointing to a chmod 600 file | Medium-high — standard practice for server deployments |
-
-Future: OS keychain integration (libsecret/GNOME Keyring on Linux, Windows Credential Manager, macOS Keychain).
-
-#### 4.2.2 Hardware Sizing
-
-The main constraint is **upload bandwidth**, not CPU or RAM.
-
-| Scenario | Simultaneous users | Upload needed | CPU | RAM |
-|---|---|---|---|---|
-| Files + chat, minimal streaming | 10 | 20–50 Mbps | 2 cores | 512 MB |
-| Active 1080p streaming (5–6 streams) | 10 | 50–80 Mbps | 2–4 cores | 1 GB |
-| Mixed use | 50 | 200–300 Mbps | 4 cores | 2 GB |
-| Active streaming | 50 | 400 Mbps | 4–8 cores | 2–4 GB |
-| All use cases | 100 | 800 Mbps–1 Gbps | 8 cores | 4–8 GB |
-
-Beyond 20–30 active streaming users, a dedicated server is required. A home fiber connection (100–500 Mbps symmetric) is suitable for small groups.
-
-**Tech stack:**
-- Language: Python (primary). Rust extension only if a specific hot path proves insufficient.
-- Transport abstraction layer: `Transport` interface decouples QUIC from TCP+TLS fallback
-- QUIC: `aioquic` (Cloudflare-maintained). Fallback: TCP + TLS 1.3 + HTTP/2 if QUIC proves insufficient in production
-- ICE/STUN: `aioice`
-- WebRTC [future]: `aiortc`
-- Crypto: `cryptography` (PyCA, OpenSSL-backed, hardware-accelerated AES-NI/ChaCha)
-- Serialization: `msgpack`
-- Compression: `zstandard` (zstd)
-- File watching: `watchdog`
-- Local DB: SQLite
-- Local web UI: served by node on `localhost:18000`
-
-### 4.3 Mesh Client
-
-Web browser or Android app. Consumes content from nodes; manages account via hub.
-
-**Hub-side operations:**
-- Account creation and login (Android: email + phone at registration)
-- Public group search and discovery
-- Group membership management
-
-**Node-side operations (direct P2P):**
-- File browsing via Mesh Group Index
-- Group chat (messages + attachments, Signal-like — core feature)
-- File download
-- Video streaming (VOD)
-- [Future] Ephemeral video feed
-
-**Client modes** [to be designed]:
-- Explorer mode: file browser for group content
-- Feed mode: chat thread with attachments
-- Hub/node UI articulation to be defined; Android app will connect to node directly as a near-term priority after account creation
-
-### 4.4 Mesh Relay
-
-**[Future feature]** Community-operated TURN relay. Used only as last-resort fallback when all P2P connection methods fail (~15–20% of connections). Traffic is always E2E encrypted — the relay sees only opaque QUIC packets.
-
-Not operated by meshbay.org. A relay registration protocol (hub-mediated) will be designed when this feature is introduced. It does not affect the current design.
-
----
-
-## 5. Group Model
-
-Groups are the core organizational unit.
-
-| Parameter | Options |
-|---|---|
-| Visibility | Public / Private |
-| Join policy | Open / On request / By invitation only |
-| Admin | The hosting node operator (legal host) |
-
-A public group functions like a themed forum: files, chat thread, member list. Join policy is independent of visibility (a public group can require approval to join).
-
-A private group's content (files, index, messages) is always E2E encrypted with the GEK. Only members holding the GEK can decrypt anything.
-
-**Group addressing:**
-```
-meshbay.org/u/username/groupname — public group via hub
-meshbay.org/g/groupname — public group (shorthand)
-group://<PK_group_fingerprint>@<node_addr> — hub-less direct access
-```
-`meshbay.org` is fully configurable throughout the codebase (constant/config file). The hub is reachable via domain or IP (IP access requires self-signed cert; browsers will warn — expected and documented behavior).
-
----
-
-## 6. Cryptographic Architecture
-
-### 6.1 Key Hierarchy
-
-```
-User Identity Key Ed25519 Signing, authentication
-User Exchange Key X25519 Key agreement
-Group Identity Key Ed25519 Group metadata signing (held by admin node)
-Group Encryption Key ChaCha20 Private content and index encryption (symmetric, 256-bit)
-Session Keys X25519/HKDF Perfect forward secrecy per P2P connection
-```
-
-All private keys stored exclusively on the node (or client device) in the encrypted keystore. The hub never sees any private key.
-
-### 6.2 GEK Management
-
-**Scope:** GEK applies to private groups only. Public groups use TLS transport only (no application-layer encryption).
-
-**Group creation:**
-1. Admin node generates GEK (ChaCha20-Poly1305, 256-bit, CSPRNG)
-2. GEK encrypted for each member via X25519 key agreement + HKDF
-3. Encrypted GEK bundles stored on hub (opaque blobs — hub cannot decrypt them; charge is negligible: ~200–400 bytes per member per group)
-
-**Storing on hub rationale:** members can retrieve their GEK bundle even when the node is offline. Hub exposure is minimal — it stores ciphertext it cannot read.
-
-**Member addition:**
-- GEK encrypted with new member's `PK_user` and uploaded to hub
-
-**Member revocation:**
-- Admin node generates new GEK
-- Re-encrypts for all remaining members, uploads new bundles
-- New content encrypted with new GEK from this point
-- Former member can still decrypt previously received content (acceptable trade-off — full retroactive re-encryption not planned)
-
-### 6.3 On-the-Fly Encryption for File Transfer
-
-Files are stored in plaintext on the host's disk. The node encrypts at read time.
-
-```
-Disk (plaintext) → zstd compress → GEK encrypt (per-chunk) → QUIC session → Client → QUIC decrypt → GEK decrypt → plaintext
-```
-
-**Chunking:**
-- Chunk size: 1 MB (amortizes AEAD overhead; enables seeking)
-- Per-chunk key derivation: `chunk_key = HKDF(GEK, "file:" || blake3(file) || "chunk:" || index)`
-- Each chunk independently decryptable → enables VOD seeking
-- Compress before encrypt (compression is ineffective on ciphertext)
-
-**Chunk authentication:** each chunk signed with the node's Ed25519 key. Client verifies before decryption. Prevents data injection by compromised relay.
-
-**Encryption optimization:**
-- `cryptography` (PyCA) uses OpenSSL under the hood, bypasses Python GIL for crypto ops
-- ChaCha20-Poly1305: ~500 MB/s on hardware without AES-NI; AES-256-GCM: >2 GB/s with AES-NI
-- For typical home node (50 Mbps upload = 6 MB/s), encryption is not the bottleneck
-- For high-concurrency scenarios: asyncio pipeline (read → compress → encrypt → send) without loading full files into memory
-- GEK-derived chunk keys computed in batch at transfer start, not per-chunk
-
-### 6.4 Transport Security
-
-- Primary: **QUIC** (TLS 1.3 integrated, UDP, multiplexed streams)
-- Fallback: **TCP + TLS 1.3 + HTTP/2** (same application protocol, lower performance)
-- Transport interface abstracted in code — swappable without protocol changes
-- Per-connection session keys via X25519 ECDH + HKDF (independent of GEK layer)
-
-### 6.5 Chat Encryption
-
-Group chat is a **core feature** (not an extension module). Uses the **Double Ratchet algorithm** (as in Signal):
-- Forward secrecy and break-in recovery per message
-- Each message independently encrypted
-- Attachment files: encrypted with the current Double Ratchet message key, hash included in message
-- Python implementation: [TBD — evaluate existing libraries]
-
----
-
-## 7. Network and Connectivity
-
-### 7.1 NAT Traversal — Attempt Order
-
-```
-1. IPv6 available on both sides → direct connection
-2. UPnP / NAT-PMP on router → node opens port automatically
-3. ICE + STUN / UDP hole punching → ~80–85% success rate
-4. Mesh Relay (TURN) → [future feature]
-```
-
-Without step 4, ~15% of connections between symmetric-NAT peers will fail. This is documented behavior until Mesh Relay is implemented.
-
-Signaling (step 3): coordinated via hub WebSocket endpoint, <1 KB per attempt, no persistent state.
-
-### 7.2 MNP — Mesh Node Protocol
-
-Application-level protocol over QUIC (or TCP+TLS fallback). All messages carry a `version` field.
-
-**Defined message types:**
-
-| Type | Description |
-|---|---|
-| `handshake` | Key exchange, JWT presentation, version negotiation |
-| `index_sync` | Encrypted Mesh Group Index delta |
-| `file_request` | Request chunk(s) of a file by hash + chunk index |
-| `file_chunk` | Chunk data + signature |
-| `stream_segment` | HLS/DASH segment (VOD), encrypted with GEK-derived key |
-| `chat_message` | Double Ratchet encrypted message frame |
-| `chat_attachment` | Attachment metadata + key; data transferred as file chunks |
-| `ephemeral_stream` | [reserved, future] Ephemeral video with TTL metadata |
-
-### 7.3 Public Content Delivery — Swarm
-
-Public files identified by `blake3` hash. Multiple nodes can serve the same file:
-
-1. Any node that has a public file and chooses to mirror it registers: `{ hash → node_address }` with the hub
-2. Hub maintains a source table: `{ blake3_hash → [node_A, node_B, ...] }`
-3. Client requests file → hub returns source list → client fetches chunks in parallel from multiple nodes
-4. Integrity verified by blake3 hash on each chunk
-
-**Transport:** TLS only for public content (no GEK). Content signed with the original node's Ed25519 key — clients verify authenticity even when served from a mirror.
-
----
-
-## 8. Indexes
-
-### 8.1 Mesh Directory (hub level)
-
-Public registry of groups, exchanged between hubs via MHP.
-
-Format: `msgpack`, signed with hub's Ed25519 key, carries `version` field.
-
-Fields per entry: group name, `PK_group`, hosting hub, description, content type tags, join policy, creation date.
-
-### 8.2 Mesh Group Index (node level)
-
-File listing for a group. Generated and maintained by the hosting node.
-
-Format: `msgpack` → `zstd` → GEK-encrypted (private groups) or plaintext + Ed25519 signature (public groups).
-
-Entry structure:
-```python
-{
- "version": 1,
- "id": "<blake3_hash>",
- "name": "filename.mkv",
- "path": "Movies/2024/",
- "size": 4294967296,
- "type": "video", # video | audio | image | document | archive | other
- "duration": 7245, # seconds, for media
- "thumb_hash": "<blake3>", # thumbnail also GEK-encrypted
- "added_at": 1720000000
-}
-```
-
-Delta updates: `{ base_version, additions, deletions }` — no full re-encryption on each change.
-
-Transit: nodes push index deltas to connected members on change; members pull full index on first connection. Hub stores no index content.
-
-### 8.3 Search
-
-**Private groups:** entirely local on the client device. Client maintains a local encrypted cache of all group indexes it has received. No network call, no hub involvement, instant.
-
-**Public groups:** client queries nodes directly at request time. Hub provides routing only.
-
-**Hub web UI search:** delegates query to relevant nodes at request time. Hub stores nothing from this. In-memory micro-cache: **60-second TTL, RAM only, never persisted to disk, public content only.** Qualifies as technical caching under EU DSA Article 13 — not indexing.
-
----
-
-## 9. Hub Federation (MHP)
-
-### 9.1 Hub Hierarchy
-
-```
-Root Hub (meshbay.org)
- ├── Full Hub (self-hosted, delegated CA)
- │ └── issues user credentials, manages own groups
- │ └── federates with other Full Hubs via MHP
- └── Mirror Hub
- └── hosts public Mesh Directory only (no user accounts, no key issuance)
-```
-
-A Full Hub receives a certificate signed by the Root Hub (or a parent Full Hub). Mirror Hubs can only replicate public directory data. Promotion/demotion is possible without breaking the protocol.
-
-### 9.2 MHP Design
-
-- Explicit peer selection: each hub maintains an allowlist of trusted peers
-- No automatic hub discovery
-- Exchanged: Mesh Directory (public groups), revocation lists, cross-hub user authentication data
-- All MHP messages carry `version` field
-
-### 9.3 Cross-Hub Client Access
-
-1. Client (Hub A user) discovers a group on Hub B via Mesh Directory or direct link
-2. Client presents Hub A JWT directly to Hub B
-3. Hub B verifies JWT using Hub A's public key (fetched once, cached)
-4. Hub B issues short-lived local session token
-5. Client connects to node as normal
-
----
-
-## 10. Moderation
-
-### 10.1 Public Content
-
-```
-Report #1 → automatic suspension of public access
- → node operator notified
-One republication allowed
-Report #2 → escalated to hub moderators
-Confirmed → group revoked on local hub
- → revocation propagated to federated hubs via MHP
-```
-
-Mechanism: `blake3` hash added to hub blocklist. Signed revocation token sent to node.
-
-### 10.2 CSAM
-
-Hash matching against NCMEC/IWF database on public content at registration time. No scanning of private/encrypted content. Participation is mandatory for hub operators and reduces legal exposure.
-
-### 10.3 Copyright
-
-DMCA/legal notice framework. Takedown on notification. No automated technical blocking (false positive risk, fair use). Hub can revoke on confirmed legal request.
-
-### 10.4 Private Content
-
-Not directly moderatable (E2E encrypted). Action available: revoke user or group at hub level on formal legal request. Hub issues Ed25519-signed revocation token verifiable by all member nodes offline.
-
----
-
-## 11. Python Extension Module System
-
-The node loads extension modules (Python) in a sandboxed subprocess. **Chat is a core built-in feature, not a module.**
-
-**Module manifest:**
-```python
-{
- "name": "my-extension",
- "version": "1.0.0",
- "mnp_version": ">=1.0",
- "permissions": ["read_index", "send_message", "receive_events"]
-}
-```
-
-**Available APIs:**
-- `read_index()` — read current group index (read-only)
-- `send_message(content)` — post to group thread
-- `receive_events(handler)` — subscribe to group events
-
-**Unavailable:** arbitrary network, filesystem access outside group context, system calls.
-
----
-
-## 12. Legal Framework
-
-**Node operator:** primary legal host of content. Fully responsible for what they share. Node setup communicates this explicitly.
-
-**Hub operator (meshbay.org):** registrar, not content host. Stores minimal data. Operates takedown mechanism. Participates in CSAM hash matching. Legal exposure analogous to a domain registrar.
-
-**Protocol/software author:** protected by substantial non-infringing uses.
-
-**Hub data:**
-- Email and optional phone: kept for account recovery and legal compliance
-- Password: Argon2id hash, never stored in cleartext
-- Connection logs: retained per legal requirements (minimum 1 year)
-- Content metadata: never stored
-- Node current IP: not persisted (signaling is ephemeral)
-
----
-
-## 13. Future Features
-
-- **Mesh Relay:** community TURN relays, relay registration protocol via hub, E2E encrypted traffic
-- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
-- **Hub mirror (load balancing):** full hub replication (user DB, group registry, GEK bundles) for load distribution. Requires distributed DB strategy (PostgreSQL streaming replication or equivalent). Complex — design when needed.
-- **Mobile video push → node:** mobile films → pushes to hosting node → ephemeral stream with TTL to group members. MNP `ephemeral_stream` type reserved.
-- **Node–mobile pairing:** QR code from local web UI
-- **Multi-source download:** parallel chunk fetching from swarm for public files
-- **iOS client**
-- **At-rest encryption on node:** optional for server-deployed nodes
-- **OS keychain integration for keystore unlock**
-
----
-
-## 14. Open Questions [TBD]
-
-1. **Refresh token validity:** 30 or 90 days?
-2. **Group address scheme:** final URL format confirmation
-3. **Double Ratchet library:** identify best Python implementation
-4. **GEK bundle location for groups with mixed access** (public-restricted): hub or node?
-5. **MHP federation sync frequency and conflict resolution**
-6. **Hub mirror replication strategy** (when implemented)
-7. **Chat attachment storage:** stored on node like regular files, or separate store?
-8. **Relay registration protocol design** (when implemented)
-9. **JWT payload claims:** exact fields to include for node group-access verification
-10. **Argon2id parameters:** tuning for target hardware (home server vs. VPS)
-
-
----
-
-<!-- ============================================================ -->
-<!-- ARCHIVED: Architecture Draft v3 (was docs/meshbay-draft-v3.md) -->
-<!-- ============================================================ -->
-
-# MeshBay — Architecture Draft v3
-
-> Status: preliminary draft — open points marked [TBD]
-> Changes from v2: jti mandatory in JWT (Spike 3), Argon2id params corrected (Spike 1), NAT traversal order corrected (Spike 4), transport flipped to TCP+TLS 1.3 v1 / QUIC v2, GEK wrapping protocol confirmed with exact parameters (Spike 6), hub API table expanded with 4 new endpoints (Spike 6), package structure decided (3 packages, uv monorepo), key persistence requirement added (Spike 6), new sections: Hub API Reference, TCP+TLS Transport v1, Package Structure.
-
----
-
-## Changes from v2
-
-The following items are **mandatory corrections** driven by POC findings (spikes 1–6). They supersede the corresponding text in v2.
-
-| # | Category | What changed | Source |
-|---|---|---|---|
-| 1 | JWT | `jti` (UUID4) is now **required** in every access token — prevents replay and enables individual revocation. Without it, two tokens issued in the same second are bit-for-bit identical (Ed25519 is deterministic). | Spike 3 |
-| 2 | Argon2id | Parameters updated: `iterations=4`, `memory_cost=262144` (256 MB). Previous params (iterations=3, 64 MB) gave 78 ms — too fast. Target is 500 ms on a home server. CLI calibration command added. | Spike 1 |
-| 3 | NAT traversal | Order corrected: IPv6 → **STUN/hole-punching** → UPnP → TURN relay. UPnP moved to step 3 (disabled on tested SFR box). STUN is now priority 2, not UPnP. | Spike 4 |
-| 4 | Transport | TCP + TLS 1.3 is now the **v1 implementation**. QUIC is the v2 target. The v2 architecture doc had this reversed (QUIC primary, TCP fallback). A `Transport` abstraction layer ensures the switch requires no protocol-layer changes. | Spike 5 |
-| 5 | GEK wrapping | Exact protocol confirmed: ephemeral X25519 + `HKDF(salt=pk_eph, info="meshbay:gek_wrap:v1")` + `ChaCha20-Poly1305(aad=pk_recipient)`. Hub stores opaque 48-byte blobs. | Spike 6 |
-| 6 | Hub API | Four new endpoints validated in Spike 6: `GET /v1/users/{username}/pubkeys`, `POST /v1/groups`, `POST /v1/groups/{group_id}/members/{username}/gek`, `GET /v1/groups/{group_id}/gek`. Full table added as §4.1.5. | Spike 6 |
-| 7 | Packages | Repository structure decided: 3 packages (`meshbay-common`, `meshbay-hub`, `meshbay-node`) in a uv workspace monorepo. RPM package names defined. | POC structure |
-| 8 | Key persistence | X25519 keypairs **must be persisted** client-side before the first hub contact. Lesson from Spike 6 (`bob_state.json` fix). | Spike 6 |
-
----
-
-## 1. Project Overview
-
-MeshBay is a decentralized, peer-to-peer platform for file sharing, video streaming, and group messaging. It combines identity federation (via Mesh Hubs) with truly peer-to-peer data exchange (via Mesh Nodes), designed to be resilient, censorship-resistant, and user-friendly.
-
-**Core principles:**
-- Data never transits through a central server — only identity and routing do
-- End-to-end encryption for all private content (files, indexes, messages)
-- The node operator is the legal host and is fully responsible for their content
-- The hub is a lightweight registrar, not a content host or indexer
-- Open source, self-hostable at every level
-
-**Domain:** meshbay.org (configurable at compile/deploy time throughout the codebase)
-
----
-
-## 2. Terminology
-
-| Term | Role |
-|---|---|
-| **Mesh Hub** | Identity authority and group registry server |
-| **Mesh Node** | Local program on the host user's machine |
-| **Mesh Client** | Web browser or Android app (end user) |
-| **Mesh Relay** | Community-operated TURN fallback relay [future] |
-| **MNP** | Mesh Node Protocol — P2P protocol between nodes and clients |
-| **MHP** | Mesh Bay Hub Protocol — inter-hub federation protocol |
-| **GEK** | Group Encryption Key — symmetric key for private group content |
-| **Mesh Directory** | Public registry of groups (hub level) |
-| **Mesh Group Index** | File listing for a group (node level, encrypted for private groups) |
-
----
-
-## 3. Protocol Versioning
-
-All protocols (MNP, MHP, hub REST API) carry explicit version information.
-
-**Format:** `MAJOR.MINOR`
-- MAJOR bump: breaking change, backward incompatible
-- MINOR bump: backward-compatible addition
-
-**Negotiation:** during handshake, both parties declare their supported version range. The highest mutually supported MINOR within the same MAJOR is used. If no common version exists, connection is refused with an explicit error.
-
-**Support policy:** a release supports the current MAJOR and at least the two previous MINOR versions (N-2).
-
-**Implementation:** a `version` field in every msgpack message header. Handshake step precedes all other exchanges.
-
----
-
-## 4. System Components
-
-### 4.1 Mesh Hub
-
-A lightweight server acting as a registrar. Intentionally minimal to limit legal exposure and operational cost.
-
-**What the hub stores:**
-- User accounts: username, email (stored for account recovery — see §4.1.1), optional phone number, `PK_user` (Ed25519 + X25519), hub ID, status, creation timestamp
-- Group registry: name, `PK_group`, hosting node identifier, visibility, join policy, member list with encrypted GEK bundles (private groups only)
-- Mandatory connection logs (see §4.1.2)
-- Revocation lists (users and groups)
-- Registered peer hubs (explicit allowlist — no auto-discovery)
-
-**What the hub never stores:**
-- File content or metadata
-- Private group indexes
-- Message content
-- Node current IP (handled by ephemeral signaling — see §4.1.3)
-
-#### 4.1.1 Account Data
-
-Email is kept in full (not hashed) to support:
-- Account recovery (password reset)
-- Legal notifications
-- Abuse contact
-
-Phone number: optional, associable after account creation. On Android, both collected at registration. Accounts are fusionable (email + phone pointing to same account).
-
-Email and phone are stored encrypted at rest in the database, using a server-side key derived from the hub's configuration secret (not the database). **[NOT YET IMPLEMENTED — currently stored in plaintext. Tracked as open question #10.]**
-
-#### 4.1.2 Mandatory IP Logging (Legal Compliance)
-
-Legal frameworks (LCEN in France, EU e-Commerce Directive, DSA) require service providers to retain connection logs. The hub logs the following with timestamp and IP address:
-
-| Event | Retention |
-|---|---|
-| Account creation | 1 year minimum |
-| Login (success and failure) | 1 year minimum |
-| Group creation | 1 year minimum |
-| Group join / leave | 1 year minimum |
-| Group deletion | 1 year minimum |
-| Revocation actions | 1 year minimum |
-
-Logs are stored in a separate, access-controlled log table. They are not used for any purpose other than legal compliance and are not exposed to users or operators beyond legal requests.
-
-#### 4.1.3 Signaling Service
-
-NAT traversal coordination is handled by a lightweight WebSocket endpoint, logically separate from the main hub API. It is stateless: connection state is held in memory only and discarded after P2P connection establishment (typically within seconds). No persistent storage of node IP addresses.
-
-**Hub interaction summary:**
-
-| Event | Hub crypto load | Frequency |
-|---|---|---|
-| Account creation | Argon2 hash, store PK | Once |
-| Login | Verify password, issue JWT (Ed25519 sign) | Per session |
-| Group creation | Register metadata | Once per group |
-| Member add/remove | Store/remove GEK bundle | On admin action |
-| Group discovery | Return node address + PK_node + GEK bundle | Per initial access |
-| NAT signaling | Relay WebSocket messages (<1 KB) | Per new P2P connection |
-| Public search | Delegate to nodes, 60s in-memory cache | On demand |
-| MHP federation sync | Exchange Mesh Directory | Background, periodic |
-| Revocation | Ed25519-sign revocation token | Rare |
-
-**The hub is never in the data path after connection setup. JWT verification by nodes is local (Ed25519, no hub roundtrip). Confirmed at 884 µs in Spike 3.**
-
-#### 4.1.4 JWT Strategy
-
-Two tokens issued at login:
-
-**Access token** (JWT, signed Ed25519):
-- Validity: 1 hour
-- Payload: `jti` (UUID4, **mandatory** — unique per token, enables individual revocation and prevents replay), `user_id`, `PK_user`, `PK_user_x25519`, `hub_id`, `issued_at`, `expires_at`, `groups` (list of group_ids the user is a member of — hub-signed membership claim)
-- The `groups` claim is **mandatory** for node-side authorization: the node checks that the requested group_id appears in the JWT before serving any content. Without this claim, any authenticated user could access any group on the node.
-- Presented to nodes for authentication and group access verification
-- Verified locally by nodes using the hub's known public key — no hub roundtrip
-- Compromise window: 1 hour maximum
-
-> **Why `jti` is mandatory:** Ed25519 signing is deterministic. Two tokens with identical payloads issued within the same second produce the same byte sequence. Without a `jti`, they are indistinguishable — a captured token is replayable forever within its validity window, and individual revocation is impossible. The `jti` also provides the revocation handle: hub stores `jti` of invalidated tokens in a server-side denylist.
->
-> This bug was found and fixed during Spike 3.
-
-**Refresh token** (opaque, random 256-bit):
-- Validity: 30–90 days [TBD exact duration]
-- Stored securely on client only
-- Used exclusively with the hub to obtain a new access token
-- Revocable immediately by the hub (invalidates all future refreshes for this token)
-- Stored server-side as a hashed value
-
-**Revocation flow:** hub invalidates the refresh token → next access token renewal fails → node access expires within 1 hour at most. For immediate revocation of an active access token: hub adds its `jti` to the token denylist; nodes that cache hub public key will periodically fetch the denylist.
-
-**Tech stack:**
-- Language: Python
-- Framework: FastAPI + Uvicorn
-- Database: PostgreSQL + SQLAlchemy + Alembic
-- Deployment: Apache reverse proxy (ProxyPass + SSL termination)
-- Authentication: own system (Ed25519 JWT, Argon2id for password hashing)
-- Hub accessible via domain and directly by IP (self-signed cert warning expected for IP access; documented)
-
-#### 4.1.5 Hub API Reference
-
-Complete table of validated and planned hub REST API endpoints. Endpoints marked ✓ were validated in the POC; endpoints marked [TBD] are designed but not yet implemented.
-
-**Hub metadata:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| GET | `/v1/hub/info` | None | Hub metadata: hub_id, versions, counters | ✓ Spike 2 |
-| GET | `/v1/hub/pubkey` | None | Hub Ed25519 public key (PEM) | ✓ Spike 2 |
-
-**User management:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/users/register` | None | Create user account (username, email, password, pk_ed25519, pk_x25519) | ✓ Spike 2 |
-| POST | `/v1/users/login` | None | Authenticate; returns access token + refresh token | ✓ Spike 2 |
-| POST | `/v1/users/token/refresh` | Refresh token | Issue new access token | ✓ Spike 2 |
-| GET | `/v1/users/{username}/pubkeys` | Access token | Fetch `pk_ed25519` and `pk_x25519` for a user (used for GEK wrapping) | ✓ Spike 6 |
-
-**Node management:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/nodes/announce` | Access token | Register node with endpoint_hint; returns node_id | ✓ Spike 2 |
-| GET | `/v1/nodes/{node_id}` | Access token | Retrieve node record (pk_node, endpoint_hint) | ✓ Spike 2 |
-
-**Group management:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/groups` | Access token | Create group (name, visibility, join_policy, pk_group) | ✓ Spike 6 |
-| GET | `/v1/groups` | None / Access token | List/search public groups; private groups require membership | [TBD] |
-| GET | `/v1/groups/{group_id}` | None / Access token | Group metadata | [TBD] |
-| DELETE | `/v1/groups/{group_id}` | Access token (admin) | Revoke group | [TBD] |
-
-**GEK distribution (private groups):**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/groups/{group_id}/members/{username}/gek` | Access token (admin) | Upload opaque 48-byte GEK bundle for a member | ✓ Spike 6 |
-| GET | `/v1/groups/{group_id}/gek` | Access token (member) | Retrieve caller's GEK bundle | ✓ Spike 6 |
-
-**Revocation:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/revoke/user/{user_id}` | Access token (admin) | Revoke a user account | [TBD] |
-| POST | `/v1/revoke/group/{group_id}` | Access token (admin) | Revoke a group | [TBD] |
-| GET | `/v1/revoke/denylist` | None | Current `jti` denylist for active access tokens | [TBD] |
-
-### 4.2 Mesh Node
-
-A local program running on the host user's machine. The node is the actual host of all content.
-
-**Responsibilities:**
-- Watch and index shared directories (Mesh Group Index) — one directory per group
-- Serve files, video streams, and group chat to members
-- Manage all cryptographic keys locally (encrypted keystore)
-- Handle P2P connections and NAT traversal (STUN + QUIC hole punching)
-- Run the MNP protocol (QUIC v2, TCP+TLS v1)
-- Host the Python extension module sandbox
-- Serve the local web UI (localhost:18000)
-
-**Multi-group architecture (decided Phase 7):**
-A node exposes **one QUIC port** for all groups it hosts. Groups are not isolated
-by port — the MNP handshake identifies the target group via the `group_id` claim
-in the client JWT. The server routes each connection to the appropriate
-DirectoryIndexer and GEK after JWT verification.
-Rationale: one NAT hole to maintain, one port to forward manually if needed.
-
-**Authorization invariant:** the node MUST verify that the JWT's `groups` claim
-contains the requested group_id before serving any content. Without this check,
-any authenticated user could access any group on the node. This is enforced at
-the MNP handshake layer, not the transport layer.
-
-**Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability.
-
-#### 4.2.1 Keystore and Unlock
-
-Private keys (user identity Ed25519, user exchange X25519, group identity Ed25519, GEK copies) are stored in a local encrypted keystore file.
-
-**Format:** msgpack container encrypted with AES-256-GCM, key derived from master password using Argon2id.
-
-**Argon2id parameters (production):**
-- `iterations = 4`
-- `memory_cost = 262144` (256 MB)
-- `parallelism = 1` (or match CPU count — tune to target hardware)
-- Target derivation time: ~500 ms on a home server
-
-> **Why these parameters:** Spike 1 measured iterations=3, memory=64 MB at 78 ms — far too fast. At 78 ms an attacker can attempt millions of guesses per second-equivalent with a GPU cluster. The target of 500 ms on a home server limits offline dictionary attacks to a tractable rate while remaining acceptable for a node that unlocks once at startup.
-
-**CLI calibration:**
-```
-meshbay-node --calibrate-argon2
-```
-This command iterates through parameter combinations and reports the derivation time on the current hardware. The operator selects parameters meeting the 500 ms target and stores them in `~/.config/meshbay/node.toml`. Recommended starting point: `iterations=4, memory_cost=262144`.
-
-**Key persistence requirement:** All keypairs (Ed25519 + X25519) **must be written to the keystore before the first hub contact.** If keypairs are generated at registration time but not persisted before the hub call, subsequent runs will regenerate different keypairs, making all stored GEK bundles on the hub undecryptable. This was identified as a real failure mode in Spike 6 (`bob_state.json` fix).
-
-**Three unlock modes:**
-
-| Mode | How it works | Security level |
-|---|---|---|
-| **Secure (default)** | Password prompted at startup via terminal or local web UI | High |
-| **Lazy file** | Password or derived key stored in `~/.config/meshbay/unlock.key` (chmod 600), read automatically at startup | Medium — acceptable for physically secure home machines. Risk documented at setup. |
-| **Service (headless)** | `MESHBAY_UNLOCK_KEY` environment variable, set via systemd `EnvironmentFile=` pointing to a chmod 600 file | Medium-high — standard practice for server deployments |
-
-Future: OS keychain integration (libsecret/GNOME Keyring on Linux, Windows Credential Manager, macOS Keychain).
-
-#### 4.2.2 Hardware Sizing
-
-The main constraint is **upload bandwidth**, not CPU or RAM.
-
-| Scenario | Simultaneous users | Upload needed | CPU | RAM |
-|---|---|---|---|---|
-| Files + chat, minimal streaming | 10 | 20–50 Mbps | 2 cores | 512 MB |
-| Active 1080p streaming (5–6 streams) | 10 | 50–80 Mbps | 2–4 cores | 1 GB |
-| Mixed use | 50 | 200–300 Mbps | 4 cores | 2 GB |
-| Active streaming | 50 | 400 Mbps | 4–8 cores | 2–4 GB |
-| All use cases | 100 | 800 Mbps–1 Gbps | 8 cores | 4–8 GB |
-
-Beyond 20–30 active streaming users, a dedicated server is required. A home fiber connection (100–500 Mbps symmetric) is suitable for small groups.
-
-Crypto overhead is confirmed negligible: Spike 5 measured full encrypt+sign and verify+decrypt at under 10 ms for a 1 MB chunk. Network latency dominates.
-
-**Tech stack:**
-- Language: Python (primary). Rust extension only if a specific hot path proves insufficient.
-- Transport abstraction layer: `Transport` interface decouples TCP+TLS 1.3 (v1) from QUIC (v2). Application protocol is identical across both transports.
-- v1 transport: **TCP + TLS 1.3** (`asyncio` + `ssl` module, standard library)
-- v2 transport (future): **QUIC** (`aioquic`, Cloudflare-maintained)
-- ICE/STUN: `aioice`
-- WebRTC [future]: `aiortc`
-- Crypto: `cryptography` (PyCA, OpenSSL-backed, hardware-accelerated AES-NI/ChaCha)
-- Serialization: `msgpack`
-- Compression: `zstandard` (zstd)
-- File watching: `watchdog`
-- Local DB: SQLite
-- Local web UI: served by node on `localhost:18000`
-
-### 4.3 Mesh Client
-
-Web browser or Android app. Consumes content from nodes; manages account via hub.
-
-**Hub-side operations:**
-- Account creation and login (Android: email + phone at registration)
-- Public group search and discovery
-- Group membership management
-
-**Node-side operations (direct P2P):**
-- File browsing via Mesh Group Index
-- Group chat (messages + attachments, Signal-like — core feature)
-- File download
-- Video streaming (VOD)
-- [Future] Ephemeral video feed
-
-**Client modes** [to be designed]:
-- Explorer mode: file browser for group content
-- Feed mode: chat thread with attachments
-- Hub/node UI articulation to be defined; Android app will connect to node directly as a near-term priority after account creation
-
-### 4.4 Mesh Relay
-
-**[Future feature]** Community-operated TURN relay. Used only as last-resort fallback when all P2P connection methods fail (symmetric NAT behind CGNAT, approximately 15–20% of connections in the worst case). Traffic is always E2E encrypted — the relay sees only opaque ciphertext.
-
-Not operated by meshbay.org. A relay registration protocol (hub-mediated) will be designed when this feature is introduced. It does not affect the current design.
-
-### 4.5 Package Structure and Repository
-
-**Repository layout:** monorepo managed with [uv workspaces](https://docs.astral.sh/uv/concepts/workspaces/).
-
-```
-meshbay/
-├── packages/
-│ ├── meshbay-common/ # Shared crypto, serialization, protocol types
-│ ├── meshbay-hub/ # Hub server (FastAPI + Uvicorn)
-│ └── meshbay-node/ # Node daemon + local web UI
-├── poc/ # POC and spikes — reference implementation
-│ ├── spike1_crypto/
-│ ├── spike2_hub/
-│ ├── spike3_node_reg/
-│ ├── spike4_nat/
-│ ├── spike5_transfer/
-│ ├── spike6_gek/
-│ └── spike-results.md
-├── docs/
-│ └── meshbay-draft-v3.md
-└── pyproject.toml # Workspace root
-```
-
-**Three packages:**
-
-| Package | RPM name | Contents |
-|---|---|---|
-| `meshbay-common` | `python3-meshbay-common` | Crypto primitives (Ed25519, X25519, ChaCha20, Argon2, HKDF), msgpack schemas, protocol constants, MNP message types |
-| `meshbay-hub` | `python3-meshbay-hub` | FastAPI hub application, database models (SQLAlchemy), Alembic migrations, JWT issuance, GEK bundle storage |
-| `meshbay-node` | `python3-meshbay-node` | Node daemon, keystore, file watcher, TCP+TLS transport, local web UI, extension module sandbox |
-
-**`meshbay-hub` and `meshbay-node` both depend on `meshbay-common`.** There is no runtime dependency between hub and node packages.
-
-**POC directory as reference implementation:** The `poc/` directory contains the working code from spikes 1–6. It is not production code and not packaged, but serves as the canonical reference for:
-- Exact crypto parameter choices (Spike 1)
-- GEK wrapping/unwrapping implementation (Spike 6)
-- Hub API skeleton (Spike 2)
-- NAT detection and STUN interaction (Spike 4)
-- TCP file transfer pipeline (Spike 5)
-
-Developers implementing production features should read the corresponding spike before writing production code.
-
----
-
-## 5. Group Model
-
-Groups are the core organizational unit.
-
-| Parameter | Options |
-|---|---|
-| Visibility | Public / Private |
-| Join policy | Open / On request / By invitation only |
-| Admin | The hosting node operator (legal host) |
-
-A public group functions like a themed forum: files, chat thread, member list. Join policy is independent of visibility (a public group can require approval to join).
-
-A private group's content (files, index, messages) is always E2E encrypted with the GEK. Only members holding the GEK can decrypt anything.
-
-**Group addressing:**
-```
-meshbay.org/u/username/groupname — public group via hub
-meshbay.org/g/groupname — public group (shorthand)
-group://<PK_group_fingerprint>@<node_addr> — hub-less direct access
-```
-`meshbay.org` is fully configurable throughout the codebase (constant/config file). The hub is reachable via domain or IP (IP access requires self-signed cert; browsers will warn — expected and documented behavior).
-
----
-
-## 6. Cryptographic Architecture
-
-### 6.1 Key Hierarchy
-
-```
-User Identity Key Ed25519 Signing, authentication
-User Exchange Key X25519 Key agreement (GEK wrapping, session ECDH)
-Group Identity Key Ed25519 Group metadata signing (held by admin node)
-Group Encryption Key ChaCha20 Private content and index encryption (symmetric, 256-bit)
-Session Keys X25519/HKDF Perfect forward secrecy per P2P connection
-```
-
-All private keys stored exclusively on the node (or client device) in the encrypted keystore. The hub never sees any private key.
-
-Both `PK_ed25519` and `PK_x25519` are registered with the hub at account creation. The hub exposes them via `GET /v1/users/{username}/pubkeys` so that group admins can wrap GEK bundles for members without any direct contact between nodes.
-
-### 6.1.1 Key Generation Strategies
-
-Three strategies, depending on client type:
-
-**A — CLI / native node (Argon2id derivation)**
-Keys are derived deterministically from `username + password`:
-```
-salt = SHA-256("meshbay:v1:" + username)
-seed = Argon2id(password, salt, length=64)
-sk_ed25519 = Ed25519.from_private_bytes(seed[:32])
-sk_x25519 = X25519.from_private_bytes(seed[32:])
-```
-Same credentials → same keys on any machine. Password recovery = key recovery.
-Implemented in `meshbay_common/keyderive.py::derive_keys_from_password()`.
-
-**B — Web browser (random keypairs + encrypted bundle)**
-Browser generates random keypairs via WebCrypto `generateKey()`, encrypts them
-with a PBKDF2-SHA512 derived key, and uploads the encrypted bundle to the hub
-alongside the public keys. On subsequent logins, the hub returns the bundle
-and the browser decrypts it locally with the password.
-
-The hub stores `keypair_bundle` (AES-256-GCM ciphertext) — opaque, cannot decrypt it.
-Implemented in `static/keyderive.js`. Python side in `keyderive.py::encrypt_keypair_bundle()`.
-
-**C — Native node with keystore file**
-Random keypairs generated once, stored in the Argon2id-encrypted keystore file
-(`~/.config/meshbay/keystore.enc`). Standard operating mode for `meshbay-node`.
-
-**Algorithm mismatch note:** strategies A and B use different KDFs (Argon2id vs PBKDF2).
-A user who registered via CLI (A) and later tries to recover via web (B) with the same
-password will get different keypairs. This is by design: users pick one registration path.
-Cross-path recovery requires the admin to issue new GEK bundles.
-
-### 6.2 GEK Management
-
-**Scope:** GEK applies to private groups only. Public groups use TLS transport only (no application-layer encryption).
-
-**GEK wrapping protocol (ECIES-like, confirmed in Spike 6):**
-
-```
-Admin side (wrap_gek):
- sk_eph, pk_eph = X25519.generate() # fresh ephemeral keypair per bundle
- shared = X25519(sk_eph, pk_recipient)
- wrap_key = HKDF(shared, salt=pk_eph,
- info="meshbay:gek_wrap:v1",
- length=32)
- nonce = random_bytes(12)
- wrapped = ChaCha20-Poly1305(wrap_key).encrypt(
- nonce, gek, aad=pk_recipient) # aad binds bundle to recipient
- bundle = pk_eph || nonce || wrapped # 32 + 12 + 32+16 = 92 bytes on wire
- # hub stores as opaque 48-byte blob
- # (without pk_eph in compact form — see note)
-
-Member side (unwrap_gek):
- shared = X25519(sk_recipient, pk_eph)
- wrap_key = HKDF(shared, salt=pk_eph,
- info="meshbay:gek_wrap:v1",
- length=32)
- gek = ChaCha20-Poly1305(wrap_key).decrypt(
- nonce, wrapped, aad=pk_recipient)
-```
-
-> **Hub-stored blob size:** the hub stores the opaque bundle. Spike 6 confirmed the hub stores 48-byte blobs (nonce=12 + ciphertext=20 + tag=16 in the compact wire format used in the spike — `pk_eph` is stored separately in the bundle record). Production schema: hub bundle record = `{ pk_eph (32B), nonce (12B), ciphertext (32B), tag (16B) }` = 92 bytes total per member per group, stored as a single column.
-
-**Security properties confirmed in Spike 6:**
-- Hub never sees the GEK in cleartext
-- Ephemeral keypair is unique per bundle — same GEK and same recipient produce different ciphertext across calls
-- AAD (`pk_recipient`) binds the bundle to its intended recipient — reuse for a different member is detected and rejected
-- Wrong private key → AEAD authentication tag failure → immediate rejection
-
-**Group creation:**
-1. Admin node generates GEK (ChaCha20-Poly1305, 256-bit, CSPRNG)
-2. GEK wrapped for each initial member via the protocol above
-3. Wrapped bundles uploaded to hub via `POST /v1/groups/{group_id}/members/{username}/gek`
-4. Members retrieve their bundle via `GET /v1/groups/{group_id}/gek`
-
-**Member addition:**
-- Admin fetches new member's `pk_x25519` from hub
-- Wraps GEK for them and uploads bundle
-
-**Member revocation:**
-- Admin node generates new GEK
-- Re-encrypts for all remaining members, uploads new bundles
-- New content encrypted with new GEK from this point
-- Former member can still decrypt previously received content (acceptable trade-off — full retroactive re-encryption not planned)
-
-**Key persistence requirement:** before uploading a GEK bundle, the recipient's keypairs must already be registered on the hub and persisted locally. If a user registers, generates keypairs, but does not persist them before the first hub contact, subsequent sessions will regenerate different keypairs and all bundles will be undecryptable. The node initializes and persists all keypairs to the keystore before any hub API call.
-
-### 6.3 On-the-Fly Encryption for File Transfer
-
-Files are stored in plaintext on the host's disk. The node encrypts at read time.
-
-```
-Disk (plaintext) → zstd compress → GEK encrypt (per-chunk) → TCP+TLS 1.3 session → Client → TLS decrypt → GEK decrypt → plaintext
-```
-
-(In v2 transport: replace TCP+TLS 1.3 with QUIC — application pipeline is identical.)
-
-**Chunking:**
-- Chunk size: 1 MB (amortizes AEAD overhead; enables seeking)
-- Per-chunk key derivation: `chunk_key = HKDF(GEK, salt=None, info="file:" || blake3(file) || ":chunk:" || index)` — salt is omitted because the GEK is a CSPRNG output (already uniform); the file/chunk context goes in `info` for domain separation, which is the correct HKDF usage per RFC 5869
-- Each chunk independently decryptable → enables VOD seeking
-- Compress before encrypt (compression is ineffective on ciphertext)
-
-**Chunk authentication:** each chunk signed with the node's Ed25519 key. Client verifies before decryption. Prevents data injection by compromised relay.
-
-**Encryption performance (Spike 5, 1 MB chunk, TCP, Fedora → OVH VPS):**
-
-| Operation | Time |
-|---|---|
-| Encrypt + sign (node side) | 3.2 ms |
-| Verify + decrypt (client side) | 3.9 ms |
-| Total crypto overhead (1 MB) | < 10 ms |
-| Network transfer | 99–234 ms (network-limited) |
-
-Encryption is not the bottleneck. Network latency and bandwidth dominate.
-
-**Pipeline optimization:**
-- `cryptography` (PyCA) uses OpenSSL under the hood, bypasses Python GIL for crypto ops
-- ChaCha20-Poly1305: ~1750 MB/s (Spike 1); AES-256-GCM: >2 GB/s with AES-NI
-- asyncio pipeline (read → compress → encrypt → send) without loading full files into memory
-- GEK-derived chunk keys computed in batch at transfer start, not per-chunk
-
-### 6.4 Transport Security
-
-**Implementation phases:**
-
-| Phase | Transport | Status | Notes |
-|---|---|---|---|
-| v1 | TCP + TLS 1.3 | Current implementation target | Standard library (`asyncio` + `ssl`), well-understood, works everywhere |
-| v2 | QUIC (TLS 1.3 integrated, UDP, multiplexed streams) | Future upgrade | `aioquic`, no protocol changes needed — only transport layer |
-
-The `Transport` abstraction interface in `meshbay-node` decouples the application protocol from the underlying transport. Switching from TCP+TLS to QUIC requires implementing a new `Transport` backend with no changes to MNP message handling, GEK pipeline, or NAT traversal logic.
-
-**Per-connection session keys:** X25519 ECDH + HKDF, independent of the GEK layer. Provides forward secrecy per connection regardless of transport.
-
-**Rationale for TCP+TLS 1.3 first:** UDP hole-punching (required for QUIC in NAT scenarios) adds complexity in the early implementation. TCP outbound from behind NAT (as used in Spike 5) works without any NAT coordination. TLS 1.3 provides equivalent confidentiality guarantees to QUIC's integrated TLS. QUIC's benefits (0-RTT, multiplexing, no head-of-line blocking) are meaningful for performance but not for correctness — they belong in v2 once the application protocol is stable.
-
-### 6.5 TCP+TLS 1.3 Transport Implementation (v1)
-
-**Connection model:**
-- Node listens on a configurable TCP port (default: 18000, same as local web UI port — separate socket)
-- Clients connect outbound; nodes behind NAT connect outbound to other nodes via hole-punching signaling (see §7.1)
-- TLS 1.3 mandatory; TLS 1.2 rejected
-- Node presents a self-signed Ed25519 certificate pinned to its `PK_node` (registered on hub)
-- Client validates certificate against `PK_node` retrieved from hub — not against a CA chain
-
-**Handshake sequence:**
-```
-Client → Node: TCP SYN
-Node → Client: TLS ServerHello (self-signed cert, PK_node)
-Client: verify cert against hub-fetched PK_node
-Client → Node: TLS ClientFinished
-Node → Client: MNP handshake request (version negotiation)
-Client → Node: MNP handshake response (JWT access token, version)
-Node: verify JWT offline (Ed25519, hub public key)
-Node → Client: session established
-```
-
-**Message framing over TCP:**
-- Length-prefixed frames: `[4-byte big-endian length][msgpack payload]`
-- Maximum frame size: 2 MB (prevents memory exhaustion; larger transfers use chunked `file_chunk` messages)
-- Each frame carries the MNP `version` field in its header
-
-**QUIC migration path (v2):**
-- Replace TCP length-framing with QUIC streams (one stream per logical exchange)
-- MNP handshake maps 1:1 to a QUIC handshake stream
-- File transfer maps to a dedicated QUIC stream per file (multiplexed, no head-of-line blocking)
-- Chat messages map to a persistent QUIC stream
-- No changes to JWT verification, GEK decryption, or Index sync logic
-
-**Port allocation:**
-- `18000/tcp` — local web UI (loopback only, not exposed externally)
-- `18001/tcp` — MNP P2P listener (exposed externally, TLS required)
-- Configurable via `~/.config/meshbay/node.toml`
-
-### 6.6 Chat Encryption and Model
-
-Group chat is a **core feature** (not an extension module).
-
-**Model (decided):** between a forum and Signal.
-- **Persistent:** messages stored on the node (not ephemeral like Signal by default)
-- **Structured:** optional threads/topics for longer discussions, flat stream for quick messages
-- **Scope:** per group (not per user pair)
-- **Attachments:** files and images, shared like regular group files
-- **Push/pull:** connected members get real-time push (WebSocket); offline members pull history on reconnect
-- **Retention:** managed by the group admin (no automatic expiry)
-
-**Encryption — Sender Keys protocol (decided in first security review, 2026-08-10):**
-
-The Double Ratchet (implemented in `meshbay_common.ratchet`) is a **pairwise** (1:1) protocol. Using a shared ratchet state for N group members would cause chain key desynchronization and nonce/key reuse — a catastrophic AEAD failure. The architecture uses **Sender Keys** instead (same approach as Signal Groups):
-
-- Each group member generates a **sender key** (random symmetric chain key + signing keypair)
-- On joining a group, the new member's sender key is distributed to all existing members via pairwise channels (GEK-wrapped or direct)
-- Each existing member sends their current sender key to the new member
-- Messages are encrypted with the sender's chain key (symmetric ratchet, one direction)
-- Forward secrecy at **member rotation** granularity: when a member is removed, all remaining members rotate their sender keys
-- O(N) state per member (one chain per group member), not O(N^2)
-- The existing Double Ratchet implementation is kept for future 1:1 direct messaging
-
-Attachment files: encrypted with GEK-derived key (same as file chunks), hash referenced in the message.
-
-> **Why not MLS (RFC 9420)?** MLS provides O(log N) message overhead and per-message forward secrecy via tree-based ratcheting. It is the superior long-term choice, but its complexity is not justified for v1 group sizes (< 50 members). Sender Keys is proven at scale (Signal, WhatsApp) and simpler to implement. Migration to MLS is a v2 option if group sizes grow.
-
----
-
-## 7. Network and Connectivity
-
-### 7.1 NAT Traversal — Attempt Order
-
-```
-1. IPv6 available on both sides → direct connection (preferred)
-2. STUN / ICE + UDP hole punching → ~80–85% success rate (Cone NAT confirmed in Spike 4)
-3. UPnP / NAT-PMP on router → port mapping if available (NOT reliable — disabled on tested SFR box)
-4. Mesh Relay (TURN) → [future feature] — symmetric NAT, CGNAT mobile
-```
-
-> **Correction from v2:** UPnP was listed as step 2 in v2. Spike 4 showed UPnP disabled on the tested SFR residential gateway. STUN + hole-punching (step 2) is more reliable and does not require router cooperation. UPnP is demoted to step 3 as a best-effort supplement, not a dependency.
-
-**Spike 4 findings:**
-- Cone NAT confirmed on SFR residential (same external port 51250 for two different STUN servers)
-- UDP hole punching functional: bidirectional echo received from OVH VPS
-- STUN servers tested: `stun.cloudflare.com`, `stun.l.google.com` — both returned consistent results
-- No CGNAT: stable public IPv4 (81.220.170.32)
-
-Without step 4 (Mesh Relay), approximately 15% of connections between symmetric-NAT peers will fail. This is documented behavior until Mesh Relay is implemented.
-
-**Signaling punch/connect (Phase 7.2 — reduces handshake from 12.7s to < 200ms):**
-Currently the node punches blindly at startup; the client may connect 10-20s later
-on an aging NAT entry, causing retransmissions. The coordinated flow uses the
-existing hub→node WebSocket (revocation channel):
-```
-Client → Hub : POST /v1/nodes/{id}/incoming {peer_ip, peer_port}
-Hub → Node (WS) : {type: "client_incoming", peer_ip, peer_port}
-Node : punch_nat(peer_ip, peer_port) immediately
-Node → Hub (WS) : {type: "punch_ready"}
-Hub → Client: 200 OK "connect now"
-Client → QUIC: first packet < 2s after probe → fresh NAT entry
-```
-demo-v2 finding: SFR residential is **Port-Restricted Cone NAT**.
-The probe must come from the QUIC server's own socket (`punch_nat()` via
-`_transport.sendto()`). The QUIC client must connect from the same port
-as the probe's destination (`local_port=QUIC_PORT`). Handshake time
-with proper signaling: < 200ms (vs 12.7s without).
-
-### 7.2 MNP — Mesh Node Protocol
-
-Application-level protocol over TCP+TLS 1.3 (v1) or QUIC (v2). All messages carry a `version` field. The protocol is transport-agnostic — the `Transport` abstraction layer handles framing differences.
-
-**Defined message types:**
-
-| Type | Description |
-|---|---|
-| `handshake` | Key exchange, JWT presentation, version negotiation |
-| `index_sync` | Encrypted Mesh Group Index delta |
-| `file_request` | Request chunk(s) of a file by hash + chunk index |
-| `file_chunk` | Chunk data + Ed25519 signature |
-| `stream_segment` | HLS/DASH segment (VOD), encrypted with GEK-derived key |
-| `chat_message` | Double Ratchet encrypted message frame |
-| `chat_attachment` | Attachment metadata + key; data transferred as file chunks |
-| `ephemeral_stream` | [reserved, future] Ephemeral video with TTL metadata |
-
-### 7.3 Public Content Delivery — Swarm
-
-Public files identified by `blake3` hash. Multiple nodes can serve the same file:
-
-1. Any node that has a public file and chooses to mirror it registers: `{ hash → node_address }` with the hub
-2. Hub maintains a source table: `{ blake3_hash → [node_A, node_B, ...] }`
-3. Client requests file → hub returns source list → client fetches chunks in parallel from multiple nodes
-4. Integrity verified by blake3 hash on each chunk
-
-**Transport:** TLS only for public content (no GEK). Content signed with the original node's Ed25519 key — clients verify authenticity even when served from a mirror.
-
----
-
-## 8. Indexes
-
-### 8.1 Mesh Directory (hub level)
-
-Public registry of groups, exchanged between hubs via MHP.
-
-Format: `msgpack`, signed with hub's Ed25519 key, carries `version` field.
-
-Fields per entry: group name, `PK_group`, hosting hub, description, content type tags, join policy, creation date.
-
-### 8.2 Mesh Group Index (node level)
-
-File listing for a group. Generated and maintained by the hosting node.
-
-Format: `msgpack` → `zstd` → GEK-encrypted (private groups) or plaintext + Ed25519 signature (public groups).
-
-Entry structure:
-```python
-{
- "version": 1,
- "id": "<blake3_hash>",
- "name": "filename.mkv",
- "path": "Movies/2024/",
- "size": 4294967296,
- "type": "video", # video | audio | image | document | archive | other
- "duration": 7245, # seconds, for media
- "thumb_hash": "<blake3>", # thumbnail also GEK-encrypted
- "added_at": 1720000000
-}
-```
-
-Delta updates: `{ base_version, additions, deletions }` — no full re-encryption on each change.
-
-Transit: nodes push index deltas to connected members on change; members pull full index on first connection. Hub stores no index content.
-
-### 8.3 Search
-
-**Private groups:** entirely local on the client device. Client maintains a local encrypted cache of all group indexes it has received. No network call, no hub involvement, instant.
-
-**Public groups:** client queries nodes directly at request time. Hub provides routing only.
-
-**Hub web UI search:** delegates query to relevant nodes at request time. Hub stores nothing from this. In-memory micro-cache: **60-second TTL, RAM only, never persisted to disk, public content only.** Qualifies as technical caching under EU DSA Article 13 — not indexing.
-
----
-
-## 9. Hub Federation (MHP)
-
-### 9.1 Hub Hierarchy
-
-```
-Root Hub (meshbay.org)
- ├── Full Hub (self-hosted, delegated CA)
- │ └── issues user credentials, manages own groups
- │ └── federates with other Full Hubs via MHP
- └── Mirror Hub
- └── hosts public Mesh Directory only (no user accounts, no key issuance)
-```
-
-A Full Hub receives a certificate signed by the Root Hub (or a parent Full Hub). Mirror Hubs can only replicate public directory data. Promotion/demotion is possible without breaking the protocol.
-
-### 9.2 MHP Design
-
-- Explicit peer selection: each hub maintains an allowlist of trusted peers
-- No automatic hub discovery
-- Exchanged: Mesh Directory (public groups), revocation lists, cross-hub user authentication data
-- All MHP messages carry `version` field
-
-### 9.3 Cross-Hub Client Access
-
-1. Client (Hub A user) discovers a group on Hub B via Mesh Directory or direct link
-2. Client presents Hub A JWT directly to Hub B
-3. Hub B verifies JWT using Hub A's public key (fetched once, cached)
-4. Hub B issues short-lived local session token
-5. Client connects to node as normal
-
----
-
-## 10. Moderation
-
-### 10.1 Public Content
-
-```
-Report #1 → automatic suspension of public access
- → node operator notified
-One republication allowed
-Report #2 → escalated to hub moderators
-Confirmed → group revoked on local hub
- → revocation propagated to federated hubs via MHP
-```
-
-Mechanism: `blake3` hash added to hub blocklist. Signed revocation token sent to node.
-
-### 10.2 CSAM
-
-Hash matching against NCMEC/IWF database on public content at registration time. No scanning of private/encrypted content. Participation is mandatory for hub operators and reduces legal exposure.
-
-### 10.3 Copyright
-
-DMCA/legal notice framework. Takedown on notification. No automated technical blocking (false positive risk, fair use). Hub can revoke on confirmed legal request.
-
-### 10.4 Private Content
-
-Not directly moderatable (E2E encrypted). Action available: revoke user or group at hub level on formal legal request. Hub issues Ed25519-signed revocation token verifiable by all member nodes offline.
-
----
-
-## 11. Python Extension Module System
-
-The node loads extension modules (Python) in a sandboxed subprocess. **Chat is a core built-in feature, not a module.**
-
-**Module manifest:**
-```python
-{
- "name": "my-extension",
- "version": "1.0.0",
- "mnp_version": ">=1.0",
- "permissions": ["read_index", "send_message", "receive_events"]
-}
-```
-
-**Available APIs:**
-- `read_index()` — read current group index (read-only)
-- `send_message(content)` — post to group thread
-- `receive_events(handler)` — subscribe to group events
-
-**Unavailable:** arbitrary network, filesystem access outside group context, system calls.
-
----
-
-## 12. Legal Framework
-
-**Node operator:** primary legal host of content. Fully responsible for what they share. Node setup communicates this explicitly.
-
-**Hub operator (meshbay.org):** registrar, not content host. Stores minimal data. Operates takedown mechanism. Participates in CSAM hash matching. Legal exposure analogous to a domain registrar.
-
-**Protocol/software author:** protected by substantial non-infringing uses.
-
-**Hub data:**
-- Email and optional phone: kept for account recovery and legal compliance
-- Password: Argon2id hash, never stored in cleartext
-- Connection logs: retained per legal requirements (minimum 1 year)
-- Content metadata: never stored
-- Node current IP: not persisted (signaling is ephemeral)
-- GEK bundles: opaque 48-byte ciphertext blobs; hub cannot decrypt them
-
----
-
-## 13. Future Features
-
-- **Mesh Relay:** community TURN relays, relay registration protocol via hub, E2E encrypted traffic. Necessary for symmetric NAT (CGNAT mobile, some professional ISPs).
-- **QUIC transport (v2):** replace TCP+TLS 1.3 with QUIC once application protocol is stable. Transport abstraction layer makes this a drop-in replacement.
-- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
-- **Hub mirror (load balancing):** full hub replication (user DB, group registry, GEK bundles) for load distribution. Requires distributed DB strategy (PostgreSQL streaming replication or equivalent). Complex — design when needed.
-- **Mobile video push → node:** mobile films → pushes to hosting node → ephemeral stream with TTL to group members. MNP `ephemeral_stream` type reserved.
-- **Node–mobile pairing:** QR code from local web UI
-- **Multi-source download:** parallel chunk fetching from swarm for public files
-- **iOS client**
-- **At-rest encryption on node:** optional for server-deployed nodes
-- **OS keychain integration for keystore unlock**
-- **WebRTC:** `aiortc` for browser-native P2P (no node required for clients)
-
----
-
-## 14. Open Questions [TBD]
-
-**Resolved by POC (no longer open):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R1 | Argon2id parameters: what values target ~500ms? | `iterations=4, memory_cost=262144` (256 MB). Use `meshbay-node --calibrate-argon2` for hardware-specific tuning. | Spike 1 |
-| R2 | JWT payload claims: what fields for offline node verification? | `jti` (UUID4), `user_id`, `PK_user`, `PK_user_x25519`, `hub_id`, `issued_at`, `expires_at`, group membership claim. `jti` is mandatory (prevents replay, enables revocation). | Spike 3 |
-| R3 | GEK wrapping protocol: exact algorithm? | ECIES-like: ephemeral X25519 + HKDF(salt=pk_eph, info="meshbay:gek_wrap:v1") + ChaCha20-Poly1305(aad=pk_recipient). Hub stores opaque 48-byte blobs. | Spike 6 |
-| R4 | NAT traversal: is STUN/hole-punching sufficient for residential users? | Yes for Cone NAT (SFR, Orange, Free). Relay needed only for symmetric NAT (CGNAT mobile). UPnP unreliable — demoted to step 3. | Spike 4 |
-| R5 | Transport: QUIC or TCP+TLS 1.3 for v1? | TCP+TLS 1.3 for v1 (lower complexity, works everywhere). QUIC for v2 via `Transport` abstraction. | Spike 5 |
-| R6 | Hub API: which endpoints for GEK distribution? | `GET /v1/users/{username}/pubkeys`, `POST /v1/groups`, `POST /v1/groups/{group_id}/members/{username}/gek`, `GET /v1/groups/{group_id}/gek` | Spike 6 |
-| R7 | Package structure? | 3 packages: `meshbay-common`, `meshbay-hub`, `meshbay-node`. uv workspace monorepo. | POC |
-
-**Resolved by first security review (2026-08-10):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R8 | Group chat encryption model? | Sender Keys protocol (Signal Groups approach). Double Ratchet kept for future 1:1 DM only. MLS considered for v2 if groups > 50 members. | Security review C1 |
-| R9 | Token denylist distribution? | Push via existing hub→node WebSocket. Node maintains an in-memory jti set. MNP handshake checks the set before accepting a JWT. No periodic polling needed. | Security review S3 |
-| R10 | Chunk key HKDF: salt or info? | `info` (domain separation), `salt=None`. GEK is CSPRNG output (already uniform), so HKDF extract step doesn't need a random salt. Spec wording corrected to match code (RFC 5869 compliant). | Security review M5 |
-| R11 | AES-GCM keystore IV size? | 96-bit (12 bytes), per NIST SP 800-38D recommendation. Code fixed from 128-bit to 96-bit. | Security review S4 |
-
-**Still open:**
-
-1. **Refresh token validity:** 30 or 90 days?
-2. **Group address scheme:** final URL format confirmation
-3. **GEK bundle location for groups with mixed access** (public-restricted): hub or node?
-4. **MHP federation sync frequency and conflict resolution**
-5. **Hub mirror replication strategy** (when implemented)
-6. **Chat attachment storage:** stored on node like regular files, or separate store?
-7. **Relay registration protocol design** (when implemented)
-8. **QUIC migration timeline:** when is the application protocol considered stable enough to begin v2 transport implementation?
-9. **Refresh token rotation:** implement one-time-use refresh tokens (rotate on each use, detect reuse as theft indicator). RFC 6819 §5.2.2.3.
-10. **Email encryption at rest:** spec requires encrypted email/phone in DB, implementation stores plaintext. Needs server-side encryption with key from hub config.
-
-
----
-
-<!-- ============================================================ -->
-<!-- ARCHIVED: Architecture Draft v4 (was docs/meshbay-draft-v4.md) -->
-<!-- ============================================================ -->
-
-# MeshBay — Architecture Draft v4
-
-> Status: active development — Phases 1–12 complete (except 10.9 → Phase 13), 191 tests.
-> Changes from v3: web client transport (WebRTC DataChannel), web UI architecture, hub roles (admin/moderator), hub mirror design, browser-specific NAT traversal, chat storage clarified, Phase 8 security items resolved, Phase 10 site overlay + admin/moderation UI + notifications + group search + version endpoint, Phase 10b self-service UI (group create/join/invite, file upload, IndexedDB caching, cross-group search), **node sovereignty model** (§4.2.x — node operator is sole content authority, deny-by-default, uploader_id tracking), **cryptographic sovereignty enforcement** (GEK-HMAC handshake challenge, Ed25519 admin challenge-response, gek_req removed), **Phase 12 — P2P crypto material** (GEK+keypair bundles moved off hub to node BundleStore, password split, key persistence in IndexedDB/sessionStorage, DTLS channel binding fix).
-
----
-
-## Changes from v3
-
-The following items are **architectural decisions** driven by Phase 8 implementation and web client design (2026-08-10). They supersede the corresponding text in v3.
-
-| # | Category | What changed | Source |
-|---|---|---|---|
-| 1 | Browser transport | Web browsers use **WebRTC DataChannel** (with ICE/STUN) for P2P to nodes behind NAT. WebTransport cannot work because browsers cannot choose their UDP source port — Port-Restricted Cone NAT requires exact port matching. Native clients (desktop, Android) continue using QUIC with `punch_nat()`. | Web client design session |
-| 2 | Hub signaling | Hub WebSocket extended to relay WebRTC signaling (SDP/ICE) between browser and node. <1 KB per message, stateless, no content. Same channel as jti denylist push and `client_incoming`. | Web client design session |
-| 3 | Hub role | Reinforced: hub is registrar + signaling facilitator ONLY. Never proxies, stores, or touches content (files, streams, chat, indexes). All data lives on nodes. Clients connect E2E to nodes. | Design constraint |
-| 4 | Chat storage | Chat messages stored on node(s) hosting the group, not on the hub. Browser retrieves chat from node via DataChannel. If no node is online, group is unavailable. | Web client design session |
-| 5 | Web UI | Preact SPA (~3 KB gzipped), dark/light theme, responsive, i18n (JSON translations). ESM modules, esbuild for minification. No heavy frameworks. | Web client design session |
-| 6 | Hub roles | Three roles: `user`, `moderator`, `admin`. Moderator can review reports and suspend content/groups/users. Admin has full hub management. | Web client design session |
-| 7 | Site overlay | meshbay.org serves both generic hub functionality and site-specific pages (landing, /downloads, /about). Separated via Caddy static file priority. | Web client design session |
-| 8 | Hub mirror | Design defined (future implementation): active-active with shared signing key, PostgreSQL logical replication, DNS round-robin. Not implemented yet. | Web client design session |
-| 9 | Security items | S1 (admin authz), S2 (email encryption), S5 (refresh token rotation) resolved in Phase 8. Argon2id bumped to 256 MB with transparent rehash. | Phase 8 implementation |
-| 10 | File search | Client-side search on cached indexes (IndexedDB). No hub involvement. Private group indexes are GEK-encrypted — hub stores opaque, client decrypts locally. | Web client design session |
-| 11 | P2P crypto material | **ALL crypto material moved off hub to P2P channel.** GEK bundles and keypair bundles stored on node (`BundleStore` SQLite), exchanged via MNP DataChannel. Hub `GEKBundle` model and `/gek` endpoint removed. Hub never touches, stores, or proxies any crypto material. | Phase 12 — T3 attack surface reduction |
-| 12 | Password split | Hub receives `auth_key` (PBKDF2-SHA512, auth salt), never raw password. Separate `bundle_key` (PBKDF2-SHA512, bundle salt) encrypts keypair bundles on the node. Hub cannot derive `bundle_key` from `auth_key`. | Phase 12 — T1 |
-| 13 | Node auth | Node daemon authenticates to hub via Ed25519 signed timestamp (`POST /v1/nodes/auth`), not password. JWT `scope: "node"` blocks group mutation endpoints. | Phase 12 — NS7 |
-| 14 | Key persistence | Browser stores `_bundleKey` (CryptoKey) in IndexedDB and `_sessionKeys` in sessionStorage. Survives page refresh without re-login. Public key derived from recovered private key via JWK export (`_pkFromSk`), no hub dependency. | Phase 12 — browser hardening |
-| 15 | DTLS channel binding | Browser saves raw answer SDP before `setRemoteDescription` (Chrome may drop sha-256 fingerprint). GEK-HMAC uses `_rawAnswerSdp` for fingerprint extraction. | Phase 12 — handshake fix |
-
----
-
-## Changes from v2
-
-The following items are **mandatory corrections** driven by POC findings (spikes 1–6). They supersede the corresponding text in v2.
-
-| # | Category | What changed | Source |
-|---|---|---|---|
-| 1 | JWT | `jti` (UUID4) is now **required** in every access token — prevents replay and enables individual revocation. Without it, two tokens issued in the same second are bit-for-bit identical (Ed25519 is deterministic). | Spike 3 |
-| 2 | Argon2id | Parameters updated: `iterations=4`, `memory_cost=262144` (256 MB). Previous params (iterations=3, 64 MB) gave 78 ms — too fast. Target is 500 ms on a home server. CLI calibration command added. | Spike 1 |
-| 3 | NAT traversal | Order corrected: IPv6 → **STUN/hole-punching** → UPnP → TURN relay. UPnP moved to step 3 (disabled on tested SFR box). STUN is now priority 2, not UPnP. | Spike 4 |
-| 4 | Transport | TCP + TLS 1.3 is now the **v1 implementation**. QUIC is the v2 target. The v2 architecture doc had this reversed (QUIC primary, TCP fallback). A `Transport` abstraction layer ensures the switch requires no protocol-layer changes. | Spike 5 |
-| 5 | GEK wrapping | Exact protocol confirmed: ephemeral X25519 + `HKDF(salt=pk_eph, info="meshbay:gek_wrap:v1")` + `ChaCha20-Poly1305(aad=pk_recipient)`. Hub stores opaque 48-byte blobs. | Spike 6 |
-| 6 | Hub API | Four new endpoints validated in Spike 6: `GET /v1/users/{username}/pubkeys`, `POST /v1/groups`, `POST /v1/groups/{group_id}/members/{username}/gek`, `GET /v1/groups/{group_id}/gek`. Full table added as §4.1.5. | Spike 6 |
-| 7 | Packages | Repository structure decided: 3 packages (`meshbay-common`, `meshbay-hub`, `meshbay-node`) in a uv workspace monorepo. RPM package names defined. | POC structure |
-| 8 | Key persistence | X25519 keypairs **must be persisted** client-side before the first hub contact. Lesson from Spike 6 (`bob_state.json` fix). | Spike 6 |
-
----
-
-## 1. Project Overview
-
-MeshBay is a decentralized, peer-to-peer platform for file sharing, video streaming, and group messaging. It combines identity federation (via Mesh Hubs) with truly peer-to-peer data exchange (via Mesh Nodes), designed to be resilient, censorship-resistant, and user-friendly.
-
-**Core principles:**
-- Data never transits through a central server — only identity and routing do
-- End-to-end encryption for all private content (files, indexes, messages)
-- The node operator is the legal host and is fully responsible for their content
-- The hub is a lightweight registrar, not a content host or indexer
-- Open source, self-hostable at every level
-
-**Domain:** meshbay.org (configurable at compile/deploy time throughout the codebase)
-
----
-
-## 2. Terminology
-
-| Term | Role |
-|---|---|
-| **Mesh Hub** | Identity authority and group registry server |
-| **Mesh Node** | Local program on the host user's machine |
-| **Mesh Client** | Web browser or Android app (end user) |
-| **Mesh Relay** | Community-operated TURN fallback relay [future] |
-| **MNP** | Mesh Node Protocol — P2P protocol between nodes and clients |
-| **MHP** | Mesh Bay Hub Protocol — inter-hub federation protocol |
-| **GEK** | Group Encryption Key — symmetric key for private group content |
-| **Mesh Directory** | Public registry of groups (hub level) |
-| **Mesh Group Index** | File listing for a group (node level, encrypted for private groups) |
-
----
-
-## 3. Protocol Versioning
-
-All protocols (MNP, MHP, hub REST API) carry explicit version information.
-
-**Format:** `MAJOR.MINOR`
-- MAJOR bump: breaking change, backward incompatible
-- MINOR bump: backward-compatible addition
-
-**Negotiation:** during handshake, both parties declare their supported version range. The highest mutually supported MINOR within the same MAJOR is used. If no common version exists, connection is refused with an explicit error.
-
-**Support policy:** a release supports the current MAJOR and at least the two previous MINOR versions (N-2).
-
-**Implementation:** a `version` field in every msgpack message header. Handshake step precedes all other exchanges.
-
----
-
-## 4. System Components
-
-### 4.1 Mesh Hub
-
-A lightweight server acting as a registrar. Intentionally minimal to limit legal exposure and operational cost.
-
-**What the hub stores:**
-- User accounts: username, email (stored for account recovery — see §4.1.1), optional phone number, `PK_user` (Ed25519 + X25519), hub ID, status, creation timestamp
-- Group registry: name, `PK_group`, hosting node identifier, visibility, join policy, member list with encrypted GEK bundles (private groups only)
-- Mandatory connection logs (see §4.1.2)
-- Revocation lists (users and groups)
-- Registered peer hubs (explicit allowlist — no auto-discovery)
-
-**What the hub never stores:**
-- File content or metadata
-- Private group indexes
-- Message content
-- Node current IP (handled by ephemeral signaling — see §4.1.3)
-
-#### 4.1.1 Account Data
-
-Email is kept in full (not hashed) to support:
-- Account recovery (password reset)
-- Legal notifications
-- Abuse contact
-
-Phone number: optional, associable after account creation. On Android, both collected at registration. Accounts are fusionable (email + phone pointing to same account).
-
-Email and phone are stored encrypted at rest in the database, using a server-side key derived from the hub's configuration secret (not the database). **[NOT YET IMPLEMENTED — currently stored in plaintext. Tracked as open question #10.]**
-
-#### 4.1.2 Mandatory IP Logging (Legal Compliance)
-
-Legal frameworks (LCEN in France, EU e-Commerce Directive, DSA) require service providers to retain connection logs. The hub logs the following with timestamp and IP address:
-
-| Event | Retention |
-|---|---|
-| Account creation | 1 year minimum |
-| Login (success and failure) | 1 year minimum |
-| Group creation | 1 year minimum |
-| Group join / leave | 1 year minimum |
-| Group deletion | 1 year minimum |
-| Revocation actions | 1 year minimum |
-
-Logs are stored in a separate, access-controlled log table. They are not used for any purpose other than legal compliance and are not exposed to users or operators beyond legal requests.
-
-#### 4.1.3 Signaling Service
-
-NAT traversal coordination is handled by a lightweight WebSocket endpoint, logically separate from the main hub API. It is stateless: connection state is held in memory only and discarded after P2P connection establishment (typically within seconds). No persistent storage of node IP addresses.
-
-**Hub interaction summary:**
-
-| Event | Hub crypto load | Frequency |
-|---|---|---|
-| Account creation | Argon2 hash, store PK | Once |
-| Login | Verify password, issue JWT (Ed25519 sign) | Per session |
-| Group creation | Register metadata | Once per group |
-| Member add/remove | Store/remove GEK bundle | On admin action |
-| Group discovery | Return node address + PK_node + GEK bundle | Per initial access |
-| NAT signaling | Relay WebSocket messages (<1 KB) | Per new P2P connection |
-| Public search | Delegate to nodes, 60s in-memory cache | On demand |
-| MHP federation sync | Exchange Mesh Directory | Background, periodic |
-| Revocation | Ed25519-sign revocation token | Rare |
-
-**The hub is never in the data path after connection setup. JWT verification by nodes is local (Ed25519, no hub roundtrip). Confirmed at 884 µs in Spike 3.**
-
-#### 4.1.4 JWT Strategy
-
-Two tokens issued at login:
-
-**Access token** (JWT, signed Ed25519):
-- Validity: 1 hour
-- Payload: `jti` (UUID4, **mandatory** — unique per token, enables individual revocation and prevents replay), `user_id`, `PK_user`, `PK_user_x25519`, `hub_id`, `issued_at`, `expires_at`, `groups` (list of group_ids the user is a member of — hub-signed membership claim)
-- The `groups` claim is **mandatory** for node-side authorization: the node checks that the requested group_id appears in the JWT before serving any content. Without this claim, any authenticated user could access any group on the node.
-- Presented to nodes for authentication and group access verification
-- Verified locally by nodes using the hub's known public key — no hub roundtrip
-- Compromise window: 1 hour maximum
-
-> **Why `jti` is mandatory:** Ed25519 signing is deterministic. Two tokens with identical payloads issued within the same second produce the same byte sequence. Without a `jti`, they are indistinguishable — a captured token is replayable forever within its validity window, and individual revocation is impossible. The `jti` also provides the revocation handle: hub stores `jti` of invalidated tokens in a server-side denylist.
->
-> This bug was found and fixed during Spike 3.
-
-**Refresh token** (opaque, random 256-bit):
-- Validity: 30–90 days [TBD exact duration]
-- Stored securely on client only
-- Used exclusively with the hub to obtain a new access token
-- Revocable immediately by the hub (invalidates all future refreshes for this token)
-- Stored server-side as a hashed value
-
-**Revocation flow:** hub invalidates the refresh token → next access token renewal fails → node access expires within 1 hour at most. For immediate revocation of an active access token: hub adds its `jti` to the token denylist; nodes that cache hub public key will periodically fetch the denylist.
-
-**Tech stack:**
-- Language: Python
-- Framework: FastAPI + Uvicorn
-- Database: PostgreSQL + SQLAlchemy + Alembic
-- Deployment: Apache reverse proxy (ProxyPass + SSL termination)
-- Authentication: own system (Ed25519 JWT, Argon2id for password hashing)
-- Hub accessible via domain and directly by IP (self-signed cert warning expected for IP access; documented)
-
-#### 4.1.5 Hub API Reference
-
-Complete table of validated and planned hub REST API endpoints. Endpoints marked ✓ were validated in the POC; endpoints marked [TBD] are designed but not yet implemented.
-
-**Hub metadata:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| GET | `/v1/hub/info` | None | Hub metadata: hub_id, versions, counters | ✓ Spike 2 |
-| GET | `/v1/hub/pubkey` | None | Hub Ed25519 public key (PEM) | ✓ Spike 2 |
-
-**User management:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/users/register` | None | Create user account (username, email, password, pk_ed25519, pk_x25519) | ✓ Spike 2 |
-| POST | `/v1/users/login` | None | Authenticate; returns access token + refresh token | ✓ Spike 2 |
-| POST | `/v1/users/token/refresh` | Refresh token | Issue new access token | ✓ Spike 2 |
-| GET | `/v1/users/{username}/pubkeys` | Access token | Fetch `pk_ed25519` and `pk_x25519` for a user (used for GEK wrapping) | ✓ Spike 6 |
-
-**Node management:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/nodes/announce` | Access token | Register node with endpoint_hint; returns node_id | ✓ Spike 2 |
-| GET | `/v1/nodes/{node_id}` | Access token | Retrieve node record (pk_node, endpoint_hint) | ✓ Spike 2 |
-
-**Group management:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/groups` | Access token | Create group (name, visibility, join_policy, pk_group) | ✓ Spike 6 |
-| GET | `/v1/groups` | None / Access token | List/search public groups; private groups require membership | [TBD] |
-| GET | `/v1/groups/{group_id}` | None / Access token | Group metadata | [TBD] |
-| DELETE | `/v1/groups/{group_id}` | Access token (admin) | Revoke group | [TBD] |
-
-**GEK distribution (private groups):**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/groups/{group_id}/members/{username}/gek` | Access token (admin) | Upload opaque 48-byte GEK bundle for a member | ✓ Spike 6 |
-| GET | `/v1/groups/{group_id}/gek` | Access token (member) | Retrieve caller's GEK bundle | ✓ Spike 6 |
-
-**Revocation:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| POST | `/v1/admin/revoke` | Access token (admin) | Revoke a user or group | ✓ Phase 8 |
-| GET | `/v1/revoke/denylist` | None | Current `jti` denylist for active access tokens | [TBD] |
-
-**Admin / moderation:**
-
-| Method | Path | Auth | Description | Status |
-|---|---|---|---|---|
-| GET | `/v1/users/me` | Access token | Current user info (id, username, role, status) | ✓ Phase 10 |
-| GET | `/v1/admin/stats` | Moderator+ | Hub stats (user/group/node counts, online nodes) | ✓ Phase 10 |
-| GET | `/v1/admin/users` | Moderator+ | List users (paginated, searchable by username) | ✓ Phase 10 |
-| GET | `/v1/admin/users/{user_id}` | Moderator+ | User detail (email, role, status, group count) | ✓ Phase 10 |
-| PATCH | `/v1/admin/users/{user_id}` | Moderator+ | Update user role or status | ✓ Phase 10 |
-| GET | `/v1/admin/groups` | Moderator+ | List all groups with member count | ✓ Phase 10 |
-| PATCH | `/v1/admin/groups/{group_id}` | Moderator+ | Update group status | ✓ Phase 10 |
-| GET | `/v1/admin/logs` | Moderator+ | IP audit logs (filterable by event, user_id) | ✓ Phase 10 |
-| GET | `/v1/admin/blocklist` | Admin | List blocked content hashes | ✓ Phase 8 |
-| POST | `/v1/admin/blocklist` | Admin | Manually block a content hash | ✓ Phase 8 |
-| DELETE | `/v1/admin/blocklist/{hash}` | Admin | Unblock a content hash | ✓ Phase 8 |
-| GET | `/v1/notifications` | Access token | List notifications (unread_only, paginated) | ✓ Phase 10 |
-| POST | `/v1/notifications/{id}/read` | Access token | Mark notification as read | ✓ Phase 10 |
-| POST | `/v1/notifications/read-all` | Access token | Mark all notifications as read | ✓ Phase 10 |
-| GET | `/v1/groups?q=` | None | Search public groups by name (ilike) | ✓ Phase 10 |
-| GET | `/v1/hub/version` | None | Client version check (hub, MNP, MHP) | ✓ Phase 10 |
-| GET | `/v1/groups/{id}/members` | Access token | List group members (requires membership) | ✓ Phase 10b |
-| POST | `/v1/groups/{id}/join` | Access token | Self-join open group | ✓ Phase 10b |
-
-### 4.2 Mesh Node
-
-A local program running on the host user's machine. The node is the actual host of all content.
-
-**Responsibilities:**
-- Watch and index shared directories (Mesh Group Index) — one directory per group
-- Serve files, video streams, and group chat to members
-- Manage all cryptographic keys locally (encrypted keystore)
-- Handle P2P connections and NAT traversal (STUN + QUIC hole punching)
-- Run the MNP protocol (QUIC v2, TCP+TLS v1)
-- Host the Python extension module sandbox
-- Serve the local web UI (localhost:18000)
-
-**Multi-group architecture (decided Phase 7):**
-A node exposes **one QUIC port** for all groups it hosts. Groups are not isolated
-by port — the MNP handshake identifies the target group via the `group_id` claim
-in the client JWT. The server routes each connection to the appropriate
-DirectoryIndexer and GEK after JWT verification.
-Rationale: one NAT hole to maintain, one port to forward manually if needed.
-
-**Authorization invariant:** the node MUST verify that the JWT's `groups` claim
-contains the requested group_id before serving any content. Without this check,
-any authenticated user could access any group on the node. This is enforced at
-the MNP handshake layer, not the transport layer.
-
-#### 4.2.x Node Sovereignty — Content Authorization Model
-
-The node operator is the **sole authority** over content stored on their machine.
-No external actor — including the hub admin — can modify, delete, or control
-files on a node they do not operate. This is a non-negotiable design invariant,
-enforced by **cryptography**, not just policy.
-
-**Two trust domains, strictly separated:**
-
-| Domain | Authority | Scope |
-|---|---|---|
-| **Hub** | Hub admin / moderator | User accounts, group registry, group membership, GEK distribution, moderation (suspend user/group at hub level) |
-| **Node** | Node operator | Files on disk, file deletion, upload acceptance, chat storage, who can do what with node content |
-
-The hub certifies **identity** (JWT) and **group membership** (`groups` claim).
-The node decides **authorization for content operations** based on that identity.
-These two concerns must never be conflated.
-
-##### Cryptographic enforcement — two defense layers
-
-A malicious hub admin controls the JWT signing key and could forge JWTs to
-impersonate any user, including the node operator. Policy-only checks (comparing
-`user_id` to `node_user_id`) are insufficient because the hub controls the
-identity layer. Two cryptographic mechanisms make this impossible:
-
-**Layer 1 — GEK proof in handshake (membership verification):**
-
-After JWT verification, the node challenges the connecting user to prove they
-possess the Group Encryption Key (GEK). The hub never has the GEK — it only
-stores opaque ECIES-wrapped bundles. Without the GEK, a hub admin who forges
-a JWT still cannot access any group content.
-
-```
-Client → Node: handshake { token, group_id }
-Node: verify JWT, verify group_id in claims
- nonce = random(32)
-Node → Client: handshake_challenge { nonce: base64(nonce) }
-Client: proof = HMAC-SHA256(GEK, nonce)
-Client → Node: handshake_response { proof: base64(proof) }
-Node: verify HMAC — if wrong, reject connection
-Node → Client: handshake_ack { is_node_admin, node_pk, v }
-```
-
-This blocks: content reading, index reading, chat reading, file upload, chat
-injection — ALL operations require passing the GEK proof first.
-
-**Layer 2 — Ed25519 challenge-response for admin operations:**
-
-The node operator's Ed25519 public key is pinned locally in `node.toml`
-(auto-pinned from keystore on first startup). Destructive operations (file
-deletion) require the user to sign a random challenge with their Ed25519
-private key. The hub cannot forge this signature.
-
-```
-Client → Node: file_delete { file_id }
-Node: (if uploader → allow immediately)
- (else) challenge = random(32)
-Node → Client: admin_challenge { challenge: base64(challenge), file_id }
-Client: signature = Ed25519.sign(sk_ed, challenge)
-Client → Node: admin_response { signature: base64(signature), file_id }
-Node: verify(admin_pk_ed25519, signature, challenge)
- if valid → delete file
-```
-
-**Node configuration — admin key pinning:**
-
-```toml
-# node.toml
-admin_pk_ed25519 = "base64-encoded-32-bytes-raw-Ed25519-public-key"
-```
-
-Auto-pinned from the node operator's keystore on first startup. The daemon
-logs: "Admin Ed25519 key pinned for node sovereignty".
-
-**GEK distribution — browser flow (node no longer serves GEK):**
-
-The node NEVER serves the GEK in plaintext. Browser clients obtain the GEK
-from their hub-stored encrypted bundle:
-
-1. `GET /v1/groups/{id}/gek` → encrypted ECIES bundle (AES-256-GCM variant)
-2. Browser unwraps with its X25519 private key (from keypair bundle)
-3. Browser uses raw GEK bytes for the handshake HMAC proof
-4. Browser imports GEK as HKDF key for chunk decryption
-
-This eliminates the `gek_req`/`gek_resp` MNP messages from the protocol.
-
-**Authorization rules for destructive file operations (enforced by the node):**
-
-| Action | Who can do it | Enforcement point |
-|---|---|---|
-| Delete a file | Node operator (Ed25519 challenge-response) OR the user who uploaded it | Node (`_do_file_delete`) |
-| Delete any file | Node operator only (Ed25519 challenge-response) | Node (`_do_file_delete`) |
-
-Default posture: **deny.** If the admin key is not pinned, all admin operations
-are refused. If the GEK proof fails, the connection is refused entirely.
-
-**Protocol enforcement — MNP handshake_ack:**
-
-The handshake_ack message carries `is_node_admin: bool` — the node tells the
-client whether the authenticated user is the node operator. Clients MUST use
-this node-reported flag (not the hub's `group.admin_id`) to decide whether
-to show destructive operations like file deletion.
-
-```
-handshake_ack:
- v: "0.1"
- node_pk: "<base64>"
- is_node_admin: true | false # node-side authorization, NOT hub-side
-```
-
-**Index entry — uploader tracking:**
-
-Each `IndexEntry` carries an `uploader_id` field (user_id of who uploaded the
-file, or null for files that pre-existed on disk). This enables the "uploader
-can delete their own files" rule without granting node-admin privileges.
-
-**What the hub admin CANNOT do on a node they don't operate:**
-- Delete files (requires Ed25519 key pinned on node — hub can't forge)
-- Read files (requires GEK — hub never has it)
-- Read index / chat (requires GEK proof in handshake)
-- Upload files (requires GEK proof in handshake)
-- Impersonate the node operator (JWT forgery blocked by Ed25519 challenge)
-
-**What the hub admin CAN do (hub-level only):**
-- Suspend a user account (blocks JWT issuance → user loses access everywhere)
-- Suspend a group (blocks signaling → no new P2P connections to nodes for that group)
-- These are hub-level actions that don't touch node content
-
-**Remaining trust assumptions:**
-- The hub serves the SPA code to browsers (a malicious hub could inject JS — fundamentally unsolvable in browser; native client or browser extension required for full integrity)
-- The hub relays WebRTC signaling — ✅ MITIGATED: DTLS channel binding in GEK-HMAC proof (`HMAC(GEK, nonce || offer_fp || answer_fp)`) detects fingerprint substitution (MitM)
-- The hub receives raw password at login — ✅ MITIGATED: password split (auth_key ≠ bundle_key, independent PBKDF2 derivations). Hub receives auth_key only, cannot derive bundle_key to decrypt keypair bundle. Legacy accounts migrated on first login.
-- The hub controls public key distribution — can substitute keys during invite to intercept GEK. Fix: out-of-band key verification (safety numbers) — Phase 12
-
-> **Design lesson (2026-08-12):** The initial implementation conflated hub
-> `group.admin_id` (who created the group on the hub) with node operator
-> authority (who runs the machine). The SPA used the hub's `is_admin` flag
-> to show file deletion controls, and the node's delete handler used a
-> fail-open check (`if node_user_id and ...` — allowed everyone when
-> `node_user_id` was not set). Both violated node sovereignty. Fixed by:
-> (1) deny-by-default on the node, (2) `is_node_admin` in handshake_ack,
-> (3) `uploader_id` tracking in the index, (4) SPA uses node-reported
-> permissions only. Then hardened with cryptographic enforcement:
-> (5) GEK-HMAC proof in handshake (blocks forged-JWT access),
-> (6) Ed25519 challenge-response for admin ops (blocks identity impersonation),
-> (7) removal of `gek_req` endpoint (node never serves GEK in plaintext),
-> (8) DTLS channel binding in GEK-HMAC proof to detect WebRTC signaling MitM,
-> (9) chat `sender_id` fixed to authenticated identity (prevents impersonation),
-> (10) Ed25519 challenge for ALL file deletions — uploaders verified by stored pk, not JWT sub,
-> (11) password split — hub receives PBKDF2 auth_key, never raw password (cannot derive bundle_key).
-
-**Platform:** Linux primary, cross-platform from the start (Windows/macOS). Python ensures portability.
-
-#### 4.2.1 Keystore and Unlock
-
-Private keys (user identity Ed25519, user exchange X25519, group identity Ed25519, GEK copies) are stored in a local encrypted keystore file.
-
-**Format:** msgpack container encrypted with AES-256-GCM, key derived from master password using Argon2id.
-
-**Argon2id parameters (production):**
-- `iterations = 4`
-- `memory_cost = 262144` (256 MB)
-- `parallelism = 1` (or match CPU count — tune to target hardware)
-- Target derivation time: ~500 ms on a home server
-
-> **Why these parameters:** Spike 1 measured iterations=3, memory=64 MB at 78 ms — far too fast. At 78 ms an attacker can attempt millions of guesses per second-equivalent with a GPU cluster. The target of 500 ms on a home server limits offline dictionary attacks to a tractable rate while remaining acceptable for a node that unlocks once at startup.
-
-**CLI calibration:**
-```
-meshbay-node --calibrate-argon2
-```
-This command iterates through parameter combinations and reports the derivation time on the current hardware. The operator selects parameters meeting the 500 ms target and stores them in `~/.config/meshbay/node.toml`. Recommended starting point: `iterations=4, memory_cost=262144`.
-
-**Key persistence requirement:** All keypairs (Ed25519 + X25519) **must be written to the keystore before the first hub contact.** If keypairs are generated at registration time but not persisted before the hub call, subsequent runs will regenerate different keypairs, making all stored GEK bundles on the hub undecryptable. This was identified as a real failure mode in Spike 6 (`bob_state.json` fix).
-
-**Three unlock modes:**
-
-| Mode | How it works | Security level |
-|---|---|---|
-| **Secure (default)** | Password prompted at startup via terminal or local web UI | High |
-| **Lazy file** | Password or derived key stored in `~/.config/meshbay/unlock.key` (chmod 600), read automatically at startup | Medium — acceptable for physically secure home machines. Risk documented at setup. |
-| **Service (headless)** | `MESHBAY_UNLOCK_KEY` environment variable, set via systemd `EnvironmentFile=` pointing to a chmod 600 file | Medium-high — standard practice for server deployments |
-
-Future: OS keychain integration (libsecret/GNOME Keyring on Linux, Windows Credential Manager, macOS Keychain).
-
-#### 4.2.2 Hardware Sizing
-
-The main constraint is **upload bandwidth**, not CPU or RAM.
-
-| Scenario | Simultaneous users | Upload needed | CPU | RAM |
-|---|---|---|---|---|
-| Files + chat, minimal streaming | 10 | 20–50 Mbps | 2 cores | 512 MB |
-| Active 1080p streaming (5–6 streams) | 10 | 50–80 Mbps | 2–4 cores | 1 GB |
-| Mixed use | 50 | 200–300 Mbps | 4 cores | 2 GB |
-| Active streaming | 50 | 400 Mbps | 4–8 cores | 2–4 GB |
-| All use cases | 100 | 800 Mbps–1 Gbps | 8 cores | 4–8 GB |
-
-Beyond 20–30 active streaming users, a dedicated server is required. A home fiber connection (100–500 Mbps symmetric) is suitable for small groups.
-
-Crypto overhead is confirmed negligible: Spike 5 measured full encrypt+sign and verify+decrypt at under 10 ms for a 1 MB chunk. Network latency dominates.
-
-**Tech stack:**
-- Language: Python (primary). Rust extension only if a specific hot path proves insufficient.
-- Transport abstraction layer: `Transport` interface decouples TCP+TLS 1.3 (v1) from QUIC (v2). Application protocol is identical across both transports.
-- v1 transport: **TCP + TLS 1.3** (`asyncio` + `ssl` module, standard library)
-- v2 transport (future): **QUIC** (`aioquic`, Cloudflare-maintained)
-- ICE/STUN: `aioice` (already a dependency)
-- WebRTC: `aiortc` (browser P2P transport — Phase 9)
-- Crypto: `cryptography` (PyCA, OpenSSL-backed, hardware-accelerated AES-NI/ChaCha)
-- Serialization: `msgpack`
-- Compression: `zstandard` (zstd)
-- File watching: `watchdog`
-- Local DB: SQLite
-- Local web UI: served by node on `localhost:18000`
-
-### 4.3 Mesh Client
-
-Web browser or Android app. Consumes content from nodes; manages account via hub.
-The hub is never in the data path — clients connect E2E to nodes for all content.
-
-**Hub-side operations (HTTPS, lightweight):**
-- Account creation, login, token refresh
-- Public group search and discovery
-- Group membership management, GEK bundle retrieval
-- WebRTC signaling relay (SDP/ICE — <1 KB per connection, stateless)
-- Notification metadata (invitations, new content indicators)
-
-**Node-side operations (direct P2P via QUIC or WebRTC DataChannel):**
-- File browsing via Mesh Group Index
-- File download (chunked, E2E encrypted)
-- Video streaming (HLS segments via DataChannel or QUIC stream)
-- Group chat (Sender Keys encrypted, stored on node)
-- File/photo/video upload (client → node push)
-
-#### 4.3.1 Web Browser Client
-
-**Transport:** WebRTC DataChannel with ICE/STUN for NAT traversal.
-WebTransport (HTTP/3) is not suitable because browsers cannot choose their UDP
-source port — Port-Restricted Cone NAT (confirmed on SFR residential) requires
-the client to connect from the exact port the node probed. WebRTC's ICE handles
-this automatically via simultaneous STUN binding requests.
-
-**UI:** Preact SPA (~3 KB gzipped) served by the hub.
-- Dark/light theme (CSS `prefers-color-scheme` + user toggle in localStorage)
-- Responsive design (sidebar → hamburger menu on mobile)
-- i18n: JSON translation files, English default
-- Build: esbuild for minification (single binary, no npm dependency)
-- Crypto: SubtleCrypto (AES-GCM) for E2E decryption in browser
-
-**Layout:**
-- Left sidebar: group list (ordered by usage — private groups first), navigation
-- Top bar: logo ("MeshBay") left, user menu right (settings, profile, language, logout)
-- Main content area: file explorer, chat view, or settings depending on context
-
-**Client modes:**
-- Explorer: file/folder browser for group content (read-only browse, download, stream)
-- Chat/forum: per-group discussion thread with photo/video posting
-- Settings: general, per-group, notifications, privacy, theme, language
-
-**Local storage:**
-- IndexedDB: cached group indexes for instant local search (~50–100 MB quota)
-- localStorage: theme preference, language, session state
-- `keypair_bundle`: encrypted keypair retrieved from hub, decrypted locally with password
-
-**File search:** entirely client-side on cached indexes. No hub involvement.
-Private group indexes are GEK-encrypted — stored opaque on the hub, decrypted
-by the client locally. Search runs against the decrypted index in IndexedDB.
-
-#### 4.3.2 Android Client
-
-**Transport:** QUIC with `punch_nat()` — same as desktop native clients.
-Android has full UDP access; no WebRTC needed. Uses `quiche` (Cloudflare, Rust
-via JNI) for QUIC transport.
-
-**Stack:** Kotlin + Jetpack Compose. Bouncy Castle JVM for crypto.
-
-**Capabilities:** same as web browser (browse, download, stream, chat, upload).
-Additional: contact list integration (Android Contacts API, permission-gated).
-Account creation from app. No node functionality on mobile (client-only).
-
-**Cross-device compatibility:** the `keypair_bundle` (encrypted, stored on hub)
-enables seamless switching between web and Android with the same credentials.
-Notification state and read markers sync via hub (small encrypted blob per user).
-
-**Out of scope:** Mac/iPhone support. Node on mobile.
-
-### 4.4 Mesh Relay
-
-**[Future feature]** Community-operated TURN relay. Used only as last-resort fallback when all P2P connection methods fail (symmetric NAT behind CGNAT, approximately 15–20% of connections in the worst case). Traffic is always E2E encrypted — the relay sees only opaque ciphertext.
-
-Not operated by meshbay.org. A relay registration protocol (hub-mediated) will be designed when this feature is introduced. It does not affect the current design.
-
-### 4.5 Package Structure and Repository
-
-**Repository layout:** monorepo managed with [uv workspaces](https://docs.astral.sh/uv/concepts/workspaces/).
-
-```
-meshbay/
-├── packages/
-│ ├── meshbay-common/ # Shared crypto, serialization, protocol types
-│ ├── meshbay-hub/ # Hub server (FastAPI + Uvicorn)
-│ └── meshbay-node/ # Node daemon + local web UI
-├── poc/ # POC and spikes — reference implementation
-│ ├── spike1_crypto/
-│ ├── spike2_hub/
-│ ├── spike3_node_reg/
-│ ├── spike4_nat/
-│ ├── spike5_transfer/
-│ ├── spike6_gek/
-│ └── spike-results.md
-├── docs/
-│ └── meshbay-draft-v3.md
-└── pyproject.toml # Workspace root
-```
-
-**Three packages:**
-
-| Package | RPM name | Contents |
-|---|---|---|
-| `meshbay-common` | `python3-meshbay-common` | Crypto primitives (Ed25519, X25519, ChaCha20, Argon2, HKDF), msgpack schemas, protocol constants, MNP message types |
-| `meshbay-hub` | `python3-meshbay-hub` | FastAPI hub application, database models (SQLAlchemy), Alembic migrations, JWT issuance, GEK bundle storage |
-| `meshbay-node` | `python3-meshbay-node` | Node daemon, keystore, file watcher, TCP+TLS transport, local web UI, extension module sandbox |
-
-**`meshbay-hub` and `meshbay-node` both depend on `meshbay-common`.** There is no runtime dependency between hub and node packages.
-
-**POC directory as reference implementation:** The `poc/` directory contains the working code from spikes 1–6. It is not production code and not packaged, but serves as the canonical reference for:
-- Exact crypto parameter choices (Spike 1)
-- GEK wrapping/unwrapping implementation (Spike 6)
-- Hub API skeleton (Spike 2)
-- NAT detection and STUN interaction (Spike 4)
-- TCP file transfer pipeline (Spike 5)
-
-Developers implementing production features should read the corresponding spike before writing production code.
-
----
-
-## 5. Group Model
-
-Groups are the core organizational unit.
-
-| Parameter | Options |
-|---|---|
-| Visibility | Public / Private |
-| Join policy | Open / On request / By invitation only |
-| Node admin | The hosting node operator — sovereign over content, sole delete authority (see §4.2.x) |
-| Hub group creator | The user who registered the group on the hub — manages membership and GEK distribution |
-
-A public group functions like a themed forum: files, chat thread, member list. Join policy is independent of visibility (a public group can require approval to join).
-
-A private group's content (files, index, messages) is always E2E encrypted with the GEK. Only members holding the GEK can decrypt anything.
-
-**Group addressing:**
-```
-meshbay.org/u/username/groupname — public group via hub
-meshbay.org/g/groupname — public group (shorthand)
-group://<PK_group_fingerprint>@<node_addr> — hub-less direct access
-```
-`meshbay.org` is fully configurable throughout the codebase (constant/config file). The hub is reachable via domain or IP (IP access requires self-signed cert; browsers will warn — expected and documented behavior).
-
----
-
-## 6. Cryptographic Architecture
-
-### 6.1 Key Hierarchy
-
-```
-User Identity Key Ed25519 Signing, authentication
-User Exchange Key X25519 Key agreement (GEK wrapping, session ECDH)
-Group Identity Key Ed25519 Group metadata signing (held by admin node)
-Group Encryption Key ChaCha20 Private content and index encryption (symmetric, 256-bit)
-Session Keys X25519/HKDF Perfect forward secrecy per P2P connection
-```
-
-All private keys stored exclusively on the node (or client device) in the encrypted keystore. The hub never sees any private key.
-
-Both `PK_ed25519` and `PK_x25519` are registered with the hub at account creation. The hub exposes them via `GET /v1/users/{username}/pubkeys` so that group admins can wrap GEK bundles for members without any direct contact between nodes.
-
-### 6.1.1 Key Generation Strategies
-
-Three strategies, depending on client type:
-
-**A — CLI / native node (Argon2id derivation)**
-Keys are derived deterministically from `username + password`:
-```
-salt = SHA-256("meshbay:v1:" + username)
-seed = Argon2id(password, salt, length=64)
-sk_ed25519 = Ed25519.from_private_bytes(seed[:32])
-sk_x25519 = X25519.from_private_bytes(seed[32:])
-```
-Same credentials → same keys on any machine. Password recovery = key recovery.
-Implemented in `meshbay_common/keyderive.py::derive_keys_from_password()`.
-
-**B — Web browser (random keypairs + encrypted bundle)**
-Browser generates random keypairs via WebCrypto `generateKey()`, encrypts them
-with a PBKDF2-SHA512 derived key, and uploads the encrypted bundle to the hub
-alongside the public keys. On subsequent logins, the hub returns the bundle
-and the browser decrypts it locally with the password.
-
-The hub stores `keypair_bundle` (AES-256-GCM ciphertext) — opaque, cannot decrypt it.
-Implemented in `static/keyderive.js`. Python side in `keyderive.py::encrypt_keypair_bundle()`.
-
-**C — Native node with keystore file**
-Random keypairs generated once, stored in the Argon2id-encrypted keystore file
-(`~/.config/meshbay/keystore.enc`). Standard operating mode for `meshbay-node`.
-
-**Algorithm mismatch note:** strategies A and B use different KDFs (Argon2id vs PBKDF2).
-A user who registered via CLI (A) and later tries to recover via web (B) with the same
-password will get different keypairs. This is by design: users pick one registration path.
-Cross-path recovery requires the admin to issue new GEK bundles.
-
-### 6.2 GEK Management
-
-**Scope:** GEK applies to private groups only. Public groups use TLS transport only (no application-layer encryption).
-
-**GEK wrapping protocol (ECIES-like, confirmed in Spike 6):**
-
-```
-Admin side (wrap_gek):
- sk_eph, pk_eph = X25519.generate() # fresh ephemeral keypair per bundle
- shared = X25519(sk_eph, pk_recipient)
- wrap_key = HKDF(shared, salt=pk_eph,
- info="meshbay:gek_wrap:v1",
- length=32)
- nonce = random_bytes(12)
- wrapped = ChaCha20-Poly1305(wrap_key).encrypt(
- nonce, gek, aad=pk_recipient) # aad binds bundle to recipient
- bundle = pk_eph || nonce || wrapped # 32 + 12 + 32+16 = 92 bytes on wire
- # hub stores as opaque 48-byte blob
- # (without pk_eph in compact form — see note)
-
-Member side (unwrap_gek):
- shared = X25519(sk_recipient, pk_eph)
- wrap_key = HKDF(shared, salt=pk_eph,
- info="meshbay:gek_wrap:v1",
- length=32)
- gek = ChaCha20-Poly1305(wrap_key).decrypt(
- nonce, wrapped, aad=pk_recipient)
-```
-
-> **Hub-stored blob size:** the hub stores the opaque bundle. Spike 6 confirmed the hub stores 48-byte blobs (nonce=12 + ciphertext=20 + tag=16 in the compact wire format used in the spike — `pk_eph` is stored separately in the bundle record). Production schema: hub bundle record = `{ pk_eph (32B), nonce (12B), ciphertext (32B), tag (16B) }` = 92 bytes total per member per group, stored as a single column.
-
-**Security properties confirmed in Spike 6:**
-- Hub never sees the GEK in cleartext
-- Ephemeral keypair is unique per bundle — same GEK and same recipient produce different ciphertext across calls
-- AAD (`pk_recipient`) binds the bundle to its intended recipient — reuse for a different member is detected and rejected
-- Wrong private key → AEAD authentication tag failure → immediate rejection
-
-**Group creation:**
-1. Admin node generates GEK (ChaCha20-Poly1305, 256-bit, CSPRNG)
-2. GEK wrapped for each initial member via the protocol above
-3. Wrapped bundles uploaded to hub via `POST /v1/groups/{group_id}/members/{username}/gek`
-4. Members retrieve their bundle via `GET /v1/groups/{group_id}/gek`
-
-**Member addition:**
-- Admin fetches new member's `pk_x25519` from hub
-- Wraps GEK for them and uploads bundle
-
-**Member revocation:**
-- Admin node generates new GEK
-- Re-encrypts for all remaining members, uploads new bundles
-- New content encrypted with new GEK from this point
-- Former member can still decrypt previously received content (acceptable trade-off — full retroactive re-encryption not planned)
-
-**Key persistence requirement:** before uploading a GEK bundle, the recipient's keypairs must already be registered on the hub and persisted locally. If a user registers, generates keypairs, but does not persist them before the first hub contact, subsequent sessions will regenerate different keypairs and all bundles will be undecryptable. The node initializes and persists all keypairs to the keystore before any hub API call.
-
-### 6.3 On-the-Fly Encryption for File Transfer
-
-Files are stored in plaintext on the host's disk. The node encrypts at read time.
-
-```
-Disk (plaintext) → zstd compress → GEK encrypt (per-chunk) → TCP+TLS 1.3 session → Client → TLS decrypt → GEK decrypt → plaintext
-```
-
-(In v2 transport: replace TCP+TLS 1.3 with QUIC — application pipeline is identical.)
-
-**Chunking:**
-- Chunk size: 1 MB (amortizes AEAD overhead; enables seeking)
-- Per-chunk key derivation: `chunk_key = HKDF(GEK, salt=None, info="file:" || blake3(file) || ":chunk:" || index)` — salt is omitted because the GEK is a CSPRNG output (already uniform); the file/chunk context goes in `info` for domain separation, which is the correct HKDF usage per RFC 5869
-- Each chunk independently decryptable → enables VOD seeking
-- Compress before encrypt (compression is ineffective on ciphertext)
-
-**Chunk authentication:** each chunk signed with the node's Ed25519 key. Client verifies before decryption. Prevents data injection by compromised relay.
-
-**Encryption performance (Spike 5, 1 MB chunk, TCP, Fedora → OVH VPS):**
-
-| Operation | Time |
-|---|---|
-| Encrypt + sign (node side) | 3.2 ms |
-| Verify + decrypt (client side) | 3.9 ms |
-| Total crypto overhead (1 MB) | < 10 ms |
-| Network transfer | 99–234 ms (network-limited) |
-
-Encryption is not the bottleneck. Network latency and bandwidth dominate.
-
-**Pipeline optimization:**
-- `cryptography` (PyCA) uses OpenSSL under the hood, bypasses Python GIL for crypto ops
-- ChaCha20-Poly1305: ~1750 MB/s (Spike 1); AES-256-GCM: >2 GB/s with AES-NI
-- asyncio pipeline (read → compress → encrypt → send) without loading full files into memory
-- GEK-derived chunk keys computed in batch at transfer start, not per-chunk
-
-### 6.4 Transport Security
-
-**Implementation phases:**
-
-| Phase | Transport | Status | Notes |
-|---|---|---|---|
-| v1 | TCP + TLS 1.3 | Current implementation target | Standard library (`asyncio` + `ssl`), well-understood, works everywhere |
-| v2 | QUIC (TLS 1.3 integrated, UDP, multiplexed streams) | Future upgrade | `aioquic`, no protocol changes needed — only transport layer |
-
-The `Transport` abstraction interface in `meshbay-node` decouples the application protocol from the underlying transport. Switching from TCP+TLS to QUIC requires implementing a new `Transport` backend with no changes to MNP message handling, GEK pipeline, or NAT traversal logic.
-
-**Per-connection session keys:** X25519 ECDH + HKDF, independent of the GEK layer. Provides forward secrecy per connection regardless of transport.
-
-**Rationale for TCP+TLS 1.3 first:** UDP hole-punching (required for QUIC in NAT scenarios) adds complexity in the early implementation. TCP outbound from behind NAT (as used in Spike 5) works without any NAT coordination. TLS 1.3 provides equivalent confidentiality guarantees to QUIC's integrated TLS. QUIC's benefits (0-RTT, multiplexing, no head-of-line blocking) are meaningful for performance but not for correctness — they belong in v2 once the application protocol is stable.
-
-### 6.5 TCP+TLS 1.3 Transport Implementation (v1)
-
-**Connection model:**
-- Node listens on a configurable TCP port (default: 18000, same as local web UI port — separate socket)
-- Clients connect outbound; nodes behind NAT connect outbound to other nodes via hole-punching signaling (see §7.1)
-- TLS 1.3 mandatory; TLS 1.2 rejected
-- Node presents a self-signed Ed25519 certificate pinned to its `PK_node` (registered on hub)
-- Client validates certificate against `PK_node` retrieved from hub — not against a CA chain
-
-**Handshake sequence:**
-```
-Client → Node: TCP SYN
-Node → Client: TLS ServerHello (self-signed cert, PK_node)
-Client: verify cert against hub-fetched PK_node
-Client → Node: TLS ClientFinished
-Node → Client: MNP handshake request (version negotiation)
-Client → Node: MNP handshake response (JWT access token, version)
-Node: verify JWT offline (Ed25519, hub public key)
-Node → Client: session established
-```
-
-**Message framing over TCP:**
-- Length-prefixed frames: `[4-byte big-endian length][msgpack payload]`
-- Maximum frame size: 2 MB (prevents memory exhaustion; larger transfers use chunked `file_chunk` messages)
-- Each frame carries the MNP `version` field in its header
-
-**QUIC migration path (v2):**
-- Replace TCP length-framing with QUIC streams (one stream per logical exchange)
-- MNP handshake maps 1:1 to a QUIC handshake stream
-- File transfer maps to a dedicated QUIC stream per file (multiplexed, no head-of-line blocking)
-- Chat messages map to a persistent QUIC stream
-- No changes to JWT verification, GEK decryption, or Index sync logic
-
-**Port allocation:**
-- `18000/tcp` — local web UI (loopback only, not exposed externally)
-- `18001/tcp` — MNP P2P listener (exposed externally, TLS required)
-- Configurable via `~/.config/meshbay/node.toml`
-
-### 6.6 Chat Encryption and Model
-
-Group chat is a **core feature** (not an extension module).
-
-**Model (decided):** between a forum and Signal.
-- **Persistent:** messages stored on the node (not ephemeral like Signal by default)
-- **Structured:** optional threads/topics for longer discussions, flat stream for quick messages
-- **Scope:** per group (not per user pair)
-- **Attachments:** files and images, shared like regular group files
-- **Push/pull:** connected members get real-time push (WebSocket); offline members pull history on reconnect
-- **Retention:** managed by the group admin (no automatic expiry)
-
-**Encryption — Sender Keys protocol (decided in first security review, 2026-08-10):**
-
-The Double Ratchet (implemented in `meshbay_common.ratchet`) is a **pairwise** (1:1) protocol. Using a shared ratchet state for N group members would cause chain key desynchronization and nonce/key reuse — a catastrophic AEAD failure. The architecture uses **Sender Keys** instead (same approach as Signal Groups):
-
-- Each group member generates a **sender key** (random symmetric chain key + signing keypair)
-- On joining a group, the new member's sender key is distributed to all existing members via pairwise channels (GEK-wrapped or direct)
-- Each existing member sends their current sender key to the new member
-- Messages are encrypted with the sender's chain key (symmetric ratchet, one direction)
-- Forward secrecy at **member rotation** granularity: when a member is removed, all remaining members rotate their sender keys
-- O(N) state per member (one chain per group member), not O(N^2)
-- The existing Double Ratchet implementation is kept for future 1:1 direct messaging
-
-Attachment files: encrypted with GEK-derived key (same as file chunks), hash referenced in the message.
-
-> **Why not MLS (RFC 9420)?** MLS provides O(log N) message overhead and per-message forward secrecy via tree-based ratcheting. It is the superior long-term choice, but its complexity is not justified for v1 group sizes (< 50 members). Sender Keys is proven at scale (Signal, WhatsApp) and simpler to implement. Migration to MLS is a v2 option if group sizes grow.
-
----
-
-## 7. Network and Connectivity
-
-### 7.1 NAT Traversal — Attempt Order
-
-```
-1. IPv6 available on both sides → direct connection (preferred)
-2. STUN / ICE + UDP hole punching → ~80–85% success rate (Cone NAT confirmed in Spike 4)
-3. UPnP / NAT-PMP on router → port mapping if available (NOT reliable — disabled on tested SFR box)
-4. Mesh Relay (TURN) → [future feature] — symmetric NAT, CGNAT mobile
-```
-
-> **Correction from v2:** UPnP was listed as step 2 in v2. Spike 4 showed UPnP disabled on the tested SFR residential gateway. STUN + hole-punching (step 2) is more reliable and does not require router cooperation. UPnP is demoted to step 3 as a best-effort supplement, not a dependency.
-
-**Spike 4 findings:**
-- Cone NAT confirmed on SFR residential (same external port 51250 for two different STUN servers)
-- UDP hole punching functional: bidirectional echo received from OVH VPS
-- STUN servers tested: `stun.cloudflare.com`, `stun.l.google.com` — both returned consistent results
-- No CGNAT: stable public IPv4 (81.220.170.32)
-
-Without step 4 (Mesh Relay), approximately 15% of connections between symmetric-NAT peers will fail. This is documented behavior until Mesh Relay is implemented.
-
-**Signaling punch/connect (Phase 7.2 — reduces handshake from 12.7s to < 200ms):**
-Currently the node punches blindly at startup; the client may connect 10-20s later
-on an aging NAT entry, causing retransmissions. The coordinated flow uses the
-existing hub→node WebSocket (revocation channel):
-```
-Client → Hub : POST /v1/nodes/{id}/incoming {peer_ip, peer_port}
-Hub → Node (WS) : {type: "client_incoming", peer_ip, peer_port}
-Node : punch_nat(peer_ip, peer_port) immediately
-Node → Hub (WS) : {type: "punch_ready"}
-Hub → Client: 200 OK "connect now"
-Client → QUIC: first packet < 2s after probe → fresh NAT entry
-```
-demo-v2 finding: SFR residential is **Port-Restricted Cone NAT**.
-The probe must come from the QUIC server's own socket (`punch_nat()` via
-`_transport.sendto()`). The QUIC client must connect from the same port
-as the probe's destination (`local_port=QUIC_PORT`). Handshake time
-with proper signaling: < 200ms (vs 12.7s without).
-
-#### 7.1.1 Browser-Specific NAT Traversal (WebRTC DataChannel)
-
-Browsers cannot use the QUIC `punch_nat()` mechanism because WebTransport does
-not allow the browser to choose its UDP source port. Port-Restricted Cone NAT
-requires exact port matching on both IP and port — impossible for browsers.
-
-**Solution:** WebRTC DataChannel with ICE/STUN. The browser's built-in WebRTC
-stack handles NAT traversal automatically:
-
-1. Browser gathers ICE candidates via STUN (discovers its external IP:port)
-2. Node gathers ICE candidates via `aioice` (discovers its external IP:port)
-3. Candidates exchanged via hub signaling (WebSocket relay, <1 KB)
-4. ICE connectivity checks: both sides send STUN binding requests simultaneously
-5. STUN binding requests serve as NAT hole-punching (both directions)
-6. ICE finds a valid candidate pair — DataChannel established
-7. MNP protocol runs over DataChannel (same messages, same E2E encryption)
-
-**Signaling flow:**
-```
-Browser → Hub (HTTPS) : POST /v1/nodes/{id}/webrtc/offer {sdp, ice_candidates}
-Hub → Node (WS) : {type: "webrtc_offer", sdp, ice_candidates, peer_id}
-Node (aiortc) : creates PeerConnection, gathers answer candidates
-Node → Hub (WS) : {type: "webrtc_answer", sdp, ice_candidates, peer_id}
-Hub → Browser (SSE) : answer SDP + ICE candidates
-DataChannel : P2P established — hub no longer involved
-```
-
-ICE is strictly superior to custom `punch_nat()` for browser use:
-- No need for the client to pre-announce its port
-- Handles both sides behind NAT simultaneously
-- Automatic candidate prioritization and fallback
-- Battle-tested by billions of daily users (Google Meet, Discord, Zoom)
-
-**Node dual transport:** the node listens on both:
-- QUIC (UDP port 19000) — native clients (desktop, Android)
-- WebRTC — browsers (via `aiortc`, separate UDP socket managed by ICE)
-
-The MNP application protocol is identical on both transports. Same handshake,
-same file_request/file_chunk, same chat_message, same E2E encryption.
-
-### 7.2 MNP — Mesh Node Protocol
-
-Application-level protocol over TCP+TLS 1.3 (v1) or QUIC (v2). All messages carry a `version` field. The protocol is transport-agnostic — the `Transport` abstraction layer handles framing differences.
-
-**Defined message types:**
-
-| Type | Description |
-|---|---|
-| `handshake` | Key exchange, JWT presentation, version negotiation |
-| `handshake_challenge` | Node sends GEK proof nonce (base64, 32 bytes random) — see §4.2.x |
-| `handshake_response` | Client proves GEK possession: HMAC-SHA256(GEK, nonce) |
-| `handshake_ack` | Node response: version, node public key, `is_node_admin` (node-level authorization) |
-| `index_sync` | Encrypted Mesh Group Index delta |
-| `file_request` | Request chunk(s) of a file by hash + chunk index |
-| `file_chunk` | Chunk data + Ed25519 signature |
-| `file_delete` | Client requests file deletion by file_id |
-| `file_delete_ack` | Node confirms deletion |
-| `file_upload` | Client pushes file chunk to node |
-| `file_upload_ack` | Node acknowledges chunk receipt |
-| `admin_challenge` | Node sends Ed25519 sign challenge for admin ops (base64, 32 bytes) |
-| `admin_response` | Client returns Ed25519 signature over the challenge |
-| `stream_request` | Client requests MSE video stream |
-| `stream_init` | Node sends codec info + signals stream start |
-| `stream_data` | Node sends encrypted fMP4 segment |
-| `stream_end` | Node signals end of stream |
-| `stream_segment` | HLS/DASH segment (VOD), encrypted with GEK-derived key |
-| `chat_message` | Sender Keys encrypted message frame (group chat) |
-| `chat_history` | Client requests chat history |
-| `chat_history_response` | Node responds with stored messages |
-| `chat_attachment` | Attachment metadata + key; data transferred as file chunks |
-| `ephemeral_stream` | [reserved, future] Ephemeral video with TTL metadata |
-
-### 7.3 Public Content Delivery — Swarm
-
-Public files identified by `blake3` hash. Multiple nodes can serve the same file:
-
-1. Any node that has a public file and chooses to mirror it registers: `{ hash → node_address }` with the hub
-2. Hub maintains a source table: `{ blake3_hash → [node_A, node_B, ...] }`
-3. Client requests file → hub returns source list → client fetches chunks in parallel from multiple nodes
-4. Integrity verified by blake3 hash on each chunk
-
-**Transport:** TLS only for public content (no GEK). Content signed with the original node's Ed25519 key — clients verify authenticity even when served from a mirror.
-
----
-
-## 8. Indexes
-
-### 8.1 Mesh Directory (hub level)
-
-Public registry of groups, exchanged between hubs via MHP.
-
-Format: `msgpack`, signed with hub's Ed25519 key, carries `version` field.
-
-Fields per entry: group name, `PK_group`, hosting hub, description, content type tags, join policy, creation date.
-
-### 8.2 Mesh Group Index (node level)
-
-File listing for a group. Generated and maintained by the hosting node.
-
-Format: `msgpack` → `zstd` → GEK-encrypted (private groups) or plaintext + Ed25519 signature (public groups).
-
-Entry structure:
-```python
-{
- "version": 1,
- "id": "<blake3_hash>",
- "name": "filename.mkv",
- "path": "Movies/2024/",
- "size": 4294967296,
- "type": "video", # video | audio | image | document | archive | other
- "duration": 7245, # seconds, for media
- "thumb_hash": "<blake3>", # thumbnail also GEK-encrypted
- "added_at": 1720000000,
- "uploader_id": "<user_id>" # who uploaded this file (null = pre-existing on disk)
-}
-```
-
-Delta updates: `{ base_version, additions, deletions }` — no full re-encryption on each change.
-
-Transit: nodes push index deltas to connected members on change; members pull full index on first connection. Hub stores no index content.
-
-### 8.3 Search
-
-**Private groups:** entirely local on the client device. Client maintains a local encrypted cache of all group indexes it has received. No network call, no hub involvement, instant.
-
-**Public groups:** client queries nodes directly at request time. Hub provides routing only.
-
-**Hub web UI search:** delegates query to relevant nodes at request time. Hub stores nothing from this. In-memory micro-cache: **60-second TTL, RAM only, never persisted to disk, public content only.** Qualifies as technical caching under EU DSA Article 13 — not indexing.
-
----
-
-## 9. Web Client UI
-
-### 9.1 Architecture
-
-The web client is a Preact SPA served by the hub at `/app/`. It communicates
-with the hub via HTTPS (auth, group management, signaling) and with nodes via
-WebRTC DataChannel (file transfer, streaming, chat). The hub is never in the
-data path.
-
-**Technology choices:**
-- **Preact** (~3 KB gzipped): lightweight React-compatible framework
-- **preact-router**: client-side routing (no server round-trips)
-- **esbuild**: minification/bundling (single binary, no npm/node_modules)
-- **SubtleCrypto**: browser-native AES-GCM for E2E decryption
-- **IndexedDB**: local cache for group indexes (client-side search)
-
-No heavy frameworks (React, Vue, Angular). No build toolchain dependencies beyond
-esbuild. ESM modules loaded natively by modern browsers.
-
-### 9.2 UI Structure
-
-```
-┌─────────────────────────────────────────────────────────┐
-│ [MeshBay] [User ▾] [⚙] │
-├──────────┬──────────────────────────────────────────────┤
-│ │ │
-│ Groups │ Main content area │
-│ │ │
-│ ● Private│ - File explorer (folders, files, download) │
-│ Group1 │ - Chat/forum view │
-│ Group2 │ - Video player (HLS via MediaSource API) │
-│ │ - Settings │
-│ ○ Public │ - Notifications feed │
-│ Group3 │ │
-│ │ │
-└──────────┴──────────────────────────────────────────────┘
-```
-
-- **Left sidebar:** group list, ordered by usage frequency. Private groups first.
- Collapses to hamburger menu on mobile viewports.
-- **Top bar:** logo (left), user menu dropdown (right) — settings, profile,
- language, online/offline status, logout.
-- **Main area:** context-dependent content based on selected group and view.
-
-### 9.3 Views
-
-**Front page (no group selected):**
-- Notification feed, prioritized: known contacts → private group activity → public
-- System notifications (maintenance, updates)
-- Quick access to recent groups
-
-**Group view — File Explorer:**
-- Directory tree (folders, subfolders) — read-only browsing
-- File metadata: name, size, type, date added
-- Actions: download, stream (for media files)
-- Files fetched directly from node via DataChannel
-- Upload: photos/videos posted to the group's shared upload folder
-- Delete: node operator can delete any file; uploader can delete their own uploads.
- Hub admin has NO delete authority on nodes they don't operate (see §4.2.x).
-
-**Group view — Chat/Forum:**
-- Sender Keys encrypted messages, fetched from node
-- Post text, photos, videos (uploads go to node, not hub)
-- Optional thread/topic structure for organized discussions
-- Real-time push for connected members, pull history on reconnect
-
-**Group view — Video Player:**
-- HLS segments fetched via DataChannel from node
-- Decrypted client-side (GEK-derived key per segment)
-- Played via MediaSource API (browser-native, no plugins)
-
-**Settings:**
-- General: theme (dark/light/auto), language, notification preferences
-- Per-group: notification mute, display options, filtering/blocking
-- Privacy: online/offline status, profile visibility
-- Profile: display name, avatar, account details
-
-### 9.4 Theming and i18n
-
-**Theme:** CSS custom properties for colors, toggled via:
-1. `prefers-color-scheme` media query (OS default)
-2. User override stored in localStorage
-3. Toggle button in top bar or settings
-
-**i18n:** JSON translation files loaded client-side.
-```
-static/i18n/
-├── en.json # English (default, always loaded)
-├── fr.json # French (loaded on demand)
-└── ... # Other languages added later
-```
-
-Keys are identifiers, not English text. Translation function: `t('group.join')`.
-
-### 9.5 meshbay.org Site Overlay
-
-meshbay.org serves both the generic hub application and site-specific pages:
-
-```
-site/ # meshbay.org-specific (not packaged with hub)
-├── index.html # Landing page — project promotion, features
-├── downloads.html # Package repos: Ubuntu, Fedora, Android APK
-├── about.html # Project info, team, GitHub, contact
-└── assets/ # Landing-specific CSS, images, icons
-```
-
-Caddy serves `site/` with priority. Requests not matching a static file fall
-through to the hub FastAPI application. The hub serves `/app/` (SPA) and `/v1/`
-(API). This separation ensures the hub package remains generic and deployable
-by any operator, while meshbay.org has its own public-facing identity.
-
-### 9.6 Hub Mirror (future — design only)
-
-A mirror hub is a complete active-active replica of the primary hub.
-
-**Purpose:** load distribution for growing traffic. DNS round-robin (2+ A records).
-
-**Design:**
-- Shared Ed25519 signing key (transferred once, securely)
-- PostgreSQL logical replication for bidirectional read/write
-- Both mirrors issue JWTs with the same key
-- Both mirrors accept registrations, logins, and group operations
-- If one mirror goes down, the other serves all traffic
-
-**Implementation constraints (must not violate in current development):**
-- Hub config and key paths must be externalizable (already the case)
-- No hub-instance-specific state that cannot be replicated
-- JWT verification must not depend on hub-local state (already the case)
-- Session state (refresh tokens, IP logs) must be in PostgreSQL (already the case)
-
-**Not implemented now.** Design documented to avoid blocking decisions.
-
----
-
-## 10. Hub Federation (MHP) <!-- was §9 in v3 -->
-
-### 9.1 Hub Hierarchy
-
-```
-Root Hub (meshbay.org)
- ├── Full Hub (self-hosted, delegated CA)
- │ └── issues user credentials, manages own groups
- │ └── federates with other Full Hubs via MHP
- └── Mirror Hub
- └── hosts public Mesh Directory only (no user accounts, no key issuance)
-```
-
-A Full Hub receives a certificate signed by the Root Hub (or a parent Full Hub). Mirror Hubs can only replicate public directory data. Promotion/demotion is possible without breaking the protocol.
-
-### 9.2 MHP Design
-
-- Explicit peer selection: each hub maintains an allowlist of trusted peers
-- No automatic hub discovery
-- Exchanged: Mesh Directory (public groups), revocation lists, cross-hub user authentication data
-- All MHP messages carry `version` field
-
-### 9.3 Cross-Hub Client Access
-
-1. Client (Hub A user) discovers a group on Hub B via Mesh Directory or direct link
-2. Client presents Hub A JWT directly to Hub B
-3. Hub B verifies JWT using Hub A's public key (fetched once, cached)
-4. Hub B issues short-lived local session token
-5. Client connects to node as normal
-
----
-
-## 11. Moderation <!-- was §10 in v3 -->
-
-### 10.1 Public Content
-
-```
-Report #1 → automatic suspension of public access
- → node operator notified
-One republication allowed
-Report #2 → escalated to hub moderators
-Confirmed → group revoked on local hub
- → revocation propagated to federated hubs via MHP
-```
-
-Mechanism: `blake3` hash added to hub blocklist. Signed revocation token sent to node.
-
-### 10.2 CSAM
-
-Hash matching against NCMEC/IWF database on public content at registration time. No scanning of private/encrypted content. Participation is mandatory for hub operators and reduces legal exposure.
-
-### 10.3 Copyright
-
-DMCA/legal notice framework. Takedown on notification. No automated technical blocking (false positive risk, fair use). Hub can revoke on confirmed legal request.
-
-### 10.4 Private Content
-
-Not directly moderatable (E2E encrypted). Action available: revoke user or group at hub level on formal legal request. Hub issues Ed25519-signed revocation token verifiable by all member nodes offline.
-
----
-
-## 12. Python Extension Module System <!-- was §11 in v3 -->
-
-The node loads extension modules (Python) in a sandboxed subprocess. **Chat is a core built-in feature, not a module.**
-
-**Module manifest:**
-```python
-{
- "name": "my-extension",
- "version": "1.0.0",
- "mnp_version": ">=1.0",
- "permissions": ["read_index", "send_message", "receive_events"]
-}
-```
-
-**Available APIs:**
-- `read_index()` — read current group index (read-only)
-- `send_message(content)` — post to group thread
-- `receive_events(handler)` — subscribe to group events
-
-**Unavailable:** arbitrary network, filesystem access outside group context, system calls.
-
----
-
-## 13. Legal Framework <!-- was §12 in v3 -->
-
-**Node operator:** primary legal host of content. Fully responsible for what they share. Node setup communicates this explicitly.
-
-**Hub operator (meshbay.org):** registrar, not content host. Stores minimal data. Operates takedown mechanism. Participates in CSAM hash matching. Legal exposure analogous to a domain registrar.
-
-**Protocol/software author:** protected by substantial non-infringing uses.
-
-**Hub data:**
-- Email and optional phone: kept for account recovery and legal compliance
-- Password: Argon2id hash, never stored in cleartext
-- Connection logs: retained per legal requirements (minimum 1 year)
-- Content metadata: never stored
-- Node current IP: not persisted (signaling is ephemeral)
-- GEK bundles: opaque 48-byte ciphertext blobs; hub cannot decrypt them
-
----
-
-## 14. Future Features <!-- was §13 in v3 -->
-
-- **Mesh Relay:** community TURN relays, E2E encrypted traffic. Low priority — typical residential NAT works with ICE/STUN. Needed only for symmetric NAT (CGNAT mobile, ~15% of connections).
-- ~~**QUIC transport (v2)**~~ ✅ DONE (Phase 5) — QUIC replaces TCP+TLS.
-- **Content replication between nodes:** node-to-node, admin-authorized, no hub involvement
-- **Hub mirror (load balancing):** design documented in §9.6. Active-active with shared key, PostgreSQL replication, DNS round-robin. Implementation deferred.
-- **Mobile video push → node:** mobile films → pushes to hosting node → ephemeral stream with TTL. MNP `ephemeral_stream` type reserved.
-- **Node–mobile pairing:** QR code from local web UI
-- **Multi-source download:** parallel chunk fetching from swarm for public files
-- **At-rest encryption on node:** optional for server-deployed nodes
-- **OS keychain integration for keystore unlock**
-- ~~**WebRTC**~~ ✅ Validated (Phase 9.1–9.5) — `aiortc` for browser-to-node P2P via DataChannel. Tested on SFR residential NAT (Port-Restricted Cone) + 4G CGNAT. No TURN needed.
-- **Extension-triggered views:** local apps providing custom views for group content (gallery, kanban). MNP extension hook reserved.
-
----
-
-## 15. Open Questions [TBD]
-
-**Resolved by POC (no longer open):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R1 | Argon2id parameters: what values target ~500ms? | `iterations=3, memory_cost=262144` (256 MB). pw_version=2, transparent rehash on login. | Spike 1 + Phase 8.10 |
-| R2 | JWT payload claims: what fields for offline node verification? | `jti` (UUID4), `user_id`, `PK_user`, `hub_id`, `issued_at`, `expires_at`, `groups` claim. | Spike 3 + Phase 7 |
-| R3 | GEK wrapping protocol: exact algorithm? | ECIES-like: ephemeral X25519 + HKDF(salt=pk_eph, info="meshbay:gek_wrap:v1") + ChaCha20-Poly1305(aad=pk_recipient). | Spike 6 |
-| R4 | NAT traversal: is STUN/hole-punching sufficient for residential users? | Yes for Cone NAT (SFR, Orange, Free). Relay needed only for symmetric NAT (CGNAT mobile). | Spike 4 |
-| R5 | Transport: QUIC or TCP+TLS 1.3 for v1? | TCP+TLS 1.3 for v1, QUIC for v2. QUIC is now the active transport (Phase 5). | Spike 5 |
-| R6 | Hub API: which endpoints for GEK distribution? | 4 endpoints confirmed. | Spike 6 |
-| R7 | Package structure? | 3 packages: `meshbay-common`, `meshbay-hub`, `meshbay-node`. | POC |
-
-**Resolved by first security review (2026-08-10):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R8 | Group chat encryption model? | Sender Keys protocol. Double Ratchet kept for future 1:1 DM. | Security review C1 |
-| R9 | Token denylist distribution? | Push via hub→node WebSocket. In-memory jti set on node. | Security review S3 |
-| R10 | Chunk key HKDF: salt or info? | `info` (domain separation), `salt=None`. RFC 5869 compliant. | Security review M5 |
-| R11 | AES-GCM keystore IV size? | 96-bit (12 bytes), per NIST SP 800-38D. | Security review S4 |
-
-**Resolved by Phase 8 implementation (2026-08-10):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R12 | Refresh token rotation? | One-time-use with family-based reuse detection. Old token reuse revokes entire family. | Phase 8.3 |
-| R13 | Email encryption at rest? | AES-256-GCM, key derived from hub Ed25519 private key via HKDF(info="meshbay:email:v1"). | Phase 8.2 |
-| R14 | Admin authorization model? | Config-based: `admin_usernames` in hub.toml + `MESHBAY_ADMIN_USERS` env var. | Phase 8.1 |
-| R15 | QUIC migration timeline? | Done — QUIC is the active transport since Phase 5. | Phase 5 |
-
-**Resolved by web client design session (2026-08-10):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R16 | Browser transport for NAT traversal? | WebRTC DataChannel with ICE/STUN. WebTransport cannot work (port-restricted cone NAT). | Design session |
-| R17 | Chat storage location? | On nodes, not hub. Hub never stores content. | Design session |
-| R18 | Web UI framework? | Preact SPA (~3 KB), esbuild, dark/light theme, i18n, responsive. | Design session |
-| R19 | Hub mirror design? | Active-active, shared signing key, PostgreSQL replication, DNS round-robin. | Design session |
-
-**Resolved by Phase 9 spike (2026-08-10):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R20 | WebRTC DataChannel validation? | Confirmed: browser→NAT→node file transfer works. Tested 3 scenarios on SFR residential (Port-Restricted Cone NAT) + 4G CGNAT: WiFi LAN (IPv6 direct, ~100ms), 4G IPv6 inter-network (~600ms), 4G IPv4 STUN hole-punch (~650ms). No TURN relay needed. | Phase 9.5 spike |
-
-**Resolved by node sovereignty fix (2026-08-12):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R21 | Who authorizes file deletion on a node? | Node operator (sovereign) OR original uploader. Hub admin has no authority over node content. Enforced: deny-by-default in MNP `file_delete`, `is_node_admin` in handshake_ack, `uploader_id` in IndexEntry. | Security fix — §4.2.x |
-| R22 | Can a malicious hub admin access node content? | No. Two cryptographic layers: (1) GEK-HMAC proof in handshake — hub never has the GEK, can't pass the challenge. (2) Ed25519 challenge-response for admin ops — hub can't forge the node operator's signature. `gek_req` endpoint removed. | Crypto enforcement — §4.2.x |
-
-**Resolved by Phase 12 — P2P crypto material (2026-08-13):**
-
-| # | Question | Resolution | Source |
-|---|---|---|---|
-| R23 | Where are GEK bundles stored? | On node only (`BundleStore` SQLite, `data_dir/bundles.db`). Hub `GEKBundle` model removed. Exchanged via MNP `gek_bundle_store`/`gek_bundle_fetch`/`gek_bundle_resp` over WebRTC DataChannel. | Phase 12 — T3 |
-| R24 | Where are keypair bundles stored? | On node only (`BundleStore`). Encrypted with password-derived AES key (`bundle_key`). Browser pushes after registration, recovers during handshake. Hub `keypair_bundle` column removed. | Phase 12 — T3 |
-| R25 | How does the browser recover keys after localStorage cleared? | Transport fetches `keypair_bundle` from node during handshake, decrypts with `_bundleKey` (PBKDF2 from password). Public key derived from private key via JWK export — no hub fetch needed. `_bundleKey` persisted in IndexedDB, `_sessionKeys` in sessionStorage. | Phase 12 |
-| R26 | How does the browser handle Chrome SDP re-serialization? | `this._rawAnswerSdp = answer.sdp` saved before `setRemoteDescription`. DTLS fingerprint extracted from raw SDP, not `pc.remoteDescription.sdp` (Chrome may drop sha-256 line when re-serializing multi-hash SDP from aiortc). | Phase 12 |
-| R27 | Should the browser auto-regenerate keys on login? | No. Auto-regeneration silently rotates hub keys, breaking GEK unwrap (GEK wrapped for old keys). Keys recovered from node via `_bundleKey`. Regeneration only on explicit user request. | Phase 12 |
-
-**Still open:**
-
-1. **Refresh token validity:** 30 or 90 days?
-2. **Group address scheme:** final URL format confirmation
-3. **GEK bundle location for groups with mixed access** (public-restricted): hub or node? → Resolved: always on node.
-4. **MHP federation sync frequency and conflict resolution**
-5. **Chat attachment storage:** stored on node like regular files, or separate store?
-6. **Relay registration protocol design** (when implemented)
-
-
----
-
-<!-- ============================================================ -->
-<!-- ARCHIVED: POC v1 (was docs/poc-v1.md) -->
-<!-- ============================================================ -->
-
-# MeshBay — POC v1
-
-> Goal: validate key concepts before committing to a full implementation.
-> Scope: Hub/Node exchange in Python, crypto stack, NAT traversal, encrypted file chunk transfer.
-> Everything in-memory (no database), minimal code, TCP only (no QUIC yet).
-
----
-
-## Environment
-
-### Remote — meshbay.org (Hub)
-- OVH VPS, Ubuntu 26.04 LTS, Python 3.14.4
-- Public fixed IP, ports 80 and 443 open
-- Clean slate: no web server installed
-- SSH access: `ssh cbesson@meshbay.org`
-
-### Local — Fedora 44 (Node)
-- Laptop behind SFR residential NAT (likely Restricted Cone NAT — UPnP supported)
-- Python 3.13+ via system packages
-- User: `cbesson` (sudoer, no password)
-
----
-
-## Python Dependencies
-
-```bash
-# Shared (hub and node)
-cryptography>=43.0 # Ed25519, X25519, ChaCha20-Poly1305, Argon2id
-PyJWT>=2.9 # JWT with EdDSA (Ed25519) support
-blake3>=1.0 # Fast content hashing
-
-# Hub only (meshbay.org)
-fastapi>=0.115
-uvicorn[standard]>=0.30
-
-# Node only (Fedora laptop)
-httpx>=0.28 # Async HTTP client for hub→node calls
-aioice>=0.9 # STUN queries for NAT discovery
-miniupnpc>=2.2 # UPnP port mapping on SFR box
-```
-
-Install on each machine:
-```bash
-python3 -m venv .venv
-source .venv/bin/activate
-pip install <packages above>
-```
-
----
-
-## Hub Setup on meshbay.org
-
-For the POC, uvicorn runs directly on port 80 via iptables redirect (no Caddy/nginx needed yet — HTTPS added before production).
-
-```bash
-# On meshbay.org
-# Redirect port 80 → 8000 (persistent via iptables-save if needed)
-sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000
-
-# Run hub (from poc directory, venv activated)
-uvicorn hub:app --host 127.0.0.1 --port 8000 --reload
-```
-
-> Note: HTTPS (via Caddy + Let's Encrypt) is mandatory before any data beyond this POC. Not in scope here.
-
----
-
-## Spike Overview
-
-| # | Name | Where | Validates | Duration |
-|---|---|---|---|---|
-| 1 | Crypto primitives | Local | Python crypto stack covers all needs | ~1h |
-| 2 | Hub skeleton | meshbay.org | Hub API, JWT issuance | ~2h |
-| 3 | Node registration | Fedora | Hub-Node handshake, JWT offline verify | ~1h |
-| 4 | NAT traversal | Both | SFR box UPnP + STUN, P2P reachability | ~2h |
-| 5 | Encrypted transfer | Both | On-the-fly GEK encryption, P2P chunk | ~2h |
-
----
-
-## Spike 1 — Crypto Primitives (local only)
-
-**Goal:** confirm `cryptography` (PyCA) covers all MeshBay cryptographic needs without gaps or performance surprises.
-
-**File:** `spike1_crypto.py`
-
-**What to test:**
-
-```python
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
-from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives.kdf.argon2 import Argon2id # PyCA 43+
-from cryptography.hazmat.primitives import hashes, serialization
-import blake3, os, time
-```
-
-**Test 1: Ed25519 — hub keypair, sign JWT payload, verify**
-```python
-sk_hub = Ed25519PrivateKey.generate()
-pk_hub = sk_hub.public_key()
-msg = b"test payload"
-sig = sk_hub.sign(msg)
-pk_hub.verify(sig, msg) # raises if invalid
-print("Ed25519 OK")
-```
-
-**Test 2: X25519 — two-party key agreement for GEK wrapping**
-```python
-sk_a = X25519PrivateKey.generate()
-sk_b = X25519PrivateKey.generate()
-shared_a = sk_a.exchange(sk_b.public_key())
-shared_b = sk_b.exchange(sk_a.public_key())
-assert shared_a == shared_b
-print("X25519 OK")
-```
-
-**Test 3: GEK derivation and ChaCha20-Poly1305 on a 1 MB chunk**
-```python
-gek = ChaCha20Poly1305.generate_key()
-cipher = ChaCha20Poly1305(gek)
-chunk = os.urandom(1024 * 1024) # 1 MB
-
-t0 = time.perf_counter()
-nonce = os.urandom(12)
-ct = cipher.encrypt(nonce, chunk, None)
-pt = cipher.decrypt(nonce, ct, None)
-elapsed = time.perf_counter() - t0
-
-assert pt == chunk
-print(f"ChaCha20-Poly1305 1MB: {elapsed*1000:.1f} ms")
-```
-
-**Test 4: HKDF chunk key derivation**
-```python
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives import hashes
-chunk_key = HKDF(
- algorithm=hashes.SHA256(), length=32, salt=None,
- info=b"file:" + blake3.blake3(chunk).digest() + b":chunk:0"
-).derive(gek)
-print(f"HKDF derived key: {chunk_key.hex()[:16]}...")
-```
-
-**Test 5: Argon2id keystore key derivation**
-```python
-from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
-salt = os.urandom(16)
-t0 = time.perf_counter()
-kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
-key = kdf.derive(b"mypassword")
-print(f"Argon2id: {(time.perf_counter()-t0)*1000:.0f} ms, key: {key.hex()[:16]}...")
-```
-
-**Test 6: PyJWT with Ed25519 (EdDSA)**
-```python
-import jwt
-sk_hub_pem = sk_hub.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption()
-)
-pk_hub_pem = pk_hub.public_bytes(
- serialization.Encoding.PEM,
- serialization.PublicFormat.SubjectPublicKeyInfo
-)
-payload = {"sub": "user_abc", "pk_user": "base64...", "exp": 9999999999}
-token = jwt.encode(payload, sk_hub_pem, algorithm="EdDSA")
-decoded = jwt.decode(token, pk_hub_pem, algorithms=["EdDSA"])
-assert decoded["sub"] == "user_abc"
-print("JWT EdDSA OK")
-```
-
-**Success criteria:** all tests pass, ChaCha20 1MB < 20ms, Argon2id ~1s.
-
----
-
-## Spike 2 — Hub Skeleton (meshbay.org)
-
-**Goal:** minimal FastAPI hub, in-memory storage, 5 endpoints.
-
-**File:** `hub.py` (on meshbay.org)
-
-### Hub keypair generation (run once, save to disk)
-
-```python
-# gen_hub_keys.py — run once on meshbay.org
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives import serialization
-import base64, json
-
-sk = Ed25519PrivateKey.generate()
-pk = sk.public_key()
-
-with open("hub_private.pem", "wb") as f:
- f.write(sk.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption()
- ))
-with open("hub_public.pem", "wb") as f:
- f.write(pk.public_bytes(
- serialization.Encoding.PEM,
- serialization.PublicFormat.SubjectPublicKeyInfo
- ))
-print("Hub keypair generated.")
-```
-
-### Hub API (`hub.py`)
-
-```python
-from fastapi import FastAPI, HTTPException, Depends, Header
-from pydantic import BaseModel
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives import serialization, hashes
-from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
-import jwt, uuid, os, time, base64
-
-app = FastAPI(title="MeshBay Hub POC")
-
-# Load hub keypair
-with open("hub_private.pem", "rb") as f:
- HUB_SK_PEM = f.read()
-with open("hub_public.pem", "rb") as f:
- HUB_PK_PEM = f.read()
-
-HUB_ID = "meshbay.org"
-ACCESS_TOKEN_TTL = 3600 # 1 hour
-REFRESH_TOKEN_TTL = 86400 * 30 # 30 days
-
-# In-memory stores (POC only — not persistent)
-users = {} # username → {user_id, pw_hash, pw_salt, pk_ed25519, pk_x25519}
-nodes = {} # node_id → {user_id, pk_node, endpoint_hint, registered_at}
-refresh_tokens = {} # token → user_id
-
-# --- Models ---
-
-class UserRegister(BaseModel):
- username: str
- password: str
- pk_user_ed25519: str # base64
- pk_user_x25519: str # base64
-
-class UserLogin(BaseModel):
- username: str
- password: str
-
-class NodeAnnounce(BaseModel):
- pk_node: str # base64 Ed25519 public key
- endpoint_hint: str | None = None # "ip:port" or null
-
-# --- Helpers ---
-
-def hash_password(password: str) -> tuple[bytes, bytes]:
- salt = os.urandom(16)
- kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
- return kdf.derive(password.encode()), salt
-
-def verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool:
- kdf = Argon2id(salt=salt, length=32, iterations=3, lanes=4, memory_cost=65536)
- try:
- kdf.verify(password.encode(), pw_hash)
- return True
- except Exception:
- return False
-
-def issue_access_token(user: dict) -> str:
- payload = {
- "iss": HUB_ID,
- "sub": user["user_id"],
- "pk_user": user["pk_ed25519"],
- "hub_id": HUB_ID,
- "iat": int(time.time()),
- "exp": int(time.time()) + ACCESS_TOKEN_TTL,
- }
- return jwt.encode(payload, HUB_SK_PEM, algorithm="EdDSA")
-
-def get_current_user(authorization: str = Header(...)) -> dict:
- try:
- scheme, token = authorization.split()
- if scheme.lower() != "bearer":
- raise ValueError
- payload = jwt.decode(token, HUB_PK_PEM, algorithms=["EdDSA"])
- user_id = payload["sub"]
- user = next((u for u in users.values() if u["user_id"] == user_id), None)
- if not user:
- raise HTTPException(status_code=401, detail="User not found")
- return user
- except Exception:
- raise HTTPException(status_code=401, detail="Invalid token")
-
-# --- Endpoints ---
-
-@app.get("/v1/hub/info")
-def hub_info():
- return {
- "hub_id": HUB_ID,
- "pk_hub_ed25519": base64.b64encode(
- Ed25519PrivateKey.from_private_bytes(
- # shortcut for POC — load pk directly
- open("hub_public.pem","rb").read()
- ).public_bytes(...) # see note below
- ).decode(),
- "mnp_version": "0.1",
- "mhp_version": "0.1",
- }
- # Note: return pk_hub_pem directly for POC, nodes store it on first contact
-
-@app.get("/v1/hub/pubkey")
-def hub_pubkey():
- """Return hub Ed25519 public key PEM — cached by nodes on first contact."""
- return {"pk_hub_pem": HUB_PK_PEM.decode()}
-
-@app.post("/v1/users/register", status_code=201)
-def register(body: UserRegister):
- if body.username in users:
- raise HTTPException(status_code=409, detail="Username taken")
- pw_hash, pw_salt = hash_password(body.password)
- user_id = str(uuid.uuid4())
- users[body.username] = {
- "user_id": user_id,
- "username": body.username,
- "pw_hash": pw_hash,
- "pw_salt": pw_salt,
- "pk_ed25519": body.pk_user_ed25519,
- "pk_x25519": body.pk_user_x25519,
- }
- return {"user_id": user_id}
-
-@app.post("/v1/users/login")
-def login(body: UserLogin):
- user = users.get(body.username)
- if not user or not verify_password(body.password, user["pw_hash"], user["pw_salt"]):
- raise HTTPException(status_code=401, detail="Invalid credentials")
- access_token = issue_access_token(user)
- refresh_token = base64.urlsafe_b64encode(os.urandom(32)).decode()
- refresh_tokens[refresh_token] = user["user_id"]
- return {
- "access_token": access_token,
- "refresh_token": refresh_token,
- "token_type": "bearer",
- "expires_in": ACCESS_TOKEN_TTL,
- }
-
-@app.post("/v1/users/token/refresh")
-def refresh(body: dict):
- rt = body.get("refresh_token", "")
- user_id = refresh_tokens.get(rt)
- if not user_id:
- raise HTTPException(status_code=401, detail="Invalid refresh token")
- user = next((u for u in users.values() if u["user_id"] == user_id), None)
- if not user:
- raise HTTPException(status_code=401, detail="User not found")
- return {"access_token": issue_access_token(user), "token_type": "bearer"}
-
-@app.post("/v1/nodes/announce", status_code=201)
-def announce_node(body: NodeAnnounce, user: dict = Depends(get_current_user)):
- node_id = str(uuid.uuid4())
- nodes[node_id] = {
- "node_id": node_id,
- "user_id": user["user_id"],
- "pk_node": body.pk_node,
- "endpoint_hint": body.endpoint_hint,
- "announced_at": int(time.time()),
- }
- return {"node_id": node_id}
-
-@app.get("/v1/nodes/{node_id}")
-def get_node(node_id: str, user: dict = Depends(get_current_user)):
- node = nodes.get(node_id)
- if not node:
- raise HTTPException(status_code=404, detail="Node not found")
- return {
- "node_id": node["node_id"],
- "pk_node": node["pk_node"],
- "endpoint_hint": node["endpoint_hint"],
- }
-```
-
-**Success criteria:**
-- Hub starts, all 6 endpoints respond correctly
-- `GET /v1/hub/pubkey` returns the PEM
-- `POST /v1/users/register` + `POST /v1/users/login` returns a valid JWT
-- JWT verified by `jwt.decode()` with hub public key — passes
-
----
-
-## Spike 3 — Node Registration (Fedora laptop)
-
-**Goal:** node generates its keypair, registers a user on the hub, gets a JWT, and verifies it locally without contacting the hub again.
-
-**File:** `node.py`
-
-```python
-import httpx, asyncio, jwt, base64, os
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
-from cryptography.hazmat.primitives import serialization
-
-HUB_URL = "http://meshbay.org" # HTTP for POC, HTTPS later
-
-async def main():
- async with httpx.AsyncClient() as client:
-
- # 1. Fetch hub public key (first contact — cache this)
- r = await client.get(f"{HUB_URL}/v1/hub/pubkey")
- hub_pk_pem = r.json()["pk_hub_pem"].encode()
- print(f"[node] Hub PK fetched ({len(hub_pk_pem)} bytes)")
-
- # 2. Generate node identity keypairs
- sk_ed = Ed25519PrivateKey.generate()
- pk_ed = sk_ed.public_key()
- sk_x = X25519PrivateKey.generate()
- pk_x = sk_x.public_key()
-
- pk_ed_b64 = base64.b64encode(
- pk_ed.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
- ).decode()
- pk_x_b64 = base64.b64encode(
- pk_x.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
- ).decode()
-
- # 3. Register user (skip if already registered)
- r = await client.post(f"{HUB_URL}/v1/users/register", json={
- "username": "testnode",
- "password": "testpass123",
- "pk_user_ed25519": pk_ed_b64,
- "pk_user_x25519": pk_x_b64,
- })
- print(f"[node] Register: {r.status_code} {r.text}")
-
- # 4. Login, get access token
- r = await client.post(f"{HUB_URL}/v1/users/login", json={
- "username": "testnode",
- "password": "testpass123",
- })
- data = r.json()
- access_token = data["access_token"]
- print(f"[node] Login OK, token: {access_token[:40]}...")
-
- # 5. Verify JWT locally — NO hub roundtrip
- decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"])
- print(f"[node] JWT verified locally: sub={decoded['sub']}, exp={decoded['exp']}")
-
- # 6. Announce node to hub
- r = await client.post(
- f"{HUB_URL}/v1/nodes/announce",
- json={"pk_node": pk_ed_b64, "endpoint_hint": None},
- headers={"Authorization": f"Bearer {access_token}"}
- )
- node_id = r.json()["node_id"]
- print(f"[node] Node announced: {node_id}")
-
-asyncio.run(main())
-```
-
-**Success criteria:**
-- Node registers, logs in, receives JWT
-- JWT decoded offline using only the hub's public key — no hub call
-- Node announced; `GET /v1/nodes/{node_id}` from hub returns correct PK
-
----
-
-## Spike 4 — NAT Traversal (both machines)
-
-**Goal:** discover the local node's external IP:port via STUN and UPnP; test reachability from meshbay.org.
-
-**File:** `spike4_nat.py` (Fedora laptop)
-
-### Part A — UPnP (try first, most reliable on SFR box)
-
-```python
-import miniupnpc
-import socket
-
-def try_upnp(internal_port=19000):
- u = miniupnpc.UPnP()
- u.discoverdelay = 200
- ndevices = u.discover()
- if ndevices == 0:
- print("UPnP: no IGD found")
- return None
-
- u.selectigd()
- external_ip = u.externalipaddress()
- local_ip = socket.gethostbyname(socket.gethostname())
-
- result = u.addportmapping(
- internal_port, 'TCP', local_ip, internal_port,
- 'MeshBay POC', ''
- )
- if result:
- print(f"UPnP: mapped {external_ip}:{internal_port} → {local_ip}:{internal_port}")
- return f"{external_ip}:{internal_port}"
- else:
- print("UPnP: mapping failed")
- return None
-```
-
-### Part B — STUN discovery
-
-```python
-import asyncio
-import aioice
-
-async def stun_discover(local_port=19001):
- # Use Cloudflare STUN server
- stun_servers = [("stun.cloudflare.com", 3478), ("stun.l.google.com", 19302)]
-
- connection = aioice.Connection(ice_controlling=True, stun_server=stun_servers[0])
- await connection.gather_candidates()
-
- for candidate in connection.local_candidates:
- if candidate.type == "srflx": # server-reflexive = external address
- print(f"STUN srflx: {candidate.host}:{candidate.port}")
- return f"{candidate.host}:{candidate.port}"
-
- print("STUN: no srflx candidate found (may be symmetric NAT)")
- return None
-```
-
-### Part C — Reachability test from meshbay.org
-
-Once the node has an external address (from UPnP or STUN), it announces it to the hub (`endpoint_hint`). Then from meshbay.org:
-
-```bash
-# On meshbay.org — manually test TCP reachability
-nc -zv <external_ip> <external_port>
-# or
-python3 -c "import socket; s=socket.create_connection(('<external_ip>', <port>), timeout=5); print('REACHABLE'); s.close()"
-```
-
-And on the Fedora node, a simple listener:
-```python
-# On Fedora, open a listener on the discovered port
-import socket
-s = socket.socket()
-s.bind(('', 19000))
-s.listen(1)
-print("Listening on 19000...")
-conn, addr = s.accept()
-print(f"Connection from {addr}")
-conn.sendall(b"HELLO FROM NODE\n")
-conn.close()
-```
-
-**Expected outcomes on SFR residential:**
-
-| Method | Expected result | Confidence |
-|---|---|---|
-| UPnP | Works — SFR La Box supports UPnP IGD | High |
-| STUN srflx | Discovered — SFR is cone NAT for residential | High |
-| Direct TCP from meshbay.org | Works if UPnP succeeded | High |
-| Hole punching only | Depends on NAT type discovered | Medium |
-
-**Success criteria:** at least one method allows meshbay.org to reach the Fedora node's port directly.
-
----
-
-## Spike 5 — Encrypted File Transfer (both machines)
-
-**Goal:** node serves an encrypted file chunk via direct P2P TCP connection; client decrypts and verifies.
-
-**Prerequisite:** Spike 4 succeeded — external IP:port is known and reachable.
-
-**File:** `spike5_server.py` (Fedora), `spike5_client.py` (meshbay.org)
-
-### Node side — serve one encrypted chunk
-
-```python
-# spike5_server.py — Fedora laptop
-import asyncio, os, base64
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives import hashes, serialization
-import blake3, struct, json
-
-# Keypair (reuse from Spike 3 or generate here)
-sk_node = Ed25519PrivateKey.generate()
-pk_node_bytes = sk_node.public_key().public_bytes(
- serialization.Encoding.Raw, serialization.PublicFormat.Raw
-)
-
-# Generate GEK (in a real system, loaded from keystore)
-gek_raw = ChaCha20Poly1305.generate_key()
-cipher = ChaCha20Poly1305(gek_raw)
-
-CHUNK_SIZE = 1024 * 1024 # 1 MB
-
-def make_chunk(file_path: str, chunk_index: int) -> bytes:
- """Read, compress (skipped for POC), encrypt, sign a chunk."""
- with open(file_path, "rb") as f:
- f.seek(chunk_index * CHUNK_SIZE)
- data = f.read(CHUNK_SIZE)
-
- file_hash = blake3.blake3(open(file_path, "rb").read()).digest()
-
- # Per-chunk key derivation
- chunk_key = HKDF(
- algorithm=hashes.SHA256(), length=32, salt=None,
- info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, "big")
- ).derive(gek_raw)
- chunk_cipher = ChaCha20Poly1305(chunk_key)
-
- nonce = os.urandom(12)
- ct = chunk_cipher.encrypt(nonce, data, None)
- chunk_hash = blake3.blake3(ct).digest()
-
- # Sign: chunk_index + nonce + ciphertext_hash
- sig_payload = chunk_index.to_bytes(4, "big") + nonce + chunk_hash
- sig = sk_node.sign(sig_payload)
-
- return json.dumps({
- "chunk_index": chunk_index,
- "nonce": base64.b64encode(nonce).decode(),
- "ciphertext": base64.b64encode(ct).decode(),
- "chunk_hash": base64.b64encode(chunk_hash).decode(),
- "signature": base64.b64encode(sig).decode(),
- "pk_node": base64.b64encode(pk_node_bytes).decode(),
- "gek_hint": base64.b64encode(gek_raw).decode(), # POC: send GEK in band — never in production!
- }).encode()
-
-async def handle_client(reader, writer):
- request = await reader.read(1024)
- req = json.loads(request)
- chunk_index = req.get("chunk_index", 0)
- file_path = req.get("file", "testfile.bin")
-
- print(f"[node] Client requests chunk {chunk_index} of {file_path}")
- chunk_data = make_chunk(file_path, chunk_index)
-
- writer.write(len(chunk_data).to_bytes(4, "big") + chunk_data)
- await writer.drain()
- writer.close()
- print(f"[node] Chunk {chunk_index} sent ({len(chunk_data)} bytes)")
-
-async def main():
- # Create a 5MB test file
- if not os.path.exists("testfile.bin"):
- with open("testfile.bin", "wb") as f:
- f.write(os.urandom(5 * 1024 * 1024))
- print("[node] Test file created (5 MB)")
-
- server = await asyncio.start_server(handle_client, "0.0.0.0", 19000)
- print("[node] Serving on port 19000 — waiting for client...")
- async with server:
- await server.serve_forever()
-
-asyncio.run(main())
-```
-
-### Client side — request, verify, decrypt
-
-```python
-# spike5_client.py — meshbay.org
-import asyncio, base64, json
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
-from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
-from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from cryptography.hazmat.primitives import hashes, serialization
-import blake3
-
-NODE_HOST = "<external_ip>" # from Spike 4
-NODE_PORT = 19000
-
-async def main():
- reader, writer = await asyncio.open_connection(NODE_HOST, NODE_PORT)
-
- # Request chunk 0
- request = json.dumps({"file": "testfile.bin", "chunk_index": 0}).encode()
- writer.write(request)
- await writer.drain()
-
- # Receive
- length_bytes = await reader.readexactly(4)
- length = int.from_bytes(length_bytes, "big")
- data = await reader.readexactly(length)
- writer.close()
-
- chunk = json.loads(data)
- print(f"[client] Received chunk {chunk['chunk_index']}")
-
- # 1. Verify signature
- pk_node_bytes = base64.b64decode(chunk["pk_node"])
- pk_node = Ed25519PublicKey.from_public_bytes(pk_node_bytes)
- ct = base64.b64decode(chunk["ciphertext"])
- nonce = base64.b64decode(chunk["nonce"])
- chunk_hash = base64.b64decode(chunk["chunk_hash"])
- sig = base64.b64decode(chunk["signature"])
-
- sig_payload = (0).to_bytes(4, "big") + nonce + chunk_hash
- pk_node.verify(sig, sig_payload) # raises on failure
- print("[client] Signature OK")
-
- # 2. Verify ciphertext hash
- assert blake3.blake3(ct).digest() == chunk_hash
- print("[client] Ciphertext hash OK")
-
- # 3. Derive chunk key and decrypt (GEK from POC hint — never in production)
- gek_raw = base64.b64decode(chunk["gek_hint"])
- # (in production, client has GEK from hub's GEK bundle)
- chunk_key = HKDF(
- algorithm=hashes.SHA256(), length=32, salt=None,
- info=b"file:" + bytes(32) + b":chunk:" + (0).to_bytes(4, "big")
- # Note: in production, file_hash is sent separately or in index
- ).derive(gek_raw)
- plaintext = ChaCha20Poly1305(chunk_key).decrypt(nonce, ct, None)
- print(f"[client] Decrypted {len(plaintext)} bytes")
- print("[client] Encrypted P2P transfer: SUCCESS")
-
-asyncio.run(main())
-```
-
-**Note on GEK in POC:** the GEK is included in the response as `gek_hint` for POC convenience only. In production, the client gets the GEK from the hub's encrypted GEK bundle (delivered at login, decrypted client-side with the user's X25519 private key).
-
-**Success criteria:**
-- Client receives chunk from node via direct TCP connection
-- Signature verification passes
-- Ciphertext hash matches
-- Decryption produces the original bytes
-- End-to-end: `original_bytes == decrypted_bytes` ✓
-
----
-
-## What POC Validates (and Doesn't)
-
-### Validated by these spikes
-
-| Concept | Spike | Validation |
-|---|---|---|
-| Python crypto stack is sufficient | 1 | All primitives work, performance acceptable |
-| Hub/Node JWT handshake | 2, 3 | JWT issued by hub, verified offline by node |
-| Hub-Node REST protocol (minimal MNP/HTTP) | 2, 3 | API contract works end-to-end |
-| SFR NAT traversal via UPnP | 4 | P2P reachability confirmed |
-| STUN external address discovery | 4 | Confirmed/fallback documented |
-| On-the-fly per-chunk encryption | 5 | GEK + HKDF chunk derivation + ChaCha20 |
-| Chunk signature and verification | 5 | Ed25519 sign/verify before decryption |
-| Real P2P file transfer | 5 | No hub in data path |
-
-### NOT in scope
-
-- Database (all in-memory)
-- HTTPS / TLS (HTTP for POC)
-- QUIC transport (plain TCP)
-- GEK bundle distribution via hub (GEK sent in-band for POC)
-- Group management
-- Chat / Double Ratchet
-- Mesh Group Index
-- MHP federation
-- Android client
-- Module system
-- Persistence between restarts
-
----
-
-## Spike Order Dependency Graph
-
-```
-Spike 1 (crypto)
- └──→ Spike 2 (hub skeleton)
- └──→ Spike 3 (node registration)
- └──→ Spike 4 (NAT traversal)
- └──→ Spike 5 (encrypted transfer)
-```
-
-Spike 1 is a prerequisite for all others. Spikes 2 and 3 can overlap if two people work in parallel. Spike 4 can begin independently once Spike 3 is running.
-
-
----
-
-<!-- ============================================================ -->
-<!-- ARCHIVED: Development Phases (1-12) (was docs/devel-phases.md) -->
-<!-- ============================================================ -->
-
-# MeshBay — Development Phases
-
-> Reference: architecture spec in draft v3 (section 3 of this archive)
-> POC results: `poc/spike-results.md`
-
----
-
-## Phase 1 — Foundations ✅ DONE
-
-**Goal:** validate all blocking concepts before writing production code.
-
-### Deliverables
-
-| Item | Status | Notes |
-|---|---|---|
-| POC Spike 1 — Crypto primitives | ✅ | All 22 tests pass. ChaCha20 1MB in 1.1ms. |
-| POC Spike 2 — Hub skeleton | ✅ | 12/12 endpoints. JWT EdDSA offline verify in 884µs. |
-| POC Spike 3 — Node registration | ✅ | Full handshake. jti bug found and fixed. |
-| POC Spike 4 — NAT traversal | ✅ | Cone NAT on SFR. UDP P2P works. UPnP disabled (SFR). |
-| POC Spike 5 — Encrypted transfer | ✅ | 1MB P2P. 3.2ms encrypt, 3.9ms decrypt. 4.3MB/s. |
-| POC Spike 6 — GEK distribution | ✅ | X25519+HKDF wrap/unwrap. 0.48ms/0.59ms. Hub opaque. |
-| Security cleanup meshbay.org | ✅ | UFW: 22/80/443 only. No services exposed. |
-| Git monorepo | ✅ | 3 packages: meshbay-common, meshbay-hub, meshbay-node. |
-| Draft v3 | ✅ | POC findings integrated. All corrections applied. |
-| CLAUDE.md conventions | ✅ | Python 3.12+, uv, ruff, SemVer, commit format. |
-
-### Key findings from POC
-
-- jti mandatory in all JWTs (Ed25519 is deterministic — same payload = same token)
-- Argon2id at 64MB/3iter = 78ms — increase to 256MB for production (~500ms target)
-- NAT order: STUN/hole-punching is priority 2, not UPnP (UPnP disabled on tested SFR box)
-- TCP+TLS for v1 transport; QUIC in v2
-- GEK wrapping: ephemeral X25519 + HKDF(salt=pk_eph) + ChaCha20-Poly1305(aad=pk_recipient)
-
----
-
-## Phase 2 — Node v1 ✅ DONE
-
-**Goal:** working Mesh Node: indexes a directory, registers with hub,
-serves encrypted chunks over TCP+TLS, local web UI on localhost:18000.
-
-**Transport:** TCP+TLS 1.3 (QUIC in v2). Self-signed cert per node.
-Node identity verified via Ed25519 PK from hub, not TLS cert chain (client uses CERT_NONE).
-
-### Milestones
-
-| # | Component | File(s) | Tests |
-|---|---|---|---|
-| 2.1 | Keystore | `meshbay_node/keystore.py` | 10/10 |
-| 2.2 | Hub client | `meshbay_node/hub_client.py` | 6/6 |
-| 2.3 | Directory indexer | `meshbay_node/indexer/indexer.py` | 5/5 |
-| 2.4 | Mesh Group Index | `meshbay_node/indexer/group_index.py` | 5/5 |
-| 2.5 | TCP+TLS chunk server | `meshbay_node/transport/server.py` | 3/3 |
-| 2.6 | TCP+TLS chunk client | `meshbay_node/transport/client.py` | included above |
-| 2.7 | TLS cert helper | `meshbay_node/transport/tls_cert.py` | — |
-| 2.8 | Config (TOML + env) | `meshbay_node/config.py` | — |
-| 2.9 | Local web UI | `meshbay_node/ui/app.py` | — |
-| 2.10 | Daemon + CLI | `meshbay_node/daemon.py` | — |
-
-**Total: 29/29 tests passing**
-
-### Python dependencies (dev venv — `/home/cbesson/meshbay/.venv`)
-
-Installed packages (freeze) as of Phase 2 completion:
-
-```
-aioice==0.10.2 # ICE/STUN for NAT traversal
-blake3==1.0.9 # fast content hashing
-cryptography==50.0.0 # Ed25519, X25519, ChaCha20, Argon2id, AES-GCM
-fastapi==0.141.1 # local web UI + hub POC
-httpx==0.28.1 # hub client HTTP
-meshbay-common==0.1.0 # editable install
-meshbay-node==0.1.0 # editable install
-msgpack==1.2.1 # wire serialisation
-PyJWT==2.13.0 # JWT EdDSA
-pytest==9.1.1 # test runner
-pytest-asyncio==1.4.0 # async test support
-uvicorn==0.52.1 # ASGI server (local UI)
-watchdog==6.0.0 # filesystem watcher
-zstandard==0.25.0 # zstd compression
-```
-
-Also installed transitively: pydantic 2.13.4, starlette 1.6.0, anyio 4.14.2, uvloop 0.22.1.
-
-### meshbay_common/crypto.py — Argon2id NOTE
-
-Current params: `iterations=3, memory_cost=65536` (64MB) → ~78ms on dev laptop.
-**Must increase to `memory_cost=262144` (256MB) before production keystore use.**
-Run `meshbay-node calibrate-argon2` on target hardware to tune.
-
-### Out of scope for Phase 2
-
-Multiple groups, chat, QUIC, module sandbox, mobile pairing, HLS streaming.
-
----
-
-## Phase 3 — Hub v1 production ✅ DONE
-
-**Goal:** replace POC in-memory hub with a production-ready service on meshbay.org.
-PostgreSQL persistence, HTTPS via Caddy, all endpoints hardened, legal IP logging, deploy.
-
-### Environment
-
-- **Server:** meshbay.org — Ubuntu 26.04 LTS, Python 3.14.4, OVH VPS
-- **Database:** PostgreSQL 16 (to install)
-- **Proxy:** Caddy (to install — handles Let's Encrypt automatically)
-- **Service:** systemd `meshbay-hub.service`
-
-### Python dependencies to add (Phase 3)
-
-```
-sqlalchemy>=2.0 # async ORM (SQLAlchemy 2.x)
-alembic>=1.13 # DB migrations
-asyncpg>=0.30 # PostgreSQL async driver
-aiosqlite>=0.20 # SQLite async driver (tests only)
-slowapi>=0.1 # rate limiting (FastAPI middleware)
-```
-
-### Milestones
-
-| # | Component | File(s) | Status |
-|---|---|---|---|
-| 3.1 | DB models | `meshbay_hub/db/models.py` | ✅ |
-| 3.2 | DB engine + session | `meshbay_hub/db/engine.py` | ✅ |
-| 3.3 | Alembic migrations | `meshbay_hub/db/migrations/` | ✅ initial_schema |
-| 3.4 | Hub config | `meshbay_hub/config.py` | ✅ |
-| 3.5 | Auth (JWT + Argon2id) | `meshbay_hub/auth.py` | ✅ |
-| 3.6 | API deps | `meshbay_hub/api/deps.py` | ✅ |
-| 3.7 | Hub info router | `meshbay_hub/api/hub.py` | ✅ |
-| 3.8 | Users router | `meshbay_hub/api/users.py` | ✅ |
-| 3.9 | Nodes router | `meshbay_hub/api/nodes.py` | ✅ |
-| 3.10 | Groups router | `meshbay_hub/api/groups.py` | ✅ |
-| 3.11 | Rate limiting | `meshbay_hub/api/middleware.py` | ✅ slowapi |
-| 3.12 | App factory + lifespan | `meshbay_hub/app.py` | ✅ |
-| 3.13 | Hub daemon CLI | `meshbay_hub/daemon.py` | ✅ |
-| 3.14 | PostgreSQL 16 | meshbay.org | ✅ DB: meshbay_hub |
-| 3.15 | Caddy + Let's Encrypt | meshbay.org Caddyfile | ✅ HTTPS auto-cert |
-| 3.16 | Systemd service | `/etc/systemd/system/meshbay-hub.service` | ✅ |
-| 3.17 | Deploy + smoke test | https://meshbay.org | ✅ 11/11 endpoints |
-
-**Total: 40 local tests (SQLite) + 11/11 smoke tests HTTPS production**
-
-### Phase 3 deployment details (meshbay.org)
-
-- PostgreSQL 16, user `meshbay`, DB `meshbay_hub`
-- Caddy auto-handles Let's Encrypt for `meshbay.org` and `www.meshbay.org`
-- `www.meshbay.org` → 301 redirect → `meshbay.org`
-- TLS setup documented in `HTTPS.md`
-- Hub listens on `127.0.0.1:8000`, Caddy proxies 80/443
-- Systemd service: `meshbay-hub.service` (restart-on-failure)
-- Hub config: `~/.config/meshbay/hub.toml`
-- Hub keypair: `~/.config/meshbay/hub_private.pem` (chmod 600)
-- Source deployed at: `~/meshbay-hub/` (common_pkg + hub_pkg)
-
-### Additional Python dependencies added in Phase 3
-
-```
-sqlalchemy==2.0.51 # async ORM
-alembic==1.19.1 # DB migrations
-asyncpg==0.31.0 # PostgreSQL async driver
-aiosqlite # SQLite async (tests only)
-slowapi==0.1.10 # rate limiting
-hatchling==1.31.0 # build backend (needed for pip install)
-```
-
-### Notes
-
-- Initial DB schema created via `init_db()` (`create_all`) — Alembic tracks future changes
-- IP logging records: account_create, login, login_fail, group_create, node_announce
-- Rate limiting active on /v1/users/register and /v1/users/login (slowapi)
-- Revocation, CSAM hash matching, moderation deferred to Phase 5
-
-### Phase 3 scope
-
-**In scope:**
-- All POC endpoints from Spike 2+6, production-ready
-- PostgreSQL via SQLAlchemy async + Alembic migrations
-- IP logging (creation, login, group events) — 1-year retention, legal compliance
-- Access token (JWT, 1h) + refresh token (30d, stored hashed)
-- Rate limiting on auth endpoints
-- Hub config file `/etc/meshbay/hub.toml` or env vars
-- HTTPS via Caddy + auto Let's Encrypt on meshbay.org
-- Systemd service with restart-on-failure
-
-**Deferred to later:**
-- Revocation push (WebSocket signaling to nodes)
-- CSAM hash matching (NCMEC/IWF integration)
-- Moderation flow (blocklist + takedown)
-- MHP federation
-- RPM/DEB packaging
-
-### Testing strategy
-
-- Unit tests with SQLite in-memory (`aiosqlite`) — no PostgreSQL needed locally
-- API tests via `httpx.AsyncClient` + `ASGITransport` — no network
-- All tests run in the existing `.venv` after adding Phase 3 deps
-
----
-
-## Phase 4 — Integration & Web Client ✅ DONE
-
-**Goal:** end-to-end working product in a browser: login via hub, discover groups,
-browse files on a node, download and stream public content.
-
-### Architecture decision
-
-Browsers cannot make raw TCP connections — the node must speak HTTP.
-- Node adds an **HTTP file API** (port 19001) serving public content via standard `fetch()`
-- Private content (GEK decrypt in browser) deferred to Phase 5 (requires WebCrypto + ChaCha20 WASM)
-- Hub serves the web application at `meshbay.org/app/`
-
-### Milestones
-
-| # | Component | File(s) | Status |
-|---|---|---|---|
-| 4.1 | Node HTTP file API | `meshbay_node/transport/http_server.py` | ✅ 7/7 tests |
-| 4.2 | Hub web app (HTML/JS) | `meshbay_hub/api/webapp.py` + `static/app.js` | ✅ live on meshbay.org |
-| 4.3 | Group listing endpoint | `meshbay_hub/api/groups.py` GET /v1/groups | ✅ |
-| 4.4 | End-to-end integration test | local node ↔ hub ↔ browser | ✅ smoke test |
-| 4.5 | HLS streaming (public) | node `/hls/{id}/playlist.m3u8` + `.ts` via ffmpeg | ✅ included in 4.1 |
-
-**Total: 47/47 tests. https://meshbay.org live with web client.**
-
-### Phase 4 deployment
-
-- `https://meshbay.org/` — web app (HTML/JS SPA)
-- `https://meshbay.org/app.js` — JS client
-- `https://meshbay.org/v1/groups` — public group listing (no auth)
-- Node HTTP API (port 19001): `/index`, `/file/{id}`, `/hls/{id}/*.m3u8`, `/hls/{id}/*.ts`
-- HLS streaming uses ffmpeg for on-the-fly segmentation
-- Public content: no auth for index, auth required for chunks
-- Private content (GEK decrypt in browser): deferred to Phase 5
-
-### Dependencies added in Phase 4
-
-```
-# Node (runtime)
-ffmpeg (system package) — HLS segmentation via subprocess
-```
-
-### Phase 4 scope
-
-**In scope (public content only):**
-- Node HTTP API: serve public Mesh Group Index (JSON) + file chunks (binary)
-- Hub web app: login, group discovery, file browser, download link
-- HLS basic streaming: node segments video on-the-fly, browser plays natively
-- JWT auth passed as query param or header to node HTTP API
-
-**Deferred to Phase 5:**
-- Private group content in browser (requires ChaCha20-Poly1305 via WASM)
-- Chat UI
-- Multi-group node
-- Android client
-
----
-
-## Phase 5 — QUIC, Federation, Moderation ✅ DONE (Mobile deferred)
-
-**Goal:** full decentralization, mobile support, community infrastructure.
-
-| # | Component | Notes |
-|---|---|---|
-| # | Component | File(s) | Status |
-|---|---|---|---|
-| 5.1 | QUIC transport (MNP v2) | `transport/quic_server.py` + `quic_client.py` | ✅ 3/3 tests |
-| 5.2 | MHP federation | `meshbay_hub/api/federation.py` | ✅ GET/POST /mhp/* |
-| 5.3 | Mesh Relay | `meshbay_hub/api/relay.py` | ✅ register+list+approve |
-| 5.4 | Android client | — | ⏳ deferred (different tech) |
-| 5.5 | Content replication | — | ⏳ deferred |
-| 5.6 | iOS client | — | ⏳ after Android |
-| 5.7 | Revocation push | `api/revocation.py` + `node/revocation.py` | ✅ 3/3 tests |
-| 5.8 | CSAM hash matching | `meshbay_hub/csam.py` | ✅ CSAMChecker + admin API |
-| 5.9 | Moderation | `api/moderation.py` | ✅ 6/6 tests |
-| 5.10 | RPM/DEB packaging | `packaging/` | ✅ spec + control + systemd |
-
-**Total: 59/59 tests.**
-
-### Phase 5 bugs fixed
-
-- **QUIC**: `asyncio.Event` race condition in client recv loop (quic_event_received
- overwrote `_stream_events[0]` created by `_recv`). Fixed with `asyncio.Queue`
- (no shared mutable state between coroutines).
-- **QUIC**: `verify_peer` parameter renamed to `verify_mode` in aioquic 1.3.0.
-- **QUIC**: `connect()` returns protocol directly (not `(transport, proto)` tuple).
-
-### Phase 5 dependencies added
-
-```
-aioquic==1.3.0 # QUIC transport (Cloudflare-maintained)
-websockets # hub→node revocation push
-```
-
-### Deferred to future
-
-- Android/iOS client (Kotlin/Flutter — different tech stack, dedicated effort)
-- Content replication (node-to-node) → Phase 6
-- MHP federation persistence (currently in-memory) → Phase 6
-
----
-
-## Phase 6 — Chat, Multi-group, Federation persistence, Replication ✅ DONE
-
-**Goal:** complete the product with group chat (Double Ratchet), multi-group node support,
-persistent MHP federation, content replication, and browser private group decryption.
-
-### Milestones
-
-| # | Component | File(s) | Status |
-|---|---|---|---|
-| 6.1 | Double Ratchet chat | `meshbay_common/ratchet.py` | ✅ 11/11 tests |
-| 6.2 | Multi-group node | `meshbay_node/config.py` [[groups]] | ✅ |
-| 6.3 | MHP federation persistence | `FederatedGroup` + `SwarmSource` DB tables | ✅ |
-| 6.4 | Content replication | `node/replication.py` + hub `/v1/swarm/*` | ✅ |
-| 6.5 | Browser private group | `webcrypto.py` + `static/crypto.js` | ✅ 4/4 tests |
-| 6.6 | Dérivation clés depuis password | `meshbay_common/keyderive.py` + `static/keyderive.js` | ✅ 7/7 tests |
-| 6.7 | Bundle clés chiffré (web) | Hub: `keypair_bundle` field + migration Alembic | ✅ |
-| 6.8 | Scripts démo opérationnels | `QE/demo-v1/` + `QE/demo-v2/` (non versionné) | ✅ testés |
-| 6.9 | QUICKSTART réécrit | `QUICKSTART.md` | ✅ |
-| 6.6 | Dérivation clés depuis password | `meshbay_common/keyderive.py` + `static/keyderive.js` | ✅ 7/7 tests |
-| 6.7 | Bundle clés chiffré (web) | Hub: `keypair_bundle` field + migration Alembic | ✅ |
-| 6.8 | Scripts démo opérationnels | `QE/demo-v1/` (non versionné) | ✅ testés |
-| 6.9 | QUICKSTART réécrit | `QUICKSTART.md` | ✅ |
-
-**Total: 81/81 tests.**
-
----
-
-## Phase 7 — Node v2: production, streaming, chat ✅ DONE
-
-**Goal:** multi-group node, Sender Keys chat, QUIC 0-RTT, jti denylist push, HLS streaming.
-
-Commit: fc56585 — 26 files, +2155/−159 lines, 109 tests.
-
-See `devel-phases-next.md` for details.
-
----
-
-## Phase 8 — Hub v2: admin, federation, security ✅ DONE
-
-**Goal:** admin roles, email encryption, refresh token rotation, rate limiting, healthcheck.
-
-Commit: 46918ec — 20 files, +508/−90 lines, 117 tests.
-Deployed to meshbay.org. All security review items S1/S2/S5 resolved.
-
-See `devel-phases-next.md` for details.
-
----
-
-## Phase 9 — Web client: WebRTC transport + core SPA ✅ DONE
-
-**Goal:** browser connects P2P to a node behind residential NAT via WebRTC DataChannel.
-Full SPA: login, groups, file browser, download, video playback, chat, i18n, dark/light.
-
-### Milestones
-
-| # | Component | Status |
-|---|---|---|
-| 9.1–9.5 | WebRTC DataChannel spike + E2E NAT validation | ✅ |
-| 9.6–9.12 | Preact SPA (login, groups, files, video, chat, i18n, settings) | ✅ |
-| 9.13 | Tests: 132 passing | ✅ |
-| 9.14–9.16 | Performance: pipelining, binary wire format, I/O reduction | ✅ |
-| 9.17 | Large file download: File System Access API (stream to disk) | ✅ |
-
-**Total: 132/132 tests. Deployed to meshbay.org + Orange node (2026-08-11).**
-
-### Key technical decisions
-
-- **Transport:** WebRTC DataChannel (aiortc on node) — browsers can't use QUIC for NAT traversal
-- **Wire format:** length-prefixed msgpack, binary chunk fields (no base64)
-- **UI:** Preact + htm ESM (vendored, no build step, no CDN, no npm)
-- **Crypto:** WebCrypto SubtleCrypto AES-256-GCM for E2E chunk decryption in browser
-- **Large files:** File System Access API (`showSaveFilePicker`) — stream to disk, ~8 MB RAM
-- **Indexer:** 2s debounce + path-based dedup for file copy events
-
-### NAT traversal validated
-
-Two ISPs (SFR + Orange residential NAT), Chrome + Firefox, IPv4 STUN + IPv6 direct.
-No TURN relay needed. See `devel-phases-next.md` for detailed test matrix.
-
-### QE deployment state (2026-08-11)
-
-- **Hub (meshbay.org):** running as `meshbay-hub.service`, DB has 3 users
- (admin, cbesson, grenet), 1 group (`d3bbd90b`), `admin_usernames = ["admin"]`
-- **Node (Orange host via `ssh cbesson@localhost -p 2222`):** running as
- `nohup .venv/bin/python3 QE/demo-v3/run_node_simple.py`, user grenet,
- connected via WS to hub, WebRTC + QUIC dual transport
-- **Credentials:** `QE/demo-v3/creds.json` (not versioned)
-- **Shared dir on node:** `~/meshbay/QE/demo-v3/shared/`
-- All 3 users password: see creds.json
-
-### Dependencies added
-
-- `aiortc>=1.9` in meshbay-node (WebRTC DataChannel)
-- `preact` + `htm` vendored as `static/vendor/htm-preact.js` (ESM, ~3 KB gzipped)
-
----
-
-## Conventions
-
-- Commits: `feat(node):`, `fix(hub):`, `chore(common):`, `docs:`, `test(node):`
-- Branch per feature/fix, merge to `main` (when remote configured)
-- **Always close test UFW ports after any spike on meshbay.org**
-- **Never commit key material** (keystore.enc, hub_private.pem, *.key, node_state.json, unlock.key)
-- meshbay.org is internet-facing: only run known-safe services, close ports after tests
diff --git a/docs/per-node-identity-v1.md b/docs/per-node-identity-v1.md
deleted file mode 100644
index da3c6d4..0000000
--- a/docs/per-node-identity-v1.md
+++ /dev/null
@@ -1,225 +0,0 @@
-# MeshBay — Per-node identity
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the per-node identity design; its design
-> content now lives in §3.2.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **implemented 2026-08-14**, deployed and exercised end to end against a wiped
-> hub and a wiped node. Written first as a proposal; §9 records what shipped.
-> Reduces **C4** from "one crack yields the network" to "one crack yields one node".
-> Removes the hub-published user keys, which stopped being load-bearing when H3 closed.
-> Follows the v5 convention: every claim names the adversary it holds against.
-
----
-
-## 1. What this changes, in one paragraph
-
-Today one identity keypair is copied to every node its owner joins. Cracking the copy
-stored on *any* node yields the identity used on *all* of them. This proposal gives each
-node its own keypair, generated the first time its owner joins it. An operator who cracks
-what sits on their own disk then holds a key that is worthless anywhere else — and on
-their own node they already hold everything it could unlock.
-
-No new screen, no extra code to type, no change to how anyone signs in.
-
----
-
-## 2. Where we are
-
-Two things are **already per node**, which is what makes this cheap:
-
-| | Today |
-|---|---|
-| Roster pin | per node — each node pins the key it was shown (`roster.identities`) |
-| Bundle storage | per node — each node has its own `bundles.db` |
-| **Key material** | **global — the same keypair is copied into every node's database** |
-
-So the plumbing is in place and only the contents are shared. The change is: generate a
-fresh keypair per node instead of reusing one.
-
-The global key exists because registration creates it (`keyderive.js registerUser`) and
-publishes it to the hub, from where everything else used to fetch it. Since H3 closed,
-almost nothing does.
-
----
-
-## 3. The design
-
-### 3.1 One keypair per node
-
-The first time a browser joins node N, it generates a keypair for N, encrypts it under the
-passphrase-derived key, and stores it on N — which is the message it already sends
-(`keypair_bundle_store`). N pins the public half through the ordinary join, using the
-pairing code its operator already issues for a first contact.
-
-Nothing else in the join changes: same transcript, same code, same pin, same refusals.
-
-### 3.2 Flows
-
-**First join to a node** — unchanged from the user's side:
-
-```
-browser no key for node N yet → generate one
-browser → N join_request {pk_ed25519_N, pk_x25519_N, code, sig}
-N code valid → pins the pair, wraps the GEK for pk_x25519_N
-browser → N keypair_bundle_store (that node's key, encrypted under the passphrase)
-```
-
-**Second browser, same node** — unchanged:
-
-```
-browser → N keypair_bundle_fetch → decrypt with the passphrase → same key as browser 1
-browser → N join_request (no code) → recognised
-```
-
-**Joining a second node** — already requires a code from that node's operator, so the new
-key is generated in the same step. The user does nothing extra.
-
-**Operator pairing** — identical; the operator's key is per node like everyone else's.
-
-### 3.3 Where the browser keeps them
-
-A map `node_id → bundle` in IndexedDB, instead of one identity. A browser that has never
-seen node N simply fetches N's bundle from N; it only ever needs the key of the node it is
-talking to.
-
----
-
-## 4. What this fixes, and what it does not
-
-**Fixes: the blast radius.** An operator who cracks the bundle on their own disk gets the
-key used with their own node. There they already control the content, the index and every
-byte they serve — the key adds almost nothing. What disappears is the part that mattered:
-reading that person's content on **other** operators' nodes, and signing as them anywhere
-else. That is the whole of what made C4 more than a redundancy.
-
-**Fixes, incidentally: linkability.** Two operators can no longer tell they host the same
-person by comparing keys. Today they can.
-
-**Does not fix:** a weak passphrase still gives up that node's key, and the bundles still
-sit on disks their owner does not control. Only Phase 13.3 (native client, keys on the
-device) removes that. C4 stays open, with a smaller consequence.
-
-**Does not change** the operator's ability to read what they host. That is by design and
-stated in draft-v5 §2.
-
----
-
-## 5. Removing the hub-published user keys
-
-They were the directory H3 exploited. Since the node wraps the GEK itself, nothing wraps
-anything for a key fetched from the hub. What remains is inventory.
-
-### 5.1 What still uses them
-
-| Use | Verdict |
-|---|---|
-| `_sessionKeys.pkXB64` set from `/pubkeys` (`app.js:974, 2768`) | **replaceable** — the browser can derive its own public half from its own secret (`_pkXFromSk`, already written) |
-| Invite: username → `user_id` (`app.js:1602`, `ui/app.py`) | **keep the endpoint** — an account id is not a key, and it is how a name is resolved |
-| Settings: `pk_node_ed25519` (`app.js:2210`) | **keep** — that is the node linking key, a different field |
-| JWT claim `pk_user` (`auth.py:151`, filled from `user.pk_ed25519`) | **remove** — see 5.2, it is a live defect |
-
-### 5.2 A defect this uncovered
-
-`pk_user` travels in the JWT and the node records it as `uploader_pk` at upload
-(`webrtc_server.py:1178`), then uses it to authorize deletion by the uploader
-(`:1329-1340`). That key is chosen by the **hub**. A hub that issued a token naming its own
-key could then delete that user's files on any node — deny-by-default was supposed to make
-deletion node-authorized, and this is a hole in it.
-
-With per-node identity there is a better answer available for free: authorize deletion
-against the key the **roster pinned**, which the node established locally and the hub never
-touched. This should be fixed whether or not the rest of the proposal proceeds.
-
-### 5.3 What goes
-
-- `User.pk_ed25519`, `User.pk_x25519` columns (Alembic migration)
-- `pk_user_ed25519` / `pk_user_x25519` in the registration body
-- `PUT /v1/users/me/keys`, and `regenerateKeys()` in `keyderive.js` — rotation becomes
- per node: `member unpin` plus a fresh code, which already exists
-- the `pk_user` JWT claim, and `AuthorizedPeer.pk_user`
-- the two key fields in the `/pubkeys` response; the endpoint stays for `user_id` and
- `pk_node_ed25519`
-
-Old tokens keep working while they live (1 h): the node already reads the claim with
-`.get()`, so its absence is not an error.
-
----
-
-## 6. Work plan
-
-| # | Slice | Where | Effort |
-|---|---|---|---|
-| 1 | `uploader_pk` from the roster pin, not the JWT | `webrtc_server.py` | small — and it is a fix on its own |
-| 2 | Per-node keypair: generate at first join, store per node, keep a `node_id → bundle` map | `app.js`, `transport.js`, `keyderive.js` | **the bulk of it**, all client-side |
-| 3 | Derive our own `pkX` locally instead of reading it back from the hub | `app.js` | small |
-| 4 | Remove the published keys: columns, endpoint fields, registration body, JWT claim, `regenerateKeys` | `db/models.py`, `api/users.py`, `auth.py`, migration, `keyderive.js`, `handshake.py` | medium, touches the schema |
-| 5 | Harness + docs: `e2e.py` per-node keys, draft-v5 §2/§7.1/§8.1, `second-review.md` | QE, docs | small |
-
-Slices 1 and 3 stand alone and could land first. Slice 4 is the only one with a migration.
-
----
-
-## 7. Risks and open questions
-
-**Existing users.** No big-bang migration: a bundle already on a node simply becomes that
-node's key, and only *new* joins generate fresh ones. Someone already on three nodes keeps
-one shared key across those three until they re-pair — the improvement applies going
-forward. Forcing it would mean unpinning everyone, which is not worth it.
-
-**A browser that loses its map.** It refetches from the node it is connecting to; nothing
-is lost, since a node's key is only needed with that node.
-
-**Is per node the right granularity, rather than per group?** Per node matches the roster,
-which pins per account and not per group, and matches the trust boundary — the operator is
-the adversary, and one operator may host several of your groups. Per group would multiply
-keys with no adversary to justify them.
-
-**Does anything need one identity across nodes?** Nothing found. Chat identity and upload
-attribution are per node; account identity on the hub is the username plus `auth_key`, which
-is untouched. If a future feature needs a global identity — cross-node contacts, say — it
-would need its own key, published deliberately, not this one reused by accident.
-
----
-
-## 8. How it gets tested
-
-- `e2e.py`: a member joins two groups on the node with one key (unchanged), and the
- second-browser recovery still works. Add a check that the key stored on the node is the
- one pinned there, not a global one
-- a negative test: a key pinned by node A, presented to node B, is refused without a code —
- which is the property the whole proposal buys
-- `test_spa_ordering.py`: generation of the per-node key must happen before `joinGroup()`,
- same class of ordering guard as the others
-- the hub tests must fail if `/pubkeys` starts returning user keys again
-
-
----
-
-## 9. What shipped
-
-All five slices, against a deployment wiped for the next demo — so no compatibility path
-was kept and none is owed.
-
-| Slice | Outcome |
-|---|---|
-| 1 | `uploader_pk` comes from the roster pin (`_pinned_pk`), never from the token |
-| 2 | Identity is created at first contact with a node and left there; `transport.js` fetches it or generates it, `app.js` no longer holds a global one |
-| 3 | The browser derives its own public half; nothing is read back from the hub |
-| 4 | `users.pk_ed25519` / `pk_x25519` dropped (migration `a7c31f9e40b2`), `PUT /me/keys` and `regenerateKeys()` gone, `pk_user` claim gone, `/pubkeys` reduced to an account id and the node linking key |
-| 5 | Harness mirrors the client (recover, else generate, then leave the key with the node); tests for the property; docs |
-
-**A simplification worth noting.** Registration no longer generates anything, so a scripted
-signup is now a real account — `demo.py bootstrap` brings a wiped deployment to a working
-demo without a browser, which was impossible before. The old rule "only the admin can be
-registered by script" is gone with the keys it existed for.
-
-**Tests added:** a key pinned by one node, presented to another, is refused as a first
-contact; and someone else's code does not admit it either. That pair is the property this
-whole change buys.
diff --git a/docs/photos.md b/docs/photos.md
deleted file mode 100644
index b5612ec..0000000
--- a/docs/photos.md
+++ /dev/null
@@ -1,516 +0,0 @@
-# MeshBay — Photos application (design)
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the Photos application design; its design
-> content now lives in §9.9.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **built** — `photos-app.js`, `photos-app-settings.js` and
-> `enrich_photo.py` all shipped; this header said "not implemented" long after
-> they did. Read `docs/apps.md` first — Photos is
-> a new group application built on the plug-in mechanism described there.
-> Read `docs/mediacenter.md` and `docs/musicbay.md` second: Photos reuses
-> their node-side pattern (thumbnails generated and cached by the node,
-> delivered through the existing chunk path, `IndexEntry` gains a few more
-> additive fields) wherever the same shape applies, and this document states
-> only where Photos differs and why.
->
-> Follows the project convention: every claim names the adversary it holds
-> against (§8).
-
----
-
-## 0. What was asked, in one paragraph
-
-A group "application" with the same principles as Videos/Music — a view over
-the existing file index, no catalogue, enable/disable per group on the same
-signed-op mechanism — for a shared photo library: the classic photo-album
-elements (album grid, next/previous within a folder, a lightbox), a small
-button to download a photo folder as a zip (Files already has this), and
-optional per-photo info read from the image's own EXIF data. Three things are
-explicitly **not** wanted, and they are what makes Photos smaller than Videos,
-not bigger: **several** root folders rather than one, a single album-grid view
-rather than a mode toggle with a "flat" fallback, and no third-party service
-at all.
-
----
-
-## 1. What this design does not reopen
-
-Everything Videos/Music already established stands, and this plan fits
-inside it:
-
-- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10,
- draft-v6 §2.7). A file stays tied to its filesystem representation; an
- album is a directory, exactly as a season is a folder in Videos.
-- **The apps plug-in mechanism** (`apps.md`): a new `photos-app.js`, one
- registry entry, one node-side `ALLOWED_APPS` entry, i18n keys, the asset
- list, the two file-set tests. Enablement is a per-group, operator-signed
- setting, same shape as `member_upload`/`apps_enabled`.
-- **Group-related server state lives on the node** (E9). Nothing here puts a
- row on the hub.
-- **Node-side derived-data caching, never in a shared root** — thumbnails
- live in `data_dir/media_cache.db`, the same file Videos and Music already
- use, never beside the originals.
-- **Filesystem portability** (§6.8) — nothing here writes into a shared root.
-- **`thumb_hash`/`width`/`height` on `IndexEntry` are already generic**, not
- video-specific despite their current comments (`protocol.py:166`) — Photos
- populates them exactly like Videos does, no new delivery mechanism.
-
----
-
-## 2. Where Photos differs from Videos/Music, and why
-
-### 2.1 Several roots, not one
-
-Videos and Music each gate on a single `video_root`/`audio_root` — one
-folder, because their expensive work (TMDB/MusicBrainz lookups) needed an
-explicit, deliberate opt-in and a real media library is usually one tree.
-A photo library is routinely scattered: a "Vacances" folder here, a
-"Famille" folder there, an old "Scans" folder from a different import,
-none of them nested inside a common parent that would make sense to expose
-whole. **Photos takes a *set* of root folders**, each independently chosen,
-each independently removable.
-
-- New per-group setting: `photo_roots` — a JSON list of root-relative paths,
- stored the same way `enabled_apps` already is (`roster.py`,
- `SETTING_ENABLED_APPS`'s own `json.dumps(sorted(...))` pattern):
-
- ```python
- SETTING_PHOTO_ROOTS = "photo_roots"
-
- async def photo_roots(self, group_id: str) -> list[str]:
- value = await self.get_setting(group_id, self.SETTING_PHOTO_ROOTS)
- if value is None:
- return []
- try:
- return list(json.loads(value))
- except (ValueError, TypeError):
- return []
-
- async def set_photo_roots(self, group_id: str, roots: list[str],
- set_by: str = "") -> list[str]:
- await self.set_setting(group_id, self.SETTING_PHOTO_ROOTS,
- json.dumps(sorted(roots)), set_by)
- return roots
- ```
-
- Empty list means "nothing configured yet" — same "absent means show
- nothing" discipline `underVideoRoot` already established, not "the whole
- index": the node runs no thumbnail/EXIF work for a group before at least
- one root exists either (§2.3's enrichment gate), so falling back to
- everything would show files nothing has enriched.
-
-- New signed op, same shape as `apps_enabled` (a *set*, not a single value,
- signed in one message rather than one op per root — adding three folders
- in Settings costs one signature):
-
- ```
- photo_roots { roots: [...] } # client → node
- photo_roots_ack { roots: [...] } # node → every connected peer
- ```
-
- `OP_PHOTO_ROOTS = "photo_roots"` in `adminop.py`, subject = the sorted,
- comma-joined root list — identical convention to `apps_enabled`'s subject,
- so the operator's browser and the node arrive at identical bytes to
- sign/verify without inventing a second serialization.
-
-- **Validation happens before a signature is ever asked for**, same
- principle as `apps_enabled`'s "empty set refused up front" and
- `video_root`'s path check: every candidate path is resolved against the
- group's actual `RootSet` and must name a real, currently-readable
- directory, or the whole request is refused immediately — one bad path
- in a batch of five never reaches the operator's browser as a signing
- prompt. Unlike `apps_enabled`, an **empty** `roots` list is accepted (it
- is the "nothing configured yet" state, not a lockout — there is no
- Photos-equivalent of "the operator would be locked out of their own
- group" to guard against here).
-- Broadcast in `handshake_ack` next to `video_root`/`audio_root`
- (`"photo_roots": list(self._group_ctx().get("photo_roots") or [])`), and
- `photo_roots_ack` to every already-connected peer on change, same as
- `video_root_ack`.
-- **No root may be nested inside another already-configured root** —
- same rule §6.7 of `desktop-client-v1.md` already applies to a group's
- *named* roots, applied here one level down to avoid double-listing the
- same directory's images once directly and once as part of a parent.
- Checked case-insensitively (§6.8), at validation time, alongside the
- real-directory check.
-
-### 2.2 UI: an add/remove list, not a single `<select>`
-
-`group-settings.js`'s existing `videoRootDraft`/`saveVideoRoot` pattern
-(a depth-indented `<select>` built from `rootFolderOptions`, one path,
-confirm-on-change) does not fit a *set*. Photos gets its own small
-component: the same `rootFolderOptions` `<select>` to **pick a folder to
-add**, plus a list of already-configured roots each with a remove button,
-and one **Save** that signs the whole resulting set in one op — mirroring
-how `apps_enabled`'s checkbox list stages several changes before one
-signature, not `video_root`'s single-value save. No separate "confirm,
-this is destructive" dialog is needed the way `video_root`'s change has:
-removing one root only drops that root's albums from view, it does not
-replace the whole tab's content the way changing `video_root` does.
-
-### 2.3 One view, not two — and no third-party service
-
-Videos and Music each offer a toggle between an enriched view (TMDB/
-MusicBrainz-driven grouping) and a plain "flat" fallback, because the
-enriched view can fail to resolve a match and the flat view is the
-honest fallback for that case. **Photos has no enriched view to fall back
-from** — there is no external catalogue for photos, no matching step that
-can succeed or fail. So there is exactly **one** mode: a directory-driven
-album grid, which is structurally what Videos' own "flat" mode already is.
-This is also why the toolbar has no poster/flat toggle and no `localStorage`
-view-mode preference — nothing to choose between.
-
-**Concretely, per this document's title, "pas de vue de type Flat files"
-means:** no raw sortable file-listing table (that is Files' job, already
-available in the same group), and no secondary "ungrouped, alphabetical"
-mode alongside a primary one — the album grid *is* the only mode, not one
-of two.
-
-**No 3rd-party lookups at all.** Per-photo "info" (§2.4) is read from the
-file's own embedded EXIF data, entirely locally, on the node, at index
-time — no credential, no `tmdb_enabled`-style per-group toggle, no
-`tmdb_api_token`-style node-wide config, no outbound network call to
-anything. This is strictly less exposure than Videos/Music (§8): the class
-of risk `mediacenter.md` §8 spent a whole table row on ("new outbound
-traffic (node → TMDB)") does not exist for Photos.
-
-### 2.4 EXIF: what gets read, and what deliberately does not
-
-`Pillow` (new dependency, `packages/meshbay-node/pyproject.toml`, same
-"one purpose-built pip dependency per app" precedent as `guessit`/
-`mutagen`) reads two things per image at index time, mirroring the ffprobe
-technical-probe / guessit-parse split Videos already has:
-
-- **Technical facts, always read**: pixel `width`/`height` (already generic
- fields on `IndexEntry`, §5.1) and, for the thumbnail itself, the EXIF
- `Orientation` tag — **not exposed to clients**, only used to correct the
- thumbnail's own rotation before it is generated (§6). A phone photo
- stored "sideways" with an orientation tag is an extremely common real
- file, and skipping this produces a library of sideways thumbnails — a
- concrete, testable correctness requirement, not a nice-to-have.
-- **A minimal info set, best-effort**: `taken_at` (from `DateTimeOriginal`,
- falling back to nothing rather than guessing — `added_at`, the index
- timestamp, is already shown elsewhere and is not a substitute) and
- `camera` (`Make` + `Model`, joined, when both are present). That is the
- entire new wire surface (§5.2) — deliberately narrow, matching the
- "éventuellement" (optional, best-effort) framing of the ask rather than
- building a full EXIF-viewer panel (dozens of fields: exposure, ISO, focal
- length, lens, GPS) that nobody asked for. A fuller info panel is listed as
- an open item (§10), not built here.
-
-**GPS is read by nobody, on purpose.** `GPSInfo` is present in the EXIF of a
-photo taken on most phones and is a location disclosure the instant it is
-surfaced — to every group member, from a thumbnail's own metadata, with no
-extra step. Nothing in this design extracts it, caches it, or wires it onto
-`IndexEntry` or any response. This does not change what a member who
-downloads the *original* file can already extract themselves — the file's
-own bytes are unchanged, exactly as they are for Files today — but the
-Photos application itself never becomes a channel that makes that data
-casually visible to everyone browsing an album. Named per the project
-convention (§8): this holds against another member and a passive/active
-hub identically (the node never computes or transmits the field), and it is
-a policy choice about what the *view* shows, not a claim that GPS data does
-not exist in the file.
-
-### 2.5 The album view: every directory that contains an image
-
-The ask is explicit: a view of every directory containing images under the
-configured roots, not a folder tree to click through level by level. This
-needs **no new wire data** — it is a client-side derivation over the
-existing per-file index, the same kind of work `underVideoRoot`/
-`groupVideoEntries` already do:
-
-```js
-function underAnyPhotoRoot(entry, photoRoots) {
- const p = entry.path || '';
- return photoRoots.some(r => p === r || p.startsWith(r + '/'));
-}
-
-function groupPhotoAlbums(entries, photoRoots) {
- const byDir = new Map();
- for (const e of entries) {
- if (e.type !== 'image' || !underAnyPhotoRoot(e, photoRoots)) continue;
- const dir = e.path.includes('/') ? e.path.slice(0, e.path.lastIndexOf('/')) : '';
- if (!byDir.has(dir)) byDir.set(dir, []);
- byDir.get(dir).push(e);
- }
- return [...byDir.entries()]
- .map(([dir, photos]) => ({ dir, photos: photos.sort(/* name */) }))
- .sort((a, b) => a.dir.localeCompare(b.dir));
-}
-```
-
-Every directory that has at least one image becomes one album card — a
-subfolder of a subfolder qualifies independently, exactly as a season
-folder is its own category under Videos. No recursion is needed to build
-the *list* of albums; recursion is not merged away, it just means a deeply
-nested library produces more, smaller album cards rather than fewer, larger
-ones, which matches "directory is the category" already established for
-Videos/Music.
-
-**Landing page**: a flat, alphabetically sorted grid of album cards (dir
-name, photo count, a thumbnail from the first — or, better, a stable
-"first with a thumbnail already ready" — photo, same fallback Videos'
-`PosterGrid` already uses when picking a representative episode). Clicking
-a card opens that one directory's own photo grid.
-
----
-
-## 3. Album grid and lightbox — the classic elements asked for
-
-- **Grid**: one thumbnail tile per photo in the open album, same
- `LazyTile`/`MediaThumb` virtualization Videos already built and exported
- for reuse (`video-app.js`'s trailing `export { VideoApp, MediaThumb,
- LazyTile }`, already consumed by `music-app.js` the same way) — Photos
- imports both rather than reimplementing them, per `apps.md`'s checklist.
-- **Lightbox**: clicking a tile opens a full-size view. Unlike a grid tile
- (which shows the cached, resized thumbnail), the lightbox fetches the
- **original file** through the existing chunk path — the same mechanism
- `FilePreview`/`ChatImage` already use for an image attachment — cached
- per session by file id, same pattern as `_thumbBlobCache`.
-- **Next/previous**: cycles through the currently open album's photo list
- (the same array the grid rendered from — no server round trip to know
- what's next), bound to on-screen buttons and the left/right arrow keys.
- Wraps or stops at the ends (a UI choice, not architectural — pick
- whichever `video-player.js`'s own seek controls already read as
- idiomatic for this codebase).
-- **Per-photo info**: filename, dimensions (`width`×`height`), file size,
- `taken_at` and `camera` when present (§2.4) — shown in the lightbox,
- never on the grid tile itself (a grid of a hundred thumbnails does not
- need a hundred date stamps competing with the image).
-- **Zip an album**: a small toolbar button in the open album view, next to
- the mode-less toolbar (filter only — there is no mode toggle, §2.3).
- **Reuses Files' existing mechanism rather than reimplementing it.**
- `files-app.js`'s `downloadDirectory` (`entriesUnder`/`ZipStream`/
- `_openDownloadTarget`/`transfers`, `files-app.js:95-172`) is
- directory-path-in, streamed-zip-out and has nothing Files-specific in it
- once `entries`/`transportRef`/`gekRef`/`setError` are already props any
- app receives (`apps.md` §2). **Refactor**: lift `downloadDirectory` out of
- `files-app.js` into `file-utils.js` (already the shared home for the
- download/decrypt pipeline it's built on — `pipelinedDownload`,
- `_openDownloadTarget`, `_saveBlob`, `CHUNK_SIZE`) as an exported function
- taking `(transport, gek, entries, dir, { setError })`; `files-app.js`'s
- own toolbar action becomes a one-line caller, and `photos-app.js` calls
- the same function for the open album's `dir`. One implementation, two
- call sites — not a second zip writer.
-
----
-
-## 4. Protocol and index changes
-
-### 4.1 Reused, not new
-
-`thumb_hash`, `width`, `height` (`protocol.py:166`) — already declared,
-already wired through `index_entry_wire`, already delivered via the chunk
-path (`_try_serve_thumbnail`). Photos populates them for `type == "image"`
-entries exactly as Videos populates them for `type == "video"`. Their
-comments ("video only") should be updated to reflect that they are
-media-type-generic once this lands — a one-line doc fix, not a protocol
-change.
-
-### 4.2 New: two small fields
-
-```python
-taken_at: int | None = None # unix timestamp, EXIF DateTimeOriginal — Photos app
-camera: str | None = None # "Make Model", when both present — Photos app
-```
-
-Additive fields on the same dataclass, added to `index_entry_wire`'s dict —
-MNP **MINOR** bump (whatever the current version is by the time this is
-built), same class of change as Videos' `display_title`/`season`/`episode`
-addition. An older client simply doesn't render them.
-
-### 4.3 New wire messages: only for the root-set change
-
-```
-photo_roots { roots: [...] } # client → node, admin-challenged (§2.1)
-photo_roots_ack { roots: [...] } # node → every connected peer of the group
-```
-
-No `photo_meta_req`/`resp` pair (contrast Videos' `media_meta_req`, needed
-because a TMDB call is a network round trip worth deferring per-tile).
-`taken_at`/`camera` are cheap, local, and already computed once at index
-time, so they ride the ordinary index the same way `duration` does — no
-per-tile fetch, no virtualization concern for the *metadata* (only the
-thumbnail image bytes themselves are fetched lazily, same as any
-`thumb_hash`).
-
-### 4.4 `ALLOWED_APPS` / `DEFAULT_APPS`
-
-`webrtc_server.py`'s `ALLOWED_APPS` frozenset gains `"photo"`.
-`roster.py`'s `DEFAULT_APPS` stays `("chat", "files")` — a brand-new group
-does not get Photos for free, same reasoning as Videos/Music: it is new
-per-file node CPU cost (thumbnail generation, EXIF parse) across whatever
-the operator eventually points it at, and it shows nothing useful until at
-least one root is chosen anyway (§2.1), so there is nothing lost by making
-it an explicit opt-in via the existing Settings checklist.
-
----
-
-## 5. Node-side implementation, concretely
-
-| Piece | Where | What |
-|---|---|---|
-| Thumbnail + EXIF enrichment | new `meshbay_node/indexer/enrich_photo.py`, `PhotoEnricher` class | Own bounded pool (`asyncio.Semaphore`, own small `max_concurrent`, own short timeout) — sibling to `enrich.py`'s Videos pool and `enrich_audio.py`'s Music pool, **never shared with either**, same "never touches `max_concurrent_streams`" rule §6.10/§5.2 of the other two docs already establish. Pillow-based: `ImageOps.exif_transpose` before resizing (orientation correction, §2.4), resize to a bounded long edge (e.g. 480px, matching the size class Videos' own thumbnails already use), re-encode JPEG, extract `DateTimeOriginal`/`Make`/`Model` via `Image.getexif()` |
-| Enrichment gate | `meshbay_node/daemon.py`, new `_enrich_new_photo_entries`/`_enrich_photo_roots_now`, mirroring `_enrich_new_video_entries`/`_enrich_video_root_now` exactly, but checking membership against a **list** of roots (`_under_any_root(entry.path, photo_roots)`) rather than one string | Fires for `type == "image"` entries under any configured `photo_roots`; a sweep re-runs whenever the root *set* changes (add or remove), from `ops.set_photo_roots` |
-| Caches | `data_dir/media_cache.db`, existing `thumbs` table (`(thumb_hash) → jpeg bytes`, keyed by the file's own id — no synthetic id needed, a photo's thumbnail belongs to exactly one file, unlike a TMDB poster shared by many episodes) | No schema change |
-| Cache lifecycle | same pruning hook Videos/Music already use on an `IndexEntry` leaving the index | No new code path, same event |
-| Operator config | `roster.py` `group_settings`, real `group_id` (not the `""` sentinel — `photo_roots` is per-group, like `video_root`/`audio_root`, unlike the node-wide TMDB credential) | `SETTING_PHOTO_ROOTS`, `photo_roots()`/`set_photo_roots()`; `ops.py` gains `set_photo_roots(state, group_id, roots)`, one `_op(...)` line, same loopback/CLI/MNP adapters as everything else in `ops.py` |
-| Admin op | `meshbay_common/adminop.py` | `OP_PHOTO_ROOTS = "photo_roots"` |
-| Wire types | `meshbay_common/protocol.py` (`MNP.*`) | `PHOTO_ROOTS`, `PHOTO_ROOTS_ACK` |
-| Handler | `meshbay_node/transport/webrtc_server.py` | `_do_photo_roots`/`_admin_exec_photo_roots`, mirroring `_do_video_root`/`_admin_exec_video_root`, validating a list; `handshake_ack` gains `"photo_roots": list(self._group_ctx().get("photo_roots") or [])` |
-| `pyproject.toml` | `packages/meshbay-node/pyproject.toml` | add `Pillow>=10` |
-
----
-
-## 6. Client-side, per `apps.md`'s checklist
-
-1. `photos-app.js` — receives the standard props (`apps.md` §2), plus
- `photoRoots` (threaded through `group-page.js` exactly like `videoRoot`/
- `audioRoot`: `useState`, reset on `groupId` change, passed down, updated
- from `photo_roots_ack`). Imports `MediaThumb`/`LazyTile` from
- `video-app.js` and the lifted `downloadDirectory` from `file-utils.js`
- (§3) — no reimplementation of either.
-2. Register `{ key: "photo", icon: "image", labelKey: "group.tab_photos",
- Component: PhotosApp }` in `apps.js`.
-3. `ALLOWED_APPS` (§4.4).
-4. `group.tab_photos` (and a handful of `photo.*` strings — lightbox
- labels, "no roots configured yet", the zip button's tooltip) in all ten
- `static/locales/*.js`. `test_locales.py` holds them to the same key set.
-5. `webapp.py`'s `_ASSETS` tuple — add `photos-app.js`.
-6. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py`
- (`SPLIT_FILES`) — add the new file to both.
-7. `group-settings.js` — the add/remove root-list component (§2.2), wired
- the same way the Videos/Music root pickers already are (`transport.
- setPhotoRoots(roots, signFn)`, a new `transportRef` method mirroring
- `setVideoRoot`/`setAudioRoot`).
-8. `npm run sync-ui` in `meshbay-client`.
-
-No hub change. Protocol change is limited to §4.2's two additive fields and
-§4.3's one message pair — smaller than either Videos or Music, consistent
-with Photos doing less (no metadata-matching round trip, no mode toggle).
-
----
-
-## 7. What Photos deliberately does not do
-
-- **No TMDB/MusicBrainz-equivalent matching service.** There is nothing to
- match a photo *to* — it already is what it is, per its own folder and
- filename. §2.3.
-- **No mode toggle, no `localStorage` view preference.** One album-grid
- view. §2.3.
-- **No GPS surfaced anywhere in the application.** §2.4.
-- **No recursive "album of albums" browsing UI beyond the flat landing
- list.** Every qualifying directory is one card; there is no folder-tree
- affordance to build or maintain. §2.5.
-- **No RAW / HEIC support in v1.** The node's indexer today classifies
- `.jpg/.jpeg/.png/.gif/.webp/.svg/.bmp/.tiff` as `image`
- (`indexer.py:58`) — Pillow reads all of those natively. HEIC (the default
- format on recent iPhones) needs an extra native dependency
- (`pillow-heif`) not currently in the tree; RAW formats need a different
- library family entirely (`rawpy`/LibRaw). Both are real gaps for a
- photo-focused audience and are listed as open items (§10), not silently
- assumed away.
-- **No thumbnail preloading of the next/previous lightbox image.** A
- nice-to-have for a snappier feel on a slow connection; not required for
- a working v1. §10.
-
----
-
-## 8. Security — per adversary
-
-| Claim | Passive hub | Active hub | Malicious node operator | Another member |
-|---|---|---|---|---|
-| Thumbnail/original delivery | ✅ unchanged transport | ✅ unchanged transport | sees it already (holds the plaintext file) | same GEK-proofed MNP channel as files/streaming — no new authorization surface |
-| EXIF extraction | — | — | already has the plaintext file, could read EXIF manually — no new exposure | reads only what the node chooses to surface (`taken_at`/`camera`), never GPS (§2.4) — a strictly narrower surface than downloading the original, which any member with file access could already do |
-| New outbound traffic | **none** — Photos makes zero third-party network calls, unlike Videos/Music | **none** | — | — |
-| Stale cache after file deletion | — | — | pruned on the same index-deletion event Videos/Music already use — no new gap to introduce | — |
-| Root-set change (`photo_roots`) | ✅ signed, admin-challenged | ✅ signed, admin-challenged | the operator's own instruction | cannot forge — same `_verify_admin_sig` path as `apps_enabled`/`video_root` |
-
-**The claim this design supports:** Photos adds no new authorization
-boundary and, unlike Videos/Music, no new *category* of exposure either —
-there is no credential to hold, no third party to leak metadata to, and the
-one genuinely new piece of client-visible data (EXIF) is deliberately
-narrowed to exclude the one field (GPS) that would matter.
-
-**The claim it must not make:** that GPS "isn't in the file" — it is, for
-most phone photos, in the original bytes any member with file access can
-already download. What this design controls is only what the *Photos
-application itself* computes and surfaces, not what the underlying file
-contains.
-
----
-
-## 9. Filesystem/Windows
-
-Nothing new beyond what `desktop-client-v1.md` §6.8/§7.5 already
-establishes. Pillow ships with its own codecs for every format §7's table
-lists and needs no external `ffmpeg`-style binary the way Videos' thumbnail
-path does — if anything, Photos has *fewer* platform-dependent moving parts
-than Videos, not more.
-
----
-
-## 10. Open items
-
-| # | Item | Why it is not decided here |
-|---|---|---|
-| P1 | HEIC/RAW support | Needs a real dependency decision (`pillow-heif`, `rawpy`/LibRaw) and a licensing/build check, not just a config constant |
-| P2 | A fuller EXIF info panel (exposure, ISO, focal length, lens) beyond `taken_at`/`camera` | Product/UX call — the ask said "éventuellement", and the minimal set already answers it; extending is cheap once the plumbing (§4.2's pattern) exists |
-| P3 | Lightbox next/previous image preloading | Perf nicety, not required for a working v1 |
-| P4 | Wrap-around vs. stop-at-ends for next/previous at album boundaries | UI choice, mirror whatever `video-player.js`'s own controls already do for consistency |
-| P5 | Album cover selection (always "first photo" vs. an operator/member choice) | Product call; "first photo, stable" is a reasonable, zero-config default and is what this document assumes |
-
-## 10b. One photo, not two, in the cross-group Search view (2026-09-02)
-
-Reported against Videos and true here by construction: two groups sharing one
-directory listed every photo twice inside one album. `source-merge.js` folds
-entries on the content hash and resolves one source per album; the units come
-from `groupPhotoAlbums` itself (exported for this), called on the un-merged
-list purely to learn them.
-
-**One consequence is deliberate and is not a bug.** Albums are keyed by
-directory (§2.5), so two groups whose roots have *different basenames* put the
-same photo in two differently-named albums, and the merge — scoped to a unit —
-leaves it in both. That is correct: they are two albums. Only same-named albums
-collapse, which is the reported shape. Videos and Music do not have this case,
-their units being title- and tag-based rather than path-based.
-
-The album card's source badge counts the union over **the album**, not the
-cover photo: the cover is `photos.find((p) => p.thumb_hash) || photos[0]`, so
-an album in two groups whose cover sits in only one would have claimed a single
-source.
-
-Design: `docs/refactoring-search.md`. `test_search_media_merge.py` covers both
-the collapsing and the non-collapsing case.
-
-## 11. Acceptance before shipping
-
-1. Orientation correction verified against a real EXIF-rotated phone photo
- (§2.4) — a thumbnail generated from a "sideways" source file renders
- upright. Covered by `test_photo_enrichment.py`.
-2. Cache pruning on file deletion actually fires for photo thumbnails, same
- acceptance step `mediacenter.md` §11 already required for video
- thumbnails — not just argued, covered by a test.
-3. `taken_at`/`camera` come back empty (not an error) for a file with no
- EXIF block at all (a screenshot, a scanned/edited image with metadata
- stripped) — the ordinary case for a lot of real libraries, must degrade
- the same way "no TMDB match" already does for Videos.
-4. GPS fields are confirmed absent from every response/index field a client
- ever receives — not just "not intentionally added" (§2.4's claim), a
- grep-based test over `index_entry_wire` and any new response shape, the
- same discipline `test_hub_address_seam.py`/`test_task_lifetime.py`
- already apply elsewhere in this codebase to a property that must never
- silently regress.
-5. Live smoke test against a real, messy photo library (scattered roots,
- nested subfolders, a mix of phone photos with orientation tags and old
- scans with none) before calling this done — per this project's own
- repeated lesson (`CLAUDE.md`) that a source-reading test is weak
- evidence and launching the real thing finds what it cannot.
diff --git a/docs/playlists.md b/docs/playlists.md
index aef2366..8e57546 100644
--- a/docs/playlists.md
+++ b/docs/playlists.md
@@ -1,13 +1,14 @@
# MeshBay — Playlists (design)
-> Status: **proposal**, not implemented. This is M4 in `docs/musicbay.md` §9,
+> Status: **proposal**, not implemented. This was deferred out of the Music
+> application's design,
> which deferred it for the right reason: *"a genuinely new category of
> per-account node state, not covered by anything E9 already enumerates —
> needs its own design pass (ownership, sync across devices, whether it's
> node-local or something else)"*. This document is that pass.
>
-> Read `docs/musicbay.md` first — Music is built, and this adds nothing to
-> its playback path. Read `docs/refactoring-search.md` second: the
+> Read `MESHBAY_DESIGN.md` §9.8 first — Music is built, and this adds nothing
+> to its playback path. Read §9.11 second: the
> cross-group consolidation this feature needs already exists there, and
> most of the work is recognising that.
>
@@ -33,16 +34,16 @@ this must not corrupt anything.
## 1. What this design does not reopen
-- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10,
+- **Views over the index, never a catalogue** (`MESHBAY_DESIGN.md` §6.5, §9.1,
draft-v6 §2.7). A playlist is a list of *references*; it creates no second
identity for a file and no server-side database of content.
- **Nothing about content reaches the hub** (H7, draft-v6 §2.5). §3.1.
-- **No new streaming path.** `musicbay.md` §2.2 stands untouched: a track is
+- **No new streaming path.** `MESHBAY_DESIGN.md` §9.8 stands untouched: a track is
fetched through `pipelinedDownload` and handed to `<audio>`. Playlists add
no node-side playback code whatsoever.
- **Node-side derived data lives in the node's own `data_dir`**, never beside
- a shared file (`mediacenter.md` §2). The blob in §3.3 goes in `bundles.db`.
-- **Identity keys are per node** (`per-node-identity-v1.md`). §3.4 exists
+ a shared file (`MESHBAY_DESIGN.md` §6.5). The blob in §3.3 goes in `bundles.db`.
+- **Identity keys are per node** (`MESHBAY_DESIGN.md` §3.2). §3.4 exists
entirely because of this, and getting it wrong is the fastest way to ship a
playlist that cannot be read from the second device it was invented for.
@@ -101,7 +102,7 @@ that has nothing to do with this. Beyond the protocol that would have to be
invented, it leaks the thing this architecture is most careful about:
node A would learn that this account also uses node B — that two unrelated
operators host the same person. Per-node identity exists precisely so that
-this correlation is unavailable (`per-node-identity-v1.md`).
+this correlation is unavailable (`MESHBAY_DESIGN.md` §3.2).
### 3.3 The node, as an opaque per-account blob — a shape already built
@@ -424,7 +425,7 @@ Playlists cross groups, so they do not belong to a group's Music tab:
already resolves a connection per track from `entry.groupId`, and already
crosses groups within one queue. This is worth stating loudly, because the
cross-group requirement reads like the hard part and is in fact already
- built (`musicbay.md` §9b: *"The player needed no change"*).
+ built (the cross-group merge already covers it — `MESHBAY_DESIGN.md` §9.11).
### 8.3 The IndexedDB detail that will otherwise be missed
@@ -434,7 +435,7 @@ handling it in the existing `onupgradeneeded`, which currently creates one
store and would otherwise never run again. A store that is never created
throws on first access, at a point far from the version constant.
-### 8.4 Checklist, per `apps.md`
+### 8.4 Checklist, per `MESHBAY_DESIGN.md` §9.4
1. `playlists.js` (data) and `playlists-page.js` (UI).
2. Route + sidebar entry in `app.js`.
diff --git a/docs/refactor-groups.md b/docs/refactor-groups.md
deleted file mode 100644
index 9e0ecf1..0000000
--- a/docs/refactor-groups.md
+++ /dev/null
@@ -1,916 +0,0 @@
-# Groups Refactor — Per-Root Permissions & App Plugin Architecture
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the roots and app-settings refactor; its design
-> content now lives in §6.2 (RO/RW, eject), §9.3 (enablement and settings).
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **Complete** (2026-09-07). All three phases built, reviewed and
-> tested against a running node.
->
-> This is the most significant refactoring of the project. It changes how roots
-> are permissioned, how group applications are configured, and how the Settings
-> and Create Group pages are structured.
->
-> §7b, §7c and §7d record what each phase's review found and where the plan
-> below was wrong. Several entries are rules rather than one-off fixes; §7d
-> also lists what a person still has to test by hand.
-
----
-
-## 0. Summary of changes
-
-| Area | Before | After |
-|---|---|---|
-| Root permissions | One root marked `upload=True`; binary `member_upload` toggle per group | Each root is **RO** (default) or **RW**; multiple RW roots allowed; fully RO group is valid |
-| Upload policy | Separate section in Settings; `member_upload` signed op | **Removed.** RO/RW on the root is the mechanism. Files shows Upload only on RW roots. Chat disables attachments when its configured directory is not on a RW root |
-| Root metadata | `name, path, kind, upload, direct` | `name, path, kind, writable, removable, direct` |
-| Removable flag | Not tracked | Per-root boolean, set by operator. Enables the **eject/plug** button for safe device removal |
-| Safe eject | Auto-detected only (`path.is_dir()`) | Operator-initiated eject button in Settings AND Files root view. `ejected` state distinct from `available`. Indexer freezes entries, no data loss |
-| Files app | Can be disabled | **Always enabled**, transparently. Not shown in the app toggle list |
-| App selection at group creation | Checkbox list of all apps | **Removed.** Files is enabled automatically; other apps are configured later in Settings |
-| Settings layout | Monolithic: apps checkboxes, TMDB, MusicBrainz, directories, uploads — all in `group-settings.js` | **Structured:** Shared directories (top, expanded) → per-app sections (each with toggle + icon + title, collapsed, settings hidden until enabled) → Scan/Danger/Devices/Members |
-| App settings code | All inlined in `group-settings.js` (1338 lines) | Each app has `ui/<APP_NAME>-app-settings.js`; loaded by discovery (presence of the file) |
-| App enablement UI | One "Applications" section with checkboxes | Each app is a collapsible section with its own toggle in the title. The toggle is the enablement control |
-| Folder picker | Flat `<select>` with depth-indented names | **Folder tree popup**: modal, root icons, `[+]` expand, sub-directory selection |
-| Server-side app ops | Per-app functions in `ops.py` (`set_video_root`, `set_audio_root`, `set_photo_roots`, ...) | Generic `set_app_directory()` / `set_app_directories()` + app-specific wrappers where needed |
-| CLI | `group add --dir --upload-dir`; `member upload` concept | `group add --dir [--writable]`; `root add/remove/set` with `--writable`/`--read-only`/`--removable` |
-| MNP | 1.0 | 1.1 (additive: new fields on roots, new generic app-directory messages). 1.0 peers still work |
-
----
-
-## 1. Design decisions
-
-### 1.1 RO/RW replaces upload + member_upload
-
-The current model has two orthogonal mechanisms: (a) one root is the upload target,
-(b) `member_upload` toggles whether non-operators can upload there. The new model
-collapses both into one property per root: **writable**.
-
-- `writable = false` (default): the root is read-only for everyone, including the
- operator via the UI. Content is placed there out-of-band (filesystem, rsync, USB).
-- `writable = true`: any group member may upload to this root (into the quarantine
- subdirectory, same protections as today — allowlist, no overwrite, size cap).
-
-Multiple roots can be writable. Zero can be writable (fully read-only group). The
-operator controls which roots are RW by toggling a switch in the shared directories
-table.
-
-**What this removes:**
-- The `member_upload` toggle and its `OP_MEMBER_UPLOAD` signed op
-- The `member_upload` / `member_upload_ack` MNP message types (deprecated, still
- parsed for backward compat)
-- The Uploads section in Settings
-- The concept of "the upload root" (singular)
-
-**What this preserves:**
-- The quarantine directory, filename allowlist, no-overwrite check, size cap
-- The `_do_file_upload` handler in `webrtc_server.py` — now checks `writable` on
- the target root instead of checking `upload` + `member_upload`
-- The operator's ability to create a read-only group (set all roots RO)
-
-### 1.2 Files is always enabled
-
-`files` is removed from the toggleable app list. It is always present in
-`enabled_apps` and cannot be disabled. The current ability to hide it was misleading:
-MNP still permits root exploration regardless. The tab bar always shows Files.
-
-`apps.js` keeps `files` in `APPS` but marks it `alwaysEnabled: true`. The Settings
-page skips it when rendering app toggle sections.
-
-### 1.3 Per-app settings files
-
-Each app that has configurable settings exports a settings component from
-`ui/<APP_NAME>-app-settings.js`. The file is optional — an app with no settings
-(like Files today) has no settings file and gets only a toggle.
-
-The `APPS` registry in `apps.js` gains an optional `Settings` field per entry,
-imported from the corresponding settings file. The group-settings page iterates
-`APPS`, skips `files`, and renders a collapsible section for each, with:
-
-- The app's monochrome icon + localized title in the section header
-- A toggle switch in the header (disabled by default)
-- The app's `Settings` component below, **hidden until the toggle is on**
-- A Save button per app section (some saves trigger caching — TMDB, MusicBrainz)
-
-**Discovery mechanism:** In the browser context, "file discovery" is registration in
-`apps.js`. Adding a new app means: write `<APP>-app.js` + `<APP>-app-settings.js`,
-add one entry to `APPS` in `apps.js`, add the key to `ALLOWED_APPS` on the node.
-Server-side enforcement via `ALLOWED_APPS` prevents client-side hacks from enabling
-an unrecognized app.
-
-### 1.4 Folder tree widget
-
-A reusable modal popup (`FolderTreePicker`) that:
-
-- Appears centered on screen, semi-transparent backdrop
-- Lists root directories at the top level, with the same folder icons as Files
-- Each root can be expanded via `[+]` / collapsed via `[-]`, explorer-tree style
-- Sub-directories load from the existing `nodeDirs` data (already available from the
- file index, no new endpoint needed)
-- Supports **single-select** mode (Chat) and **multi-select** mode (Videos, Music,
- Photos)
-- Shows the root's RO/RW badge next to each root name
-- For apps that require RW (Chat): RO roots and their children are greyed out /
- unselectable, with a tooltip explaining why
-- OK / Cancel buttons at the bottom
-- Returns the selected path(s) relative to the root (e.g., `Movies/Action`)
-
-The widget replaces the current flat `<select>` dropdowns in all app settings. It is
-also usable in other UI contexts (the video player's folder navigation already does
-something similar ad-hoc).
-
-### 1.5 Shared directories table
-
-A reusable component (`SharedDirectoriesTable`) used in both the group Settings page
-and the Create Group wizard (developed once, shared). Features:
-
-- Borderless table, one row per root
-- Columns: **Name** (with folder icon), **Path** (truncated with tooltip on hover),
- **RW toggle** (switch, default off), **Removable toggle** (checkbox),
- **Eject/Plug button** (visible only when removable is checked — see §1.5b),
- **Delete button** (trash icon, with confirmation)
-- Ejected roots show a distinct visual state: greyed-out row, eject icon replaced by
- a plug icon
-- The first root in the Create Group wizard defaults to RW
-- A concise explanatory sentence at the top: "At least one directory is required.
- Read-write directories accept uploads from group members."
-- Add button: opens the native folder picker (Electron) or a path input (web, admin
- only)
-- Cannot delete the last root (refused with explanation)
-- Each change is a signed operator op (`ROOT_ADD`, `ROOT_REMOVE`, or new
- `ROOT_UPDATE` for toggling writable/removable on an existing root)
-
-### 1.5b Safe eject for removable devices
-
-**Problem.** An operator stores data on a USB drive. Unplugging it without warning
-triggers the watchdog — file deletions propagate as though the operator erased an
-entire library. The existing `refresh_availability()` auto-detects this and freezes
-entries (good), but there is no way to eject cleanly before unplugging, and no way
-to re-plug without a full rescan.
-
-**The `ejected` state.** A root has two independent runtime states:
-
-- `ejected` (bool, default `false`): operator-controlled, persisted in `roster.db`.
- Set by clicking the eject button; cleared by clicking plug.
-- `available` (bool, runtime): computed as `not ejected and is_live()`. This is
- what clients and the indexer see.
-
-The distinction matters: when the operator clicks "eject" but hasn't physically
-unplugged yet, `is_live()` returns `true` but `available` is `false` because
-`ejected` is `true`. Without this, `refresh_availability()` would immediately
-flip it back to available.
-
-**Eject flow:**
-
-1. Operator clicks the eject button (⏏) on a removable root
-2. Confirmation dialog: "Eject *Movies*? Files from this directory will be
- temporarily hidden to all members. You can safely unplug the device."
-3. On confirm: `PUT /api/groups/{gid}/roots/{name}/eject` → `ops.eject_root()`
-4. `ops.eject_root()`: sets `ejected = true` in `roster.db`, marks root
- `available = false`, stops the watchdog observer for that root
-5. The indexer **freezes** all entries from that root (existing behavior — no
- deletions, no index updates, cached data preserved)
-6. `index_sync` update propagates to connected peers: the root's `available` is
- now `false`
-7. All apps filter out entries from unavailable roots (Files already does this
- partially — needs to be complete across Videos, Music, Photos)
-8. The operator can now safely unplug the device
-
-**Plug flow:**
-
-1. Operator plugs the device back in and clicks the plug button (🔌)
-2. `PUT /api/groups/{gid}/roots/{name}/plug` → `ops.plug_root()`
-3. `ops.plug_root()`: checks `is_live()` first — if the path is not accessible,
- refuses with "Directory not found. Is the device connected?"
-4. On success: sets `ejected = false`, marks root `available = true`, restarts
- the watchdog observer
-5. The indexer **rescans the root** — its frozen entries are dropped and the
- directory is read again. (The plan said "a reconciliation, not a full
- rescan"; it is a rescan, deliberately. It is the same path a root coming
- back from `refresh_availability` already took, and a device people carry
- around can come back arbitrarily different — the hash cache means unchanged
- files are not re-read, which is where the cost would have been.)
-6. `index_sync` update propagates — entries reappear in all apps
-
-**The flag is persisted, and restored at startup.** `ejected` lives in
-`roster.db` (`root_ejected:<folded name>`), not in `node.toml`: it is runtime
-state, and an operator's hand-written config must not be rewritten because a USB
-drive was unplugged. It has to survive a restart — a restart is exactly what an
-operator does after noticing a drive fell off, and a flag that only lived in
-memory would let the scan that follows read the empty mount point as an erased
-library. `daemon._build_roots()` merges the two sources; it is the only place
-that builds a `RootSet` for a group.
-
-**Auto-detection safety net.** If a `removable` root's path suddenly disappears
-(operator unplugged without clicking eject):
-
-- `refresh_availability()` detects `is_live() = false`
-- Because `removable = true`, it sets `ejected = true` automatically (as if the
- operator had clicked eject), and reports it through the indexer's
- `on_root_ejected` callback so the daemon writes it to `roster.db` — an
- auto-eject that only existed in memory would be undone by the next restart
-- Entries freeze, no deletions propagate
-- The root stays in "ejected" state until the operator explicitly plugs it back
-
-For non-removable roots, the existing behavior is unchanged: `available` flips
-based on `is_live()`, entries freeze when unavailable, rescan when available again.
-
-**Eject button in Files app.** In addition to the Settings table, an eject button
-appears in the Files app root-level view, next to each removable root's name. This
-provides quick access without navigating to Settings. Same confirmation dialog,
-same API call. Ejected roots show as greyed-out with a plug icon to re-enable.
-
-**What is NOT deleted on eject:**
-
-- Index entries (frozen, not removed)
-- TMDB / MusicBrainz cached metadata
-- Video thumbnails in `media_cache`
-- Chat message history referencing files on that root
-- App directory configurations pointing to that root (but flagged as temporarily
- invalid — the app shows a warning, not an error)
-
-**MNP message:** `ROOT_EJECT` / `ROOT_EJECT_ACK` and `ROOT_PLUG` / `ROOT_PLUG_ACK`
-— signed operator ops, same pattern as `ROOT_UPDATE`. Broadcast to all connected
-peers so they see the availability change immediately without waiting for the next
-`index_sync`.
-
-### 1.6 Server-side normalization (ops.py)
-
-Two generic functions replace the per-app specific ones:
-
-```python
-def set_app_directory(state, group_id, app_key, path, *, require_writable=False):
- """Set a single directory for an app. Validates path is within a named root.
- If require_writable, refuses paths under RO roots."""
-
-def set_app_directories(state, group_id, app_key, paths, *, require_writable=False):
- """Set multiple directories for an app. Same validation."""
-```
-
-Existing functions (`set_video_root`, `set_audio_root`, `set_photo_roots`) become
-thin wrappers calling the generic versions, preserving the current MNP message types
-and roster keys for backward compat. New apps use the generic functions directly.
-
-### 1.7 Chat settings additions
-
-- **Directory picker** (single, RW-only): selects the directory for chat file
- attachments. If no RW root exists, the picker shows an explanation and the
- attachment button is disabled in the chat UI. If the selected directory's root is
- later set to RO, the setting is flagged as invalid and attachments are disabled
- until corrected.
-- **Link preview toggle** (new, server-side): the operator can disable link previews
- for the group. Stored in `roster.db` as `chat_link_preview` (default: enabled).
- The node's `linkpreview.py` checks this setting before unfurling. The toggle is a
- `ToggleSwitch` in the Chat settings section.
-
-### 1.8 Videos/Music/Photos settings changes
-
-**Videos:**
-- Directory picker changes from single to **multi-directory** (via folder tree widget)
-- TMDB settings section moved here from the monolithic settings
-- TMDB API key field: no longer says "optional" or mentions the default key.
- Instead, a prompt to sign up on TMDB with a direct link to generate a key
-- Per-app Save button triggers TMDB cache sweep
-
-**Music:**
-- Directory picker changes from single to **multi-directory**
-- MusicBrainz settings section moved here
-- Per-app Save button triggers MusicBrainz cache sweep
-
-**Photos:**
-- Multi-directory picker (already multi, just moves to the folder tree widget)
-- No third-party service settings
-
-### 1.9 Handshake ack changes
-
-The `handshake_ack` payload gains per-root metadata:
-
-```python
-# Current
-"roots": [{"name": "Movies", "path": "/mnt/movies", ...}]
-"member_upload": True
-
-# New
-"roots": [{"name": "Movies", "path": "/mnt/movies", "writable": False, "removable": True, ...}]
-# member_upload removed (deprecated, still parsed by old clients)
-```
-
-For backward compatibility with MNP 1.0 peers:
-- A 1.0 client that does not see `writable` on roots falls back to the old model
- (root with `upload=True` is writable, `member_upload` from the ack controls access)
-- A 1.1 node continues to send `member_upload` as a computed value: `True` if any
- root is writable, `False` otherwise — so old clients behave sensibly
-- `member_upload` is no longer writable via MNP ops; the node computes it from roots
-
-### 1.10 CLI changes
-
-As built. The group is a `--group` option rather than a positional, matching
-every other verb in this CLI, and the negative flags are spelled `--no-writable`
-/ `--no-removable` rather than `--read-only`, so each pair reads as one setting.
-
-```
-# Group creation (first root defaults to RW)
-meshbay-node group add <name> --dir <path> # first root, RW
-meshbay-node group add <name> --dir <path> --no-writable # first root, RO
-
-# Root management (--group is optional with one group configured)
-meshbay-node root list [--group <name>]
-meshbay-node root add <path> [--name <name>] [--writable] [--removable]
-meshbay-node root remove <name> [--yes]
-meshbay-node root set <name> --writable | --no-writable
-meshbay-node root set <name> --removable | --no-removable
-meshbay-node root eject <name> # safe eject
-meshbay-node root plug <name> # re-plug
-
-# Deprecated (accepted with a warning)
---upload-dir → "use --writable on the target root instead"
-member upload → removed; use 'root set --no-writable' / '--writable'
-```
-
-### 1.11 HelloWorld proof-of-concept
-
-A minimal app that validates the plugin architecture end to end:
-
-- `static/helloworld-app.js`: renders a greeting and lists files in its configured
- directory
-- `static/helloworld-app-settings.js`: single-directory picker (via folder tree
- widget), no other settings
-- Entry in `apps.js` with `key: "helloworld"`, icon, label, Component, Settings
-- `ALLOWED_APPS` extended on the node
-- No dedicated `helloworld.py` — uses the generic `set_app_directory()` function,
- which is the whole point
-
-The HelloWorld app is **not shipped in production**. It lives in the tree as a
-reference implementation and can be excluded from the build. Its value is proving
-that the plugin mechanism works: zero changes to `group-settings.js`,
-`group-page.js`, `webrtc_server.py` or `ops.py` to add it.
-
-### 1.12 Migration (existing nodes)
-
-A script in `QE/migration/` (not versioned) handles Fedora and Ubuntu nodes:
-
-**node.toml:**
-- `upload = true` → `writable = true`
-- `upload = false` (or absent) → `writable = false`
-- Add `removable = false` to all roots that lack it
-- Remove `upload_dir` from `[[groups]]` blocks (if present)
-
-**roster.db:**
-- If `member_upload = "off"` for a group: set all that group's roots to
- `writable = false` (the intent was "no uploads")
-- Rename `video_root` → `video_directories` (wrap single value in a list)
-- Rename `audio_root` → `audio_directories` (same)
-- `photo_roots` → `photo_directories` (rename only)
-- Remove `member_upload` rows
-- Add `chat_link_preview = "true"` default for groups with chat enabled
-- Add `chat_directory` for groups that had an upload root (default: the upload
- root's path)
-
-**Protocol version:**
-- MNP version file bumped to 1.1
-
-**The script is idempotent** — running it twice is safe.
-
----
-
-## 2. Phase 1 — Root RO/RW model + Shared Directories UI
-
-**Goal:** Change the data model from `upload` to `writable`/`removable`, build the
-shared directories table, restructure the top of Settings and the Create Group
-wizard. Remove the Uploads section. Files always enabled.
-
-**Testable after this phase:** Create a group with RO/RW roots, toggle RW in
-Settings, add/remove roots in the new table, see the Upload button appear/disappear
-in Files based on the current root's writable flag, eject a removable root and verify
-files disappear from all apps without data loss, plug it back and verify files
-reappear, CLI works with new syntax including `root eject/plug`.
-
-### 2.1 Backend changes
-
-| File | Change |
-|---|---|
-| `config.py` | `RootSpec`: add `writable: bool = False`, `removable: bool = False`. Remove `upload` field. `__post_init__` migration: `upload=True` → `writable=True`. Parse new fields from node.toml |
-| `roots.py` | `Root` dataclass: add `writable`, `removable`, `ejected`. Remove `upload`. `available` becomes a computed property: `not self.ejected and self.is_live()`. `_settle_upload_root()` removed. `RootSet.build()`: validate at least one root exists (no RW minimum). `refresh_availability()`: when a `removable` root's path disappears, auto-set `ejected=True` (safety net). Collision checks unchanged |
-| `ops.py` | `add_root()`: accept `writable`, `removable` params. `remove_root()`: refuse removing last root (unchanged). `attach_group()`: first root defaults to `writable=True`. Remove `set_member_upload()`. New: `update_root()` for toggling writable/removable on an existing root (signed op `OP_ROOT_UPDATE`). New: `eject_root()` — sets `ejected=True`, stops watchdog for that root. New: `plug_root()` — checks `is_live()`, sets `ejected=False`, triggers reconciliation |
-| `roster.py` | No schema change (generic key/value). Remove `member_upload` handling from `_apply_group_settings()` |
-| `ui/app.py` | `POST /api/groups/{gid}/roots`: accept `writable`, `removable`. New: `PATCH /api/groups/{gid}/roots/{name}` → `ops.update_root()`. New: `PUT /api/groups/{gid}/roots/{name}/eject` → `ops.eject_root()`. New: `PUT /api/groups/{gid}/roots/{name}/plug` → `ops.plug_root()`. Remove `PUT /api/groups/{gid}/member-upload` |
-| `daemon.py` (CLI) | New `root` subcommand: `add`, `remove`, `set`, `list`. `group add --dir` defaults to `writable=True`. Deprecate `--upload-dir` with warning. Remove `member upload` command |
-| `webrtc_server.py` | Handshake ack: add `writable`/`removable`/`ejected` per root. Compute `member_upload` for backward compat. `_do_file_upload`: check `root.writable` instead of `root.upload` + `member_upload_allowed`. Handle `ROOT_UPDATE`, `ROOT_EJECT`, `ROOT_PLUG` MNP messages. Broadcast root availability changes to all connected peers. `ALLOWED_APPS`: add `"files"` to always-enabled set |
-| `protocol.py` | New message types: `ROOT_UPDATE` / `ROOT_UPDATE_ACK`, `ROOT_EJECT` / `ROOT_EJECT_ACK`, `ROOT_PLUG` / `ROOT_PLUG_ACK`. `ROOT_ADD` gains `writable`, `removable` fields |
-| `indexer.py` | `eject_root()`: stop the watchdog observer for that root, do NOT touch entries. `plug_root()`: restart observer, trigger reconciliation pass. `refresh_availability()`: auto-eject removable roots whose path disappears (set `ejected=True` instead of just flipping `available`) |
-| `handshake.py` | MNP version → 1.1 (minor, additive) |
-
-### 2.2 Frontend changes
-
-| File | Change |
-|---|---|
-| `group-settings.js` | New `SharedDirectoriesTable` component (reusable). Includes eject/plug button per removable root. Move to top of settings (after Invite/Pair). Remove the Uploads toggle section. Remove the old Directories section (root management part — app root pickers stay for now). Calls loopback API for add/remove/update/eject/plug root |
-| `create-group-page.js` | Replace app checkboxes with nothing (Files auto-enabled). Replace directory section with `SharedDirectoriesTable` (same component). First root defaults `writable=true`. Step 2: remove `set enabled_apps` call (Files is automatic) |
-| `files-app.js` | Upload button visibility: check `currentRoot.writable && currentRoot.available`. Upload target: the root currently being browsed. Hide upload affordances on RO roots. Eject/plug button next to each removable root name in root-level view. Ejected roots greyed out with plug icon. Entries from unavailable roots filtered out of all views |
-| `chat-app.js` | Attachment button: disabled if no RW root exists or if the chat directory's root is RO or unavailable. Tooltip explaining why |
-| `group-page.js` | `nodeRoots` state: include `writable`/`removable`/`ejected` from handshake ack. Handle `ROOT_EJECT_ACK`/`ROOT_PLUG_ACK` broadcasts to update root state live. Remove `memberUpload` state. Remove `onUploadPolicy` callback |
-| `video-app.js` | Filter entries: exclude files from unavailable roots |
-| `music-app.js` | Filter entries: exclude files from unavailable roots |
-| `photos-app.js` | Filter entries: exclude files from unavailable roots |
-| `apps.js` | Add `alwaysEnabled: true` to `files` entry |
-| `transport.js` | Parse new root fields from handshake ack |
-
-### 2.3 Localization
-
-New keys in `locales/*.js`:
-- `sharedDirectories`, `sharedDirectoriesHint` ("At least one directory is
- required...")
-- `readOnly`, `readWrite`, `removableDevice`
-- `uploadNotAvailableRO` (tooltip: "This directory is read-only")
-- `ejectRoot`, `ejectRootConfirm` ("Eject *{name}*? Files from this directory
- will be temporarily hidden...")
-- `plugRoot`, `plugRootFailed` ("Directory not found. Is the device connected?")
-- `rootEjected` (status label shown on ejected roots)
-- Deprecation: `uploadToggle*` keys can be removed
-
----
-
-## 3. Phase 2 — App Settings Plugin Architecture + Folder Tree Widget
-
-**Goal:** Split app settings into per-app files, build the folder tree widget,
-restructure Settings with per-app collapsible sections. Normalize server-side
-ops.
-
-**Testable after this phase:** Each app has its own settings section with toggle.
-Folder tree popup works for directory selection. TMDB/MusicBrainz settings are in
-their app sections. Chat has link preview toggle. Videos/Music use multi-directory.
-
-### 3.1 Folder tree widget
-
-| File | Change |
-|---|---|
-| `static/folder-tree.js` (new) | `FolderTreePicker` component. Props: `roots` (with writable/removable), `dirs` (flat list from index), `mode` ("single"/"multi"), `requireWritable` (bool), `selected` (current selection), `onSelect` callback. Renders a modal with tree-view of directories. Builds tree structure from flat `nodeDirs` paths |
-
-**Behavior:**
-- Modal overlay with semi-transparent backdrop, centered panel
-- Root level: each root with folder icon, name, RO/RW badge, removable badge
-- `[+]` / `[-]` toggle to expand/collapse children
-- Directories sorted alphabetically at each level
-- Single mode: clicking a directory selects it (highlight), deselects previous
-- Multi mode: clicking a directory toggles its selection (checkbox visual)
-- `requireWritable=true`: RO roots and all their children are greyed out and
- unclickable, with a brief explanation at the top of the modal
-- Selected path shown at the bottom of the modal
-- OK (disabled if nothing valid selected) and Cancel buttons
-- Escape key closes
-
-### 3.2 Per-app settings files
-
-| File | Content |
-|---|---|
-| `static/chat-app-settings.js` (new) | `ChatSettings` component. Single-directory picker (folder tree, `requireWritable=true`). Link preview toggle (`ToggleSwitch`). Save button. Validates: selected directory must be on a RW root |
-| `static/video-app-settings.js` (new) | `VideoSettings` component. Multi-directory picker (folder tree). TMDB section: toggle + API key + language (moved from `group-settings.js`). TMDB key prompt: "Sign up on TMDB to generate your API key" with link, no "optional" wording. Save triggers TMDB sweep |
-| `static/music-app-settings.js` (new) | `MusicSettings` component. Multi-directory picker. MusicBrainz section: toggle (moved from `group-settings.js`). Save triggers MusicBrainz sweep |
-| `static/photos-app-settings.js` (new) | `PhotoSettings` component. Multi-directory picker. No third-party settings. Save button |
-| `static/apps.js` | Each `APPS` entry gains `Settings` field imported from the corresponding settings file. `files` has no `Settings` (no per-app config). `files` has `alwaysEnabled: true` (from Phase 1) |
-
-### 3.3 Settings page restructure
-
-| File | Change |
-|---|---|
-| `group-settings.js` | Remove all inlined app settings (TMDB section, MusicBrainz section, app root pickers). Remove the Applications checkbox section. New rendering loop: for each app in `APPS` where `!app.alwaysEnabled && app.Settings`, render a `CollapsibleSection` with: icon + title in header, toggle switch in header, `app.Settings` component inside (visible only when enabled). Section collapsed by default. App toggle triggers signed `OP_APPS_ENABLED` op |
-
-**Final settings layout:**
-
-```
-1. Invite form (if admin & invite-only)
-2. Pair operator (if nodeAdmin & not paired)
-── App configuration ──
-3. Shared directories (expanded, SharedDirectoriesTable)
-4. 💬 Chat [toggle] (collapsed)
- └─ Directory picker, Link preview toggle, Save
-5. 🎬 Videos [toggle] (collapsed)
- └─ Directory picker (multi), TMDB settings, Save
-6. 🎵 Music [toggle] (collapsed)
- └─ Directory picker (multi), MusicBrainz settings, Save
-7. 📷 Photos [toggle] (collapsed)
- └─ Directory picker (multi), Save
-8. [Future apps discovered from APPS registry]
-── Node tuning ──
-9. Scan tuning (reconcile, debounce)
-── Danger zone ──
-10. Leave / Delete group
-── Identity ──
-11. My devices
-12. Members table
-```
-
-### 3.4 Server-side normalization
-
-| File | Change |
-|---|---|
-| `ops.py` | New generic: `set_app_directory(state, group_id, app_key, path, require_writable=False)` and `set_app_directories(state, group_id, app_key, paths, require_writable=False)`. Both validate path(s) within named roots and check writable if required. Existing `set_video_root` → wrapper calling `set_app_directories("video", ...)`. Same for audio, photo. New: `set_chat_directory(state, group_id, path)` → `set_app_directory("chat", path, require_writable=True)`. New: `set_chat_link_preview(state, group_id, enabled)` |
-| `webrtc_server.py` | Handle new MNP messages: `CHAT_DIRECTORY` / `ACK`, `CHAT_LINK_PREVIEW` / `ACK`. Refactor `VIDEO_ROOT` handler to use generic. Handshake ack: add `chat_directory`, `chat_link_preview` |
-| `protocol.py` | New message types: `CHAT_DIRECTORY`, `CHAT_LINK_PREVIEW` (and acks) |
-| `ui/app.py` | New endpoints: `PUT /api/groups/{gid}/chat-directory`, `PUT /api/groups/{gid}/chat-link-preview`. Generic: `PUT /api/groups/{gid}/app-directories/{app_key}` |
-| `linkpreview.py` | Check `chat_link_preview` setting before unfurling |
-
-### 3.5 Handshake ack additions
-
-```python
-"chat_directory": "/Shared/uploads",
-"chat_link_preview": True,
-"video_directories": ["/Movies", "/Series"], # replaces video_root (single)
-"audio_directories": ["/Music"], # replaces audio_root (single)
-"photo_directories": ["/Photos", "/Camera"], # replaces photo_roots
-```
-
-Old field names (`video_root`, `audio_root`, `photo_roots`) still sent for backward
-compat with MNP 1.0 clients. New clients read the `*_directories` form.
-
----
-
-## 4. Phase 3 — HelloWorld, CLI polish & Migration
-
-**Goal:** Prove the plugin architecture with a HelloWorld app, finalize CLI changes,
-write and test the migration script.
-
-### 4.1 HelloWorld app
-
-| File | Content |
-|---|---|
-| `static/helloworld-app.js` (new) | Minimal component: renders "Hello, World!" heading + lists files from its configured directory. Uses `entries` from `commonProps`, filtered by the configured path |
-| `static/helloworld-app-settings.js` (new) | `HelloWorldSettings` component: single-directory picker via `FolderTreePicker`, Save button. Uses `set_app_directory("helloworld", path)` on the generic endpoint |
-| `apps.js` | New entry: `{ key: "helloworld", icon: "👋", labelKey: "helloWorld", Component: HelloWorldApp, Settings: HelloWorldSettings }` |
-| `webrtc_server.py` | Add `"helloworld"` to `ALLOWED_APPS` |
-
-**Validation:** enabling HelloWorld in Settings, picking a directory, saving, and
-seeing the file list in the HelloWorld tab — with **zero changes** to
-`group-settings.js`, `group-page.js`, or `ops.py`. If this works, any future app
-can be added the same way.
-
-### 4.2 CLI final polish
-
-- `root` subcommand fully tested: `add`, `remove`, `set`, `list`
-- `group add --upload-dir` prints deprecation warning and maps to `--dir <path>
- --writable`
-- `member upload` command removed (prints migration guidance)
-- Help text updated to reflect RO/RW model
-- `test_cli_dispatch.py` updated for all new verbs
-
-### 4.3 Migration script
-
-`QE/migration/migrate_groups_v2.sh` (or `.py`) — not versioned, for Fedora/Ubuntu
-nodes only.
-
-**node.toml transformations:**
-```
-upload = true → writable = true
-upload = false → writable = false
-(no upload field) → writable = false
-(add removable = false to every root that lacks it)
-(remove upload_dir lines from [[groups]] blocks)
-```
-
-**roster.db transformations:**
-```sql
--- Convert member_upload=off to all-RO roots (handled via ops on restart)
--- Rename app directory keys
-UPDATE group_settings SET key = 'video_directories' WHERE key = 'video_root';
-UPDATE group_settings SET key = 'audio_directories' WHERE key = 'audio_root';
-UPDATE group_settings SET key = 'photo_directories' WHERE key = 'photo_roots';
--- Wrap single values in JSON arrays for video/audio
--- Add chat defaults
-INSERT INTO group_settings (group_id, key, value)
- SELECT group_id, 'chat_link_preview', 'true'
- FROM group_settings WHERE key = 'enabled_apps' AND value LIKE '%chat%';
--- Remove member_upload rows
-DELETE FROM group_settings WHERE key = 'member_upload';
-```
-
-**Idempotency:** every transformation is guarded (`IF NOT EXISTS`, check before
-rename, etc.). Safe to run twice.
-
-**Rollback:** the script backs up `node.toml` and `roster.db` before any change.
-
-### 4.4 Windows notes
-
-No migration script needed for Windows (manual setup). Functional non-regression
-testing only:
-- Drive letter roots (`D:\Movies`, `E:\Music`) work with RO/RW
-- Removable flag on USB drives
-- Folder tree widget handles backslash paths
-- Create Group wizard with Windows paths
-
----
-
-## 5. Risk assessment
-
-| Risk | Mitigation |
-|---|---|
-| Upload regression | Phase 1 is self-contained: test uploads on RW roots, verify refused on RO roots, before touching app settings |
-| Eject data loss | Eject freezes entries (existing `freeze-not-empty` path). Ejected roots are never rescanned. Auto-eject safety net for surprise unplugs on removable roots. Explicit reconciliation (not full rescan) on plug |
-| Indexer race on eject | Watchdog observer is stopped synchronously before `ejected=true` is set. No window where the watchdog sees a missing path and processes deletions |
-| MNP backward compat | Computed `member_upload` in ack for 1.0 clients. New fields additive. Old field names kept alongside new ones |
-| Monolithic `group-settings.js` diff | Phase 2 extracts code into new files; the old code is deleted, not refactored. Clear before/after |
-| Folder tree perf with large indexes | `nodeDirs` is already computed. Tree construction is O(n) on directory count, not file count. Lazy child rendering on expand |
-| Windows path handling | Existing NFC normalization and path handling unchanged. New fields (`writable`, `removable`) are path-independent booleans. Drive letters work as root paths |
-| Migration data loss | Script backs up before changes. Idempotent. Tested on a staging node before production |
-
----
-
-## 6. Files touched (by phase)
-
-### Phase 1
-```
-packages/meshbay-node/src/meshbay_node/config.py
-packages/meshbay-node/src/meshbay_node/roots.py
-packages/meshbay-node/src/meshbay_node/ops.py
-packages/meshbay-node/src/meshbay_node/roster.py
-packages/meshbay-node/src/meshbay_node/ui/app.py
-packages/meshbay-node/src/meshbay_node/daemon.py
-packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
-packages/meshbay-common/src/meshbay_common/protocol.py
-packages/meshbay-common/src/meshbay_common/handshake.py
-packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
-packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
-packages/meshbay-hub/src/meshbay_hub/static/group-page.js
-packages/meshbay-hub/src/meshbay_hub/static/files-app.js
-packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
-packages/meshbay-hub/src/meshbay_hub/static/apps.js
-packages/meshbay-hub/src/meshbay_hub/static/transport.js
-packages/meshbay-node/src/meshbay_node/indexer/indexer.py
-packages/meshbay-hub/src/meshbay_hub/static/video-app.js
-packages/meshbay-hub/src/meshbay_hub/static/music-app.js
-packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
-packages/meshbay-hub/src/meshbay_hub/static/locales/*.js
-```
-
-### Phase 2
-```
-packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js (new)
-packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js (new)
-packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js (new)
-packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js (new)
-packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js (new)
-packages/meshbay-hub/src/meshbay_hub/static/group-settings.js (extract)
-packages/meshbay-hub/src/meshbay_hub/static/apps.js
-packages/meshbay-node/src/meshbay_node/ops.py
-packages/meshbay-node/src/meshbay_node/ui/app.py
-packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
-packages/meshbay-node/src/meshbay_node/linkpreview.py
-packages/meshbay-common/src/meshbay_common/protocol.py
-packages/meshbay-hub/src/meshbay_hub/static/locales/*.js
-```
-
-### Phase 3
-```
-packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js (new)
-packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js (new)
-packages/meshbay-hub/src/meshbay_hub/static/apps.js
-packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
-packages/meshbay-node/src/meshbay_node/daemon.py
-tests/test_cli_dispatch.py
-QE/migration/migrate_groups_v2.py (new, not versioned)
-```
-
----
-
-## 7. Estimated effort
-
-| Phase | Scope | Approx. size |
-|---|---|---|
-| Phase 1 | Data model + shared dirs UI + eject/plug + CLI + remove uploads | ~1800 lines changed/added |
-| Phase 2 | Folder tree + 4 app-settings files + settings restructure + server normalization | ~1800 lines changed/added |
-| Phase 3 | HelloWorld + CLI polish + migration script | ~500 lines |
-
-Each phase is one focused Claude session. Test between phases.
-
----
-
-## 7b. Phase 1 review (2026-09-06)
-
-What the plan above got wrong, and what was actually built. The first three
-entries are **rules for phases 2 and 3**, not one-off fixes: each describes a
-shape the same code can take again.
-
-### The rules
-
-**An operator is not sitting at their node.** The shared directories table read
-its roots exclusively from the loopback API (`platform.node.available`), which
-resolves to "not available" in a browser. So the section rendered for nobody on
-the web — while the Uploads controls it replaced *had* worked there — and the
-`transport.updateRoot` / `ejectRoot` / `plugRoot` methods written next to it
-were unreachable. §2.2 of the plan said "calls loopback API", and that was the
-mistake: MNP is the path that must exist, and loopback is the fallback for a
-local node with no live connection. Every operator-facing control phase 2 adds
-(the folder tree, four app settings panes, the link-preview toggle) needs the
-MNP route first. Pinned by `test_upload_controls_hidden.py`.
-
-**A reply that carries state nobody could have predicted has to be handed on.**
-`transport.js` resolves an admin `*_ack` against the pending request and
-returns, deliberately: every caller already updates local state from the value
-it chose. The root acks are not like that — they carry the node's whole roots
-table, including things only it knows (availability, the name it settled on,
-the eject a failed plug left in place). Returning left the operator who clicked
-Eject as the single client that never saw it happen, while every *other* peer
-got the broadcast. Any phase-2 op returning computed state has the same shape.
-
-**A control that writes needs to name where.** The node was given a `root`
-field on `file_upload` and no client ever sent it, so every upload went to
-`writable_roots[0]` while the Files toolbar offered the button based on the root
-being browsed. With two writable roots, uploading from one wrote into the other.
-This is the failure `_settle_upload_root`'s deleted docstring existed to
-prevent, reintroduced by removing it. Chat's attachments have the same problem
-one level up and get an explicit `attachRoot` until §1.7 gives them a
-configured directory.
-
-### The rest
-
-- **`ejected` was written to `roster.db` and never read back**, and the
- auto-eject path did not persist at all. Both fixed; see §1.5b.
-- **`PUT /api/groups/{gid}/member-upload` became a stub returning `200
- {"deprecated": true}`.** A route that answers OK and changes nothing is
- indistinguishable from a working one to whoever calls it. Removed.
-- **The wizard ignored the first root's RW switch** — `ops.attach_group` always
- wrote `writable = true`. It takes the flag now.
-- **`refresh_availability` was the only reader of a root's config.** The reload
- path compared roots on `(name, path)`, so an operator editing `writable` in
- `node.toml` and reloading saw nothing happen. The comparison includes the
- flags.
-- **The table had no Path column** (§1.5 asked for one). Two libraries whose
- folders share a basename are indistinguishable without it, and the basename is
- the identity — so it is the one thing that has to be visible.
-- **Phase 1 shipped no tests.** 29 of the suite's failures were its own. The
- gap that mattered was not the broken helpers but that eject, plug, per-root
- upload refusal and the `node.toml` rewrite had no coverage at all:
- `test_root_eject.py`, `test_root_writable_policy.py` and the new cases in
- `test_ops.py` / `test_node_status.py` / `test_security_regressions.py` are
- that. `test_member_upload_policy.py` is gone — it tested a removed feature.
-- **`chat-app.js` was in §2.2's file list and was never touched.**
-- **Eight of the ten locales were missing the new keys.** `test_locales.py`
- holds them to `en.js`, so this was a failing test rather than a silent gap —
- but it is worth noting that adding a key means adding it ten times.
-
-### Still open, deliberately
-
-- **The operator can no longer have a directory only they may write to.** RW is
- open to every member; RO refuses everyone including the operator. This
- reverses draft-v6's structural decision 9, which is annotated there. It is a
- real capability removed, and if it turns out to be wanted the answer is a
- third state on the root, not the old group-wide switch.
-- `test_ops.py::test_a_backslash_path_written_into_node_toml_stays_parseable`
- fails on any non-Windows machine and always has — it builds a
- `PurePosixPath` from a Windows path. Unrelated to this refactor, left alone.
-
----
-
-## 7c. Phase 2 as built (2026-09-06)
-
-The plan held. Four things were done differently, and one of them is a rule.
-
-### The rule
-
-**A settings key added to the client must be added ten times.** `test_locales`
-holds the nine other catalogues to `en.js`, so a missing key is a failing test
-rather than a silent gap — but Phase 2 added 27 keys, and doing them one file
-at a time is how the Phase 1 gap happened. Write the table, generate the
-insert.
-
-**And a second one, which cost a bug in this phase:** `node --check foo.js`
-does **not** reliably report a module syntax error. It accepted a file with
-`${/* ... */''}` — htm template syntax, pasted into a plain object literal —
-and reported success. Copying to `.mjs` first forces the module parser, which
-reports it. `test_spa_syntax.py` now does that for every module; the suite had
-no syntax check at all before, which is how the file was committed.
-
-### Done differently
-
-- **Almost no migration script.** The plan (§4.3) called for one to rename
- `video_root` → `video_directories` in `roster.db`. Instead the roster falls
- back to the old key when the new one is unset, and the first save through the
- new path leaves it behind. A script that has to be run by hand on the machine
- where it matters is a step that does not happen; a fallback is one that
- cannot be skipped. `node.toml` needs nothing either — `upload = true` is read
- as `writable`.
-
- **One transformation genuinely cannot be a fallback**, and an earlier draft
- of this section wrongly said the script was unnecessary altogether. A group
- whose operator had turned `member_upload` *off* has that switch ignored after
- the upgrade, because nothing consults it any more — and its root still says
- `upload = true`, so it accepts uploads from every member again. Nobody is
- told. "Uploads are off for this group" and "this root is writable" are two
- different sentences that happened to disagree, and only the operator knows
- which they meant; there is nothing to infer. `QE/migration/check_upload_policy.py`
- reads both files, reports the groups affected, and prints the `root set
- --no-writable` line for each. Read-only, exits non-zero when something needs
- a decision, so it can gate a deploy.
-- **`music`, not `audio`.** The app's registry key was `music` while its
- storage said `audio_root` and its ops said `set_audio_root`. One identifier
- per app now — the registry key — with the correspondence in exactly one
- table (`Roster.LEGACY_DIR_KEYS`).
-- **One storage shape.** `set_app_directory` (single) writes a one-element
- list, so there is no scalar form anywhere below the wire. `video_root` and
- friends survive on the handshake ack only, *derived* from the list rather
- than stored beside it — a second stored value drifts within one run, which
- reads as "it works after a restart".
-- **The panes call the transport themselves.** The plan had every pane report
- through one `onSave`, which would have made the page a dispatcher naming
- every app's settings keys — the thing the phase exists to remove. The line
- is: what every app has (directories) the page does, generically; what one
- app alone has (a TMDB key, a link-preview switch) the pane does with the
- transport it is handed. An app that only wants directories touches neither
- file, which is `test_app_settings_plugin.py`'s subject.
-
-### Worth knowing
-
-- `settings-ui.js` exists because `group-settings` → `apps` → a pane →
- `group-settings` is an import cycle, and ES modules answer that with a
- temporal-dead-zone `ReferenceError` at first render — the component simply
- does not appear, which is the fault already recorded in CLAUDE.md about hook
- ordering. The shared widgets live outside both.
-- The folder picker asks the node for **nothing**. The tree is derived from
- paths the client already holds, so it shows what the group's index contains
- and no more — a folder the node never indexed does not exist as far as the
- group is concerned. There is no folder-browsing protocol and this does not
- add one.
-- `_ASSETS` in `webapp.py` had to grow by six. Modules reached through the
- registry rather than imported by name are exactly the ones nothing else would
- notice changing, and a stale one is served from a browser cache with no
- version bump. `test_asset_versioning` caught it.
-- **What Phase 3 still owes:** the HelloWorld app (§4.1) — which is the actual
- proof of the above, since every test here reads source rather than adding an
- app and watching it work — plus the CLI polish and the Windows pass.
-
----
-
-## 7d. Phase 3 as built (2026-09-07)
-
-### HelloWorld earned its place
-
-It was written last and immediately found two things no amount of source
-reading had: `group-settings.js` fell back to the *whole* registry when a group
-had no `enabled_apps` yet — which would have enabled a hidden app for everyone
-— and `group-page.js` wrote out `videoDirectories` / `musicDirectories` /
-`photoDirectories` by hand, so a fifth app would have needed that file edited.
-Both are fixed by making the code less app-specific, and the plugin claim is
-now true rather than nearly true.
-
-That is the argument for keeping it: every other test of the architecture reads
-source for the *absence* of app names, which proves nobody wrote a special case
-— not that a new app works. Deleting HelloWorld would leave the claim resting
-entirely on tests that read text.
-
-**It is hidden behind `?dev=1`**, not excluded from the build as §4.1 imagined.
-There is no build step to exclude it from, and an unregistered app proves
-nothing, since registration is exactly what is claimed to be sufficient. The
-flag is the same opt-in shape as `transport.js`'s `?trace=1`.
-
-### The CLI
-
-**`--upload-dir` is gone, not deprecated.** It was documented as the old
-spelling first, which was wrong and was caught in review. It wrote `upload_dir`
-into a *brand-new* `[[groups]]` block, and `GroupConfig.__post_init__` reads
-that key by forcing every other root read-only and appending that path as the
-one writable one — so `group add --dir X --writable --upload-dir Y` silently
-made X read-only. Two mechanisms deciding which directories accept uploads, one
-of them invisible, in a group created after the model that replaced it. The
-*read* path stays, because an existing node.toml must keep working; that is the
-only legitimate use, and nothing writes the key any more.
-
-`member upload` reached the generic usage line for the other `member` verbs —
-"usage: meshbay-node member upload <username>" — which advertises a removed
-feature and sends the operator looking for a username it would then reject. It
-names `root set --writable` now. `--upload-dir` still works, so an existing
-script keeps working, but its help and the man page say it is the old spelling.
-
-### Windows
-
-`test_windows_root_shapes.py` covers what can be covered from here: drive
-letters and UNC through `as_posix()` into TOML, a drive root having no basename
-to derive a name from, and a case-insensitive collision — which on NTFS and
-exFAT is one directory indexed as two roots. All pass.
-
-**What still needs a person on Windows**, and cannot be faked:
-
-- `ReadDirectoryChangesW` dropping events under load — the reason periodic
- reconciliation is mandatory, and the reason eject exists at all
-- `MAX_PATH` against a deep library, on download and on upload
-- whether an eject actually lets the drive be removed, and a plug picks it back
- up — the eject/plug pair is the least-exercised thing in all three phases
-- the folder-tree picker against backslash paths in the UI
-- the Create Group wizard with a drive-letter root
-
-### What the whole refactor still owes
-
-Nothing in the plan. Two things it did not think of:
-
-- **`_do_dir_delete` was never checked against RO/RW.** `_do_file_upload` and
- `_do_dir_create` both gained the `writable` check; deletion is operator-only
- and so is not the same hole, but the asymmetry is worth a look.
-- **`index_delta` carries roots but not `dirs`.** A folder created by another
- member does not reach a connected client's folder picker until a full
- `index_sync`. Small, and the picker offers root names from the roots table
- regardless, so nothing is unreachable — but it is the same class as the bug
- §7d's roots fix closed.
diff --git a/docs/refactor-node-ui.md b/docs/refactor-node-ui.md
deleted file mode 100644
index 7dbf3da..0000000
--- a/docs/refactor-node-ui.md
+++ /dev/null
@@ -1,413 +0,0 @@
-# Refactor: fold the node's local admin surface into the desktop Node page
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the node admin surface refactor; its design
-> content now lives in §6.7.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **complete** (2026-09-01) — all six phases landed; see §11 for what
-> each did. This stays as the decision record.
-> Scope: the node's *own* administration surface. Group-level settings
-> (member-upload, apps, scan-settings) are not touched — they live in the group
-> UI and stay there.
->
-> **Net result:** `ui/app.py` is a JSON-only, ruff-clean loopback control API
-> (1074 → 453 lines); the server-rendered dashboard, the `/audit` HTML page,
-> the `meshbay-node ui` verb and three never-wired endpoints are gone; the
-> desktop client's Node page (`static/node-page.js`) gained an Overview tab,
-> node-wide Roster, Peers, a paginated Audit log with CSV export, index-cache
-> Maintenance, and an Unlink action, organised into six tabs; the pointless
-> `18000/tcp` firewall profiles were deleted. One capability is intentionally
-> gone: browser-based admin on a headless server — the CLI covers every
-> operation there.
-
----
-
-## 1. The distinction that matters
-
-"The local admin UI" is three things bundled in `packages/meshbay-node/src/meshbay_node/ui/`:
-
-| Component | Consumers today | Redundant with `node-page.js`? |
-|---|---|---|
-| **Loopback JSON API** `/api/*` (~35 routes), `127.0.0.1:{ui_port}`, per-run token | the **entire CLI** (`_daemon_api` — every verb is an HTTP call), `node-page.js` (via `platform.node.call`), `platform.node.detect()`, the create-group wizard's index-progress poll | **No.** This is the node's control plane. |
-| **Server-rendered HTML** — `GET /` (dashboard), `GET /audit` (`_DASHBOARD` / `_AUDIT_HTML` templates + `_render_*` helpers) | a human with a browser (`meshbay-node ui` prints a tokened URL; `ssh -L` from a remote host) | **Yes, entirely.** |
-| uvicorn server + middleware (token gate, CSP headers) | — | Stays — the API needs it. |
-
-**This refactor removes the HTML layer only.** Removing the module would break
-the CLI, node detection, and the Node page itself.
-
----
-
-## 2. Decision
-
-1. Enrich `packages/meshbay-hub/src/meshbay_hub/static/node-page.js` so it covers
- everything the HTML dashboard showed (peers, audit, index-cache maintenance,
- node-wide roster, node identity/QUIC/hub display).
-2. Add a **Unlink node** action (endpoint already exists, nothing surfaces it).
-3. Fix the audit view to show real usernames.
-4. Organise `node-page.js` into tabs.
-5. Delete `GET /` and `GET /audit` from `ui/app.py`, plus the templates, the
- `render` helpers, the `meshbay-node ui` CLI verb, and every doc/packaging
- reference to a browser admin UI.
-6. Keep the whole `/api/*` surface and the uvicorn server.
-
-**Accepted cost:** on a headless server (the meshbay.org model) the only admin
-path becomes the CLI. The CLI already covers every operation, so this is
-acceptable — but it is a real capability reduction and is recorded here as a
-conscious choice, not an oversight.
-
----
-
-## 3. Inventory — nothing is lost
-
-### 3a. HTML dashboard content → destination
-
-| Dashboard element | Backing endpoint | Destination |
-|---|---|---|
-| Node status badge, `needs` hints | `GET /api/status` | Node page header (already partly there) |
-| Stats: groups / files / peers / user | `GET /api/status` | Overview tab |
-| **Connected Peers** table | `GET /api/peers` | **Peers tab (new wiring)** |
-| Roster (node-wide) | `GET /api/roster?group_id=` | **Roster tab — extend the current per-group view to node-wide** |
-| Groups list | `GET /api/groups` | Groups tab (already there) |
-| Node Configuration: Hub URL, QUIC port, Node ID | `GET /api/status` (`endpoint_hint`, `quic_port`, `hub_url`) | Overview tab (new fields) |
-| Node Configuration: 5 `[node]` settings | `GET /api/node-settings` | Settings tab (already there) |
-| **Maintenance**: index-cache count + Prune | `GET /api/index-cache`, `POST /api/index-cache/prune` | **Settings/Maintenance tab (new wiring)** |
-| `/audit` page | `GET /api/audit` | **Audit tab (new wiring)** |
-
-### 3b. `/api/*` endpoints — consumer and disposition
-
-| Endpoint | Consumed by | Disposition |
-|---|---|---|
-| `GET /api/status` | Electron `node:detect`, CLI `status`, node-page | keep |
-| `DELETE /api/unlink` | *nothing* | keep + **surface in node-page** |
-| `GET /api/groups` | CLI, node-page | keep |
-| `POST /api/groups/attach` · `POST /api/groups/detach` | CLI, node-page (detach) | keep |
-| `DELETE /api/groups/{g}/files/{f}` | CLI `file rm`, group Files UI | keep |
-| `GET /api/groups/{g}/files` | CLI `file list`, HTML dashboard | keep (drop dashboard caller) |
-| `GET /api/denylist` · `POST /api/denylist/clear` | CLI, node-page | keep |
-| `GET /api/index-cache` · `POST /api/index-cache/prune` | **HTML dashboard only** | keep + **wire into node-page** |
-| `POST /api/groups/{g}/video/rematch` | CLI, group Videos UI | keep |
-| `GET /api/peers` | **HTML dashboard only** | keep + **wire into node-page** |
-| `GET /api/audit` | **HTML `/audit` only** | keep + **wire into node-page** + username fix (§5) |
-| `GET /api/config` | *nothing* | **triage — likely dead (§6b)** |
-| `POST /api/operator/pair` | CLI, node-page | keep |
-| `GET /api/roster` | CLI, node-page | keep (node-page uses node-wide mode too) |
-| `POST /api/groups/{g}/invites` · `GET /api/resolve` | CLI | keep |
-| `POST /api/members/{u}/revoke` | CLI | keep + optionally surface in node-page |
-| `POST /api/members/{u}/unpin` | CLI, node-page | keep |
-| `POST /api/groups/{g}/gek` | CLI `gek init/rotate`, node-page (rotate) | keep |
-| `POST /api/groups/{g}/roots` · `DELETE .../roots/{n}` | node-page | keep |
-| `GET /api/groups/{g}/index-status` | create-group wizard | keep |
-| `PUT /api/groups/{g}/member-upload` · `.../apps` · `.../scan-settings` | group Settings UI | keep (out of scope) |
-| `POST /api/reload` | CLI, node-page | keep |
-| `GET /api/node-settings` · `PUT /api/node-settings` | CLI `stun`, node-page | keep |
-| `GET /api/chat/history` · `WS /ws/chat` | *nothing* | **triage — likely dead (§6b)** |
-| `GET /` · `GET /audit` | browser only | **DELETE** |
-
-### 3c. CLI verbs
-
-`init reset status ui gek-init gek operator member group file video denylist stun
-reload restart-daemon calibrate-argon2`.
-
-Only **`ui`** goes away. `status` output drops its `admin UI meshbay-node ui`
-line. Nothing else changes — the CLI keeps talking to the same `/api/*`.
-
----
-
-## 4. Gap fills in `node-page.js`
-
-New sections/tabs, each a thin `nodeCall`:
-
-- **Peers** — `GET /api/peers` → table (user, IP, group, state). Poll ~5 s while visible.
-- **Audit** — `GET /api/audit` with the event/user/limit filters the HTML page had.
- Render every field as **text** (htm/preact escapes by default — never
- `dangerouslySetInnerHTML` on `detail`/`username`; `detail` carries
- member-chosen filenames, H2).
-- **Maintenance** — `GET /api/index-cache` (count) + `POST /api/index-cache/prune`.
-- **Roster (node-wide)** — call `GET /api/roster` with no `group_id`. Show every
- identity and its devices; keep the existing `unpin`, add `revoke_member` per
- member. (`revoke_device` for a single device is a later item — the endpoint
- does not exist yet.)
-- **Overview** — Node ID (`endpoint_hint`), QUIC port, hub URL, version, from
- `GET /api/status`.
-
-Tabs: **Overview · Groups · Roster · Peers · Audit · Settings**. Pure UI
-refactor of the current 749-line single scroll; no protocol change. Split into
-per-tab files if it grows (same move as `docs/apps.md` did for the group UI) —
-optional.
-
----
-
-## 5. Audit usernames
-
-**Symptom:** rows show `8f7e206e` (that is `user_id[:8]`, the fallback in the
-HTML template's `e.username || e.user_id.slice(0,8)`).
-
-**Cause:** the `username` column exists and `_audit()` already passes
-`username=self._username`, but it is blank for (a) legacy rows written before the
-column was populated and (b) events where the handshake payload carried no
-username (pre-handshake `auth_failed`, some token shapes).
-
-**Fix — resolve at read time, no migration:** in the audit endpoint (or a new
-`ops.get_audit`), build `{user_id: username}` from the roster
-(`roster.list_members()` + identities already expose `username` node-wide, same
-source `_render_roster` uses) and fill blanks:
-`row.username or roster_name.get(row.user_id) or row.user_id[:8]`.
-
-**Security:** this is **not** the stored-XSS concern. That concern is rendering
-attacker-influenced strings (`username` from the hub, `detail` filenames) as
-**markup**. Showing the real name is fine *as text*. The rule for the Preact
-Audit tab: interpolate, never inject HTML.
-
----
-
-## 6. Cleanup
-
-### 6a. Dead code / config once the HTML is gone
-
-- `ui/app.py`: `@app.get("/")`, `@app.get("/audit")`, `_DASHBOARD_*`/`_AUDIT_HTML`
- templates, `_render_dashboard`, `_render_node_settings`, `_render_roster` (HTML
- variant), `_render_audit_page`, and any `escape`-only helpers left unused.
-- `daemon.py`: the `ui` CLI verb + its `argparse` choice + help text + `quiet`
- list entry; the `_ui_url`/`ui` handler block; `log.info("Admin UI ready — open
- it with: meshbay-node ui")`; the `admin UI meshbay-node ui` line in
- `_print_status`.
-- `packaging/firewall/ufw/meshbay` and
- `packaging/firewall/firewalld/meshbay-node.xml`: drop the `18000/tcp` service.
- The UI binds `127.0.0.1` only (`daemon.py` `host="127.0.0.1"`), so these rules
- never did anything useful and become a footgun if the bind address ever
- changes. Remove regardless of the rest of this refactor.
-- Docs/man: `docs/QUICKSTART.md:110`, `QE/deploy/README.md:99`,
- `man/meshbay-node.1` (the `meshbay-node ui` entry), `packaging/deb/.../control`
- and `packaging/rpm/meshbay-node.spec` ("a local admin UI on localhost:18000").
-
-**Keep:** the uvicorn server, the token middleware, the CSP middleware (harmless
-and good hygiene on JSON responses), the `ui-token` file, `ui_port` in
-`node.toml`. Renaming `ui_*` → `localapi_*` is churn for no user value — deferred,
-noted only so the naming mismatch is a known thing.
-
-### 6b. Orphan endpoints — ✅ removed (phase 6)
-
-`GET /api/config`, `GET /api/chat/history`, `WS /ws/chat` (plus
-`broadcast_chat_to_ui` / `app.broadcast_chat` / `_chat_subscribers`) were a
-never-wired local operator chat view plus a superseded config dump. Confirmed
-dead against git history and removed. See phase 6 above.
-
----
-
-## 7. Security review
-
-The loopback API's model is unchanged and adequate: bind `127.0.0.1`; per-run
-token (`os.urandom(18)`, `ui-token` mode 0600, required as `?t=` or
-`X-MeshBay-Token`); CSP `default-src 'none'; connect-src 'self'`. Threats
-modelled: DNS-rebinding from the operator's browser, and any local process. The
-token closes both.
-
-- **Folding functions into `node-page.js` adds no surface** — every call goes
- through the same token-gated API; the Electron main process holds the token,
- the renderer never sees it (same model as `hub:fetch`).
-- **Do not shell out to the CLI from the Node page.** The CLI is itself an HTTP
- client of this API; spawning it would be a strictly worse `nodeCall` (process
- spawn, arg-injection, PATH assumptions, unstructured errors). If a function is
- missing from the API, add the endpoint. `main.js` shelling out to
- `systemctl --user` is a legitimate exception — OS service control has no API
- equivalent; node *operations* stay on the API.
-- **Audit tab:** render as text (§5).
-- **Unlink copy:** unlink clears only the hub's stored linking key
- (`DELETE /v1/users/me/node_key` → `user.pk_node_ed25519 = None`). It does **not**
- touch the roster, GEKs, pins, or on-disk group data — the node keeps serving
- anyone who reaches it directly over MNP. The UI must say "the hub stops
- brokering connections to this node", not imply a wipe. `reset` stays the
- destroy-everything path. After unlink, `POST /v1/nodes/auth` fails and the hub
- WebSocket drops — expected; show it as "offline from hub", not an error.
-
----
-
-## 8. Node-key linking — confirmed automatic (native)
-
-`create-group-page.js` `linkNodeKey(pk)` is called from `detectNode()` after
-`platform.node.detect()` returns the node's `pk_node_ed25519`; it
-`PUT /v1/users/me/node_key`. So in the desktop wizard the key links
-automatically on node detection. The manual paste box in `profile-page.js` is the
-fallback for the browser-only case (no bridge to read the node's key) and for
-re-linking. No change needed; the **unlink** counterpart is the only missing half
-(§2.2, §7).
-
----
-
-## 9. Draft correction
-
-`docs/meshbay-draft-v6.md` §2.11 and §2.12 describe the Node page as connecting
-**over MNP** (`node_status_ack`, `node_settings_set`). The code went
-loopback-HTTP instead (`node-page.js` header comment says so explicitly), which is
-better for a native client — no signaling round-trip, no GEK/session, works
-before any group is joined. The MNP handlers
-(`webrtc_server.py:_do_node_status`, `_do_node_settings_set`) are dormant.
-
-Update §2.11/§2.12 to state: the Node page uses the node's loopback API; the MNP
-`node_*` message types are reserved for a possible future browser-side (no
-Electron) path and are not the mechanism today.
-
----
-
-## 10. Tests
-
-- `test_cli_dispatch.py` — drop the `["ui", ...]` row; assert `ui` is no longer a
- parser choice.
-- New `ops`/endpoint test for audit username backfill from the roster.
-- Node-page: extend whatever source-reading test pins its structure (per the SPA
- testing posture) to cover the new tabs' `nodeCall` paths.
-- `test_packaging_units.py` / packaging tests — assert the firewall service files
- no longer open 18000.
-- Grep guard (or manual): no `meshbay-node ui` string left in `man/`, `docs/`,
- `packaging/`.
-
----
-
-## 11. Phases
-
-1. **Draft + firewall** — ✅ done 2026-09-01. Corrected draft-v6 §2.11/§2.12
- (loopback API, not MNP) with a 2026-09-01 amendment line. Deleted
- `packaging/firewall/firewalld/meshbay-node.xml`; stripped the `[MeshBay Node]`
- stanza from `packaging/firewall/ufw/meshbay` (the `[MeshBay Cast]` profile
- stays). Follow-on edits so the build still passes:
- `packaging/build/build-node.sh` (drop the firewalld copy),
- `packaging/rpm/meshbay-node.spec` `%files` (drop the `.xml` line),
- `docs/PACKAGING-GUIDE.md` (drop the "Node admin UI / TCP 18000" section). No
- behaviour change. The deb/spec "local admin UI on localhost:18000" description
- lines are left for phase 5.
-2. **node-page.js gap fills** — ✅ done 2026-09-01. Added five sections to the
- existing single-scroll page (tabs come in phase 4): **Overview** (version,
- node id, QUIC port, hub — from `GET /api/status`, fetched in
- `fetchStatus`/`refresh`), **Node roster** (`GET /api/roster` with no
- `group_id`, node-wide, with `unpin`), **Connected peers** (`GET /api/peers`),
- **Audit log** (`GET /api/audit` with event + limit filters, every field
- rendered as text), **Maintenance** (`GET /api/index-cache` +
- `POST /api/index-cache/prune`). Backend: `GET /api/audit` now backfills a
- blank `username` from `roster.list_identities()` at read time (no migration).
- ~31 `node.*` i18n keys added to all ten locale catalogues (English
- placeholders, matching the existing node-page convention). `test_locales.py`
- green; `node --check` green; node ops/audit/status/roster tests green.
- Per-member `revoke` in the node-wide view is deferred — revoke is
- group-scoped and the node-wide list has no group context.
-3. **Unlink** — ✅ done 2026-09-01. Added a danger-styled "Unlink from hub"
- section at the bottom of `node-page.js` (`unlinkNode` → `DELETE /api/unlink`,
- confirm + consequence copy per §7, hidden while `status ===
- 'waiting_for_node_key'`). Backend endpoint was already present and unchanged.
- 5 `node.unlink_*` i18n keys added to all ten locales; `.node-group-danger`
- added to `style.css`. `node --check` + `test_locales.py` green. Note: the
- endpoint 503s when the node has no hub session — the catch surfaces the
- bridge message, which is acceptable for that edge case.
-4. **Tabs** — ✅ done 2026-09-01. `node-page.js` now has a `tab` state and an
- `.admin-tabs` bar (reusing the existing admin-page tab styles) with six tabs:
- **Overview** (node info + Maintenance), **Groups** (per-group cards),
- **Roster** (node-wide roster), **Peers**, **Audit**, **Settings** (denylist +
- node settings + STUN + ICE + Unlink). Header, service panel, action message
- and the operator-pair banner stay above the bar, visible on every tab. Each
- section is gated `${tab === '<x>' && …}`; the groups IIFE is
- `${tab === 'groups' && (() => {…})()}`. 6 `node.tab_*` i18n keys added to all
- ten locales. Verified with a per-tab render probe (htm parses and the
- component executes cleanly for all six tabs) plus `node --check` and
- `test_locales.py`.
-5. **Remove HTML** — ✅ done 2026-09-01.
- - `ui/app.py`: deleted `GET /`, `GET /audit`, and everything below the
- `# ── HTML UI ──` marker (`_render_page` + inline dashboard template,
- `_render_roster`, `_render_node_settings`, `_render_audit_page`,
- `_AUDIT_HTML`, `_fmt_size`). ~500 lines gone. Dropped now-unused imports
- (`base64`, `time`, `html.escape`, `HTMLResponse`). Module docstring rewritten
- — it is a JSON control API, not a web UI. The uvicorn server, the token
- middleware and the CSP middleware stay.
- - `daemon.py`: removed the `ui` argparse choice + its help text + `quiet`
- entry + the `if args.command == "ui":` handler. Startup log line is now
- `Control API on 127.0.0.1:<port>`; `_print_status` drops the `admin UI`
- line; the "waiting_for_node_key" hint now points at `meshbay-node status`
- and the desktop client. `_daemon_api` docstring reworded. (Net effect on
- ruff: one F541 fewer; the file's pre-existing lint debt is untouched.)
- - Tests: `test_cli_dispatch.py` drops the `["ui"]` case and asserts `ui` is
- no longer a parser choice. The two `_render_page` H2 tests
- (`test_admin_ui_escapes_filenames`, `_roster_usernames`) are replaced by
- `test_node_control_api_serves_no_html`, which pins the routes at 404 and
- the render helpers as absent. Full node suite: 749 passed, same 2
- pre-existing failures (`test_packaging_units` bad split logic;
- `test_webrtc_transport` aiortc flake).
- - Docs/packaging: `docs/QUICKSTART.md`, `man/meshbay-node.1` (the `ui` verb
- entry deleted, "admin UI" → "control API" throughout),
- `packaging/deb/.../control`, `packaging/rpm/meshbay-node.spec`,
- `devel-phases-next.md` (14.1b struck through), `docs/invite-pairing-v1.md`
- (`_render_roster` pointer → `node-page.js`), `CLAUDE.md` key-modules row,
- `config.py` example-config comment. `QE/deploy/README.md` also touched but
- it is gitignored.
-6. **Orphan-endpoint triage** — ✅ done 2026-09-01. All three confirmed dead and
- removed from `ui/app.py`:
- - `GET /api/config` — no consumer (the CLI, SPA and Electron never call it);
- its payload is a strict subset of `/api/status` + `/api/groups` +
- `/api/node-settings`. `test_security_regressions.py` used it only as a
- sample path for the token-gate check → switched to `/api/groups`.
- - `GET /api/chat/history` — reads `state["chat_store"]`, a **global** store
- that the daemon deliberately never sets (per-group stores since finding
- H1), so it always returned `{"messages": []}`; the message shape was stale
- too. The real chat-history path is the MNP `chat_history` handler in
- `webrtc_server.py` (unchanged, `test_transport_contracts.py` still green).
- - `WS /ws/chat` + `_chat_subscribers` + `broadcast_chat_to_ui` +
- `app.broadcast_chat` — added in the Phase-7 "Node v2" commit and never
- wired: `git grep broadcast_chat` finds only the definition and the
- assignment, nothing invokes it, so the socket's subscribers received
- nothing.
- - Fallout: dropped now-unused imports (`json`, `WebSocket`,
- `WebSocketDisconnect`) and ran `ruff --fix` for the file's remaining
- pre-existing import debt (`Path`, `DEFAULT_CONFIG_PATH`, `generate_gek`,
- `wrap_gek_aes`, `ROLE_MEMBER`, `ROLE_OPERATOR`, the function-local
- `Roster`). **`ui/app.py` now passes `ruff check` with no errors** (1074 →
- 453 lines across phases 5–6).
- - Tightened the `_security_headers` CSP to `default-src 'none'; frame-ancestors
- 'none'; base-uri 'none'` — the JSON-only API has no HTML, inline script or
- stylesheet to allow, so the old `'unsafe-inline'` / `connect-src 'self'`
- grants were dead weight. No test asserted the string. Middleware docstrings
- refreshed ("admin UI" → "control API").
-
-4b. **Node page UI polish** — ✅ done 2026-09-01 (operator feedback on phase 4):
- - Overview/Maintenance, Roster, Peers and Audit tabs **auto-load on open** —
- the per-tab "Load" buttons are gone (`useEffect` on `[tab, status]`, audit
- also on filter/page). Per-group roster and denylist keep their buttons.
- - **Names, not ids**: `ui/app.py` gained `_display_names(state)` →
- `(user_id→username from roster, group_id→name from node.toml)`. `/api/peers`
- resolves both (`username`, new `group_name`); `/api/audit` adds
- `group_name` and keeps the username backfill. The SPA renders the name and
- falls back to a shortened id.
- - **Audit pagination**: `get_entries` gained `offset`; `/api/audit` takes
- `offset`, clamps `limit` to 1000, and fetches `limit+1` to report
- `has_more` without a count. The tab has Previous/Next + "Page N", newest
- first, page size 50/100/200/500. `test_audit.py::test_pagination_newest_first`.
- - **Audit overflow**: table wrapped in `.node-table-scroll` (`overflow-x:auto`);
- `.node-table-audit` keeps time/event/user/ip/group on one line (IP never
- clipped) and wraps only the detail column.
- - **CSV export**: `Export CSV` button → `exportAuditCsv` walks the whole log
- for the current filter in 1000-row pages (de-duped by id so an event
- written mid-export cannot duplicate a row; 1M-row hard stop) and
- `saveCsv()` writes every matching entry — native Save As on the desktop
- (`platform.nativeSave`), blob download link otherwise. RFC-4180 quoting.
- - Settings tab section order: node settings → STUN → ICE → **denylist** →
- **Unlink** (denylist sits directly above the danger zone).
- - 6 new i18n keys (`node.loading`, `node.audit_export/_exporting/_prev/_next/_page`),
- 4 dead `*_load` keys removed, across all ten locales. Per-tab render probe +
- `node --check` + `test_locales.py` + node test subset (193) green.
-
-Phases 1–4 lose nothing and can land independently. Phase 5 is the one that
-removes a capability (browser admin on a headless box) and should be its own
-reviewed commit.
-
----
-
-## 12. Out of scope
-
-- Group-level settings UI (member-upload, apps, scan-settings) — stays in the
- group UI.
-- Renaming `ui_port` / `ui-token` / `create_ui_app`.
-- `revoke_device` (single-device revocation) — needs a new endpoint; track
- separately.
-- Any MNP browser-side node admin path.
diff --git a/docs/refactoring-search.md b/docs/refactoring-search.md
deleted file mode 100644
index 3172a24..0000000
--- a/docs/refactoring-search.md
+++ /dev/null
@@ -1,426 +0,0 @@
-# Refactor: one file, one entry — merging duplicate sources in the Search view
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the cross-group source merging; its design
-> content now lives in §9.10.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **complete** (2026-09-02) — all nine phases landed. This stays as
-> the decision record.
-> Branch `feat/search-source-merge`. Videos, Music and Photos are merged,
-> failover is in, and a card with several sources says `N sources` instead of a
-> group name. Confirmed live for Videos on 2026-09-02: one card per film,
-> playback and download work from a merged entry.
-> Scope: the cross-group Search page (`static/search-page.js`) and the three
-> media applications it reuses (Videos, Music, Photos). The Files **explorer**
-> inside Search is explicitly out of scope and must not change.
->
-> **The bug, in one sentence:** a file shared by two groups is two entries in the
-> Search view, so a film shows twice in the poster grid, an episode twice in a
-> show's list, and a track twice in an album.
->
-> The convention from draft-v6 is carried forward: **a claim in this document
-> must name the adversary it holds against.**
-
----
-
-## 1. What was observed
-
-A single node hosts two groups, `demo35` and `media`. Both were given the *same*
-directory as their video root — that is the whole point of having two groups:
-different people are invited to different libraries, and one library may be in
-several of them.
-
-Everything works per group. In **Search files**, which walks every group the
-account belongs to and merges their indexes into one view, every file of that
-shared directory is listed twice:
-
-| View | Symptom |
-|---|---|
-| Videos — Posters | Two identical cards for the same film, one badged `demo35`, one badged `media` |
-| Videos — Flat list | Same, and a show folder that expands to each episode twice |
-| Videos — detail modal | The season/episode list under the synopsis lists every episode twice |
-| Music (not reported, same by construction) | Every track twice inside one album |
-| Photos (not reported, same by construction) | Every photo twice inside one album |
-
-**Files (the explorer) is not affected and must stay that way.** There, each
-group is a top-level folder and the two copies live in two different folders —
-which is correct and is how a member navigates *per group*. Confirmed in
-`search-page.js`'s `fileEntries`, which prefixes every path with the group name
-precisely so that navigation works.
-
-**Within a single group this cannot happen.** `GroupIndex` is keyed by blake3
-(`group_index.py`, `_entries: dict # id → IndexEntry`), so the same bytes at two
-paths inside one group are already one entry — the lesson recorded in `CLAUDE.md`
-("a content-addressed index cannot represent the same bytes at two paths"). The
-duplication is created by the Search page, which concatenates *N* independently
-keyed indexes into one list, and by nothing else.
-
----
-
-## 2. Decision
-
-1. **Identity is the content hash.** `IndexEntry.id` is blake3 of the file
- (`protocol.py:182`). Two entries with the same `id` are the same file, whatever
- group announced them, whatever their path.
-2. In the three media views, entries sharing an `id` are **merged into one
- entry** carrying a list of sources.
-3. **One source is chosen per logical unit**, not per file — a movie, a whole
- show, a whole album, a whole photo album. Streaming, thumbnails, TMDB /
- MusicBrainz metadata and the download link all use that one source.
-4. The choice is: **a group hosted by the local node wins**; otherwise a
- deterministic pseudo-random pick, stable for one user, spread across users.
-5. If the chosen source turns out to be unreachable, the unit **fails over** to
- another source that has the file.
-6. The badge that today names the group becomes: the group name when there is
- exactly one source, `N sources` when there is more than one. **Which** source
- was picked is never shown.
-7. **The Files explorer is untouched.** Not "mostly untouched" — the merge code
- is never called on that path.
-
----
-
-## 3. Answers to the questions this raised
-
-Recorded here because each of them changed the plan.
-
-### 3.1 Merge across nodes, or only within one node?
-
-**Decided: across all groups**, whatever node hosts them. Merging only groups
-that share a `node_id` would fix the reported case with no trust question at all
-(same process, same file on disk), but it would deliver nothing else: two
-different operators hosting the same film would stay two entries, and the
-failover in §2.5 would have nothing to fail over to.
-
-**The adversary this names.** Chunks are encrypted and authenticated with the
-group's GEK (`crypto.js` `deriveChunkKey(gek, fileHashHex, chunkIndex)`), and the
-client does **not** re-hash the plaintext against `file_id`. So the GCM tag proves
-"encrypted by someone holding this group's GEK for this file id", not "these bytes
-hash to this id". After the merge, opening a file the Search view shows may fetch
-bytes from a group the reader did not name.
-
-Bounded by three things, which is why it is accepted rather than blocking:
-
-- only groups the reader is **already a member of** are ever candidates — the
- Search page indexes nothing else;
-- an operator of such a group can already serve that reader arbitrary content
- *inside their own group*, so no new capability is granted, only a new occasion
- to use it;
-- the local node wins whenever it is a candidate, which is the reported case and
- the common one.
-
-Not accepted silently: the merged entry shows `N sources`, so a reader can see
-that more than one group is involved. Re-hashing the plaintext client-side would
-close it properly and is **not** proposed here — blake3 is not in WebCrypto, and
-a streamed film is exactly the case where it cannot be done before playback.
-Recorded in §9 as still open.
-
-### 3.2 What does "random" mean, concretely?
-
-**Decided: deterministic per user.** `Math.random()` re-evaluated during a render
-would flip the source mid-stream and re-fetch every thumbnail on each re-render;
-re-evaluated once per session it changes on every reload, and any index refetch
-has to be careful to preserve it.
-
-The pick is `sourceIndex = hash(unitKey + userId) % sources.length` over the
-sources sorted by group id. Stable for one reader across renders and reloads;
-different readers land on different sources, which is what "random" was for.
-
-### 3.3 What if the chosen source is unreachable?
-
-**Decided: fail over.** A group whose index could not be fetched at all
-contributes no entries and is already excluded (`fetchAllIndexes`'s `unreachable`).
-What is new is a group that indexed fine and whose WebRTC connection later fails:
-the pick must skip it and the unit must re-resolve. Without this, merging could
-make a file *less* available than it is today, which would be a regression
-dressed as a feature.
-
-### 3.4 Which views?
-
-Videos, Music and Photos. **Not** Files: the explorer stays navigable per group,
-as it is today.
-
----
-
-## 4. Where the duplication actually comes from
-
-`search-page.js` builds four independent entry lists. Each walks
-`indexedGroups` (a `Map` of groupId → `{entries, roots, groupName, groupOwner}`),
-filters by that group's own root, and pushes a *copy* of the entry annotated with
-its group's connection:
-
-```js
-result.push({
- ...e,
- path: SEARCH_VIDEO_ROOT + '/' + e.path,
- groupId, groupName, groupOwner,
- _tRef: conn ? conn.tRef : null, // which transport fetches this file
- _gRef: conn ? conn.gRef : null, // which GEK decrypts it
- _connGen: conn ? conn.gen : 0, // refetch key when that transport reconnects
-});
-```
-
-Everything downstream reads the source off the entry and nothing else:
-
-| Consumer | Reads |
-|---|---|
-| `MediaThumb`, `PosterCard`, `FlatMovieRow` | `_tRef` / `_gRef` / `_connGen` |
-| `useMediaMeta` (TMDB), `useMusicMeta` | `_tRef`, `entry.id` |
-| `VideoPlayer` (streaming) | the modal's transport, set from `connectGroup(entry.groupId)` |
-| `music-player.js` | `getConnection(entry.groupId)` (`music-player.js:288`) |
-| `downloadEntry` | the modal's transport, same origin |
-
-**This is the good news, and it decides the shape of the fix.** "The source" is
-already one triple of fields on one entry. Producing *one* merged entry with one
-source is therefore the whole change on the consumer side — the players, the
-downloader and the metadata hooks need no modification at all.
-
----
-
-## 5. The shape of the fix
-
-### 5.1 A new module, `static/source-merge.js`
-
-Pure functions, no Preact, no transport — testable by reading them out of the
-file and running them under node, the idiom `test_video_default_season.py`
-already uses.
-
-```
-mergeUnitEntries(units, opts) → merged entries
-pickSource(sources, unitKey, opts) → source | null
-sourceLabel(entries | entry) → { count, name, groupId }
-```
-
-- `units` is a list of `{ key, entries }`. **The unit lists are produced by the
- applications' own grouping functions**, never by a second copy of them (§5.2).
-- `opts` carries `salt` (the user id), `isLocal(groupId)` and `isDown(groupId)`.
-- `sourceLabel` returns a shape, not a string: the module imports nothing (its
- test executes it standalone), so it holds no reference to `i18n.js`.
-- Each output entry is one merged `IndexEntry` plus `_sources` (every group that
- has it, sorted by group id) and the resolved `groupId` / `_tRef` / `_gRef` /
- `_connGen` of its effective source.
-
-**Field provenance rule: every displayed field comes from the chosen source's
-entry, and no field is back-filled from another source.** A `thumb_hash` or a
-`display_title` that only one node computed is only fetchable over *that* node's
-connection, so borrowing it would produce a poster request the chosen transport
-cannot answer. Stated here because "merge two records field by field" is the
-obvious thing to write and it is wrong.
-
-### 5.2 Units come from the real grouping functions
-
-The unit key must agree with how each application groups, or a show would get one
-source and its episodes another. Rather than re-deriving the keys in
-`search-page.js` — a copy that keeps passing after the original changes, the trap
-`test_video_default_season.py`'s docstring names — the page calls the exported
-grouping functions on the **un-merged** list purely to learn the units, merges
-within each unit, and hands the merged flat list to the application, which groups
-it again exactly as it does today.
-
-| View | Grouping function | Unit key |
-|---|---|---|
-| Videos | `groupVideoEntries` (exported) | show: `show:<title>`; movie: `movie:<id>` |
-| Music | `groupMusicEntries` (exported) | album: `album:<artist>/<album>`; loose track: `track:<id>` |
-| Photos | `groupPhotoAlbums` (**must be exported**, `photos-app.js:29`) | `album:<dir>` |
-
-Grouping therefore runs twice per recompute. It is a linear pass over an index
-already held in memory and already re-run on every keystroke of the filter; the
-cost is not worth a duplicated implementation.
-
-**Consequence to accept for Photos.** Photo albums are keyed by directory. Two
-groups whose roots have different basenames put the same photo in two
-differently-named albums, and the merge — scoped to a unit — will leave it in
-both. That is correct: they *are* two albums. Only same-named albums collapse,
-which is the reported shape.
-
-**Consequence to accept for Videos.** `PosterGrid.mergedShows` merges two
-differently-parsed show titles once both resolve to the same TMDB id
-(`video-app.js:845`). That happens after metadata arrives, inside the component,
-and the two constituents may hold different chosen sources. Left alone: they were
-two units when the source was picked, the episode lists are already disjoint, and
-re-picking a source under a card the reader is looking at is worse than a mixed
-one.
-
-### 5.3 Choosing the source
-
-```
-candidates = sources of every file in the unit, minus the ones marked down
-local = candidates whose group is hosted by the local node
-pool = local.length ? local : candidates
-chosen = pool[ hash(unitKey + salt) % pool.length ] // pool sorted by group id
-```
-
-Then per file in the unit: use `chosen` if that file has it, otherwise re-run the
-same rule over that file's own sources. An episode present in only one of the two
-groups still plays.
-
-**"Hosted by the local node" is read from the handshake, not from the hub.**
-`handshake_ack` already carries `is_node_admin`, computed by the node from its own
-record of who it belongs to and never from a hub claim
-(`webrtc_server.py:3916`). `fetchGroupIndex` has the ack in hand and today keeps
-only the three root fields from it; it will keep `is_node_admin` too. A hub that
-lied about it would only change which of the reader's own groups is preferred, and
-the reader is a member of all of them.
-
-This is a proxy, not the literal question: it says "the operator of the node
-serving this group is me", which for a person browsing their own libraries is the
-same set. A browser has no other way to know — only the desktop client reaches
-the daemon's loopback API. If the node id is wanted later, `fetchGroupIndex`
-already knows which one it connected to and can record it at no cost.
-
-### 5.4 Failover
-
-`ConnectionPool` gains nothing; `search-page.js` gains a `downGroups` set:
-
-- `connectGroup(groupId)` records a failure and bumps `connectionGen`, which is
- already the signal every entry list recomputes on;
-- a later successful connect clears the mark;
-- `pickSource` skips marked groups, and falls back to the full candidate list if
- every one of them is marked (better a broken tile than a vanished film).
-
-Two properties this must have and must be tested for: a unit whose chosen source
-goes down **re-resolves to another source without a page reload**, and a unit with
-one source behaves exactly as it does today.
-
-### 5.5 The badge
-
-One component, `SourceTag`, four call sites:
-
-| File | Today | After | Counts over |
-|---|---|---|---|
-| `video-app.js` `PosterCard` | `repEntry.groupName` | group name, or `N sources` | the movie, or the show's episodes |
-| `video-app.js` `FlatMovieRow` | group-name link badge | same, non-clickable when `N > 1` | the one entry |
-| `music-app.js` `AlbumCard` | `repTrack.groupName` | same rule | the album's tracks |
-| `photos-app.js` `AlbumCard` | `cover.groupName` | same rule | the album's photos |
-
-**`SourceTag` lives in `group-name.js`**, not in `source-merge.js` (which must
-keep importing nothing) and not in each of the three apps (a copy apiece is
-three chances to disagree about what a merged card says). It renders a `div` by
-default — the three card badges rely on `text-overflow: ellipsis`, which does
-nothing on an inline box — and the flat row's inline pill on `link`.
-
-`files-app.js:446` is **not** in this list: the Files explorer is not merged and
-its group column keeps naming exactly one group.
-
-New i18n key `search.n_sources` (`{ one: '{n} source', other: '{n} sources' }`)
-in `en.js` and the nine other catalogues — `test_locales.py` holds them to `en`'s
-key set and will fail otherwise.
-
----
-
-## 6. What must not change
-
-A checklist for the review, not prose. Each of these is a regression that would
-be easy to ship and hard to notice.
-
-1. **The Files explorer.** Same folder tree, same per-group top level, same
- badges, same navigation. `fileEntries` keeps building one entry per
- (group, file).
-2. **The single-group Group page.** `VideoApp`, `MusicApp`, `PhotosApp` are
- rendered there with entries that carry no `_sources` and no `_tRef`. Every
- changed component must behave identically when those fields are absent —
- `entry._tRef || transportRef` is the existing idiom and stays.
-3. **Streaming and download.** `VideoPlayer` and `downloadEntry` read the modal's
- transport, set from `connectGroup(entry.groupId)`. With one `groupId` per
- merged entry this is unchanged by construction — but a merged entry with a
- *null* `groupId` would silently break both, so the merge must never emit one.
-4. **The music queue.** ~~`onPreview`'s audio branch must be rebuilt over the
- merged entries or a queue will contain each track twice.~~ **Wrong, checked
- 2026-09-02 and left alone.** That branch builds siblings over the un-merged
- `allEntries` but filters `e.groupId === groupId`, so a queue only ever holds
- one group's copies. It is also reachable only from `FilesPanel`, which is not
- merged — the Music view never goes through `onPreview`, it calls `onPlayQueue`
- with its own (merged) tracks. Both facts had to be false for the bug to
- exist; neither is.
-5. **The connection pool's eviction path.** `_onEvict` drops `groupConns` and
- bumps `connectionGen`. Eviction is not failure and must not mark a group down.
-6. **TMDB / MusicBrainz overrides.** An operator's "Fix match" and "Rematch" go
- to the chosen source's node (`transportRef.current.rematchTmdbMatch`). With the
- local node preferred, that is the operator's own node — correct. Worth a line
- in `mediacenter.md` §10 all the same: on a file the operator does not host, the
- override lands on whichever node was picked.
-7. **`cacheGroupIndex`.** The IndexedDB cache is per group and stores raw index
- entries. Merged entries must never be written to it.
-8. **`webapp.py`'s `_ASSETS`.** A new static file missing from it changes
- without moving the asset URL, so a browser that cached the page keeps the
- old copy — and nothing errors. `source-merge.js` shipped missing from it;
- caught afterwards, and `test_every_static_script_participates_in_the_fingerprint`
- now holds the list to every `.js` in `static/` so the next one cannot.
-
----
-
-## 7. Execution order
-
-Each phase is independently testable and leaves the tree working.
-
-| # | Phase | Files |
-|---|---|---|
-| 1 ✅ | Export `groupPhotoAlbums`; record `is_node_admin` in `fetchGroupIndex`'s result | `photos-app.js`, `search-page.js` |
-| 2 ✅ | `source-merge.js` — `pickSource`, `mergeUnitEntries`, `sourceLabel`. No caller yet | new file |
-| 3 ✅ | Tests for phase 2, read out of the source | `test_search_source_merge.py`, `test_search_video_merge.py` |
-| 4 ✅ | Wire the Videos list through the merge | `search-page.js` |
-| 5 ✅ | Same for Music (`foldKey` exported — an album's display strings are the first-seen spelling, so keying a unit on them would let the source change between page loads) | `search-page.js`, `music-app.js` |
-| 6 ✅ | Same for Photos | `search-page.js` |
-| 7 ✅ | `downGroups` and failover | `search-page.js` |
-| 8 ✅ | `SourceTag` at the four display sites; `search.n_sources` in ten catalogues | `group-name.js`, `video-app.js`, `music-app.js`, `photos-app.js`, `locales/*.js` |
-| 9 ✅ | Docs: `mediacenter.md` §10.6, `musicbay.md` §9b, `photos.md` §10b, `apps.md` §2b + checklist step 5 | `docs/` |
-
-Phase 4 alone was enough to confirm the reported bug is gone; phases 5–8 are the
-same mechanism applied outward.
-
-**One thing phase 8 changed about the design.** `sourceLabel` was specified to
-take one entry. A card stands for a *unit*, and the entry it is drawn from is
-picked for its thumbnail — `episodes.find((e) => e.thumb_hash)` — so a show in
-two groups whose cover episode sits in only one of them would have said one
-source. It takes the whole unit now, and unions.
-
----
-
-## 8. Tests
-
-The SPA has no runtime test harness beyond "read the source and run it under
-node", so that is what these are. Source-reading tests are weak evidence and are
-the only evidence available here — which is why every one below **re-derives** a
-value from the real source rather than restating a constant.
-
-| Test | Holds |
-|---|---|
-| `test_search_source_merge.py` ✅ | The whole of `source-merge.js` executed standalone — it has no imports precisely so that it can be, and the test refuses a build where it gains one. A file in two groups yields one entry with two sources; a local source always wins, over every salt; the pick is stable across calls, varies with the salt, and spreads one reader across units; source order does not decide it; a unit's files share the unit's source; a file the unit's source lacks falls back with its siblings; a down group is skipped, a down *local* group yields to a live remote, all-down still returns an entry; no field is back-filled from another source |
-| `test_search_video_merge.py` ✅ | The reported symptom end to end. `groupVideoEntries` + `buildSeasons` (video-app.js) and `videoUnits` (search-page.js) are lifted from their real sources, the pipeline is assembled as the page assembles it, and the result is re-grouped the way `VideoApp` re-groups it — so what is counted is what the grid renders. Two groups sharing one library give one film card and one show whose seasons hold three episodes, not six; one group is unchanged; an episode only one group has survives |
-| extend `test_locales.py` | already fails on a key present in `en.js` and missing elsewhere — no change needed, listed so the ten-catalogue edit is not forgotten |
-| `test_search_files_unmerged.py` ✅ | reads `search-page.js` and refuses a build where `fileEntries` is fed through the merge — the one guarantee §6.1 makes, and the one a later refactor is most likely to break by tidying the four lists into one. It also asserts the other three lists *are* merged, or deleting the merge outright would leave it passing and saying nothing |
-| extend `test_transport_contracts.py` | the existing "declared vs. called setters" check covers the new state in `search-page.js` for free |
-| `test_search_media_merge.py` ✅ | the same end-to-end shape for Music (an album's tracks listed once; a differently-cased tag not splitting the unit; a bonus track only one group has; an untagged track kept as its own unit) and Photos (one album, each photo once; two differently-named albums each keeping the photo) |
-
-**Every one of these was checked against the fix removed**, which is where the
-first version of "a unit's files share its source" turned out to prove nothing:
-with every episode in every group, picking per file and picking per unit give
-the same answer — the same key over the same set — so the test passed against a
-per-file implementation. It now uses a unit whose files have *unequal* sources,
-which is the only shape where the two rules come apart. Five mutations are
-caught: dropping the local preference, picking per file, not de-duplicating a
-group announcing a file twice, dropping the group-id sort, and hashing the salt
-without the unit key.
-
-Beyond the suite, this needs a person: two groups sharing one directory, one
-film and one multi-season show, checked in Posters, Flat list, the detail modal,
-Music and Photos, plus one playback and one download from a merged entry. The
-standing rule from `CLAUDE.md` applies — a stylesheet does not tell you where
-anything lands, and neither does a source-reading test tell you what a poster
-grid renders.
-
----
-
-## 9. Still open
-
-| Item | Status |
-|---|---|
-| Client-side content verification | **Open.** The merge lets bytes arrive from a group the reader did not name (§3.1). Closing it means re-hashing plaintext against `file_id`, which needs blake3 in the browser and is impossible before playback for a stream. Not attempted here |
-| Showing *which* source was picked | Deliberately not shown, per the request. `_sources` is on the entry, so a debug affordance is cheap if it is ever wanted |
-| Merging across nodes for *availability* | The failover in §5.4 is per unit and reactive. Preferring a node that is already connected, or one that answered faster, is a further step and is not planned |
-| Preferring the local node by `node_id` rather than `is_node_admin` | §5.3. The exact signal exists and costs nothing to record; the proxy is used because it needs no new field on the wire |
diff --git a/docs/second-review.md b/docs/second-review.md
deleted file mode 100644
index aca65d7..0000000
--- a/docs/second-review.md
+++ /dev/null
@@ -1,880 +0,0 @@
-# MeshBay — Second Architecture & Security Review
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the second security review; its design
-> content now lives in §13.3, and the invariants each finding names throughout.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Date: 2026-08-13
-> Scope: architecture and security design review of the hub ↔ node ↔ client protocol,
-> as specified in draft v4 and the Phase 1–12 log (both archived in `old-draft.md`), `devel-phases-next.md`,
-> and as **implemented** in `packages/` (Phases 1–12 + 10b/10c).
->
-> Unlike `first-review.md` (2026-08-10), which was a design-level review, this one reads
-> the code that implements the protocol: `protocol.py`, `webrtc_server.py`, `quic_server.py`,
-> `server.py`, `http_server.py`, `daemon.py`, `bundle_store.py`, `ui/app.py`, the hub API
-> routers, and the browser client (`transport.js`, `crypto.js`, `keyderive.js`, `app.js`).
->
-> Finding numbering is **independent** of `first-review.md`. All C1/H1/M1 references below
-> are new.
->
-> Not verified: the test suite could not be run (`pytest` is not installed in `.venv`), so
-> the "191 tests" claim is taken at face value. No live testing against meshbay.org was done.
-> This is a code and design review, not a penetration test.
-
----
-
-## 1. Executive summary
-
-The cryptographic core remains sound: ECIES GEK wrapping, HKDF domain separation, AEAD
-chunk encryption, Ed25519 JWT with `jti`, refresh-token rotation. The *ideas* added since
-the first review — GEK-HMAC handshake proof, DTLS channel binding, Ed25519 admin
-challenge-response, password split, node sovereignty — are the right ideas, and several of
-them are genuinely clever.
-
-**But the implementation does not enforce the model the documents describe.** The
-protection added in Phases 11–12 lives almost entirely on the WebRTC path, while three
-other paths into the same node (HTTP API, QUIC, TCP) were left as they were. The most
-serious result is that **every private group served by a node daemon is exposed in
-plaintext, without any authentication, over the node's HTTP API on `0.0.0.0`**. That single
-defect nullifies the entire GEK-proof / node-sovereignty layer for anyone who can reach the
-node's HTTP port.
-
-Answering the question directly:
-
-> *"The client and the node want to communicate safely, with everything encrypted and
-> unreadable by other parties, even the hub. Does it do what it claims?"*
-
-**Partially, and not today.**
-
-- Against a **passive/honest-but-curious hub**: yes for file content. The hub never sees
- the GEK, never sees chunks, and is out of the data path after signaling. This part works.
-- Against an **active malicious hub**: **no.** The hub is the public-key directory. When a
- member invites someone, the inviter fetches the invitee's `pk_x25519` *from the hub* and
- wraps the GEK for it (`app.js:1399-1410`). A hub that returns its own key gets the GEK for
- that group. This is documented as trust assumption **T2** but it is not a residual risk —
- it is a complete break of the confidentiality claim, requiring no exotic capability.
-- **"Everything encrypted"**: **no.** Chat messages are plaintext on the wire (application
- layer) and plaintext at rest in SQLite. The Mesh Group Index is sent in cleartext over the
- WebRTC DataChannel. Uploads are transmitted and stored in plaintext. Files are stored in
- plaintext on the node by design.
-- **"Unreadable by other parties"**: it is readable by every group member, by the node
- operator, and — via the findings below — by anyone who can reach the node's HTTP port or
- who can hijack a node's signaling registration on the hub.
-
-There are **6 critical** and **7 high** findings. Most are not exotic crypto issues; they
-are missing authorization checks and paths that were never brought up to the level of the
-newest path. None of them invalidate the architecture — all are fixable inside the existing
-design — but the current build should not be described as end-to-end secure, and should not
-host real private data until C1–C6 are closed.
-
----
-
-## 2. What is solid
-
-Worth recording, because the delta since the first review is real:
-
-1. **GEK-HMAC handshake proof with DTLS channel binding** (`webrtc_server.py:294-335`,
- `crypto.js:235-246`). Binding `HMAC(GEK, nonce ‖ offer_fp ‖ answer_fp)` to the DTLS
- fingerprints of both sides is a correct, well-chosen defence: a signaling relay that
- substitutes its own fingerprints cannot produce a proof the node accepts. The Chrome
- raw-SDP workaround (`transport.js:127`) shows this was actually made to work, not just
- specified.
-2. **Deny-by-default on destructive operations** (`webrtc_server.py:750-754`). If no key is
- pinned, deletion is refused. Correct posture.
-3. **Ed25519 node→hub authentication** with a domain-separated message
- (`meshbay:node_auth:{username}:{timestamp}`, `nodes.py:55`) and a node-scoped JWT that
- `require_user_scope` refuses for mutations. Clean.
-4. **Refresh-token family rotation with reuse detection** (`users.py:213-267`). Textbook
- OAuth BCP.
-5. **HKDF domain separation** is consistent and the AES/ChaCha20 variants are properly
- separated by info string (`:aes` suffix), so the two ciphers can never derive the same
- key from one GEK.
-6. **AEAD-only chunk wire format.** Dropping per-chunk Ed25519 signatures in favour of
- AES-GCM tags (Phase 9.15) is defensible: the tag authenticates the ciphertext under a key
- only members hold. (It does have a consequence — see H3.)
-7. **The trust-domain separation in §4.2.x of draft-v4 is the right model.** "The hub
- certifies identity; the node authorizes content operations" is exactly the correct
- framing for this system. The problem is enforcement coverage, not the model.
-
----
-
-## 3. Critical findings
-
-### C1 — Private group content is served in plaintext with no authentication (node HTTP API)
-
-**Location:** `transport/http_server.py:115-160`, wired in `daemon.py:341-366`
-
-The daemon starts `create_http_app()` for **every configured group**, private ones included,
-bound to `0.0.0.0:http_port` (default 19001).
-
-Two endpoints have **no authentication of any kind** — no JWT, no group check, no GEK proof:
-
-```python
-@app.get("/index") # http_server.py:115 — full file listing, no auth
-@app.get("/file/{file_id}") # http_server.py:141 — FileResponse(path) — raw plaintext file
-```
-
-`download_file` reads the file straight off disk and streams it. The `gek` parameter is only
-consulted by the *chunk* endpoint (`/file/{id}/{chunk}`), and even that one accepts **any**
-JWT signed by the hub — no group-membership check, no GEK proof.
-
-The docstring says "Note: this server handles PUBLIC content only", but nothing in the code
-enforces it: the daemon passes the private group's `shared_root` and index unconditionally.
-
-**Impact.** Complete bypass of the entire Phase 12 sovereignty layer. Anyone who can reach
-the port gets the full private index and every private file in cleartext:
-- anyone on the node operator's LAN/VLAN (guest WiFi, roommate, compromised IoT device);
-- anyone on the Internet if the operator forwarded the port (the docs encourage port
- forwarding for NAT edge cases) or has a permissive IPv6 firewall;
-- any local process/user on the machine.
-
-No JWT forgery, no hub compromise, no GEK required. This is the single most severe issue in
-the codebase and it silently negates NS1/NS3/R22 in the documentation.
-
-**Fix.** Bind to `127.0.0.1` at minimum. Then: refuse to start the HTTP app at all for
-groups with `visibility = "private"`; require a valid JWT *and* group membership on every
-endpoint including `/index` and `/file/{id}`; never serve plaintext bytes for a group that
-has a GEK. Better: delete this server. It predates the WebRTC/QUIC paths and duplicates them
-without any of their controls.
-
----
-
-### C2 — Any authenticated user can hijack a node's identity on the hub (WebSocket)
-
-**Location:** `api/revocation.py:131-163`
-
-```python
-decoded = decode_access_token(msg["token"])
-node_id = msg.get("node_id") or decoded.get("sub", "unknown") # ← client-supplied
-_connected_nodes[node_id] = ws
-group_ids = msg.get("group_ids", []) # ← client-supplied
-_node_groups[node_id] = group_ids
-```
-
-The hub accepts whatever `node_id` and `group_ids` the connecting party claims. There is no
-check that the JWT subject owns that node record, and no check that `scope == "node"`.
-
-**Impact — this is a full client-impersonation primitive.** Any registered user can:
-
-1. Connect to `/v1/nodes/ws` with their ordinary user JWT and claim the `node_id` of a
- victim node, overwriting the legitimate entry in `_connected_nodes`.
-2. All subsequent `POST /v1/nodes/{node_id}/webrtc/offer` requests from browsers are relayed
- to the **attacker** (`signaling.py:56`), who answers with their own SDP.
-3. The victim's browser now has a DataChannel to the attacker, believing it is the node.
-
-The DTLS channel binding does *not* help here: the attacker is the endpoint, not a relay.
-The browser sends its GEK proof to the attacker, who simply ignores it and replies
-`handshake_ack` — `transport.js:204` only checks `ack.type === 'handshake_ack'`.
-
-The attacker then receives:
-- the victim's **encrypted keypair bundle** (`storeKeypairBundle`, `app.js:850`) → offline
- password brute-force target (see C4);
-- every chat message the victim sends (plaintext);
-- every file the victim uploads (plaintext);
-- and can serve a forged index and forged chat history.
-
-Also: `group_ids` is attacker-controlled, so the attacker can advertise as an online node for
-any group and appear in `GET /v1/groups/{id}/nodes` — the browser picks `nodes[0]`
-(`app.js:822`) with no further verification.
-
-**Fix.** Require `scope == "node"`; look up the `Node` row and verify `node.user_id ==
-payload["sub"]`; derive `group_ids` from the database (`GroupMember` for that user), never
-from the message; reject a second registration for an already-connected `node_id` instead of
-overwriting it.
-
----
-
-### C3 — The node never authenticates itself to the client
-
-**Location:** `transport.js:56-215`, `app.js:808-827`, `webrtc_server.py:351-362`
-
-Authentication is one-directional. The client proves its identity (JWT) and its membership
-(GEK-HMAC). The node proves *nothing*:
-
-- `handshake_ack` carries `node_pk` but there is no signature over anything — possession of
- `sk_node` is never demonstrated.
-- The browser fetches `pk_node` from `GET /v1/groups/{id}/nodes` and then **discards it**;
- `app.js:822` uses only `nodes[0].node_id`.
-- Per-chunk Ed25519 signatures were removed in Phase 9.15, so no later message proves node
- identity either.
-
-The only implicit authentication is possession of the GEK, and it only covers *file chunks*
-(they will not decrypt otherwise). Everything else — the index, chat history, `is_node_admin`,
-`handshake_challenge`, and everything the client *pushes* — is unauthenticated.
-
-**Impact.** Enables C2 end-to-end, and independently means a hub that returns an attacker's
-`node_id` for a group achieves the same result. `is_node_admin` is trusted by the SPA
-(`app.js:829`) to decide which controls to display, and it comes from an unauthenticated
-peer.
-
-**Fix.** Mutual proof in the handshake. Simplest correct version: the node returns, alongside
-its challenge, `HMAC(GEK, "meshbay:node_proof:v1" ‖ nonce_c ‖ offer_fp ‖ answer_fp)` over a
-client-supplied nonce, and the client verifies it before sending anything sensitive. Add
-`Ed25519(sk_node)` over the same transcript and have the client pin `pk_node` from the hub
-(TOFU + change alerts), so that node identity does not rest on a group-shared secret.
-
----
-
-### C4 — Users' encrypted private-key bundles are handed to third parties, and are only PBKDF2-protected
-
-**Location:** `webrtc_server.py:194-197, 451-492`, `bundle_store.py:84-100`,
-`keyderive.js:74-87`, `app.js:848-856`
-
-Phase 12 moved keypair bundles off the hub and onto nodes. Three problems compound:
-
-1. **The bundle is served before the GEK proof.** In `_handle_message`, both
- `GEK_BUNDLE_FETCH` and `KEYPAIR_BUNDLE_FETCH` are dispatched on the condition
- `self._gek_challenge is not None` — i.e. after JWT verification but **before**
- `_do_handshake_response` has validated anything. (`_gek_challenge` is even set on the
- error path where the group has no GEK, `webrtc_server.py:279-292`.) A hub that forges a
- JWT for user X — trivial, it holds the signing key — retrieves X's encrypted keypair
- bundle without ever possessing the GEK. This is a chicken-and-egg the design has to solve,
- but as written the pre-proof window is a data-disclosure window.
-
-2. **The bundle is pushed to every node the user connects to.** `app.js:848` pushes
- `_pendingBundlePush` to whichever node the group connection landed on. Join five groups
- hosted by five different people and five unrelated operators now hold your private-key
- bundle on their disk.
-
-3. **The bundle is protected only by PBKDF2-SHA512, 600 000 iterations**
- (`keyderive.js:23,74-87`), salted with `SHA-256("meshbay:bundle:v1:" + username)` — a
- deterministic, non-random salt.
-
-Consequence: the "password split" (T1) does not deliver what §4.2.x claims. It is true that
-the hub cannot *derive* `bundle_key` from `auth_key`. It is not true that the hub is
-therefore locked out: the hub obtains the bundle by forging a JWT (path 1) and then runs an
-offline dictionary attack that costs **only PBKDF2**, not the Argon2id-256MB the hub's own
-password verifier is protected by. The user's password is the last line of defence, and it
-is defended by the *cheaper* of the two KDFs. Recovering it yields `sk_ed25519` and
-`sk_x25519` → unwrapping every GEK bundle → all groups, all content, plus the ability to
-sign as that user.
-
-Every node operator whose group you join gets the same offline target (path 2).
-
-**Fix.** Ranked:
-- **Do not store keypair bundles on other people's machines.** This is the wrong home for
- them. A native client keeps keys in a local OS-protected keystore; the browser can keep them
- in IndexedDB with an explicit, user-initiated encrypted export.
-- If the bundle must be remotely recoverable, protect it with Argon2id (256 MB) via WASM, not
- PBKDF2, and use a random per-user salt fetched alongside the bundle.
-- Serve it only *after* a successful GEK proof, and only from the user's own node.
-- Separate the bundle key from the login password entirely (recovery phrase), so that
- cracking one does not yield the other.
-
----
-
-### C5 — Any group member can overwrite arbitrary files in the shared directory, and can seize the group key
-
-Two independent authorization gaps in the MNP handlers, both reachable by any authenticated
-group member (the GEK proof does not distinguish members from each other).
-
-**C5a — Upload overwrites anything** (`webrtc_server.py:683-726`)
-
-```python
-safe_name = filename.replace("/", "_").replace("\\", "_").replace("..", "_")
-...
-final_path = shared_root / safe_name
-tmp_path.rename(final_path) # unconditional overwrite
-```
-
-Path traversal is blocked, but nothing prevents overwriting an existing file. There is no
-size limit, no quota, no per-user restriction, no operator approval. So:
-- any member can destroy or replace any file at the root of the shared directory —
- a direct violation of "the node operator is the sole authority over content";
-- and this **bypasses the deletion controls entirely**: overwrite the victim's file, then
- `_register_uploader` (`:727-736`) tags the entry with *your* `uploader_pk`, after which you
- can legitimately delete it via the uploader path (`:798-806`);
-- disk-fill DoS is unconstrained.
-
-**C5b — GEK bundle store has no authorization, and auto-activates**
-(`webrtc_server.py:391-449`)
-
-`_do_gek_bundle_store` writes whatever `(group_id, user_id, bundle)` the caller supplies, with
-no check that the caller is the group admin or the node operator, and `INSERT OR REPLACE`
-overwrites existing bundles. Then:
-
-```python
-if node_user_id and target_user_id == node_user_id and group_id:
- await self._try_activate_gek(group_id, target_user_id) # unwraps and swaps the live GEK
-```
-
-The node operator's `pk_x25519` is public (it is even handed out in `handshake_ack` as
-`node_pk_x25519`, `:359-361`). So any member can wrap a **GEK of their own choosing** for the
-operator's key, store it, and the node will unwrap it and replace the group's active GEK.
-Result: all existing content becomes undecryptable for the legitimate members, and the
-attacker controls the key used from that point on. A member can also silently overwrite other
-members' bundles to lock them out.
-
-**Fix.** Uploads: quarantine to `.uploads/{user_id}/`, refuse to overwrite an existing index
-entry, enforce quotas and a max file size, and require operator opt-in for writes outside the
-upload directory. GEK bundles: require an Ed25519 challenge-response against the pinned admin
-key for `gek_bundle_store`, and never auto-activate a GEK from a peer message — GEK
-initialization belongs to the local admin UI only, which is already implemented
-(`ui/app.py:175-260`).
-
----
-
-### C6 — The GEK proof only exists on the WebRTC path; QUIC and TCP accept a JWT alone
-
-**Location:** `quic_server.py:148-186`, `server.py:134-165` vs `webrtc_server.py:242-335`
-
-Draft-v4 §4.2.x states: *"ALL operations require passing the GEK proof first."* That is true
-only for `webrtc_server.py`. The QUIC server (started on `::` port 19000) and the TCP server
-(started on `0.0.0.0` port 18001) still perform the Phase 7 handshake: verify JWT → check
-`groups` claim → `handshake_ack`. No challenge, no proof.
-
-**Impact.** A forged JWT (hub) or a stolen JWT reaches the node over QUIC/TCP and can:
-- fetch index and chunks — these are GEK-encrypted, so confidentiality holds *there*;
-- **inject chat messages** into the group store — `_do_chat_message_sync` in `quic_server.py`
- stores and broadcasts plaintext payloads without any GEK involvement. Chat injection and
- impersonation of the group's discussion with nothing but a hub-signed token.
-- consume node resources without ever holding the group key.
-
-It also means the denylist/GEK/sovereignty story has to be reasoned about per-transport,
-which is exactly the kind of divergence that produces the next C1.
-
-**Fix.** Factor the handshake (JWT → denylist → group claim → GEK challenge → proof → ack)
-into one function in `meshbay_common` and call it from all three transports. If native
-clients are not using QUIC/TCP yet, disable those listeners by default until they are brought
-to parity.
-
----
-
-## 4. High findings
-
-### H1 — Cross-group data leakage on multi-group nodes (chat store and peer set)
-
-**Location:** `daemon.py:249`, `webrtc_server.py:601-681, 617, 343-345`
-
-The daemon builds a proper per-group context (`groups_ctx[gid]["chat_store"]`,
-`daemon.py:219-226`) and then sets a single global one:
-
-```python
-self._webrtc._ctx["chat_store"] = first.get("chat_store") # daemon.py:249 — the FIRST group
-```
-
-Both chat handlers read from the *top-level* context, not the group context:
-
-```python
-chat_store = self._ctx.get("chat_store") # webrtc_server.py:602 and :650
-```
-
-So on a node hosting several groups, **all groups write into the first group's chat database,
-and `chat_hist` serves that database to members of every group.** Members of group B read
-group A's private conversation.
-
-The same bug affects broadcast: `_peers` lives in the shared `_ctx` (`:974`, `:343-345`), so
-`_do_chat_message` (`:617-632`) fans out every message to **all connected peers on the node,
-regardless of group**.
-
-**Fix.** `chat_store` and `_peers` must come from `self._group_ctx()`, with one peer registry
-per group. Add a test with two groups and two users that asserts isolation.
-
----
-
-### H2 — Stored XSS in the node admin UI via uploaded filename → node takeover
-
-**Location:** `ui/app.py:359-365` (and `:632-639` for the audit page)
-
-```python
-file_rows += f"<tr><td>{e.name}</td><td>{e.type}</td>..."
-```
-
-Filenames are interpolated into HTML with no escaping. The upload sanitizer
-(`webrtc_server.py:701`) strips path separators but not `<`, `>`, `"`. Any group member can
-upload a file named `<img src=x onerror="fetch('/api/groups/GID/gek',{method:'POST'})">`.
-
-The local UI has **no authentication at all** (by design, "localhost only"). So when the
-operator opens `http://localhost:18000`, attacker JavaScript runs with full access to the node
-admin API: re-initialize/rotate the GEK, enumerate all groups and shared paths, read the whole
-audit log (users, IPs, actions), read the config. The audit page builds rows with `innerHTML`
-from `e.detail`, which also carries filenames — same vector, different page.
-
-**Fix.** Escape all interpolated values (`html.escape`), use `textContent` in the audit page,
-sanitize uploaded filenames to a conservative allowlist, and add a CSP header to the UI app.
-Consider a localhost token in the URL to blunt DNS-rebinding against the unauthenticated UI.
-
----
-
-### H3 — An active hub breaks confidentiality through key substitution (T2 is not a residual risk)
-
-> **CLOSED 2026-08-14.** Not by the fix proposed below. The invite path no longer reads
-> the directory at all: the node holds the GEK and wraps it for a key the recipient
-> proves possession of over the authenticated channel, and identities are bound to
-> accounts by one-time codes the hub never sees. Safety numbers would have made the
-> substitution *detectable by a human who checks*; removing the lookup makes it
-> impossible. See `invite-pairing-v1.md` and draft-v5 §5.5.
->
-> `gek-init` had the same flaw with the node as the victim — it fetched every member's
-> public key from the hub and wrapped for the answer. That is gone too.
-
-**Location:** `app.js:1389-1415`, `users.py:340-358`, `users.py:310-337`
-
-The invite flow is: fetch `pk_x25519` for the invitee **from the hub**, wrap the GEK for it,
-store the bundle on the node. The hub is the sole key directory, and `PUT /v1/users/me/keys`
-lets keys be replaced at any time.
-
-A malicious hub returns its own X25519 key for the invitee. The inviting member wraps the GEK
-for the hub. The hub now holds the group key and can decrypt every chunk it can obtain —
-including chunks captured via C1, C2, or C6. No JWT forgery needed, no JS injection needed,
-nothing detectable by the client.
-
-The documents list this as **T2** under "remaining trust assumptions", alongside T3 (hub
-serves the SPA). That framing understates it: with T2 open, the sentence "unreadable by other
-parties, even the hub" is not true against an adversarial hub, and the GEK-HMAC/sovereignty
-work in Phase 12 does not change that, because the hub obtains the GEK legitimately.
-
-**Fix.** Out-of-band key verification is the only real answer: safety numbers / fingerprint
-comparison, key-change warnings ("Alice's key changed on 2026-08-13 — verify before sharing"),
-and key transparency (a signed append-only log of key bindings the client audits). Until then,
-the honest claim is *"the hub cannot read your content unless it actively attacks you."*
-
----
-
-### H4 — Group revocation never reaches nodes; jti denylist is volatile
-
-**Location:** `daemon.py:316-330`, `revocation.py:82-96`
-
-The hub signs revocation tokens with `target ∈ {"user", "group"}` and broadcasts them. The
-node handler only implements two cases:
-
-```python
-if target == "user": denylist.deny_user(tid)
-elif target == "jti": denylist.deny_jti(tid)
-# target == "group" → silently dropped
-```
-
-So `POST /v1/admin/revoke` for a group marks it revoked in the hub DB and does nothing on any
-node. Combined with the fact that `webrtc_offer` (`signaling.py:44-85`) checks neither group
-status nor membership, "suspend a group blocks signaling" (draft-v4 §4.2.x) is not true — a
-client holding a `node_id` and a still-valid JWT keeps connecting. The denylist is also
-in-memory only (`Denylist()`), so it is cleared by any node restart.
-
-**Fix.** Handle `target == "group"` on the node (drop sessions, refuse handshakes for that
-group); check group status in `webrtc_offer`; persist the denylist to `data_dir` with
-expiry-based pruning.
-
----
-
-### H5 — The Ed25519 admin challenge is an unbound signing oracle
-
-**Location:** `webrtc_server.py:756-763`, `keyderive.js:249-256`
-
-```python
-challenge = os.urandom(32) # node → client
-```
-```js
-const sig = await crypto.subtle.sign('Ed25519', sk, challenge); // client signs 32 raw bytes
-```
-
-The client signs 32 arbitrary bytes chosen by the node, with its long-term identity key,
-with no domain separator, no context, and no length constraint. The signed payload does not
-mention "file_delete", the `file_id`, the group, the node, or a timestamp.
-
-Consequences:
-- A malicious or compromised node can request a "deletion" and obtain a signature over any
- 32-byte string it likes. `meshbay:node_auth:{username}:{timestamp}` is exactly 32 bytes for
- a 3-character username — currently not exploitable because node auth verifies against
- `pk_node_ed25519` rather than the user identity key, but that separation is a coincidence of
- the current schema, not a designed defence.
-- Signatures are not bound to the operation, so a captured signature is reusable for any
- future challenge that happens to repeat (it will not, but nothing structurally prevents
- replay across contexts either).
-
-**Fix.** Sign a structured, domain-separated transcript:
-`Ed25519(sk, "meshbay:file_delete:v1" ‖ node_pk ‖ group_id ‖ file_id ‖ nonce ‖ timestamp)`,
-and have the client display *what* it is signing. Apply the same rule to every future
-challenge (this is a protocol-wide invariant, not a one-off fix).
-
----
-
-### H6 — Unauthenticated resource exhaustion on nodes
-
-Several unbounded paths, all reachable by any hub user (no group membership needed for some):
-
-| Vector | Location | Effect |
-|---|---|---|
-| `POST /v1/nodes/{id}/webrtc/offer` | `signaling.py:44` — any authenticated user, no membership check, no rate limit | Node allocates an `RTCPeerConnection` + ICE gathering per request; `_sessions` grows |
-| DataChannel receive buffer | `webrtc_server.py:121-139` — `MAX_MSG = 64 MB`, buffer grows before handshake | Memory exhaustion by claiming a 64 MB frame and dribbling bytes, pre-auth |
-| `stream_req` | `webrtc_server.py:828-906` — spawns `ffmpeg` per request, no concurrency cap | CPU/process exhaustion by any member |
-| `stream_seg` | `webrtc_server.py:573-590` — **synchronous `subprocess.run(timeout=30)` inside the event loop** | One request blocks the entire node for up to 30 s |
-| `file_upload` | `webrtc_server.py:683` — no size/quota limit | Disk fill |
-| `POST /v1/nodes/{id}/incoming` | `revocation.py:202` — any user picks `peer_ip`/`peer_port` | Node emits UDP probes to arbitrary destinations (small reflection primitive) |
-
-**Fix.** Per-user connection caps and rate limits on signaling; membership check before
-relaying an offer; cap the pre-handshake buffer at a few KB; a semaphore around ffmpeg;
-make `stream_seg` async or delete it (superseded by `stream_req`); upload quotas; validate
-that `peer_ip` matches the requester's source address.
-
----
-
-### H7 — Swarm registration publishes private-group file hashes to the hub (currently masked by a routing bug)
-
-**Location:** `daemon.py:382-387, 501-506`, `groups.py:120`, `hub_client.py:277-294`
-
-The daemon registers the blake3 hashes of **every group's** files with the hub swarm table,
-private groups included — there is no visibility filter. Draft-v4 §7.3 describes the swarm as
-a *public content* mechanism.
-
-Right now this fails silently: the route is declared as `@router.post("/v1/swarm/register")`
-on a router with `prefix="/v1/groups"`, so it is mounted at `/v1/groups/v1/swarm/register`,
-while the node posts to `/v1/swarm/register` → 404, swallowed by `except Exception: pass`.
-
-**Impact.** The bug is currently protecting privacy. Fixing the path without adding a filter
-would immediately leak, to the hub, a content-identifier fingerprint of every private file
-on every node — enough for the hub (or anyone with `GET /v1/swarm/{hash}`, which requires no
-auth) to confirm "does this known file exist in the network, and which node has it". That is
-precisely the metadata the "hub stores no content metadata" claim rules out.
-
-**Fix.** Register hashes only for groups with `visibility == "public"`, fix the route, and
-require authentication on the lookup endpoint.
-
----
-
-## 5. Medium findings
-
-**M1 — `group_id` is optional in the handshake, which skips the membership check.**
-`webrtc_server.py:257,261` guard on `if group_id and ...`. With `group_id = ""` both checks
-are skipped and `_group_ctx()` (`:506-509`) falls back to `self._ctx`, which the daemon
-populates with the **first group's** gek/index/shared_root (`daemon.py:238-247`). Access still
-requires that group's GEK, so it is not a full bypass — but a user removed from the group on
-the hub who kept the GEK regains access, and the JWT `groups` claim stops being authoritative.
-Make `group_id` mandatory.
-
-**M2 — Argon2id in the node keystore is still 64 MB.** `crypto.py:173-174`
-(`ARGON2_MEMORY_COST = 65536`) with a comment saying to raise it. Only the hub's password
-verifier got the 256 MB bump (`auth.py:30-35`). The docs record R1/8.10 as done, which is true
-for the hub and false for the keystore. Also, `create_keystore` accepts an 8-character
-minimum password, and the calibration command prints instructions to hand-edit a constant in
-`meshbay_common` rather than writing a per-node parameter — so the keystore parameters cannot
-actually be tuned per hardware as §4.2.1 promises.
-
-**M3 — Node operator cannot delete files in the default configuration.** *(CLOSED
-2026-08-14 — the auto-pin is deleted; authority comes from the node's roster, established
-locally by `meshbay-node operator pair`. Asking the hub for the operator's key, the
-obvious-looking fix, would have let the hub install itself as node administrator.)*
-`_resolve_admin_pk`
-(`daemon.py:451-465`) auto-pins the **node keystore's** Ed25519 key, while the browser signs
-challenges with the **user identity** key from the keypair bundle (`app.js:983`). These are
-different keys, so verification fails unless the operator manually sets `admin_pk_ed25519` to
-their browser key. Fails closed, so it is a correctness problem rather than a hole — but the
-sovereignty feature is effectively inert as shipped, and the mismatch will invite the wrong
-fix (relaxing the check) unless it is documented.
-
-> **C4 — REDUCED 2026-08-14, not closed.** The bundle's KDF moved from PBKDF2-SHA512
-> 600k to Argon2id 128 MB/t=3 in the browser (vendored WebAssembly), so an operator
-> attacking one offline no longer enjoys the GPU economics of a compute-only KDF. The
-> pre-proof window is unchanged and still bounded. What remains: bundles are still stored
-> on every node their owner joins, and a weak passphrase still loses — draft-v5 §7.1 gives
-> the measured numbers. It closes at 13.3.
-
-**M4 — Response-to-request matching by arrival order.** `transport.js:390-422` resolves the
-**oldest** pending promise with whatever message arrives, ignoring type. With the 8-deep
-pipelined download window, a node that reorders responses (or an `error` message arriving
-mid-flight) resolves the wrong promise. Add a request id (`rid`) to MNP and echo it in
-responses — cheap, and it also removes the `index_sync` special case at `:408-415`.
-
-**M5 — Index and chat are not encrypted on the WebRTC path.** `_do_index_sync`
-(`webrtc_server.py:511-528`) sends entries as cleartext msgpack; the QUIC/TCP path uses
-`GroupIndex.serialize()` which *is* GEK-encrypted. So the same object has two different
-protection levels depending on transport, and draft-v4 §8.2 ("GEK-encrypted, hub stores
-opaque") describes only one of them. With DTLS in place this is not remotely readable, but it
-means the security property depends entirely on the transport rather than on the data.
-
-**M6 — IP audit log corruption on registration.** `users.py:118-122`:
-
-```python
-await db.execute(IPLog.__table__.update().where(IPLog.user_id == None).values(user_id=user.id))
-```
-
-This backfills **every** IPLog row that has a NULL `user_id` — including failed-login rows for
-other usernames and other users' registrations — with the newly created user's id. For logs
-kept for a year specifically to answer legal requests, this is a data-integrity defect that
-attributes other people's connections to the wrong account. Set `user_id` on the row you just
-created (flush first, or add the row after `db.refresh(user)`).
-
-**M7 — `X-Forwarded-For` is trusted unconditionally.** `users.py:361-365`, `groups.py:313`,
-`nodes.py:131`. Behind Caddy this is fine today; if the hub is ever reachable directly, or a
-second proxy is added, any client can forge the IP written into the compliance log and evade
-per-IP rate limiting. Use a trusted-proxy list and take the rightmost untrusted hop.
-
-**M8 — `announce_node` accepts any `pk_node`.** `nodes.py:88-109` — a user can announce a node
-record containing someone else's public key, and node records accumulate without limit. Combine
-with C2 for a more convincing impersonation. Verify possession (sign a challenge with
-`sk_node`) and enforce one active node record per user unless multi-node is intended.
-
-**M9 — Node accepts node-scoped tokens as client tokens.** All three transports call
-`jwt.decode` without inspecting `scope` (`webrtc_server.py:246`, `quic_server.py:153`,
-`server.py:141`). A node-scoped token (which is also issued with the full `groups` claim,
-`nodes.py:66-71`) is accepted as a regular client anywhere. Check `scope == "user"` on the
-client path.
-
----
-
-## 6. Low findings / notes
-
-- **L1 — Dead protocol constants.** `GEK_REQUEST`/`GEK_RESPONSE` remain in `protocol.py:32-33`
- though the handlers are gone (NS3 says "removed"). Delete them so the wire contract matches
- the docs.
-- **L2 — No MNP version negotiation.** Every message carries `v: "0.1"` and nobody checks it
- (`webrtc_server.py`, `transport.js`). §3 of draft-v4 specifies range negotiation and an
- explicit refusal. Currently a version mismatch would fail in undefined ways. Phase 13.6
- covers this — keep it.
-- **L3 — Error strings leak internals.** `webrtc_server.py:224-226` and `server.py:127` return
- `str(e)` to the peer, which includes filesystem paths and exception detail.
-- **L4 — `hmacGEK` concatenates without length prefixes** (`crypto.js:235-246`,
- `webrtc_server.py:326`). With fixed-size inputs this is unambiguous today; if a fingerprint
- is ever missing (the extractors return empty on failure) the concatenation becomes ambiguous
- and the proof silently degrades to nonce-only. Prefix lengths, and **reject** empty
- fingerprints instead of proceeding.
-- **L5 — No security headers / CSP on the hub** (`app.py:97-126`). For an application whose
- threat model explicitly includes "the hub could inject JS", a strict CSP plus
- `Subresource-Integrity` on the static bundle at least makes a *silent* injection harder and
- gives extensions something to pin against.
-- **L6 — `EmailStr` imported but unused** (`users.py:8`, field typed `str`) — no email
- validation on registration.
-- **L7 — Sender Keys is implemented but unreferenced.** `senderkeys.py` is exercised only by
- its own tests; no production code imports it. That matches the Phase 13 plan; noting it so
- the module is not mistaken for an active protection.
-- **L8 — `_register_uploader` matches by name and root path only** (`webrtc_server.py:727-736`)
- — the first entry with a matching name at the root gets tagged, which is wrong when a file
- with the same name exists in a subdirectory.
-
----
-
-## 7. Does the system do what it claims?
-
-> This table is the verdict **on the code as it stood on 2026-08-13**, and is left as the
-> record of what the review found. It is not the current state: Phase 11.5 closed C1–C6
-> and H1–H7 except H3, and the invite redesign closed H3 and M3 on 2026-08-14. For what
-> holds today, and against which adversary, read draft-v5 §2 — never this table.
-
-| Claim (draft-v4) | Verdict | Why |
-|---|---|---|
-| Data never transits a central server | **Yes** | WebRTC DataChannel is genuinely P2P; hub relays SDP only. Well executed. |
-| Hub stores no content, no index, no chat | **Yes** | Confirmed in the schema and routers. GEK bundles are gone from the hub since Phase 12. |
-| E2E encryption for all private content (files, indexes, messages) | **No** | Files: yes. Index: cleartext on the WebRTC path (M5). Chat: plaintext on the wire and at rest (Phase 13 pending). Uploads: plaintext. |
-| Content unreadable by the hub | **Passive hub: yes. Active hub: no** | H3 (key substitution at invite) and C4 (pre-proof keypair-bundle fetch + PBKDF2 cracking) both yield the GEK. T3 (hub-served SPA) is a third path. |
-| Node operator is sole content authority | **No** | C5a (any member overwrites files), C5b (any member seizes the GEK), C1 (anyone reads everything), M3 (operator cannot actually delete). |
-| Hub admin cannot read node content | **No** | C1, and C6 for chat injection. The GEK-proof defence is real but covers one of four paths. |
-| Hub admin cannot delete files | **Yes** | Deny-by-default plus pinned key. Fails closed. Correct. |
-| Suspending a group blocks new connections | **No** | H4 — group revocations are dropped by the node and signaling never checks group status. |
-| Immediate revocation via jti denylist | **Partial** | Works while the node stays up; volatile, and group targets ignored (H4). |
-| Node IPs not persisted | **Yes** | Signaling state is in-memory. But the node's own audit DB stores peer IPs — appropriate, just worth documenting to users. |
-
-**The one-sentence honest version:** *content is encrypted between the browser and the node
-with keys the hub does not hold, and the hub is out of the data path — but the node currently
-gives that content away over an unauthenticated HTTP port, chat is not encrypted at all, and
-a hub that chooses to attack can obtain the group key through the key directory it controls.*
-
----
-
-## 8. Are the remaining phases enough?
-
-**No — the roadmap does not contain fixes for the findings above.** Mapping the planned work
-onto what was found:
-
-| Planned phase | Addresses | Verdict |
-|---|---|---|
-| 12 — Node CLI + management | M3 partially (a CLI could pin the right admin key) | Useful, not security work |
-| 13.1–13.4 — Sender Keys chat encryption | Part of "chat is plaintext"; nothing else | Necessary but narrower than it looks — see below |
-| 13.5 — Chat retention | Data-minimization only | Good hygiene |
-| 13.6 — MNP version negotiation | L2 | Correct as planned |
-| 14 — Android client | Nothing directly; adds a fourth client to keep in parity | Neutral / new risk |
-| 15 — Resilience (TURN, 0-RTT) | Nothing | Optional |
-| 16 — Packaging + CI | Would catch regressions; 16.4 release signing matters a lot for a native client | Underrated — promote it |
-| 17 — Extension sandbox | Adds a large new attack surface | Should be last, and needs its own review |
-
-**Nothing in the plan addresses C1–C6, H1, H2, H4, H5, H6, or H7.**
-
-A note on Phase 13 specifically, because it is presented as *the* remaining security item:
-Sender Keys protects chat from *someone who is not a group member but holds the node's disk*
-(a compromised node, a seized machine, a hosting provider). It does **not** protect chat from
-the node operator, because on this platform the node operator is a group member and therefore
-a sender-key recipient. It also does not help if the sender keys are distributed "via
-GEK-wrapped channels" as §6.6 describes — that makes them a function of the GEK, so anyone
-with the GEK (C5b, H3) gets them too. Real forward secrecy requires distributing sender keys
-over per-member pairwise channels (the existing `ratchet.py`) keyed to identity keys, not to
-the GEK. Worth settling before writing 13.1.
-
-**Recommendation: insert a remediation phase before Phase 12.** Suggested content, in order:
-
-```
-Phase 11.5 — Security remediation (blocking)
- 11.5.1 Disable/remove the node HTTP API for private groups; bind loopback [C1]
- 11.5.2 Authenticate the node WS registration (scope + ownership + DB groups) [C2]
- 11.5.3 Unify the handshake across WebRTC/QUIC/TCP into meshbay_common [C6]
- 11.5.4 Mutual handshake proof + pk_node pinning in the client [C3]
- 11.5.5 Per-group chat_store and per-group peer registry [H1]
- 11.5.6 Authorize gek_bundle_store; remove GEK auto-activation [C5b]
- 11.5.7 Upload: no overwrite, per-user quarantine, quotas, filename allowlist [C5a, H2]
- 11.5.8 Escape all HTML in the node admin UI [H2]
- 11.5.9 Domain-separate the admin challenge transcript [H5]
- 11.5.10 Handle group revocation on the node; persist the denylist [H4]
- 11.5.11 Rate limits and resource caps on signaling, uploads, ffmpeg [H6]
- 11.5.12 Swarm: public groups only [H7]
- 11.5.13 Decide the home of keypair bundles (see §9) [C4]
-```
-
-Add regression tests for each: two-group chat isolation, HTTP API refuses private groups,
-handshake parity across transports, upload cannot overwrite, `gek_bundle_store` rejects
-non-admins.
-
----
-
-## 9. If you build a native client, what changes?
-
-Short answer: **a native client removes the single most fundamental limitation (T3) and lets
-you delete the machinery that exists only to work around the browser — but it does not remove
-any of the findings above, and it adds obligations of its own.**
-
-### What a native client genuinely fixes
-
-- **T3 becomes detectable — it does not disappear.** *(Corrected 2026-08-13; the original
- text claimed "T3 disappears. Code integrity stops depending on the hub." That was wrong.)*
- A native client downloaded from `meshbay.org` and signed with a key the hub operator holds
- relocates the trust from "the JS they serve" to "the binary they serve". What genuinely
- changes is the **shape of an attack**: in a browser it is one HTTP response, aimed at one
- user, leaving no artifact — undetectable in principle. Natively it must ship as a build,
- which is hashable, archivable and comparable between users, so targeting one person means
- handing them a different binary. That is a real gain, but it is realised **only** by the
- verification machinery — reproducible builds, published hashes, independent rebuilds
- (Phase 18.7) — not by the packaging format. Native also costs the browser sandbox, transfers
- patch velocity for WebKitGTK and every bundled dependency onto the project, and adds new
- attack surface (loopback media server, IPC bridge, updater). A **browser extension**
- distributed through Mozilla/Chrome — a channel the hub operator does not control — achieves
- most of the same benefit while keeping the sandbox. See `tmp-decisions.md`.
-- **Real key storage.** OS keychain / Argon2id-encrypted local keystore, already implemented
- in `keystore.py`. Keys never leave the device, so **C4 evaporates** — no keypair bundles,
- no PBKDF2-only protection, no third-party nodes holding your private keys.
-- **Real crypto.** ChaCha20-Poly1305, Argon2id at 256 MB, constant-time primitives — no
- WebCrypto ceiling. The whole `webcrypto.py` / `:aes` dual-cipher split becomes unnecessary
- (keep it only while browser clients exist).
-- **Password never transmitted.** The node already authenticates with Ed25519 challenge-
- response (`nodes.py:30-80`). Clients can do the same, and then `auth_key`/`bundle_key`, the
- password split, pw_versions and the legacy migration path all go away — a large reduction in
- code and in attack surface.
-- **Key verification becomes practical.** Safety numbers, TOFU pinning of `pk_node` and of
- contacts' identity keys, and persistent warnings on key change — the fix for H3. This is
- achievable in a browser but far more credible in a client the hub does not serve.
-
-### What becomes unnecessary (delete, don't port)
-
-| Component | Reason |
-|---|---|
-| `keypair_bundle_store/fetch/resp` MNP messages, `keypair_bundles` table | Keys live locally (C4) |
-| `deriveAuthKey` / `deriveEncryptionKey` password split, pw_version 3 | Replaced by Ed25519 auth |
-| `webcrypto.py` AES variant + `:aes` HKDF suffix | Only needed for SubtleCrypto |
-| MSE streaming path (`_probe_video`, ffmpeg fMP4 remux, `stream_init/data/end`) | A native player decrypts and plays directly; the node just serves chunks |
-| Node HTTP file API | Already the source of C1; native clients speak MNP |
-| ~~WebRTC transport, hub signaling relay, DTLS channel binding~~ | **Correction (2026-08-13): keep these.** The original text here said "QUIC + `punch_nat()` is already validated" — that oversold a single-ISP demo. `punch_nat()` (`quic_server.py:446`) is one UDP probe to one address: no STUN client (`aioice` is pulled in by `aiortc` only), no candidate gathering, no dual-stack fallback, and it requires the client to already know its own external IP:port and to connect from a fixed source port. ICE/STUN — validated on 2 ISPs, 2 browsers, IPv4 + IPv6 + 4G CGNAT — is the only NAT traversal actually proven in this project, and it lives in the WebRTC path. A native client keeps it by running `aiortc` in Python (`createDataChannel` + `createOffer`), which preserves every native benefit, since none of them come from the transport. QUIC is retained at parity for LAN, port-forwarded and hub-less `group://` access. |
-| `_bundleKey` in IndexedDB, `_sessionKeys` in sessionStorage, `_pkFromSk` | Browser-specific persistence hacks |
-
-Note what this means for Phase 12's own accounting: **T3 reduction phases 1–3 were largely
-wasted motion.** Moving GEK and keypair bundles from the hub to nodes did not remove the
-hub's access (it can still forge a JWT and fetch them, C4) and it *spread* the private-key
-bundles across untrusted third-party machines. A native client makes the correct answer
-available: the material should live on the user's own device, not on the hub *or* on other
-people's nodes.
-
-### What is still needed regardless of client type
-
-- **All of C1–C6, H1, H2, H4–H7.** Every one of them is server/node-side. A native client
- changes none of them.
-- **Phase 13 (Sender Keys)** — still required, and still needs the pairwise-distribution
- decision above.
-- **Phase 12 (Node CLI)** — arguably *more* important with native clients, since group and
- GEK management moves out of the browser.
-- **Phase 16 (packaging + CI + release signing)** — becomes **critical**, not optional. Once
- users install software instead of loading a page, your update channel is the new T3. You
- need signed releases, a documented key, ideally reproducible builds, and a client that
- verifies signatures. `16.4` should be promoted alongside the remediation phase.
-- **H3 / safety numbers** — the hub remains the key directory even for native clients. Out-of-
- band verification is the fix, and it is not currently scheduled anywhere.
-
-### Suggested sequencing
-
-> **Superseded 2026-08-13** — the roadmap was rewritten against these findings.
-> See `devel-phases-next.md` for the authoritative plan. Summary:
-
-```
-Phase 11.5 Security remediation ⛔ blocking, everything else waits
-Phase 12 Hub minimization makes "the hub cannot read" structural
-Phase 13 Native desktop client Electron (2026-08-17); reduces T3, C4 partly
-Phase 14 Node CLI (was Phase 12)
-Phase 15 Sender Keys (was Phase 13) — 15.0 distribution decision first
-Phase 16 Android (was Phase 14) — reuses the Phase 13 design
-Phase 17 Resilience (was Phase 15)
-Phase 18 Packaging + CI (was Phase 16) — signing moved into 13.9
-Phase 19 Extensions (was Phase 17)
-```
-
-Also worth an explicit decision: **do you keep the browser client?** Supporting both means
-maintaining two transports, two crypto stacks, two key-storage models, and two handshake
-implementations — which is exactly how C6 and M5 came about. If the browser client stays, it
-should be positioned honestly as *"convenient access with a weaker trust model — the hub can
-serve you modified code"*, with the native client as the recommended path for anything
-sensitive.
-
----
-
-## 10. Prioritized action plan
-
-| # | Finding | Severity | Effort | When |
-|---|---|---|---|---|
-| C1 | Node HTTP API serves private content unauthenticated | Critical | S | Immediately — one-line bind change unblocks, proper fix same day |
-| C2 | Node WS identity spoofing → client impersonation | Critical | S | Immediately |
-| C5b | Any member can seize the group GEK | Critical | S | Immediately |
-| C5a | Any member can overwrite shared files | Critical | S | Immediately |
-| C6 | No GEK proof on QUIC/TCP transports | Critical | M | Before any further transport work |
-| C3 | No node authentication to the client | Critical | M | With C2 |
-| C4 | Keypair bundles on third-party nodes, PBKDF2-only | Critical | L | Needs the design decision in §9 |
-| H1 | Cross-group chat leakage | High | S | Immediately |
-| H2 | Stored XSS in node admin UI | High | S | Immediately |
-| H3 | Hub key substitution (T2) | High | L | Safety numbers — schedule explicitly |
-| H4 | Group revocation dropped; volatile denylist | High | S | Phase 11.5 |
-| H5 | Unbound Ed25519 signing oracle | High | S | Phase 11.5 |
-| H6 | Unauthenticated resource exhaustion | High | M | Phase 11.5 |
-| H7 | Private hashes registered in swarm | High | S | Fix before repairing the route |
-| M1–M9 | See §5 | Medium | S–M | Phase 11.5 / 12 |
-| L1–L8 | See §6 | Low | S | Opportunistic |
-
----
-
-## 11. Conclusion
-
-The architecture is still the right architecture. Hub-as-registrar, node-as-host,
-E2E-to-the-node, GEK-per-group, node sovereignty enforced by cryptography rather than policy —
-these are good decisions, and the Phase 12 work (GEK-HMAC proof, DTLS channel binding,
-Ed25519 admin challenge) shows real security engineering.
-
-The gap is between the documents and the code. Draft-v4 describes a system where every
-operation passes a GEK proof, where the hub admin can read nothing, where the node operator is
-sovereign, and where private content is E2E encrypted. The code implements that on one of four
-paths into the node. The other three — HTTP, QUIC, TCP — are at Phase 4/7 level, and the HTTP
-one hands out private files to unauthenticated callers. Meanwhile the hub retains a decisive
-lever it is documented as not having: it is the key directory, and whoever controls the key
-directory controls the group key.
-
-Two concrete recommendations beyond the fix list:
-
-1. **Make transport parity a structural invariant, not a habit.** One shared handshake
- function in `meshbay_common`, called by every transport, with a test that fails if a
- transport skips a step. Every finding in the C6/C1 family exists because a new path was
- added and the old ones stayed behind.
-2. **Write down the threat model explicitly** — one page: passive hub, active hub, malicious
- node operator, malicious group member, network attacker, local attacker — and mark for each
- claim which adversary it holds against. Most of the overstatements in the current docs
- ("unreadable by other parties, even the hub") come from not distinguishing the passive hub
- from the active one. Once that page exists, the honest claims are still strong ones, and
- they will be defensible.
-
-The security posture is recoverable, and most of the critical work is small. But the current
-build should not host real private data, and the project should not advertise end-to-end
-confidentiality until at least C1, C2, C3, C5 and H1–H3 are closed.
diff --git a/docs/third-review.md b/docs/third-review.md
deleted file mode 100644
index 62ab5f6..0000000
--- a/docs/third-review.md
+++ /dev/null
@@ -1,723 +0,0 @@
-# MeshBay — Third Architecture & Security Review
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the third security review; its design
-> content now lives in §13.4, and the invariants each finding names throughout.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Date: 2026-09-01
-> Scope: the code as it stands on `main` at `8a6294b`, with emphasis on what
-> changed since `second-review.md` (2026-08-13): the unified handshake
-> (`meshbay_common/handshake.py`), device linking, the invite/pairing rewrite,
-> account recovery and passphrase change (`docs/auth-confirm.md`), email
-> verification, reCAPTCHA, the hub instance-policy store, MHP federation,
-> the community relay registry, chat link previews, the TMDB/MusicBrainz
-> enrichment path, and the node's token-gated loopback control API.
->
-> Method: code reading of `packages/`. The test suite was not run and no live
-> testing was done against meshbay.org. This is a code and design review, not a
-> penetration test. Finding numbers are independent of the first two reviews.
->
-> The v5/v6 convention is kept: **a claim names the adversary it holds against.**
-> The adversaries referenced below are the ones the project already uses — passive
-> hub, active hub, malicious node operator, malicious group member, network
-> attacker, local attacker — plus two the newer features introduce: **any
-> registered hub user with no group membership**, and **a federated peer hub**.
-
----
-
-## 1. Executive summary
-
-**The critical and high findings from the second review have genuinely been
-closed, and closed well.** The unified handshake is the right shape: one
-length-prefixed, domain-separated, role-bound transcript; mandatory channel
-binding; a mutual proof where the node demonstrates GEK possession over the
-client's nonce *and* signs the transcript with its long-term key; `scope="user"`
-enforced by default; `group_id` mandatory. It is now run by **both** the WebRTC
-and the QUIC transports — the C6 divergence that produced most of the second
-review is structurally gone. C1 (the unauthenticated node HTTP file API) was deleted outright
-rather than patched. C2, C3, C5a, C5b, H1, H4, H5, H6, H7, M7, M8 are all
-addressed in the code, and the invite rewrite closed H3/M3. Device linking,
-the password split, Argon2id-256 MB on the hub verifier, refresh-token family
-rotation, email-at-rest encryption, and session/device teardown on passphrase
-change are all present and correct.
-
-**What this review finds is a second generation of the same pattern:** new
-surface was added faster than the authorization model was extended to cover it,
-and a few of the second-review fixes did not reach every path.
-
-- The **QUIC transport** got the new handshake but not the new *chat* rules:
- `_do_chat_message_sync` took `sender_id` from the wire (NS6), broadcast
- through a connection-global peer registry regardless of group (H1), and ran a
- 30-second synchronous `ffmpeg` on the event loop with no concurrency cap (H6).
- **Fixed 2026-09-01** — handlers brought to WebRTC parity, and the QUIC
- listener is now off by default (`[node] quic_enabled`) since nothing ships a
- QUIC client.
-- The **hub moderation surface** had a privilege-escalation hole: a *moderator*
- could promote any other account to *admin* (`PATCH /v1/admin/users/{id}` was
- gated by `require_moderator` but wrote `role`). **Fixed 2026-09-01.**
-- **`POST /v1/reports`** was unauthenticated, unthrottled, and auto-blocked a
- content hash after **two** reports — a network-wide censorship/DoS primitive
- for anyone who learns a public file's blake3 id. **Fixed 2026-09-01** (auth,
- rate limit, distinct-reporter counting, refused when public groups are off).
-- The **registration reCAPTCHA** was inert: the server only checked it when
- `auth_key` was absent, and the real web client always sends `auth_key`, so a
- bot skipped it by including that field. **Fixed 2026-09-01** — gate is now
- unconditional when a captcha is configured; the desktop client renders the
- widget too.
-- **Chat link previews** are a real SSRF surface (correctly identified as such in
- the module) but the gate had gaps: no per-member rate limit, no port
- restriction, and DNS rebinding a documented residual. **Fixed 2026-09-01**
- (rate limit, port allowlist, connect-address re-check, bomb guard).
-- **MHP federation** trusted any registered peer hub to push directory rows and
- revocations, never checked the token audience, and the revocation-propagation
- path was a silent no-op. **Fixed 2026-09-01** (source bound to the signer,
- push capped, revocation acts on the peer's own directory entries, replay
- rejected).
-- There was **no CSP or security-header policy** on the hub-served SPA (second
- review L5). **Fixed 2026-09-01** — a middleware applies the same policy the
- desktop client already enforces on these files. Wants a pass against the
- running SPA.
-
-None of this breaks the architecture. The cryptographic core and the trust model
-are unchanged and still sound. H1, H2 and M1–M5 were
-fixed on 2026-09-01; what is left is the L-list — opportunistic hardening, not a
-hole — plus verifying the SPA CSP (M5) against the live app.
-
----
-
-## 2. What is solid (the delta since the second review)
-
-Worth recording, because the remediation was substantial and mostly correct:
-
-1. **`meshbay_common/handshake.py`** — one implementation, called by
- `webrtc_server.py` and `quic_server.py`. `handshake_transcript()` is
- length-prefixed and domain-separated (`meshbay:mnp:handshake:v1`), the role is
- bound so a client proof can never be replayed as a node proof, and
- `make_proof()` **raises** on an empty channel binding instead of degrading to
- nonce-only (L4). `authorize_token()` enforces `scope == "user"` by default
- (M9), requires `group_id` (M1), checks the denylist, the `groups` claim and
- `hosted_groups`.
-2. **Mutual authentication (C3).** `_complete_handshake` returns
- `HMAC(GEK, node-transcript)` over the client's nonce **and**
- `Ed25519(sk_node)` over the same transcript; `transport.js` verifies both
- (`verifyNodeSignature`), refuses a bare `handshake_ack`, and TOFU-pins
- `node_pk` in `localStorage` with an explicit change warning
- (`_checkNodePin`).
-3. **C1 deleted.** The per-group HTTP file API is gone from `daemon.py`
- (step 9 is now a comment explaining why). Every client path goes through the
- MNP handshake.
-4. **C2 closed.** `_authorize_node_ws` resolves the node against the DB, checks
- `scope == "node"`, checks `node.user_id == token.sub`, derives `group_ids`
- from `GroupMember`, and refuses to displace a live registration.
-5. **C5a closed.** Uploads: `SAFE_UPLOAD_NAME` allowlist, `_free_name()`
- no-overwrite, `MAX_UPLOAD_BYTES` cap, strict chunk ordering, a quarantine
- subdirectory, and an operator-signed `OP_MEMBER_UPLOAD` kill switch enforced
- by the node (`_do_file_upload`), not by hiding a button.
-6. **C5b closed.** `gek_bundle_store` is deleted; `gek_rotate` is an
- operator-signed op where the node generates the key with its own CSPRNG
- (`_admin_exec_gek_rotate` → `ops.set_gek(rotate=True)`).
-7. **H1 (WebRTC) closed.** `_do_chat_message` / `_do_chat_history` read
- `self._group_ctx().get("chat_store")`, `_peer_registry()` is per-group, and
- `sender_id` is forced to `self._user_id`.
-8. **H4 closed.** `Denylist` persists to `denylist.json`; `on_revocation`
- handles `user`/`group`/`jti`, and `group` also drops live sessions
- (`_drop_group_sessions`); `webrtc_offer` refuses when the shared group is not
- `active`.
-9. **H5 closed.** `adminop.admin_transcript()` — domain-separated, names the
- operation, subject, node key, group, nonce and timestamp; `ADMIN_CHALLENGE_TTL`
- 120 s; verified against `roster.operator_pks()` rebuilt from node state, never
- from the response.
-10. **H6 (WebRTC) closed.** 64 KB pre-handshake buffer, a transcode semaphore,
- per-user pending-offer caps and a membership check in `signaling.py`,
- `notify_incoming` requires `peer_ip == caller_ip`.
-11. **H7 closed.** The swarm route is mounted correctly, nodes filter by
- visibility, and `GET /v1/swarm/{hash}` requires auth.
-12. **M8 closed.** `announce_node` requires a signed proof of possession.
-13. **Device linking** (`_do_device_add`, `_verify_device_signer`): a new device
- is admitted only by a signature from a **live pinned device of the same
- account**; the one-time code never reaches the node (it lists candidate
- hashes and the approver recomputes the match); requests are single-use and
- capped by `MAX_DEVICES_PER_USER`. The hub holds no user keys and so cannot
- countersign — this holds against an active hub.
-14. **Account lifecycle** (`docs/auth-confirm.md`): passphrase change and reset
- both revoke every refresh token; reset also deletes every `UserDevice` so a
- stored device key cannot sign back in past the reset. The recovery key is a
- pure client-side pass-through — never stored, never logged.
-
----
-
-## 3. High findings
-
-### H1 — A moderator can promote any account to admin (privilege escalation)
-
-> **Fixed 2026-09-01.** `admin_patch_user` now splits authorization by field:
-> `status` between `active`/`suspended` stays at `require_moderator`; setting
-> `role`, setting `status = "revoked"`, and touching an admin's account at all
-> require `user_is_admin(current_user)` (new helper in `deps.py`). Regression
-> test: `test_moderator_cannot_change_roles_or_revoke`.
-
-**Location:** `api/admin.py:185-241` (`admin_patch_user`), `api/deps.py:75-92`
-
-`PATCH /v1/admin/users/{user_id}` depends on `require_moderator`, but its body
-accepts `role`, and the handler writes it with no check that the caller is an
-admin:
-
-```python
-if body.role is not None:
- if body.role not in ("user", "moderator", "admin"):
- raise HTTPException(status_code=422, ...)
- user.role = body.role # ← moderator can set "admin"
-```
-
-The only guard is `user.id == current_user.id` ("Cannot modify your own
-account"). So a moderator cannot self-promote directly, but can:
-
-- promote a second account they control, or an accomplice, to `admin`;
-- **demote existing admins** to `user`, or set their `status` to `revoked`.
-
-`admin` is the real instance boundary: `admin_patch_settings` (public-groups
-switch), `admin_delete_user` (irreversible erasure), `admin_revoke`
-(user/group revocation broadcast to every node), `register_peer`,
-`admin_add_blocklist`. A moderator reaching `admin` reaches all of it.
-
-**Impact.** Full instance takeover from the moderator role. Moderator is meant to
-be a content-moderation role (suspend/revoke groups, read logs), not an
-administrative one — `admin_delete_user`'s own docstring draws exactly that line
-("Admin rather than moderator: suspension is reversible … this is not").
-
-**Fix.** Split the handler: `status` changes among `active`/`suspended` stay at
-`require_moderator`; `role` changes and `status = "revoked"` require
-`require_admin`. Also forbid granting a role higher than the caller's, and forbid
-demoting an equal-or-higher role.
-
----
-
-### H2 — Unauthenticated, unthrottled, permanent global content blocklisting
-
-> **Fixed 2026-09-01.** `POST /v1/reports` now requires a signed-in account
-> (`get_current_user`), is rate-limited (`10/hour`), counts **distinct reporting
-> accounts** (one vote per account per hash via `reporter_id`), and is refused
-> outright (`403`) when the hub has public groups switched off — a private-only
-> hub brokers no public content and nothing syncs the blocklist, so an open write
-> endpoint there is pure abuse surface. `AUTO_BLOCK_THRESHOLD` raised 2 → 3.
-> Tests rewritten in `test_moderation.py`.
->
-> Note also confirmed while fixing: **no node currently consumes
-> `ContentBlocklist`** — `GET /v1/blocklist` exists ("nodes sync on startup") but
-> nothing fetches it, and `swarm_register` checks the *CSAM* list, not this one.
-> So the network-wide censorship effect was latent (it activates when node sync
-> ships); the DB-fill / poisoned-moderation-signal / admin-panel-garbage surface
-> was live. The auto-block path should stay gated as above when sync lands.
-
-**Location:** `api/moderation.py:39,58-103` (`report_content`)
-
-`POST /v1/reports` has **no authentication and no rate limit**. It counts *all*
-existing `ContentReport` rows for a hash — regardless of who filed them or from
-where — and:
-
-```python
-AUTO_BLOCK_THRESHOLD = 2
-...
-if count + 1 >= AUTO_BLOCK_THRESHOLD:
- ... db.add(ContentBlocklist(content_hash=..., added_by="auto"))
-```
-
-So **two unauthenticated HTTP requests** naming the same 64-hex blake3 id add
-that id to `ContentBlocklist`. Nodes sync the blocklist
-(`GET /v1/blocklist`, unauthenticated) and `swarm_register` refuses a blocked
-hash with HTTP 451. Removal is a manual admin action
-(`DELETE /v1/admin/blocklist/{hash}`).
-
-**Impact.** Anyone who learns the blake3 id of a public file — trivially, any
-group member sees ids in the index; any registered user can probe
-`GET /v1/swarm/{hash}` — can suppress that file across the whole network with two
-anonymous requests. It is also a self-inflicted amplifier: one script can block
-thousands of hashes. `content_hash` is the only validated field (`group_id`,
-`reason`, `detail` are free-form and rendered in the admin UI).
-
-**Fix.** Require authentication on `POST /v1/reports`; dedupe reports by
-`(content_hash, reporter)` so the threshold means *distinct* reporters; add a
-rate limit; raise `AUTO_BLOCK_THRESHOLD` and/or make auto-block queue for human
-review rather than take effect immediately; authenticate `GET /v1/blocklist` and
-`/v1/blocklist/check` (node scope).
-
----
-
-## 4. Medium findings
-
-### M1 — The registration CAPTCHA is inert and trivially bypassed
-
-> **Fixed 2026-09-01 (Option A).** The server gate is now `if
-> _cfg.captcha.enabled:` — no `auth_key` carve-out, no client exemption. The web
-> client (`registerUser` in `keyderive.js`) forwards `captcha.token`, and the
-> desktop client, being Chromium, renders the same widget from the shared UI
-> assets. `captcha.reset()` is called on a failed attempt so the single-use
-> token is refreshed. Tests: `test_register_captcha.py`.
->
-> Consequence to check on the desktop side: the Electron CSP must allow
-> `https://www.google.com` and `https://www.gstatic.com` for `script-src` /
-> `frame-src`, or the widget will not render and the (already-disabled) submit
-> button stays disabled. A headless/CLI `register` has no widget and is the one
-> path with no human check — which is the path you would want gated anyway; a CLI
-> can open a browser window for it.
-
-**Location:** `api/users.py:130-191` (`register`), `static/keyderive.js:279-303`
-(`registerUser`), `static/auth-page.js:229-256`
-
-Server side:
-
-```python
-# Captcha gate — web path only (native clients send auth_key)
-if _cfg and _cfg.captcha.enabled and not body.auth_key:
- await _verify_captcha_or_raise(body.captcha_token, request)
-```
-
-The CAPTCHA is checked **only when `auth_key` is absent**. But the real web
-client's registration path (`window.MeshBayKeys` present, which is always)
-calls `registerUser()`, which sends `{ username, email, auth_key }` and **no
-`captcha_token`** at all. The branch that sends `captcha_token`
-(`auth-page.js:248`) is a dead `else` for a client without `MeshBayKeys`.
-
-So: a human filling the Register form solves a reCAPTCHA whose token is never
-transmitted and never checked, and a bot registers accounts at will by including
-any `auth_key`-shaped string. `@limiter.limit("5/minute")` is the only remaining
-brake (and see L10 for why that may also be weak).
-
-Password reset is unaffected — `password_reset_request` checks the CAPTCHA
-unconditionally when enabled.
-
-**Fix.** Gate on `_cfg.captcha.enabled` alone (drop `and not body.auth_key`), and
-have `registerUser()` include `captcha_token`. If native clients genuinely cannot
-present one, gate on the *client type* explicitly (a header or a scope), not on
-the presence of a field any caller can supply.
-
----
-
-### M2 — QUIC transport: chat sender spoofing, cross-group broadcast, and a blocking ffmpeg
-
-> **Fixed 2026-09-01, two ways.**
-> 1. **Exposure removed:** the QUIC listener is now off by default —
-> `[node] quic_enabled = false` (`MESHBAY_QUIC_ENABLED` overrides), gated in
-> `daemon.py`. Nothing ships a QUIC client, so a node started none for no one.
-> 2. **Handlers brought to parity** anyway, for when a client does exist:
-> `_do_chat_message_sync` forces `sender_id` from the authenticated session
-> (M2a), resolves `chat_store` and the peer set per group via `_group_ctx()` /
-> `_peer_registry()` (M2b, kept separate from the WebRTC peer set), and
-> `STREAM_SEGMENT` extraction runs in a thread behind a small semaphore as a
-> tracked task (M2c). Stale C6 docstring corrected.
->
-> Original finding text kept below for the record.
-
-**Location:** `transport/quic_server.py:220-247, 402-463, 509-525`
-
-The QUIC server is started in production (`daemon.py:540`, `host="::"`, default
-port 19000, `groups=groups_ctx`). It got the new unified handshake — and the
-GEK proof *is* implemented in `_do_handshake_response_sync`, so the docstring at
-`quic_server.py:259-263` ("NOT YET DONE — finding C6 remains open on this
-transport") is simply stale. But the chat and streaming handlers were never
-brought up to the WebRTC path's rules:
-
-**M2a — `sender_id` is taken from the wire (NS6 regression).**
-
-```python
-asyncio.ensure_future(chat_store.save_message(
- sender_id=msg.get("sender_id", self._user_id), ...))
-...
-broadcast = { ... "sender_id": msg.get("sender_id", self._user_id), ... }
-```
-
-An authenticated QUIC peer can post chat as any `sender_id`. The WebRTC path
-forces `sender_id=self._user_id` (`webrtc_server.py:3493,3504`).
-
-**M2b — the peer registry is connection-global, not per-group (H1 regression).**
-`_do_chat_message_sync` broadcasts to `self._ctx.get("_peers", {})`, which is a
-single dict on the `QuicChunkServer` instance shared across every group. A member
-of group A, connected over QUIC, has their (spoofable) message fanned out to
-QUIC peers of every other group on the node. (`chat_store` is never set in the
-QUIC ctx, so messages are dropped rather than persisted — but still broadcast.)
-
-**M2c — synchronous ffmpeg on the event loop, no concurrency cap (H6
-regression).** `_do_stream_segment_sync` → `_extract_segment` runs
-`subprocess.run([... "ffmpeg" ...], timeout=30)` directly inside
-`quic_event_received`. One request blocks the whole node for up to 30 s; there is
-no transcode semaphore. `_do_file_request_sync` likewise does blocking file I/O
-in the loop.
-
-**Impact.** Limited to peers reachable over QUIC/UDP 19000 — LAN, a
-port-forwarded node, or hub-less `group://` — and to native clients (browsers use
-WebRTC, a separate registry). Still: chat impersonation and cross-group leakage
-against exactly the "malicious group member" adversary the WebRTC fixes were
-written for, plus a one-request node stall.
-
-**Fix.** Route the QUIC chat and stream handlers through the same per-group
-context and `sender_id`-from-session logic as WebRTC (ideally shared helpers in
-`meshbay_common`, the same move that fixed C6); make `_extract_segment` async and
-put it behind the transcode semaphore, or disable the QUIC `STREAM_SEGMENT`
-handler until it is at parity. Update the stale docstring.
-
----
-
-### M3 — Chat link previews: SSRF gate has no rate limit, no port restriction, and a known rebinding hole
-
-> **Fixed 2026-09-01.**
-> - **Rate limit:** `_do_link_preview_request` is now bounded per connection (15)
-> and node-wide (60) over a 60 s window; a cache hit is free, and over the
-> ceiling the reply is a plain `ok: false` (bare link), not cached.
-> - **Port allowlist:** `safe_url()` restricts the port to `{80, 443, 8080,
-> 8443}` — every real OpenGraph page, none of the SSH/mail/DB/cache/search
-> ports. Deliberately not just 80/443, to keep legitimate sites on alt-HTTP
-> working.
-> - **DNS rebinding:** the connection's actual peer address is re-checked
-> against the public-address rule (`_reject_if_rebound`) before the body is
-> read. Best-effort — a full literal-pin with cert-for-name is noted as
-> remaining hardening.
-> - **Decompression bomb:** `_downscale` refuses an image whose header
-> dimensions exceed ~40 MP before decode.
->
-> Not extended to `MEDIA_META_REQ` / `TMDB_SEARCH_REQ`: those reach a fixed
-> host, so they carry a quota concern but not an SSRF one — left for a separate
-> pass.
-
-**Location:** `node/linkpreview.py`, `webrtc_server.py:3588-3636`
-(`_do_link_preview_request`)
-
-The design is right — the *node* fetches, not the browser or the hub — and
-`safe_url()` blocks non-http(s) schemes, embedded credentials, and any resolved
-address that is not globally routable, re-checking every redirect hop by hand.
-But:
-
-1. **No rate limit / no per-member cap.** `_do_link_preview_request` is reachable
- by any group member after the handshake, and the in-memory cache
- (`_LINK_PREVIEW_MAX = 256`, TTL 1 h) only dedupes exact repeats. A member
- pasting many distinct URLs drives unbounded outbound HTTP from the operator's
- machine — an amplification/DoS vector and a way to disclose the operator's IP
- to arbitrary hosts on demand.
-2. **Port is not restricted.** `safe_url()` validates the scheme and the resolved
- IP but passes `parts.port` straight through. A member can point the node at
- `http://<globally-routable-ip>:<any-port>` — third-party port scanning from
- the operator's address, and reaching services that are internet-routable but
- firewalled to the node's network.
-3. **DNS rebinding.** `safe_url()` resolves and checks the address, then
- `httpx.get()` resolves again at connect time. The module documents this as a
- deferred residual ("closed properly by pinning the checked IP"). Until the
- pin lands, a name that answers public on check and internal on connect is a
- way in.
-4. **Image decode.** `fetch_image` → `_downscale` opens attacker-supplied bytes
- with Pillow; `Image.open` + `thumbnail` after a full decode. Pillow's default
- decompression-bomb guard applies, but a 2 MB input is allowed and the guard is
- the only ceiling.
-
-**Fix.** Add a per-connection and per-node rate limit on `LINK_PREVIEW_REQ`
-(and the same for `MEDIA_META_REQ` / `TMDB_SEARCH_REQ`); restrict the port to
-80/443; pin the checked IP for the actual connection (resolve once, connect to
-the literal, send `Host:`); set `PIL.Image.MAX_IMAGE_PIXELS` low and cap decoded
-dimensions before `thumbnail`.
-
----
-
-### M4 — MHP federation: peer hubs are over-trusted, token audience is unchecked, revocation propagation is a no-op
-
-> **Fixed 2026-09-01.**
-> - `receive_directory` binds `source_hub` to the token's verified `iss`, so a
-> peer cannot relay or spoof a third hub's groups; the push is capped
-> (500/request, 2000/peer), rows are type/length-checked, and a federated id
-> that collides with a local group is refused.
-> - `receive_revocation` no longer forwards a foreign-signed token to local nodes
-> (the no-op). It verifies the inner token against the sending peer's key and,
-> for `target == "group"`, prunes our copy of that peer's directory entry — a
-> peer cannot revoke our users or a group it did not advertise.
-> - `POST /mhp/directory` and `/mhp/revoke` reject a replayed `jti` within the
-> token TTL. Audience binding is still unavailable (the sending side that would
-> set `aud` is unbuilt); the replay check covers that concern for now. Tests in
-> `test_federation.py`.
-
-**Location:** `api/federation.py:59-190`, `api/revocation.py:66-80` (node
-`verify_and_apply`), `node/daemon.py:575-596` (`on_revocation`)
-
-1. **Audience never verified.** `_verify_mhp_token` accepts an optional
- `expected_aud` but **no caller passes it** — `export_directory`,
- `receive_directory` and `receive_revocation` all call
- `_verify_mhp_token(token, db)`. `_issue_mhp_token` sets `aud = target_hub_id`,
- so the check is available and deliberately unused. A token hub B minted for
- hub C (valid 300 s) is replayable at any other hub that has B registered as a
- peer.
-2. **Any registered peer can inject the directory.** `receive_directory` iterates
- an unbounded `body.groups` list with peer-chosen `id` and `name`, upserting
- `FederatedGroup` rows. No cap, no validation. `name` is rendered in the SPA
- Explore view; `id` is peer-chosen and shares the UUID space with local groups.
-3. **Revocation propagation does nothing on nodes.** `receive_revocation` passes
- the peer's token straight to `broadcast_revocation`, which forwards it to
- local nodes. Nodes verify a revocation token against **their own hub's public
- key** (`session.hub_pk_pem`), so a peer-signed token fails
- `jwt.decode(...)` and is dropped with a warning. The federated
- `/mhp/revoke` path therefore silently accomplishes nothing — false assurance
- that "revocations propagate" across a federation.
-
-**Impact.** A malicious or compromised peer hub can flood/poison the local public
-directory and cannot be relied on to actually revoke anything. Cross-hub token
-replay within a federation. All of this is bounded by the admin having explicitly
-run `POST /mhp/peers` — federation is opt-in and manual — so the adversary is "a
-peer the admin chose to trust", which is exactly the adversary MHP's own auth is
-supposed to constrain.
-
-**Fix.** Pass `expected_aud=_hub_id` in every `_verify_mhp_token` call; cap
-`body.groups` and validate each row; namespace `FederatedGroup.id` or refuse an
-`id` that collides with a local group; for revocation, either re-sign accepted
-peer revocations with the local hub key before broadcasting (with a policy on
-which peers may revoke which targets) or drop the endpoint and document that
-revocation does not federate.
-
----
-
-### M5 — No CSP or security headers on the hub-served SPA (second review L5, still open)
-
-> **Fixed 2026-09-01.** A middleware in `create_app` adds `Content-Security-Policy`,
-> `X-Content-Type-Options: nosniff`, `Referrer-Policy` and `X-Frame-Options: DENY`
-> to every response. `webapp.CSP` is the same policy the desktop client already
-> enforces on these exact UI files (`default-src 'none'`, `script-src 'self'
-> 'wasm-unsafe-eval' <recaptcha>` — the hub origin is not a script source,
-> `frame-ancestors 'none'`, `base-uri 'none'`, `form-action 'none'`), plus the
-> reCAPTCHA hosts. The shell's dead `window.__MB_ASSET_V` inline script is
-> removed so no inline `'unsafe-inline'`/nonce is needed for scripts. **Wants a
-> pass against the running SPA** — a mis-tuned CSP shows as a blank page — but it
-> matches a policy already proven with these files under Electron. SRI on the
-> `/a/<hash>/` scripts is still not done (same-origin, so lower value than the
-> CSP). Tests in `test_security_headers.py`.
-
-**Location:** `api/webapp.py:80-123`, `app.py:160-214`
-
-The SPA shell is returned with only `Cache-Control: no-store`. There is no
-`Content-Security-Policy`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`,
-`X-Frame-Options` / `frame-ancestors`, and no Subresource Integrity on the
-scripts loaded from `/a/<hash>/` (including the vendored `argon2.min.js`). The
-hub sets no CORS middleware (correct) but also no protective headers at all.
-
-For an application whose threat model explicitly includes "the hub could inject
-JS" (T3) and which now renders third-party OpenGraph images and TMDB/MusicBrainz
-metadata inside the group UI, a strict CSP (`default-src 'none'`, an explicit
-`connect-src`/`img-src`, `frame-ancestors 'none'`, `base-uri 'none'`) plus SRI is
-the cheap mitigation that makes a *silent* injection harder and gives a browser
-extension something to pin against. The node's loopback API already sets exactly
-this kind of header block (`ui/app.py:100-117`); the hub does not.
-
-**Fix.** Add a response-header middleware on the hub with a strict CSP for the
-SPA routes and `nosniff`/`Referrer-Policy`/`frame-ancestors` globally; add SRI
-hashes to the `<script>` tags in `_HTML` (the content hash is already computed).
-
----
-
-### M6 — `add_group_member` accepts node-scoped tokens — WITHDRAWN
-
-> **Not a finding. Fixed then reverted 2026-09-01.**
->
-> `add_group_member` accepting a node-scoped token is **deliberate** (commit
-> `0443cf8`): the node calls `POST /v1/groups/{id}/members/{username}` after a
-> CLI `member invite` so the group becomes visible in the invitee's SPA, and it
-> authenticates with a node-scoped token. The `group.admin_id == caller` check
-> is the real guard — a node can only touch its own operator's groups, adding an
-> already-registered account.
->
-> This review misread that as drift (a stale test,
-> `test_node_scope_blocks_add_member`, asserted the opposite and had been left
-> red on `main`). Tightening the dependency to `require_user_scope` **broke the
-> CLI invite → hub-membership flow**: `ops.create_invite` swallows the resulting
-> 403 with a `log.warning`, so an invited user silently never appears in
-> `group_members` and the group is invisible to them. Found in live testing.
-> The dependency is back on `get_current_user` and the stale test now asserts
-> the intended behaviour (node token may add to its own group, 403 for a group
-> it does not own).
-
-The original M6 concern — a stolen node token adding accounts to the operator's
-own groups — is real but low: bounded to the operator's groups, existing
-accounts only, and it is now a *required* capability. If it is ever worth
-constraining, it needs a dedicated node→membership path, not a scope block on
-this shared endpoint.
-
----
-
-## 5. Low findings / notes
-
-- **L1 — Community relay registration is unauthenticated and has no proof of
- possession.** `api/relay.py:57-78` — `relay_register` only compares the
- submitted `pk_relay` against the admin-approved value (both effectively
- public); there is no signature over anything. Anyone who knows an approved
- `relay_id` + `pk_relay` can repoint that relay's `endpoint`. `GET /v1/relays`
- is unauthenticated. `_relays` is in-memory (lost on restart). Relay traffic is
- E2E-encrypted, so the impact is redirection / forced-relay / DoS rather than
- disclosure — but a registry write should require the relay to sign a fresh
- challenge with `sk_relay`, on the `announce_node` pattern.
-
-- **L2 — Two orphaned modules invite the wrong wiring later.**
- `node/replication.py` (`ContentReplicator`) fetches `{endpoint}/index` and
- `{endpoint}/file/{id}` — the HTTP API removed by the C1 fix — and joins an
- attacker-controlled `file_name` directly onto a local path
- (`self._local_dir / file_name`, path traversal) with an unbounded
- `read_bytes()`. `node/revocation.py` (`RevocationSubscriber`) is superseded by
- `hub_client` + `daemon.on_revocation` and keeps a weaker in-memory-only model
- with `verify_exp=False`. Neither is imported by the daemon. Delete both, or
- fold `replication.py` into the MNP path with proper name sanitisation before
- it is revived.
-
-- **L3 — Account / email enumeration is wider than before.** `register` and
- `PATCH /me` return `409 "Email already in use"`; `register` returns
- `409 "Username already taken"`; `GET /v1/users/{username}/pubkeys` returns 404
- vs 200. Together these enumerate which usernames and which email addresses have
- accounts. `login`, `device_auth` and `password_reset_request` are correctly
- uniform — the leak is on the account-management endpoints. Decide whether this
- is acceptable by design (public identities) and document it; if not, make the
- responses uniform and move the "email in use" signal into the verification
- email instead.
-
-- **L4 — No aggregate upload quota.** `MAX_UPLOAD_BYTES` caps a single file at
- 4 GB (C5a), but there is no per-user or per-group total. A member — or an
- active hub minting tokens for many synthetic accounts — can still fill the
- operator's disk one 4 GB file at a time. Second review H6 asked for quotas;
- only the per-file cap landed.
-
-- **L5 — Node control-API token accepted in the query string.**
- `ui/app.py:92-98` accepts `?t=<token>` as well as the `X-MeshBay-Token`
- header. Tokens in URLs end up in access logs and process listings. The
- loopback bind + `0600` token file already blunt this, and DNS-rebinding is
- covered (a rebound page cannot read the on-disk token) — but header-only would
- be cleaner.
-
-- **L6 — Revocation tokens never expire.** `_sign_revocation` sets no `exp`;
- both `on_revocation` and the dead `RevocationSubscriber` decode with
- `verify_exp=False`. This is fine for "deny forever" semantics, but a captured
- revocation token is replayable indefinitely, there is no positive un-revoke
- signal, and clearing is a manual operator action (`denylist clear`). At least
- bind a `revoked_at` freshness window on replay, or a monotonic sequence per
- hub.
-
-- **L7 — The CSAM check is exact-hash only.** `csam.py` compares blake3 hex
- against a (shipped-empty) newline list; production CSAM databases are
- perceptual (PhotoDNA). `check_content_hash` is called from `swarm_register`
- only. The control is structural, not yet functional — worth stating in the
- docs so it is not relied on operationally.
-
-- **L8 — Group names are unvalidated.** `create_group` trims the name but
- enforces no length or character set (only per-owner uniqueness). The name
- flows into emails (`send_invite_notification`), notifications, and the SPA.
- Python's `email` package will reject control characters at send time, so header
- injection is contained, but a length/charset check at creation is cheap.
-
-- **L9 — `decode_access_token` / `authorize_token` do not `require=["exp"]` or
- check `iss`/`aud`.** Every token the hub issues carries `exp`, so this is
- latent — but a future path that mints a token without `exp` would produce a
- non-expiring JWT accepted on every transport and every hub endpoint. Pass
- `options={"require": ["exp"], "verify_aud": ...}` and bind an audience for the
- node vs. user token split.
-
-- **L10 — Rate-limit keying depends on unpinned proxy-header handling.**
- `middleware.py` uses `slowapi`'s `get_remote_address`, which returns
- `request.client.host`. Behind the loopback Caddy proxy this is the real client
- IP **only** if uvicorn's `ProxyHeadersMiddleware` is active and Caddy sets
- `X-Forwarded-For`. `daemon.py` calls `uvicorn.run()` without an explicit
- `proxy_headers` / `forwarded_allow_ips`, so this relies entirely on uvicorn's
- defaults and the Caddy config, neither pinned in the repo. If it regresses,
- every `@limiter.limit` collapses to a single global bucket keyed on
- `127.0.0.1` — no per-client throttling, and one abuser starves everyone. Use
- the `netutil.client_ip` helper (the M7 fix) as the limiter's `key_func` so the
- behaviour is defined in one place.
-
-- **L11 — `client_diag` and `X-Forwarded-For` log hygiene.** `client_diag` is
- correctly truncated/stringified before logging. `netutil.client_ip` correctly
- takes the rightmost hop behind a trusted proxy. No action — noting that the
- M7 pattern is applied consistently *except* by the rate limiter (L10).
-
----
-
-## 6. Does the system do what it claims? (2026-09-01)
-
-Against the v6 §4 claims, updated for this review:
-
-| Claim | Passive hub | Active hub | Malicious member | Notes |
-|---|---|---|---|---|
-| Data never transits the hub | ✅ | ✅ | — | WebRTC/QUIC P2P; hub relays SDP only |
-| Hub stores no content/index/chat | ✅ | ✅ | — | Confirmed in schema; `hub_settings` is instance policy, not group content |
-| File content unreadable by the hub | ✅ | ✅ for content | — | GEK never reaches the hub; invite rewrite closed H3 |
-| Node operator is sole content authority | ✅ | ✅ | ✅ since 2026-09-01 — QUIC chat/stream handlers brought to WebRTC parity, and the QUIC listener is off by default (was M2) |
-| Mutual node authentication | ✅ | ✅ | ✅ | New handshake + `transport.js` pin — a real improvement |
-| Immediate revocation | ✅ | ✅ locally | — | Persisted denylist, group targets handled. Federation prunes the peer's directory entry (was M4); it does not reach nodes, and nothing local hosts a federated group |
-| Suspending/revoking a group blocks connections | ✅ | ✅ | — | `webrtc_offer` checks status; node drops sessions on `revoke` |
-| Device linking safe against the hub | ✅ | ✅ | ⚠️ browser link inherits T3 (documented) | Countersignature by a pinned device; hub holds no user keys |
-| Chat authenticated between members | ❌ not yet | ❌ | ❌ | Sender Keys is Phase 15; today chat is node-asserted on every transport (M2a's wire-asserted QUIC path was closed 2026-09-01) |
-| Node does not emit traffic on a member's behalf | — | — | ✅ since 2026-09-01 — link previews rate-limited, ports restricted, connect-address re-checked (was M3) |
-| Hub cannot be used to censor content | — | — | ✅ since 2026-09-01 — `POST /v1/reports` needs auth, distinct reporters, public groups on (was H2) |
-| Moderator ≠ administrator | ✅ | — | — | ✅ since 2026-09-01 — `admin_patch_user` split by field (was H1) |
-
-**One-sentence version:** *the E2E story between browser and node is now
-genuinely mutual and covers every path, the second review's critical gaps are
-closed, and H1/H2/M1–M5 were fixed the day this was written (M6 was withdrawn as
-a misread of a deliberate design) — leaving the L-list as opportunistic
-hardening and one thing to verify: the SPA's new CSP against the live app.*
-
----
-
-## 7. Prioritised action plan
-
-| # | Finding | Severity | Effort | When |
-|---|---|---|---|---|
-| H1 | Moderator can write `role` → admin | High | S | ✅ **fixed 2026-09-01** — handler split by field |
-| H2 | Unauthenticated 2-report global blocklist | High | S | ✅ **fixed 2026-09-01** — auth + distinct-reporter + rate limit + public-groups gate |
-| M1 | Registration CAPTCHA inert | Medium | S | ✅ **fixed 2026-09-01** — gate unconditional; desktop renders the widget |
-| M2 | QUIC chat: `sender_id` spoof, cross-group broadcast, sync ffmpeg | Medium | M | ✅ **fixed 2026-09-01** — handlers at WebRTC parity + `quic_enabled` off by default |
-| M3 | Link-preview SSRF: no rate limit, ports open, rebinding | Medium | M | ✅ **fixed 2026-09-01** — rate limit + port allowlist + connect-address re-check + bomb guard |
-| M4 | Federation: peer over-trust, `aud` unchecked, revoke no-op | Medium | M | ✅ **fixed 2026-09-01** — source bound to signer, push capped, revoke prunes the peer's own entries, replay rejected |
-| M5 | No CSP / security headers on the SPA | Medium | S | ✅ **fixed 2026-09-01** — CSP + `nosniff` + `frame-ancestors` middleware; verify against the live SPA |
-| M6 | `add_group_member` accepts node tokens | — | — | ⛔ **withdrawn** — deliberate (CLI invite flow, commit 0443cf8); the "fix" broke it and was reverted |
-| L1 | Relay registration no PoP | Low | S | If/when the relay registry is used |
-| L2 | Orphaned `replication.py` / `revocation.py` | Low | S | Delete now |
-| L3–L11 | See §5 | Low | S | Opportunistic |
-
-Two structural recommendations, both echoing the second review:
-
-1. **Make transport parity a test, not a habit — again.** C6 was fixed by moving
- the handshake into `meshbay_common`; the *chat* and *stream* handlers were
- not moved, and M2 was the result. The 2026-09-01 fix mirrored the WebRTC
- logic into `quic_server.py` by hand — the durable version is shared helpers
- in `meshbay_common` (`sender_id` enforcement, per-group context, the
- transcode gate) with a test that fails if a transport calls a chat/stream
- path that bypasses them.
-
-2. **Every new outbound or cross-trust surface needs a rate limit and an
- adversary named in the same commit.** Link previews, TMDB/MusicBrainz search,
- `POST /v1/reports`, `receive_directory` — each added a way for a low-privilege
- party (a member, an anonymous caller, a peer hub) to make the node or hub do
- work or accept state, and each shipped without a bound on how much. The
- 2026-09-01 fixes added the bounds to reports and link previews;
- `MEDIA_META_REQ` / `TMDB_SEARCH_REQ` still want a quota, and
- `receive_directory` a cap.
-
----
-
-## 8. Conclusion
-
-The architecture is unchanged and still correct, and the remediation since
-2026-08-13 was real: the unified handshake, the deletion of the node HTTP API,
-mutual node authentication with client-side pinning, the invite/pairing rewrite,
-device linking, and the account-recovery design are all solid security
-engineering, and most of the second review's C- and H-list is genuinely closed.
-
-The new findings are narrower and more uniform in shape than last time: a role
-check that grants too much, an anti-abuse endpoint with no abuse protection, a
-CAPTCHA wired to a condition the real client never meets, a transport that
-received the new authentication but not the new authorization, an over-trusted
-federation peer, and a missing header policy. None of them required exotic
-capability, and none of them were architectural — they were the cost of adding
-six subsystems faster than the authorization model grew to cover them.
-
-H1, H2 and M1–M5 were fixed the day this was written; M6 was withdrawn — it
-misread the node registering a hub membership during the CLI invite flow
-(deliberate, commit `0443cf8`) as authorization drift, and the "fix" broke that
-flow in live testing. What is left is the L-list — opportunistic hardening — and
-one verification: the SPA's new CSP (M5) against the running app, since a
-mis-tuned CSP shows as a blank page. On a build whose honest claims are now
-strong and largely
-defensible.
diff --git a/docs/tmp-decisions.md b/docs/tmp-decisions.md
deleted file mode 100644
index 524f4f4..0000000
--- a/docs/tmp-decisions.md
+++ /dev/null
@@ -1,191 +0,0 @@
-# Client architecture — decisions
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the client architecture decisions; its design
-> content now lives in §14.2 (D1–D4).
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Created 2026-08-13 after the second security review. D1/D2/D3 decided the same day;
-> D4 (hub minimization) deferred. Fold into `meshbay-draft-v5.md`.
-> The analysis below is kept as the rationale behind the decisions, not as open questions.
-
----
-
-## Status
-
-| # | Decision | State |
-|---|---|---|
-| D1 | Does the hub keep serving the web UI? | ✅ **DECIDED 2026-08-13 — yes** |
-| D2 | Browser extension, native desktop client, or both? | ✅ **DECIDED 2026-08-13 — native client, offered alongside the hub-served SPA.** Shell revised 2026-08-17: **Electron**, see `desktop-client-v1.md` |
-| D3 | Transport: aiortc primary, QUIC at parity, TCP+HTTP removed | ✅ Decided 2026-08-13. Unchanged for the **node**; the desktop client uses Chromium's WebRTC rather than aiortc, and QUIC via a Python sidecar |
-| D4 | Hub minimization (old Phase 12) | ⏸️ **Deferred, may be dropped** |
-
-> **2026-08-17 — the browser-extension options are no longer live.** 12.2 (signed digest
-> of the served bundle) is what Option A needed, and it remains scheduled; nothing else
-> from A or B is planned. The desktop client is the answer to T3 for users who want one.
-
-**What was decided.** The hub keeps serving the web UI — that is the zero-install path
-and it stays. A native desktop client is offered *in addition*, not as a replacement.
-Hub minimization is off the critical path and may be dropped entirely.
-
-**What that means, stated once and then respected.** Keeping the hub in the trusted path
-is a legitimate product call, and this project is not obliged to defend against its own
-operator. But two consequences should be carried deliberately rather than by accident:
-
-1. **T3 is accepted permanently for browser users.** A hub that serves the code can
- exfiltrate keys from the page regardless of what the protocol does. The native client
- gives users who care an alternative; browser users are trusting meshbay.org, and the
- docs should say so plainly rather than claiming end-to-end integrity.
-2. **H3 was the last open High finding and its only fix lived in the dropped phase.**
- The hub is the public key directory: substituting a key during an invite hands it the
- group key, silently, with no forgery and no code injection. So key transparency and
- safety numbers were kept and are now Phase 12.1 — everything else from hub
- minimization is dropped. If Phase 12 is later dropped too, H3 stays open by choice,
- and "unreadable by other parties, even the hub" stops being a claim the project can
- make about an adversarial hub.
-
-The honest framing that survives all of this: **the hub cannot read your content unless
-it actively attacks you.** That is still a strong property, and it is defensible.
-
-## Rationale — why the native client is not a T3 fix
-
-The second review recommended a native client and claimed *"T3 disappears — code integrity
-stops depending on the hub."* **That claim was wrong and has been corrected** in
-`second-review.md` §9.
-
-If the hub operator is the adversary, a native client downloaded from `meshbay.org` and
-signed with a key that operator holds does not remove the trust — it relocates it from "the
-JS they serve" to "the binary they serve." What actually changes is **detectability**:
-
-- **Browser:** an attack is one HTTP response, targeted at one user, leaving no artifact.
- Undetectable in principle.
-- **Native:** an attack requires shipping a build. That build is an artifact — hashable,
- archivable, comparable between users, reversible. Targeting one user means giving them a
- different binary, which reproducible builds and published hashes make detectable.
-
-That is a real improvement, but **the value lives in the verification machinery
-(reproducible builds, published hashes, independent rebuilds — Phase 18.7), not in the
-packaging format.** Without it, a native client from meshbay.org is only marginally more
-trustworthy than the SPA from meshbay.org.
-
-Native also has real costs that were under-weighted: loss of the browser sandbox (a Python
-process with full user privileges vs a seccomp-confined renderer), ownership of patch
-velocity for WebKitGTK and every bundled dependency, and new attack surface (loopback media
-server, IPC bridge, update client).
-
-**Conclusion recorded:** the native client is justified on *product* grounds — durable keys,
-no browser tab, background connectivity, better video, hub-less `group://` access over QUIC.
-It should not be justified as the fix for T3 unless 18.7 ships with it.
-
----
-
-## D1 rationale — hub keeps serving the UI ✅
-
-Keeping it is defensible. It is how anyone tries the platform without installing anything,
-and it stays the fallback when a device has no client installed.
-
-What must be true now that it stays (Phase 12.2/12.3):
-
-- strict CSP and Subresource Integrity on the bundle
-- the hub publishes a **signed digest** of the served bundle, so any third party — an
- extension, a native client, a curious user — can verify it
-- `/app/` carries an explicit, visible "reduced trust: this hub serves this code" notice
-- the docs never claim end-to-end integrity for the hub-served SPA path
-
-The honest framing: hub-served SPA is a **convenience tier**, not the secure tier.
-
----
-
-## D2 rationale — native chosen; extension not taken up
-
-Three shapes, cheapest first:
-
-**Option A — Extension as a verifier (hub still serves the UI)**
-The extension does not ship the UI. It hashes the bundle the hub served and compares it
-against a digest signed by the project. Mismatch → visible alarm, optionally block.
-Converts a silent targeted injection into a loud one. Small effort, keeps today's
-architecture, compatible with D1 = yes.
-
-**Option B — Extension ships the UI (hub serves the API only)**
-The UI lives in the extension, distributed and signed by Mozilla/Chrome — a channel **the
-hub operator does not control**. Manifest V3 forbids remote code, which works in our favour:
-the structure enforces exactly what we want. Keys live in extension storage, isolated from
-page JS. Moderate effort.
-
-**Option C — Native desktop client** ← **CHOSEN**
-Phase 13. Full control, durable keys in an OS keystore, QUIC, hub-less access, best UX.
-Highest effort, and the security argument depends on 18.7.
-
-> **Shell revised 2026-08-17: Electron, not pywebview** (+ an optional Python sidecar for
-> `group://` over QUIC). See `desktop-client-v1.md` §2. The comparison table below
-> was written against pywebview and **two of its rows are wrong for the chosen shell**:
->
-> - *Browser sandbox* — Electron with `sandbox` and `contextIsolation` **keeps** the
-> Chromium renderer sandbox. The row reads "❌ None (partial under Flatpak)"; that was
-> WebKitGTK's property, not native's.
-> - *Crypto available* — the client keeps WebCrypto **and** gains local Argon2id/ChaCha20
-> in the main process; the row implied WebCrypto is lost with the browser engine.
->
-> The row that gets *worse*: **patch velocity**. "You own it" is more true of Chromium
-> than of a distro-maintained WebKitGTK, and the renderer parses attacker-controlled
-> content from nodes. That is the real cost and it is accepted deliberately.
-
-### Comparison
-
-| | Hub-served SPA (today) | A: extension verifies | B: extension ships UI | C: native desktop |
-|---|---|---|---|---|
-| Code distribution channel | Hub (the adversary) | Hub, but **verified** | Store (independent) | Hub download + own signing key |
-| Silent targeted injection | Undetectable | **Detected** | Not possible | Detectable *if* 18.7 |
-| Browser sandbox | ✅ Full | ✅ Full | ✅ Full | ❌ None (partial under Flatpak) |
-| Patch velocity | Browser auto-updates | Browser auto-updates | Browser + store review latency | **You own it** (WebKitGTK, Python deps) |
-| Key storage | IndexedDB, page-reachable | unchanged | Extension storage, page-isolated | **OS keystore** |
-| Crypto available | WebCrypto only (no ChaCha20/Argon2id) | unchanged | unchanged | **Full** (ChaCha20, Argon2id 256 MB) |
-| Transport | WebRTC | WebRTC | WebRTC | WebRTC **+ QUIC** |
-| Large file → disk | Chrome only (FS Access API) | unchanged | unchanged | **Native, unlimited** |
-| Hub-less `group://` | ❌ | ❌ | ❌ | ✅ |
-| New attack surface | — | negligible | negligible | loopback server, IPC, updater |
-| Platforms to maintain | 0 | 2 stores | 2 stores | 3 OSes |
-| Effort | 0 | Low | Moderate | High |
-
-### Observations for the decision
-
-- **A and B are not exclusive with C.** A/B protect browser users; C serves users who want a
- real application. "Both" is coherent — just sequence them.
-- **B gives most of C's security benefit at a fraction of the cost**, because the win was
- never the packaging format — it was getting the code off the adversary's distribution
- channel — and the extension keeps the browser sandbox while doing it.
-- **Store review latency is the one place B is worse than C**: a critical fix waits on
- Mozilla/Google. Mitigate with a version-pinned kill switch.
-- **For node operators specifically, Phase 14 (CLI) beats all three.** The operator is the
- highest-value target — holds the GEK, is the content authority — and today must use
- hub-served JS to initialize GEKs and invite members. The CLI removes that dependency at a
- fraction of any client's cost. If only one thing gets built for T3, it should be the CLI.
-- If **D1 = yes** (hub keeps serving the UI), Option A is the natural companion and is nearly
- free once 12.6 publishes the signed digest.
-
-### Not yet investigated
-
-- Whether AMO/Chrome Web Store policy accepts an extension whose purpose is P2P file sharing
-- Whether MV3 service-worker lifetimes can hold a long-lived WebRTC DataChannel (may need an
- offscreen document — worth a spike before committing to B)
-- Safari/iOS: no extension route comparable to AMO; likely out of scope either way
-
----
-
-## Impact on the roadmap
-
-| Decision | If yes | If no |
-|---|---|---|
-| D1 hub serves UI | Phase 12.6 as written (CSP, SRI, signed digest, reduced-trust notice) | 12.6 shrinks to removing `/app/`; hub becomes API-only |
-| D2 = A | Small new phase; 12.6 is a prerequisite | — |
-| D2 = B | New phase, ~Phase 13-sized; 13.1 platform split is reused directly | — |
-| D2 = C | Phase 13 as written (13.2–13.11), + 18.7 for the security claim to hold | Phase 13 reduces to 13.1 only |
-
-**13.1 (platform adapter split) is worth doing regardless of D2.** It is pure refactoring
-whose acceptance criterion is "the browser SPA behaves identically," and it is the
-prerequisite for A, B and C alike.