summaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
blob: 269598eafe7deb84c4c4f7b396f7543e08fc2111 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
# MeshBay — Project Conventions

## What this project is

MeshBay is a decentralized peer-to-peer platform for file sharing, video streaming, and group messaging.
See `docs/meshbay-draft-v6.md` for the architecture specification. v6 restates only
what changed on 2026-08-17; `docs/meshbay-draft-v5.md` remains authoritative for
everything v6 does not touch (v3/v4 superseded).

## Repository structure

```
meshbay/
├── packages/
│   ├── meshbay-common/   # Shared crypto + protocol — python3-meshbay-common RPM
│   ├── meshbay-hub/      # Hub server (FastAPI + PostgreSQL) — meshbay-hub RPM
│   └── meshbay-node/     # Node daemon + local UI — meshbay-node RPM
├── poc/                  # POC spike scripts (reference, not production)
├── docs/                 # Architecture drafts and POC plans
├── packaging/            # RPM spec files, DEB control files, systemd units
└── QE/                   # NOT versioned (.gitignore) — test artefacts, credentials, demos
    ├── demo-v1/          #   Scripts démo opérationnels (setup_demo.py, run_node.py, download.py)
    ├── spikes/           #   Expérimentations futures (remplace ~/draft/)
    └── server-state/     #   Inventaire de ce qui tourne sur meshbay.org
```

**Règle QE/** : tout test sur meshbay.org doit ouvrir le port UFW, tester, et
fermer le port + tuer les processus dans le MÊME bloc de commandes.
Jamais de processus orphelins ni de ports ouverts après un test.

## Python environment

- **Minimum Python:** 3.12
- **Build backend:** hatchling (per package `pyproject.toml`)

```bash
# Créer le venv (--clear si recréation sur une autre machine/OS)
python3 -m venv .venv --clear
source .venv/bin/activate

# Toutes les dépendances sont déclarées dans les pyproject.toml — un seul pip install suffit
pip install -e packages/meshbay-common -e packages/meshbay-hub -e packages/meshbay-node
pip install pytest pytest-asyncio aiosqlite   # extras dev
```

Les deps clés (aioquic, watchdog, fastapi, blake3, etc.) sont dans les `pyproject.toml`
et installées automatiquement. Ne pas ajouter manuellement des packages sans les déclarer
dans le bon `pyproject.toml`.

> **Ne jamais copier `.venv/` entre machines d'OS différents.** Si rsync depuis Fedora vers Ubuntu,
> exclure `.venv/` et recréer sur la cible avec `python3 -m venv .venv --clear`.
> Sans `--clear`, `certifi.where()` pointe vers un chemin Fedora inexistant sur Ubuntu → `FileNotFoundError`.

```bash
# Lancer les tests
.venv/bin/pytest
```

## Code conventions

- **Linter/formatter:** ruff (`uv run ruff check .` / `uv run ruff format .`)
- **Line length:** 100
- **Type hints:** required on all public functions
- **Comments:** only when the WHY is non-obvious; no docstrings restating the function name
- **No prints in library code** — use `logging` module

## Versioning

### Package versions (SemVer)
- Format: `MAJOR.MINOR.PATCH`
- Pre-1.0: breaking changes bump MINOR, not MAJOR
- All three packages share the same version number (released together)

### Protocol versions (independent)
- MNP: `1.0` → bumped independently of package version. **`handshake.py`'s
  `MNP_MIN_SUPPORTED` is the other half**: both peers declare `v` and `v_min` on
  the handshake and refuse each other with a code (`version_too_old` /
  `version_too_new` / `version_unreadable`), so a mismatch is a refusal rather
  than a field that turns up missing. Shipped with 1.0 because that flag day was
  already being paid for; the next breaking change now costs a refusal message
  - 0.2 added `PING`/`PONG` and backward chat paging (`before` / `has_more`).
    Additive, so an 0.1 peer still works: it sends no `before` and is answered
    with the newest page, which is what it wanted
  - 0.4 added `apps_enabled`/`apps_enabled_ack` and `enabled_apps` on the
    handshake ack, for the group-applications registry (`docs/apps.md`).
    Additive, same reasoning
- MHP: `0.1` → bumped independently of package version
- Every wire message carries a `v` field
- Breaking change → MAJOR bump; backward-compatible → MINOR bump
- N-2 MINOR backward compatibility guaranteed

## Commit messages (Conventional Commits)

```
feat(node): add directory watcher with watchdog
fix(hub): include jti in all JWT tokens
chore(common): add Argon2id calibration to crypto.py
docs: update draft v3 with POC findings
test(common): add wrap/unwrap GEK round-trip test
```

Types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `perf`
Scope: `hub`, `node`, `common`, or omitted for cross-cutting

## Security rules

- **Never commit private keys** (hub_private.pem, *.key, unlock.key, keystore.enc)
- **Never commit QE/** — credentials, test keys, demo data go there
- **Never log GEK, private keys, or plaintext passwords** — even at DEBUG level
- **meshbay.org is internet-facing** — open port → test → close port + kill processes in same block

## First security review (2026-08-10) — see `docs/first-review.md`

**Critical (before Phase 7):**
- **C1** Chat: Sender Keys protocol, NOT shared Double Ratchet (pairwise protocol
  would cause key/nonce reuse in group context). `ratchet.py` kept for future 1:1 DM.
- **C2** JWT must carry `"groups": [group_ids]` claim. Node MNP handshake must verify
  group membership before serving content. Without this, any authenticated user
  accesses any group.

**Significant (Phase 7-8):**
- **S1** Admin revocation endpoint has no authz check ✅ DONE (Phase 8.1 — config-based require_admin)
- **S2** Email stored in plaintext (spec says encrypted at rest) ✅ DONE (Phase 8.2 — AES-256-GCM, HKDF from hub key)
- **S3** jti denylist push via hub→node WebSocket → Phase 7.2
- **S4** AES-GCM keystore IV fixed: 128-bit → 96-bit (NIST SP 800-38D) ✅ DONE
- **S5** Refresh token rotation (one-time use) ✅ DONE (Phase 8.3 — family-based reuse detection)

**Node sovereignty (2026-08-12):**
- **NS1** GEK-HMAC proof in handshake — blocks hub admin from accessing any group content ✅ DONE
- **NS2** Ed25519 challenge-response for admin operations — blocks hub admin impersonation ✅ DONE
- **NS3** `gek_req` endpoint removed — node never serves GEK in plaintext ✅ DONE
- **NS4** ~~`admin_pk_ed25519` auto-pinned from keystore~~ ❌ **that was finding M3.** The
  keystore key is not the key the browser signs with, so every admin operation failed
  closed. Authority now comes from the node's roster — `meshbay-node operator pair`
  (2026-08-14). `admin_pk_ed25519` was kept as a legacy form and is now **removed**
  (2026-08-15) — one source of authority, the roster. A config still naming it is
  warned about at startup, never obeyed. Never auto-pin again, and never resolve the
  operator's key through the hub
- **NS5** DTLS channel binding in GEK-HMAC — `HMAC(GEK, nonce || offer_fp || answer_fp)` detects WebRTC signaling MitM ✅ DONE
- **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE.
  **Superseded as sufficient (2026-08-17):** that is the node's word. Messages must be
  **signed with the sender's device key**, and clients pin `account → device keys`.
  Likewise `_admin_exec_file_delete` authorizes against `entry.uploader_pk` — the exact
  uploading key — which **device linking breaks**: it must become any non-revoked device
  of `uploader_id`, resolved through the roster (never through a token claim).
  See `docs/desktop-client-v1.md` §4.8
- **NS7** Node Ed25519 auth — node daemon authenticates to hub via `POST /v1/nodes/auth` (Ed25519 signed timestamp), no auth_key/password on node. JWT `scope: "node"` blocks group management (create/add/delete/join). Operator manages groups from browser only. ✅ DONE
- **NS8** GEK-required enforcement — node REFUSES connections when GEK is None (no `gek_required: false` bypass). GEK initialization via node local admin UI only. ✅ DONE

**Known remaining trust assumptions (Phase 12 — all actionable items done):**
- **T1** ✅ DONE: password split (auth_key / bundle_key, independent PBKDF2). Legacy migration on first login.
- **T2** ✅ **CLOSED 2026-08-14** (the finding is H3). Not by safety numbers: the invite
  path stopped reading the directory. The node holds the GEK and wraps it for a key the
  recipient proves possession of; identities are bound to accounts by one-time codes the
  hub never sees. See `docs/invite-pairing-v1.md`
- **T3** SPA served by hub → fundamentally unsolvable in browser. Fix: native client or browser extension

**T3 attack surface reduction (2026-08-12, all phases complete):**
- **Phase 1** ✅ DONE: GEK bundles moved from hub to node P2P (WebRTC DataChannel). No hub fallback.
- **Phase 2** ✅ DONE: Keypair bundles moved from hub to node P2P. Registration stores locally, pushed to node on first connect. Hub never stores keypair bundles.
- **Phase 3** ✅ DONE: Hub GEK cleanup — `GET /gek` endpoint removed, `GEKBundle` model removed, `gek_bundles` table dropped, `keypair_bundle` column removed, member-add URL cleaned (`/gek` suffix removed), Alembic migrations updated.

**Browser crypto hardening (2026-08-13):**
- `_bundleKey` persisted in IndexedDB (CryptoKey survives page refresh)
- `_sessionKeys` persisted in sessionStorage (survives refresh, cleared on tab close)
- `_pkFromSk()`: derive X25519 public key from recovered private key via JWK export (no hub fetch)
- `regenerateKeys()` is gone entirely (2026-08-14): identity keys are per node, so
  rotation is `meshbay-node member unpin <user>` plus a fresh code
- Raw answer SDP saved before `setRemoteDescription` (Chrome strips sha-256 from multi-hash SDP)
- Upload chunk size: 48KB (fits aiortc SCTP limit after msgpack overhead)

**Architecture validated:** crypto primitives, GEK wrapping (ECIES), trust model,
key hierarchy, on-the-fly encryption, transport abstraction, DTLS channel binding.

## Second security review (2026-08-13) — see `docs/second-review.md`

**6 critical, 7 high findings. Phase 11.5 is BLOCKING — see `docs/devel-phases-next.md`.**
The current build must not host real private data.

The claims above about node sovereignty and P2P crypto material were **overstated**. The
GEK-HMAC proof, Ed25519 admin challenge and channel binding are real, but they are enforced
on the WebRTC path only, and three other paths into the node were left behind.

- **C1** Node HTTP API (`http_server.py`) serves private group **index and plaintext files
  with no authentication**, on `0.0.0.0`, for every group — bypasses the entire sovereignty layer
- **C2** `/v1/nodes/ws` trusts a client-supplied `node_id` → any user hijacks a node's
  signaling identity and impersonates it to browsers
- **C3** The node never authenticates itself to the client (`node_pk` is never verified, no proof of possession)
- **C4** Keypair bundles are served pre-proof and pushed to every node joined; PBKDF2-only → offline password attack
- **C5** Any member can overwrite arbitrary shared files (upload) and seize the group GEK (`gek_bundle_store` + auto-activation)
- **C6** GEK proof exists on WebRTC only — QUIC and TCP accept a bare JWT (chat injection)
- **H1** Multi-group nodes share one `chat_store` and one peer registry → cross-group chat leak
- **H2** Stored XSS in the node admin UI via uploaded filename → node takeover
- **H3** Hub is the key directory → key substitution at invite yields the GEK. "Unreadable
  even by the hub" is true against a *passive* hub only

## Invite redesign (2026-08-14) — closes H3 and M3

See `docs/invite-pairing-v1.md`. Read it before touching invites, admin authority or
`gek_bundle_store`.

- **The node wraps the group key**, on every connection, for the X25519 key the joiner
  signed with their pinned Ed25519 identity. **Nothing fetches a public key from the hub
  to wrap for** — not the SPA, not `gek-init`. That lookup *was* H3
- **`gek_bundle_store` is deleted**, not gated. No member hands the node key material
- **The node's roster decides who gets the key**, not hub membership: a hub that invents
  an account and mints it a token gets `not_authorized_for_group`
- **One-time codes** bind a key to an account without the directory. 40 bits, single use,
  one account, node-wide lockout. 7 days for invitations, 24 h for operator pairing, both
  in `[node]` of node.toml
- **`join_policy`** (`invite`|`open`) is read from **node.toml, never the hub** — a hub
  able to declare a group open would be handed its key. Unknown group ⇒ `invite`
- Operator surface over SSH: `operator pair`, `member list|invite|revoke|unpin`. Deleting
  a file is the last browser-only operation
- Revocation now works for key delivery (nothing stored survives it) — but **still rotate
  the GEK**, the ex-member holds the current one

## Keypair bundles and the browser KDF (2026-08-14)

- The bundle key is **Argon2id 128 MB / t=3 / p=1**, WebAssembly vendored under
  `static/vendor/` (CSP forbids external hosts; 12.2 must keep `wasm-unsafe-eval`).
  **Do not change the parameters in one place**: `keyderive.js`, the QE harness and
  `test_bundle_kdf_parity.py` are held byte-identical by that test, and a mismatch
  presents as an account nobody can open
- Bundles carry an `MBK2` marker; the PBKDF2 form is still readable and is
  re-encrypted on the next backup. Both keys are derived at sign-in because the
  passphrase is deliberately not retained
- Cost is paid **once per sign-in** (650 ms bundle + 239 ms auth_key); reloading a
  page derives nothing — the key lives in IndexedDB
- The bundle is stored on **every node its owner joins**. That is what makes a
  second browser work, and it is C4: cracking one yields identity keys, hence
  content on *other* nodes and the ability to sign as that user. Draft-v5 §7.1 has
  the measured numbers. **The passphrase is the wall; the KDF is a speed bump**
- Floor: 12 characters and ~60 estimated bits, enforced client-side only — with the
  password split (T1) the hub never sees a passphrase

## Identity keys are PER NODE (2026-08-14)

See `docs/per-node-identity-v1.md`. Read it before touching registration, the
keypair bundle, or anything that looks like a user's public key.

- A keypair is created at **first contact with a node**, encrypted under the
  passphrase, and left on that node. Never reused elsewhere. Cracking one yields
  the identity used with that operator and nothing anywhere else
- **The hub stores and publishes no user keys.** `users.pk_ed25519`/`pk_x25519`
  are dropped, `PUT /me/keys` is gone, `/pubkeys` returns an account id and the
  node linking key. Do not reintroduce a key directory — that was H3
- **Tokens carry no `pk_user`.** The node recorded it as the uploader's identity
  and authorized deletion against it, so whoever issued tokens decided who could
  delete a file. Attribution uses the roster pin (`_pinned_pk`)
- Registration generates nothing, so a scripted signup is a real account —
  `QE/deploy/demo.py bootstrap` takes a wiped hub and node to a working demo
- The key handed back on a join belongs to the **group of the connection**, not
  the group named in the invitation (an operator pairs node-wide while opening a
  group)

## Where Phase 13 stands (2026-08-19)

Stages A–D are **built and running**, not designed. `docs/desktop-client-v1.md` is still
the decision record; the sections below it describe what was decided, and this says what
exists.

- Built: named roots, `ops.py` + CLI, device linking and hub device auth, the Electron
  client (protocol handler, CSP header, `safeStorage` keys, streamed downloads to disk,
  native folder picker), the group Settings tab, per-account resume positions, uploads
  the operator can close
- Deployed: the hub runs MNP 0.3 with migration `e5a2b7d31f88`. **The SPA served in
  production is older than this tree** — check `/a/<hash>/` against
  `meshbay_hub.api.webapp.ASSET_V` before concluding a fix is missing. `site/` and the
  Caddy config have never been deployed
- Not built: D5 (node management panel), D6 (first-run wizard), D8 (`.deb`/`.rpm` — the
  package must ship `chrome-sandbox` root-owned 4755), D9 (Python sidecar `group://`),
  D10 (video thumbnails), D11 (Windows), D12 (release key and repo)
- One UI source: `packages/meshbay-hub/src/meshbay_hub/static/` is the interface, for the
  web and the app alike. `packages/meshbay-client/build/sync-ui.js` copies it and CI
  fails if the copy drifts — **never edit `packages/meshbay-client/ui/` by hand**
- Throwaway `e2e*` accounts accumulate on the production hub; the operator deletes them

## Desktop client — decided, not built (2026-08-17)

See `docs/desktop-client-v1.md`. Nothing here is implemented; it is the design and the
decision record for Phase 13. Read it before touching the roster, registration, or
anything that assumes one key per person.

- **Electron**, not pywebview — structural decision 18 is reversed. The SPA depends on
  Chromium-class APIs (WebRTC, WebCrypto X25519/Ed25519, MSE, Service Workers), so
  keeping Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js` and
  `sw.js` **as the client**. They are no longer on the "delete once native" list. A
  Python sidecar reusing `quic_client.py` covers hub-less `group://` only
- **UI assets ship inside the package**, unchanged and non-negotiable. A shell pointing
  at the hub's `/app/` fixes nothing
- **Device linking**: `identities.user_id` is a PRIMARY KEY and `pin_identity` does
  `INSERT OR REPLACE` — one key per person per node, silently overwritten. Both must
  change. A second device is admitted by the **already-pinned key countersigning**,
  bound by a one-time code the new device generates; the hub holds no user keys and so
  cannot produce that signature. Never make the approval a human comparing digits —
  that is the safety-number ritual 12.1 was abandoned for
- **C4 is not fully closed by going native.** It closes for a native device
  unconditionally, and stays open for any account that also uses a browser, which needs
  a bundle on each node. An account is only as strong as its weakest client
- **`gek_rotate` may become a signed MNP op** — the C5b rule forbids *key material
  arriving from outside*, not an operator-signed instruction where the node generates
  the key itself. The initial `gek-init` stays local: with no GEK there is no session
- **Installation places files, never secrets.** No key generation in `%post`/`postinst`
  or an MSI custom action — a golden image would give every machine the same key
- **A group has several named roots, not one `shared_dir`.** The name is the chosen
  directory's **basename**, derived once at add time and *stored* — recomputing it from
  the path re-identifies a whole library the day someone renames a folder. Duplicates
  refused case-insensitively, no root nested in another, one operator-designated upload
  target, availability per root, and `kind` + `layout` reserved for the planned
  video/audio libraries. `config.py:103` is the single string this replaces
- **The planned video/audio libraries are VIEWS over the file index, not a catalogue.**
  No metadata store, no server-side database, ever, and nothing reaching the hub — it
  keeps no file names for private groups (H7). A file stays tied to its representation on
  the filesystem: folders are the categories, and moving a file makes it a different
  file. Everything a view needs already exists (whole-group index cached client-side,
  10b.5/10b.6). The only non-free piece is a video thumbnail
- **Enrichment happens on the client; what it cannot compute, the node produces on demand
  and the asking device caches.** Neither node nor hub keeps durable derived state. This
  is already the rule for chat thumbnails (draft-v5 §5.2) and it is the answer for video
  thumbnails too — a frame grab is strictly less than the decoding the node already does
  for streaming, over the same authorized path
- **A root that goes away must freeze, not empty.** `indexer.py` runs a watchdog
  `Observer` and rebuilds on any change; unmounting a USB drive either emits deletions
  for the whole tree or presents an empty directory to the next rescan. Both propagate as
  though the owner erased their library. The per-root "unavailable" state ships **before**
  root selection is offered
- **exFAT/NTFS and Windows are the common case, not an edge case.** Most users are
  expected to share from an external exFAT or NTFS drive, on Windows, whatever the build
  order says. Consequences that are correctness, not portability: filenames need NFC normalization for identity while keeping original bytes
  for display; Windows reserved names and `MAX_PATH` affect what can be downloaded;
  `ReadDirectoryChangesW` drops events under load, so periodic reconciliation is
  mandatory. Never assume POSIX, systemd or case sensitivity. The upload no-overwrite check was *not* affected — `Path.exists()` is already case-insensitive there (checked 2026-08-18); case folding is for comparisons the code makes itself
- **Shipping the UI in a package creates version skew for the first time.** Today the SPA
  and the hub deploy together, so a `/v1/` response shape and its caller change in one
  commit. Once the UI is installed rather than served, `/v1/` is a compatibility surface
  and `GET /v1/hub/version` needs a minimum client version — cheap now, awkward later

- **A content-addressed index cannot represent the same bytes at two paths.**
  `GroupIndex` is keyed by blake3, so `clip.mp4` at a root and in `uploads/` with
  identical content is **one** entry — which is also why a scan can report ten
  files and index nine. Reconciliation compares *paths*, so it decided the
  second path was a missed event every 60 s, rewrote the entry, bumped the
  version and pushed an index update to every connected peer. Found by watching
  a live node, not by a test. Anything comparing disk against index must check
  the id, not the path

- **A CLI branch nobody has run is not covered by anything.** `reload` shipped
  with `subprocess` unimported and crashed on first use; the module compiles
  fine, which is the same "syntax, not names" trap already recorded for the SPA.
  `test_cli_dispatch.py` walks every verb with the daemon stubbed, and refuses
  to let a verb be added to the parser without an entry there. It also stubs
  `os.kill` — the first version of that test SIGHUPed the developer's own
  running node

- **Device linking is live (2026-08-18).** `identities` is keyed by
  `(user_id, pk_ed25519)`, so one account holds several devices on a node; the
  migration rebuilds the table and preserves existing pins. A new device files a
  request bound by `sha256(code ‖ its own keys)`, and a key the node already
  pinned countersigns it — the hub holds no user keys and so cannot. **The code
  never reaches the node**: it lists pending requests with their hashes and the
  approver recomputes to find the match, which is what makes a substituted key
  impossible rather than merely detectable. `member unpin` still removes every
  device; `revoke_device` marks one, because a deleted row is a key the node
  would happily pin again
- **`user_devices` on the hub is not the key directory that was H3.** Nothing
  reads it but the hub, nothing wraps a group key for it, and it is a *different*
  key from the per-node identities. What it does cost is metadata: the hub now
  knows how many devices an account has and when each last signed in

- **The desktop client runs** (2026-08-18, Electron 42 / Chromium 148 under
  xvfb). Build needs Node ≥ 22 — **Ubuntu 24.04's nodejs 18 cannot install
  Electron at all**, its download script `require()`s an ESM module. Node 24 LTS
  lives in `/opt/nodejs`, fetched and checksum-verified against nodejs.org.
  `test_desktop_shell.py` pins the security contract by reading the source, and
  that is still weak evidence — it catches a property being removed. Launching it
  is what found the three things below

- **Three things only launching it could find.** (1) A CSP in a `<meta>` tag
  silently drops `frame-ancestors`; it is sent as a header by the protocol
  handler now. (2) **Service workers do not work on a custom scheme** — Chromium
  refuses whatever the privileges — so the app has none and uses the native save
  dialog; `sw.js` stays for the browser. What `secure: true` actually buys was
  measured at the same time: without it **all of `crypto.subtle` is undefined**,
  AES-GCM included. X25519 and Ed25519 are present on Chromium 148. (3) **The
  renderer cannot call the hub**: its `app://` origin is refused by CORS, and the
  hub deliberately has no CORS middleware — its API is reachable from no web
  origin. Every hub call therefore leaves from the main process, which also
  refuses any origin that is not the signed-in hub. A script served by the hub is
  refused by the policy, which is T3's mitigation demonstrated rather than
  asserted

- **A user unit cannot carry `User=`.** `meshbay-node.spec` installed the system
  template into `%{_userunitdir}`, where systemd refuses the file outright — the
  packaged unit could never have started, and nothing noticed because nobody had
  built and installed the RPM. Two units now, `test_packaging_units.py` holds
  each in its own directory. Note the test's own first version searched the
  whole file and matched the *comment* explaining why `User=` is absent; parse
  directives, not text — the same mistake as reading a CSP out of the comment
  above the meta tag

- **The device's hub key lives in the main process, never in the renderer.**
  Generated, stored and used there; the interface asks for a signature over
  `meshbay:user_auth:<username>:<ts>` and is never handed a key. Same rule as
  the save dialog, for the same reason: the renderer parses decrypted content
  from nodes, which is attacker-controlled input. It is **not** a per-node
  identity key — nothing here correlates a person across operators

- **A local hub is the way to test hub changes.** `uvicorn meshbay_hub.app:create_app
  --factory` with a SQLite URL and a throwaway key runs the current code on
  loopback in seconds. meshbay.org runs whatever was last deployed — it reported
  MNP 0.2 and 405 on the Stage-C endpoints while the tree had 0.3 — so testing
  against it proves what is deployed, not what is written

- **A second copy of the hub address is what breaks the app, not the protocol.**
  `keyderive.js` carried `const HUB = '' // same origin` — true of a page the hub
  served, false of one loaded from a package, where the origin is `app://meshbay`
  and `/v1/users/register` hits the application's own protocol handler. **Sign-up
  and sign-in, the first two things anybody does, failed with "Not found."** Found
  by a person clicking Register. `test_hub_address_seam.py` now refuses any file
  that decides where the hub is, or fetches `/v1/…` relative to the page origin

- **safeStorage is real on a desktop and honest without one** (checked
  2026-08-18, Ubuntu GNOME). `secrets.bin` carries Chromium's `v11` prefix,
  which means keyring-backed; the fixed-key fallback writes `v10`. Headless, the
  same code reports `unavailable` and refuses to store rather than downgrading
  silently

- **`globalThis.navigator` is read-only from Node 22.** `test_locales.py`
  assigned to it and broke the moment the suite ran under a newer Node — which
  the desktop client's build already requires, so the first CI machine set up
  for it would have failed these tests for no visible reason. Use
  `Object.defineProperty`; the suite is now green on 18 and 24

- **Two subtractions in different files, one scrollbar.** Twice now a page was
  permanently a few pixels too tall: `.page-center` and `.layout` each
  reserving `100vh - 52px`, then the chat panel sized to `viewport - top - 16`
  while `.main` adds 24px of padding underneath it. Neither is visible in the
  stylesheet, and both read as correct on their own. `tests/harness/
  scroll_probe.py` measures the document against the window and runs the real
  `fit()` lifted out of `app.js` — the sizing code is never reimplemented in a
  test, or the test outlives the code it was written for

- **A handler bound to the event its own writes produce.** The chat panel's
  `fit()` set a height, read `documentElement.scrollHeight` back and subtracted
  the overflow — so the document alternately did and did not overflow the
  window, the page scrollbar appeared and vanished with it, and `visualViewport`
  fired `resize` at every pass. `fit()` listens to that event: it re-entered
  itself ~120 times a second for the life of the panel (measured: 240 firings in
  2 s on an idle page, against 2 for a bare document). Each pass re-pinned the
  list to the bottom, which undid every attempt to scroll up **inside the frame
  it happened in** — before the `scroll` event that would have recorded it was
  delivered, so `atBottomRef` never went false, no jump button appeared, and the
  older messages were unreachable. Every pin in that file reads as correctly
  guarded; the reader simply never got to stop being at the bottom. Mutate-then-
  measure is a loop wherever layout can raise the event you are handling: learn
  the correction once and write nothing in the steady state. And a scroll
  position that must survive a gesture has to be released **by the gesture**
  (`wheel`/`touchmove`/`pointerdown`), never by the `scroll` event alone
- **A reply that names nothing is routed by luck.** The node answers a chat
  message with a bare `{"type": "ack"}` — no request id, no type of its own —
  so `_dispatch` had nothing to match it on and fell through to its
  arrival-order guess, which hands a reply to whichever request happens to be
  oldest. That is wrong the moment anything else this browser asked for is
  still waiting, and one *is*: the node refuses an unknown `file_id` with a
  bare `error`, which names no request either and so reaches none, leaving the
  Videos tab's `media_meta_req` in `_pending` for the full 30 s. The ack went
  to that, the send waited out its own timeout, and because the composer is
  disabled while a send is in flight, **typing a message froze the Chat tab**:
  no click, no keystroke, no message — and the message there all along on the
  next visit to the tab, since the node had stored it and answered. Every line
  of `chat-app.js` is correct and every routed message in `transport.js` is
  routed correctly; the defect is in the seam, which is why
  `tests/harness/chat_send_probe.py` drives the two together. `ack` was matched
  by request type (`chat_msg`, or the keypair-bundle store/delete that name
  themselves in `detail`), and that closed the instance — **but it left the
  class open, and it came back on 2026-09-06 through the other door.** A
  refusal has no type of its own to key on: `_dispatch_message`'s catch-all
  answers every unforeseen failure with `{"type": "error", "detail": "Request
  failed"}`, and 238 of `webrtc_server.py`'s 240 error sends name nothing
  either. So a chat send the node refused was routed by luck all over again —
  same frozen composer, same 30 s, and rare enough (it needs an older request
  still waiting, which a `music_meta_req` behind a failing third-party lookup
  supplies for over a hundred seconds) to look like once every couple of days.
  The keys were never the fix, only a workaround for a protocol that carried no
  correlation id at all: `_seqId` existed, indexed `_pending`, and was never put
  on the wire. It is now (`req_id`, see `protocol.py`) — the node stamps it on
  the reply from `_send`, via a ContextVar so a handler's spawned work still
  answers under the right id, and never on a broadcast, which answers nothing.
  With that, the arrival-order fallback is gone for any node that stamps.
  The lesson is not "key the replies": it is that **matching by arrival order
  is a guess that fails silently and asymmetrically** — the victim is never
  the request that was answered wrongly, it is the unrelated one that now
  waits for a reply already delivered elsewhere. A reply needs an identifier
  the protocol guarantees, not a field it happens to have

- **A refusal that never rejects.** Denying Chromium's `fullscreen` permission
  does not make `requestFullscreen()` throw — the promise never settles. The
  deny-everything handler was written from a true sentence ("nothing here needs
  a camera") and quietly broke watching a film full-screen, with no error
  anywhere to lead back to it. Prefer enumerating what is *granted*: the list
  is short, and the next thing Chromium invents arrives refused rather than
  silently allowed

- **A fallback chain reaches its floor silently.** `_openDownloadTarget` tries a
  granted folder, then a service worker, then "collect it in memory and hand the
  browser a blob". In the desktop application the first two do not exist —
  `showDirectoryPicker` is absent and Chromium refuses a worker on a custom
  scheme — so **every download under 512 MB went through RAM**, and the only
  visible symptom was a Save As dialog at the *end* instead of the start.
  Nothing errored. When a chain degrades, check what the floor costs on every
  platform that will reach it

- **A service worker with nothing to do is killed, and a streaming response is
  not "something to do".** Firefox terminates an idle worker after about thirty
  seconds; `event.respondWith(new Response(stream))` does not extend its life
  while the page is still writing to that stream. So the reader vanished
  mid-file and `writable.write()` **never resolved and never rejected** — no
  error, no log, no failed transfer, just a progress bar that stopped near the
  end. Measured 2026-09-08 in Firefox 154, writing 1 MB every 2 s: stalled at
  17 MB after 59 s; with a 10-second ping to the worker, 40 MB in 80 s,
  complete. The page pings while it writes and the worker answers, because
  receiving a message is the event that resets the timer.
  Three things this cost, all worth remembering. **The first stress probe wrote
  450 MB in two seconds and passed** — fast enough to hide the bug entirely, so
  a probe for anything time-based has to be paced like the real thing. **The
  node was innocent and three measurements proved it** (615 MB pulled whole
  over MNP, three files interleaved on one connection, three concurrent worker
  streams), which is exactly what made the fault unfindable: nothing was wrong
  anywhere anyone looked. And **the empty console was the evidence**, not the
  absence of it: `_sendAndWait` logs every timeout, so silence eliminated
  everything that reports itself and left the one `await` on that path with no
  bound. Every await on a download path is now bounded and says which chunk it
  gave up on — an unbounded one is a freeze nobody can report

- **A name with no spaces in it sets a table column's minimum width, and on
  Android that unpins the whole page.** Sticky headers were added to Files,
  Videos, Music and Photos on 2026-09-09 and reported broken on a phone: not
  the new bands but *everything*, the navigation bar included, which had been
  `position: sticky` for months. That is the tell. A document wider than the
  screen leaves everything pinned attached to a viewport the reader can no
  longer see, so a header doing exactly what it was told looks like one that
  was never pinned — **look for horizontal overflow before doubting the
  sticky rules**. The overflow came from two `<td>`s that had no wrapping rule
  because `.file-name` was only ever on a *file's* name: a folder called
  `Rage_Against_The_Machine_Discography_1992-2000_FLAC` made a 527px table in a
  390px window, and Search's group column did the same at 442px with an
  underscored group name. `word-break: break-word` is what lets such a cell
  stop driving the column, and it has to be on every cell that carries a name
  somebody else chose.
  Two things cost more than the fix. The harness had measured this page in two
  engines at three widths and found nothing, because its fixture said
  `note-007.txt` and `un groupe` — **a fixture narrower than real data tests
  the fixture**, and names are the one thing a file browser cannot be given
  short. And a first diagnosis blamed the soft keyboard (the Search field is
  `autofocus`, and Android's `interactive-widget` default splits the visual
  viewport from the layout one), which was plausible, cost a round trip, and
  was wrong; the screenshot showing no keyboard was already in hand.

- **Three headers decide whether a page may frame itself, and they must agree.**
  The same streamed download navigates a hidden iframe to `/_mbdl/<id>`.
  `frame-src` was reCAPTCHA's two origins with no `'self'`, `frame-ancestors`
  was `'none'`, and `X-Frame-Options` was `DENY`. Each was fixed in turn, each
  time costing a redeploy and a retest, and **all three were visible in one
  `curl -I` against the deployed hub** — which is where that should have
  started. `'self'`/`SAMEORIGIN` refuse every foreign origin exactly as
  `'none'`/`DENY` do; what they add is this origin framing itself, which is all
  the download needed. When a symptom points at a mechanism, enumerate
  everything that governs that mechanism and check the set at once

- **`encodeURIComponent` does not escape `'`, and `'` is RFC 5987's delimiter.**
  `Content-Disposition: filename*=UTF-8''<value>` became unparseable for any
  name with an apostrophe, so the browser named the file after the URL: 449 MB
  of film arrived complete and correct, called `mtsshk9w-ohqty535`. `(`, `)` and
  `*` are excluded from attr-char for the same reason. A plain ASCII
  `filename=` rides alongside now, so the next surprise loses accents rather
  than the name

- **Two elements each claiming `100vh - 52px`, one inside the other's padding.**
  `.layout` and `.page-center` both reserved the viewport below the header, and
  `main`'s `24px` top and bottom were added on top — a permanent 48px scrollbar
  on sign-in at every window size. Found by measuring in the running app
  (`document.documentElement.scrollHeight` against `innerHeight`, then the
  bottom edge of every element), not by reading the stylesheet, which is the
  standing rule here

## Two lessons that cost four rounds of live testing

- **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the
  client, written in the right order by construction: it proves the protocol and
  nothing about the SPA. Three ordering bugs passed it and failed in a browser.
  `test_spa_ordering.py` exists for that class and is worth extending
- **An unbounded `await` on the hub socket makes a node silently unreachable.**
  Three instances found in `maintain_ws`: the offer handler awaited inside the read
  loop, `ws.recv()` for auth with no timeout, and `return` on auth refusal ending
  the task for good. Symptom is always the same — daemon running, logging nothing,
  `connected_nodes: 0`, socket in CLOSE-WAIT. Look there first

- **A background task nobody holds can be collected mid-flight.** asyncio keeps
  only a *weak* reference to a task, so `asyncio.ensure_future(coro)` with the
  result thrown away may be garbage-collected while still running — the loop
  logs "Task was destroyed but it is pending!" and nothing else happens. For
  `_stream_video` that meant its `async with sem` never reached `__aexit__` and
  a transcode slot was lost for good. `WebRTCPeerSession._spawn()` exists for
  this; never call `ensure_future` there directly, and `test_task_lifetime.py`
  fails the build if anyone does

- **`await proc.wait()` after `kill()` still deadlocks on a full pipe.** ffmpeg
  outruns a credit-paced viewer; stop reading its stdout — which is what closing
  the player does — and the transport cannot finish closing, SIGKILL or not.
  Measured 2026-08-16: closing a viewer after 99 segments held a slot past the
  15 s handover timeout, so the next video hung and the one after was refused.
  Drain the pipes, then wait with a timeout, and release the slot regardless

- **Losing a peer must *stop* its work, not merely forget it.** The WebRTC
  `connectionstatechange` handler popped the session from a dict and nothing
  else, so a closed tab went on transcoding for the full 120 s credit timeout.
  Anything holding a resource needs `shutdown_tasks()` on the way out

- **A service worker being *active* is not the page being *controlled*.** An
  uncontrolled page's requests never reach the worker's fetch handler, so the
  streamed-download path handed over its stream and was never asked for it —
  `writer.write()` then blocks on backpressure that will never lift, and the
  download freezes after exactly one chunk. Require
  `navigator.serviceWorker.controller`, and have the worker confirm it actually
  served the request before trusting the sink

- **A removed `useState` leaves its setter behind and nothing complains.**
  `setActionsOpen` outlived `actionsOpen` and shipped: every action in the Files
  panel threw ReferenceError on click. `grep actionsOpen` does not find
  `setActionsOpen` — the capital breaks the match, which is exactly how it got
  through. `test_transport_contracts.py` compares called setters against
  declared ones

- **`node --check` validates syntax, not names.** It caught none of the above.
  Neither can `e2e.py`, which is a second implementation of the client. Source-
  reading tests are weak evidence and are the only evidence available for the
  SPA; prefer ones that re-derive a value from the source over ones that restate
  it

- **A test that models a fix agrees with it by construction.** The first
  buffer-ceiling test transcribed the player's credit loop into a small model and
  passed, while the player it was written for still hung on a phone. The model and
  the fix had the same author and the same misunderstanding. `tests/harness/
  mse_harness.mjs` lifts `bufferedAhead`, `evictBehind`, `flushQueue` and `pump`
  out of `app.js` *as text* and executes them; what it models is the browser. When
  even that was not enough, a headless Chrome driven against real fragmented MP4
  reproduced the defect in one run. Model the environment, never the code under
  test

- **A refresh token that rotates must be stored, or it is spent once.** The hub
  revokes the refresh token presented, returns a replacement, and treats a
  revoked one presented again as theft — revoking the whole family. The SPA kept
  only the access token out of that response, so renewal worked once and the
  second attempt destroyed the session, which is why signing out and in was the
  only cure. Nothing used the path at all: `hubFetch` reported 401 like any
  other error, and watching a film is an hour in which the hub hears nothing,
  because the video is WebRTC. Renew on a margin, on returning to the tab, and
  on a 401 with a replay; coalesce concurrent renewals, or the second presents
  what the first just spent and looks exactly like theft.
  `tests/harness/session_harness.mjs` runs it against a hub that enforces
  rotation — a lax stub would pass the broken client

- **An effect keyed on a value that used to be constant.** The WebRTC dial
  listed `token` among its dependencies. Harmless while a token only ever
  expired; once the session renewed itself the string rotated, and the effect
  tore the connection down and rebuilt it — worst at mount, where a stale token
  is renewed exactly while ICE is negotiating, so the browser abandoned the
  handshake and the node sat in `connecting` for ever. Depend on whether there
  is a token, not which one, and read the live one where it is used. Before
  making something vary that never varied before, grep the dependency arrays it
  appears in — this and the hook-ordering fault above are the same shape: code
  that is correct read on its own and wrong against the component lifecycle

- **A stylesheet does not tell you where anything lands.** The responsive tests
  pinned numbers out of `style.css` and said in their own docstring that a
  layout could not be measured because there was no browser in the suite. There
  is one — Chrome, from the video work — and the difference is the transfers
  panel: `width: 330px` was never the problem, the problem was that it is
  anchored to a button which is not at the right edge of the screen, so it hung
  138 px off the left of a 320 px phone and hid the file names. No reading of
  the rule would have shown that. `tests/harness/layout_probe.py` renders the
  real stylesheet at a given width (in an iframe — a headless window will not go
  below ~500 px) and returns rectangles; one browser measures every width,
  because one apiece put three minutes on the suite. Assert on geometry, and
  check the test fails with the fix removed

- **A hook cannot depend on one declared below it.** `const a = useCallback(fn,
  [b])` evaluates `[b]` where it is written, so a `b` further down the component
  is still in its temporal dead zone: `ReferenceError` on every render, before
  anything the component does can run. The component simply does not appear —
  clicking a video did nothing at all, with no error on screen and nothing in
  the node's log because nothing was ever requested. It reached production.
  `node --check` passes; the code is well-formed. Worse, the MSE harness
  extracted the player functions in a list order of its own and therefore
  *reordered* them, quietly repairing the one class of defect it was best placed
  to catch — it now sorts by position in the file. `test_hook_ordering.py`
  checks the whole SPA

- **Flow-control accounting comes before every early return.** A segment that
  arrived is no longer in flight, whatever is then done with it. Discarding one
  before decrementing the in-flight window leaked a slot per discard, and
  `reinitAt` is asynchronous, so a seek's whole window could arrive while it
  was still awaiting its `updateend`s — the player then believed a full window
  was in flight, granted no further credit, and the node waited for ever while
  logging a stream it had fed perfectly. A race, so it worked twice and hung on
  the third try; "it works now" is not evidence against a race, and
  `tests/harness/window_leak.mjs` forces the worst case instead

- **A new stream starts from a known state, and that list grows.** `appendingRef`
  and `endedRef` were the first two; `awaitingInitRef` and `seekTargetRef`
  repeated the same bug a session later, and worse — only `reinitAt` lowers
  `awaitingInit`, and the next film never reaches it, so every one of its
  segments would have been discarded. Reset at the *start* of the stream, never
  in the teardown of the one before, which is skippable

- **`Cache-Control: no-cache` only binds a browser that asks.** One that cached
  the SPA before that header existed applies heuristic freshness — a fraction of
  the file's age, days for a file dated weeks ago — and does not ask at all. A
  fix can be written, tested, deployed, served and still not be what runs, which
  is indistinguishable from a fix that does not work. The whole module graph is
  now served under `/a/<content-hash>/` so relative imports inherit the prefix and
  no cache can serve yesterday's build or half of each. `test_asset_versioning.py`

- **Redeploying during someone else's test destroys the evidence.** A node deploy
  restarts the daemon, which kills every live WebRTC session — the tester sees
  "transport not connected" caused by nothing they did — and `deploy-node.sh`
  truncates `/tmp/meshbay-node.log`, taking the reproduction with it. Ask before
  deploying while a reproduction is in flight

- **`updateend` fires for `remove()` as well as `appendBuffer()`.** Crediting the
  node from that event paid it for the player's own evictions: every time room was
  made, more was asked for to fill it. Credit now follows the buffer, decided in
  one place, and the append path grants nothing

- **Flow control on a media stream is a window, not a debt.** Granting a credit
  per append means pulling at network speed, which for a film is far faster than
  watching it and fills the browser's SourceBuffer ceiling; accumulating those
  credits and releasing the balance when the buffer finally drains sends the lot
  in one burst and then says nothing for forty-six seconds. Bound the read-ahead
  by *time past the playhead* and top a small window up as segments land. A viewer
  deliberately holding credit must still say so, or the node's stall timeout ends
  a film that is merely paused

- **`create_all()` is not a migration.** It creates missing *tables* and never a
  missing *column*, so a new column reaches the tests (fresh DB every run) and never
  reaches the deployed hub. Symptom: one endpoint answering 500 with an HTML body
  while everything else works, and a `psycopg` `UndefinedColumn` in the journal.
  `deploy-hub.sh` runs `alembic upgrade head` before restarting the service; a schema
  change that skips a migration file will still pass every test you have

- **Some paths only exist in a browser, and only one browser has them.** The
  download-to-disk story is three different mechanisms — File System Access
  (Chrome/Edge), a service worker streaming a response (Firefox/Safari), and a
  blob as the floor — and no test in this repo exercises any of them.
  `test_downloads.py` pins the contracts by reading the source; the behaviour
  needs a person with a large file. Confirmed by the operator on 2026-08-15:
  Firefox, 180 MB, written to disk. Nothing multi-gigabyte has been measured

**Corrections to remember:**
- `punch_nat()` is **not** a NAT traversal stack — one UDP probe, no STUN, no candidate
  gathering, one ISP validated. **ICE/STUN (WebRTC) is the traversal path**, for native
  clients too (via `aiortc` in Python)
- Argon2id 256 MB was applied to the **hub only**; `crypto.py` keystore is still 64 MB
- ~~Sender keys must be distributed pairwise to identity keys, never GEK-derived.~~
  ~~Reversed 2026-09-03: sender keys are distributed GEK-wrapped.~~
  **Sender keys are not what group chat uses at all (decided 2026-09-07, built).**
  Read `docs/chat-sender-keys.md` before touching chat. The reasoning that ended the
  question: once distribution is under the group key *and* the node serves history to
  devices that were not present, the node must retain each chain's **earliest** key, and
  a chain key at iteration *i* yields every message key from *i* on by pure HKDF. Forward
  secrecy is therefore zero either way, and what the ratchet was left buying was a large
  amount of stateful client code with silent failure modes — three of them reproduced:
  any member could sign as any other (`add_sender` accepts any distribution and the
  signing key is bound to nothing), a second device dropped the first's chain, and
  `_skipped_keys` grew without bound. `senderkeys.py` joins `ratchet.py` as "kept for a
  possible future 1:1 DM"; **nothing in production imports it**, and its green tests are
  not evidence that chat is encrypted
- ~~Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC
  path.~~ **Both halves have changed.** Chat: 2026-09-07, **MNP 2.0** — see the row above and
  `docs/chat-sender-keys.md`. **There is no switch**: chat is encrypted, the node refuses
  any message that is not sealed, and a 1.x peer is refused *at the handshake* with
  `version_too_old` rather than admitted and then unable to speak. An opt-in flag was
  proposed and refused — every node is a test node, so it would have bought nothing and
  left a plaintext branch reachable, which is C6's lesson one feature later. Existing node
  data is migrated by `QE/migration/migrate_chat_encryption.py`, node stopped.
  **The index half changed 2026-09-03 (MNP 1.0).** `index_sync`,
  `index_delta` and the `handshake_ack` configuration payload are sealed under a
  GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/
  `openGroup` in `crypto.js`); only `type`, `v`, `group_id` and the ack's own
  `node_pk`/`proof`/`sig` stay in clear, because a receiver must route and
  **authenticate** before it would trust a decryption. Chat is unchanged and out of
  scope by decision. Read `MESHBAY_NODE_PROTOCOL.md` §11.1a before touching either
  message. Three things to keep straight:
  - **The ack line is integrity, not confidentiality.** `handshake_transcript`
    names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the
    rest were authenticated by the DTLS channel alone. The AEAD tag comes from a
    key the hub does not hold
  - **The index line is defence in depth against our own next bug**, of a class
    already shipped twice: C1 (node HTTP API served the index on `0.0.0.0`
    unauthenticated) and C6 (TCP accepted a bare JWT with no GEK proof). It buys
    nothing against an observer, the hub, or a member. That is the whole claim
  - **A payload that does not open ends the session**, never a default: an
    unopenable `enabled_apps` reads as "the operator disabled every app" and an
    unopenable index as "the group is empty", both indistinguishable from
    legitimate states. `index_progress` is deliberately *not* sealed (counters
    only, every 2 s) — the reason lives next to the code, re-read it before
    changing it

## Known calibration TODOs

- Argon2id `memory_cost`: ✅ DONE — bumped to 262144 (256 MB) in pw_version=2.
  Existing v1 users (64 MB) are transparently rehashed on next successful login.
  CLI `calibrate` command still TODO for per-hardware tuning.

## NAT traversal — empirical results

### QUIC native clients (demo-v2)

SFR residential Fedora 44 → meshbay.org OVH VPS:
- **NAT type**: Port-Restricted Cone
- **Mechanism**: `QuicChunkServer.punch_nat()` sends probe from QUIC server socket
- **Scripts**: `QE/demo-v2/`

### WebRTC browser clients (Phase 9 spike, 2026-08-10)

**SFR residential NAT** — Mobile 4G SFR → node behind SFR residential (Port-Restricted Cone + CGNAT 4G):

| Test | ICE path | Result |
|---|---|---|
| WiFi LAN | IPv6 direct | OK, ~100ms |
| 4G + IPv6 | IPv6 inter-network | OK, ~600ms |
| 4G + IPv4 only (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~650ms |

**Orange Livebox NAT** — Firefox/Chrome laptop (SFR) → node behind Orange residential NAT:

| Test | ICE path | Result |
|---|---|---|
| Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms |
| Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms |
| Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms |

- **Two ISPs validated** — SFR + Orange residential NAT, both work without TURN
- **No TURN relay needed** — ICE/STUN handles both NAT types automatically
- **Hub role**: signaling only (SDP/ICE relay via WebSocket, <1 KB)
- **Data path**: browser ↔ node P2P via WebRTC DataChannel
- **Scripts**: `QE/demo-v3/run_node_webrtc.py`, test page at `/webrtc-test.html`

## Key modules — où trouver quoi

| Need | Module | File |
|---|---|---|
| Chunk encryption (prod) | `meshbay_common.crypto` | `crypto.py` |
| Sealing a payload under the GEK | `meshbay_common.groupbox` | `groupbox.py` + `sealGroup`/`openGroup` in `static/crypto.js`. `index_sync`, `index_delta`, `handshake_ack` — one envelope, purpose-separated subkeys, AAD = `"<msg_type>\|<group_id>"`. **Never reuse `chunk_key_aes` with a pseudo-file for this** |
| MNP version range | `meshbay_common.handshake` | `MNP_MIN_SUPPORTED`, `check_version` — read by both servers and both clients |
| Key derivation from password | `meshbay_common.keyderive` | `keyderive.py` |
| Key bundle (web) | `meshbay_common.keyderive` | `keyderive.py` + `static/keyderive.js` |
| GEK wrap/unwrap (ECIES) | `meshbay_common.crypto` | `crypto.py` |
| Double Ratchet (1:1 DM, future) | `meshbay_common.ratchet` | `ratchet.py` |
| Chat encryption (group chat) | `meshbay_common.chatbox` | `chatbox.py` + `sealChat`/`openChat`/`verifyChatSignature` in `static/crypto.js`. One key per group, per epoch, per **device**, derived by name from an epoch key the node generates and delivers wrapped under the GEK — so rotating the GEK is a re-wrap and does not destroy the archive. **No mutable sending state**, which is the C1/§15.0b hazard removed rather than partitioned — and not the same claim as "two devices never share a key", which is false: two clients of one account normally recover the *same* identity key from the keypair bundle, so they share a subkey. Safe because the nonce is 96 random bits and never a counter. Messages are signed over the **ciphertext** with the device's pinned Ed25519 key |
| Chat epochs (node) | `meshbay_node.ops` | `open_chat_epoch` / `ensure_chat_epoch` / `chat_epoch_keys`. Epoch 1 is opened at group load (`daemon._ensure_chat_epoch`) — a group with no epoch is a group nobody can speak in. A new epoch on every removal (member, device, unpin, `gek_rotate`); **old epochs are kept and still delivered**, which is what keeps history readable, and nothing anywhere deletes one. Keys are wrapped to the node's own X25519 key in `bundles.db`, never stored raw |
| ~~Sender Keys (group chat)~~ | `meshbay_common.senderkeys` | **Unused.** Kept for a possible future 1:1 DM, like `ratchet.py` — see the corrections above |
| AES-GCM (browser) | `meshbay_common.webcrypto` | `webcrypto.py` + `static/crypto.js` |
| Node keystore | `meshbay_node.keystore` | `keystore.py` |
| QUIC NAT punch (native) | `meshbay_node.transport.quic_server` | `QuicChunkServer.punch_nat()` |
| WebRTC transport (browser) | `meshbay_node.transport.webrtc_server` | Phase 9.3 — `aiortc` DataChannel |
| WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay |
| Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel |
| Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router. Routing and every page except a group's — see `docs/apps.md` for the group UI's file layout (2026-08-23 split) |
| Group applications (adding one) | `docs/apps.md` | Chat/Files/Videos/Music today (see `docs/mediacenter.md` for Videos: poster grid, TMDB metadata, season tabs, manual match correction; `docs/musicbay.md` for Music: album grid, MusicBrainz metadata, no new streaming path, persistent shell-level player), Photos designed but not built (see `docs/photos.md`: several photo roots per group, one album-grid view, EXIF read locally with no third party). Props contract, enablement mechanism, checklist |
| File download (large) | `static/file-utils.js` | `downloadEntry`/`_openDownloadTarget`, File System Access API (`showSaveFilePicker`) — stream to disk |
| Background tasks (node) | `meshbay_node.transport.webrtc_server` | `_spawn()` — the only way to start one; a bare `ensure_future` can be collected |
| Stream handover (node) | `meshbay_node.transport.webrtc_server` | `_replace_stream` + `shutdown_tasks` — one viewer, one film, and the slot comes back when they leave |
| Download backpressure (node) | `meshbay_node.transport.webrtc_server` | `DOWNLOAD_BUFFER_HIGH` — 8 × 1 MB answered blind queues 8 MB on the channel |
| Streamed download (browser) | `static/downloads.js` + `static/sw.js` | Needs the page *controlled*, and the worker confirms it served the request |
| Leave a group (hub) | `meshbay_hub.api.groups` | `POST /v1/groups/{id}/leave` — self only; the owner is refused |
| Public group cap (hub) | `meshbay_hub.api.groups` | `_check_public_group_quota` — 10 live public groups per owner, staff exempt. **Checked at creation only, because PATCH refuses to change visibility** |
| Uploads on/off (node) | `meshbay_node.roster` + `transport.webrtc_server` | `member_upload_allowed` / `set_member_upload`, gate in `_do_file_upload`. Per group, **operator-signed** (`OP_MEMBER_UPLOAD`), stored in `roster.db`, cached in the group context because the upload path is synchronous. **Absent means allowed** at every layer |
| Node presence (hub) | `meshbay_hub.api.groups` | `node_online` on `/v1/groups/mine`, read from the signaling registry — no poll, no timer |
| Account → device pinning (Tier 2) | `meshbay_node.roster` + `static/transport.js` | `group_devices` relays each live device of each active member **with the countersignature that admitted it** (`add_sig`/`add_nonce`/`add_ts` — kept since 2026-09-07; before that the proof was verified and discarded, which is what blocked this). `group_roster_req/resp` is sealed and answers **any member**. The client walks the chain itself (`_verifyRoster`) — the node decides nothing, because it is the party the property holds against. **Once a client has seen an account, a later key substitution is detected; nothing is gained at first sight** |
| Chat message handling (node) | `meshbay_node.transport.webrtc_server` | `_do_chat_message` + `_check_chat_envelope`. `sender_id` from the session (NS6); the *device* claim is checked against the connection's own `device_hello`, or a member could sign as anyone. Replay refused by a unique `(device, nonce)` in `chat.db` — a replay is a validly signed copy, so nothing about the signature refuses it |
| Which device is on a connection | `meshbay_node.transport.webrtc_server` | `_do_device_hello` (MNP 1.2, additive). The handshake proves the *account*; this proves the *device*. Before it, `_load_pinned_pk` used the account's oldest key and recorded it as the uploader of every file |
| Chat paging (node) | `meshbay_node.chat.store` | `get_recent` / `get_before` / `has_before`. `get_messages` pages *forwards* and is not what a chat opens with |
| Liveness (MNP) | `meshbay_common.protocol` | `PING`/`PONG` on an **already-open** channel; never for discovery — a handshake costs 0.6-7 s |
| Profile page (browser) | `static/app.js` | `ProfilePage` — identity, node link, pins, account deletion. Settings keeps behaviour |
| i18n (browser) | `static/i18n.js` | `t()` lookup + `Intl.PluralRules`, region-aware resolution, localStorage lang selection |
| Translation catalogues | `static/locales/*.js` | One per language, fetched on demand. `en.js` is the source; `test_locales.py` holds the other nine to its key set |
| Admin API (hub) | `meshbay_hub.api.admin` | Phase 10.2 — user/group mgmt, audit logs, stats |
| Admin UI (browser) | `static/app.js` | Phase 10.3–10.4 — AdminPage component, 5 tabs |
| Auth dependencies | `meshbay_hub.api.deps` | `require_admin`, `require_moderator`, `get_current_user`, `require_user_scope` |
| Node auth (hub) | `meshbay_hub.api.nodes` | `POST /v1/nodes/auth` — Ed25519 challenge-response, node-scoped JWT |
| Site overlay | `site/` | Phase 10.1 — landing, about, downloads (meshbay.org-specific) |
| Notifications (hub) | `meshbay_hub.api.notifications` | Phase 10.5 — CRUD, per-user, triggered by admin/group actions |
| Version check (hub) | `meshbay_hub.api.hub` | Phase 10.10 — `GET /v1/hub/version` |
| Group self-service (hub) | `meshbay_hub.api.groups` | Phase 10b — create, join, members (GEK exchange is P2P) |
| File upload (node) | `meshbay_node.transport.webrtc_server` | Phase 10b.4 — FILE_UPLOAD MNP handler |
| GEK wrap AES (browser) | `static/crypto.js` | Phase 10b.2 — AES-256-GCM ECIES for WebCrypto |
| GEK HMAC proof (browser) | `static/crypto.js` | `hmacGEK()` — HMAC-SHA256 with DTLS channel binding |
| DTLS fp extraction (browser) | `static/transport.js` | `_extractDtlsFingerprint()` — SDP fingerprint for channel binding |
| DTLS fp extraction (node) | `meshbay_node.transport.webrtc_server` | `_extract_dtls_fingerprint()` — SDP fingerprint for channel binding |
| Ed25519 sign (browser) | `static/keyderive.js` | `signChallenge()` — admin challenge-response |
| Auth key derivation (browser) | `static/keyderive.js` | `deriveAuthKey()` — password split, hub never sees raw password |
| GEK wrap AES (Python) | `meshbay_common.crypto` | Phase 10b.2 — `wrap_gek_aes()` / `unwrap_gek_aes()` |
| IndexedDB cache (browser) | `static/hub-client.js` | Phase 10b.5 — group index caching, moved out of app.js in the 2026-08-23 split |
| Cross-group search (browser) | `static/app.js` | Phase 10b.6 — SearchPage, client-side |
| MSE video streaming (node) | `meshbay_node.transport.webrtc_server` | Phase 10c — ffmpeg fMP4 remux + encrypted segments |
| MSE video streaming (browser) | `static/video-player.js` | Phase 10c — MediaSource + SourceBuffer progressive playback, moved out of app.js in the 2026-08-23 split |
| Video codec detection | `meshbay_node.transport.webrtc_server` | Phase 10c — `_probe_video()` ffprobe + MSE codec strings |
| Node daemon (production) | `meshbay_node.daemon` | Phase 11 — WebRTC + WS + chat + HTTP + audit all wired |
| Node config | `meshbay_node.config` | `node.toml` loader, `data_dir` for chat/audit DBs |
| Hub WS client | `meshbay_node.hub_client` | `login()` (Ed25519) + `maintain_ws()` + `send_ws()` — no auth_key on node |
| Chat store | `meshbay_node.chat.store` | SQLite per-group, `data_dir/{group_id}/chat.db` |
| Audit store | `meshbay_node.audit` | SQLite IP/action log, `data_dir/audit.db` (legal compliance) |
| Bundle store (node) | `meshbay_node.bundle_store` | SQLite P2P GEK + keypair bundles, `data_dir/bundles.db` — hub never stores crypto |
| P2P bundle exchange (MNP) | `meshbay_common.protocol` | GEK + keypair bundle STORE/FETCH/RESP message types |
| Bundle via DataChannel | `static/transport.js` | GEK + keypair bundle fetch during handshake, store after connect |
| Key persistence (browser) | `static/hub-client.js` | `session.bundleKey` (renamed from the bare `_bundleKey` in the 2026-08-23 split) in IndexedDB, `_sessionKeys` in sessionStorage |
| pkX from private key | `static/transport.js` | `_pkFromSk()` — JWK export to derive X25519 public key |
| Group delete (hub) | `meshbay_hub.api.groups` | `DELETE /v1/groups/{group_id}` — admin only |
| JWT scope enforcement | `meshbay_hub.api.deps` | `require_user_scope` — blocks node-scoped tokens from mutations |
| Operator operations | `meshbay_node.ops` | **One implementation, several front doors.** The loopback API, the CLI and the signed MNP handlers all call these; they take the daemon `state`, raise `OpError`, and know nothing about HTTP. Two implementations of one operation with two authorization checks is C1/C6 one size down |
| Node local control API | `meshbay_node.ui.app` | JSON only, loopback + per-run token (localhost:18000): status, groups/roots, roster, denylist, node settings, peers, audit. Clients: the `meshbay-node` CLI and the desktop client's Node page. Each operation endpoint is one `_op(...)` line. The server-rendered dashboard, the `ui` CLI verb and the never-wired chat/config endpoints were removed 2026-09-01 (`docs/refactor-node-ui.md`) |
| Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) |
| Video flow control (browser) | `static/video-player.js` | `pump()` — the only place credit is granted. Read-ahead bounded by `BUFFER_AHEAD_S` of film, `STREAM_WINDOW` segments in flight, driven by a clock and by playback, never by arriving data |
| Player under test | `tests/harness/mse_harness.mjs` | Runs the real `pump`/`flushQueue`/`evictBehind` against a fake SourceBuffer with a ceiling. Do not write a second model of them |
| Asset versioning (hub) | `meshbay_hub.api.webapp` | `_asset_version()` — content hash; whole module graph served under `/a/<hash>/` so a cache cannot mix two builds |
| Stream capacity (node) | `meshbay_node.config` | `[node] max_concurrent_streams` (default 8) — a slot is held for the length of a film, so it counts simultaneous viewers |
| Stream diagnosis (node) | `webrtc_server.py` | `client_diag` at DEBUG — the player's own view (`ready`, `quota`, `ranges`, `err`) in the node's log. The only window into a phone |
| Stream probe (no browser) | — | `QE/deploy/stream_probe.py` — pulls a real film over real MNP, `--start` to seek. Answers "is it the node or the browser" in one run (not versioned) |
| Seeking (browser) | `static/video-player.js` | `requestSeek` → node restarts ffmpeg with `-ss`; `reinitAt` clears the buffer and sets `timestampOffset`. `-copyts` does *not* preserve position — measured — so the offset comes from the client |
| Seeking (node) | `webrtc_server.py` | `start` on `stream_req`; `-ss` **before** `-i` (index seek, not decode-and-discard), clamped away from the end, echoed in `stream_init` |
| Resume position | `static/video-player.js` | `readResumePosition` / `writeResumePosition` — localStorage, per file, per browser. No protocol, and nothing new learns what you watch |
| Layout, measured | `tests/harness/layout_probe.py` | Renders `style.css` in Chrome at any width and returns bounding boxes. Use it for layout, not `test_layout_responsive.py`, which only pins CSS values |
| Chat scrolling, measured | `tests/harness/chat_scroll_probe.py` | Mounts the real `ChatPanel` in Chrome and reads a conversation back. Answers "can the reader scroll up" and "does the panel resize itself"; `test_chat_scroll_bottom.py` only pins the source's shape |
| Chat sending, measured | `tests/harness/chat_send_probe.py` | Mounts the real `ChatPanel` over the real `MeshBayTransport` (only the DataChannel is a stand-in) and types a message. Answers "does the send come back" — the freeze it was written for lives in the seam between the two, so neither source shows it |
| Group landing tab, measured | `tests/harness/group_tab_probe.py` | Renders the real `GroupPage` against a stub node answering a chosen `enabled_apps`, and reads the tab bar back. The landing tab is picked from a preference at mount; the app list arrives from the handshake later, and the two can disagree |
| Session renewal (browser) | `static/hub-client.js` | `refreshAccessToken` / `ensureFreshToken` — one writer (`setAuth`), one in-flight renewal, rotated refresh token stored. `hubFetch` renews on 401 and replays. Moved out of app.js in the 2026-08-23 split |
| Token lifetimes (hub) | `meshbay_hub.config` | `[jwt] access_token_ttl` 4 h, `refresh_token_ttl` 30 days. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there |

## meshbay.org server (état cible)

- OS: Ubuntu 26.04 LTS, Python 3.14.4
- SSH: `ssh cbesson@meshbay.org`
- Caddy : reverse proxy HTTPS sur 80/443
- UFW rules: **22/tcp, 80/tcp, 443/tcp uniquement**
- Services légitimes : `meshbay-hub.service`, Caddy, PostgreSQL (local)
- Inventaire détaillé : `QE/server-state/meshbay.org.md`
- Deploy hub : voir `QE/server-state/meshbay.org.md`

## new rules, from now
Documents and demo/comments are written in english unless requested in french.

No copyrighted names. Never put real brand names, trademarks, artist names, or
copyrighted titles (film titles, show titles, song/artist names, character
names, release-group handles) into code, comments, docstrings, test fixtures,
documentation, or commit messages — even when the bug being fixed or documented
was genuinely found against real content with real names. Describe the *shape*
instead ("a franchise-origin film", "a two-part saga", "a 3-season show") and
use invented placeholders in fixtures ("Some Saga", "A Different Show"). This
holds for every file, including throwaway test data and one-line commit
subjects.