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
963
964
965
966
967
968
969
970
971
972
|
# MeshBay — Project Conventions
## What this project is
MeshBay is a platform for **private, encrypted, self-hosted groups with an
application store** — a group is a set of people, a set of directories on
somebody's machine, and a set of applications over them (chat, files, video,
music, photos). It is not a public file-sharing network; public groups are an
optional hub feature and are off on the reference deployment.
**`docs/MESHBAY_DESIGN.md` is the architecture specification.**
`docs/MESHBAY_NODE_PROTOCOL.md` is the wire format. Everything else under
`docs/` is either an operational guide, or a superseded document kept for its
cross-references and carrying a banner that says so.
**`docs/QUICKSTART.md` and `docs/USERGUIDE.md` are the user documentation** —
written for somebody who is not a developer, Linux and the browser only. They
name CLI verbs, default values, setting names and interface labels, so a change
to any of those is a change to them: the previous user guide was retired
because it had drifted past the point where a reader could tell which half was
still true. What keeps them honest is that they state what is *not* built as
readily as what is (§15.2, §15.3); a feature that lands deletes its line there.
## 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[dev]" -e "packages/meshbay-hub[dev]" \
-e "packages/meshbay-node[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`.
> **L'extra `[dev]`, pas une liste à la main.** Cette ligne a longtemps été
> `pip install pytest pytest-asyncio aiosqlite`, ce qui contredisait la règle
> ci-dessus et coûtait trois choses invisibles. Sans `piexif`,
> `test_enrich_photo.py` échoue à l'import et **toute la suite du nœud s'arrête à
> la collecte**. Sans `pytest-timeout`, le `timeout = 60` du `pyproject.toml` est
> une option inconnue : la protection que son propre commentaire décrit — « un
> test bloqué ne doit pas coincer le run » — n'existe pas, et pytest ne le dit que
> dans un warning parmi d'autres. Sans `ruff`, le linter du projet n'est pas là.
> `aiosqlite` n'avait pas à être cité : c'est une dépendance de runtime du hub et
> du nœud, déjà installée par les `-e`.
> **Ne jamais copier `.venv/` entre machines d'OS différents.** Si rsync depuis Fedora vers Ubuntu,
> exclure `.venv/` et recréer sur la cible avec `python3 -m venv .venv --clear`.
> Sans `--clear`, `certifi.where()` pointe vers un chemin Fedora inexistant sur Ubuntu → `FileNotFoundError`.
```bash
# Lancer les tests
.venv/bin/pytest
```
## Code conventions
- **Linter/formatter:** ruff (`uv run ruff check .` / `uv run ruff format .`)
- **Line length:** 100
- **Type hints:** required on all public functions
- **Comments:** only when the WHY is non-obvious; no docstrings restating the function name
- **No prints in library code** — use `logging` module
## Versioning
### Package versions (SemVer)
- Format: `MAJOR.MINOR.PATCH`
- Pre-1.0: breaking changes bump MINOR, not MAJOR
- All three packages share the same version number (released together)
### Protocol versions (independent)
MNP and MHP version independently of the package version, and both peers declare
`v` and `v_min` on the handshake so a mismatch is a stated refusal rather than a
field that turns up missing. **The rules, the current values and the history of
what each bump changed are `docs/MESHBAY_DESIGN.md` §5.6 and
`docs/MESHBAY_NODE_PROTOCOL.md` §13** — kept in one place because a version list
maintained in two drifts, and this copy did.
The one rule worth repeating where changes get made: **a change to what a peer
must be able to *do* is MAJOR even when the messages are additive**, and there is
no opt-in compatibility switch — that leaves the old branch reachable on every
node, which is finding C6 one feature later.
## Commit messages (Conventional Commits)
```
feat(node): add directory watcher with watchdog
fix(hub): include jti in all JWT tokens
chore(common): add Argon2id calibration to crypto.py
docs: update draft v3 with POC findings
test(common): add wrap/unwrap GEK round-trip test
```
Types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `perf`
Scope: `hub`, `node`, `common`, or omitted for cross-cutting
## Security rules
- **Never commit private keys** (hub_private.pem, *.key, unlock.key, keystore.enc)
- **Never commit QE/** — credentials, test keys, demo data go there
- **Never log GEK, private keys, or plaintext passwords** — even at DEBUG level
- **meshbay.org is internet-facing** — open port → test → close port + kill processes in same block
## Design, security findings and protocol — one document
**`docs/MESHBAY_DESIGN.md` is the specification.** Everything that used to be
summarised here — the three security reviews, node sovereignty, the invite
redesign, per-node identity, the keypair-bundle KDF, the desktop client, the
content model — lives there, stated as the rule it is rather than the incident
that produced it.
| Looking for | Read |
|---|---|
| What a label means (`C1`, `H3`, `NS6`, `T3`, `C5b`, `W2`, `E9`, `F1`, `AV4`, …) | `docs/MESHBAY_DESIGN.md` §13 |
| What one member can cost the others (`AV1`–`AV26`) | §13.5b — the newest category, and the one the first three reviews had no question for |
| Trust model, and what the project may and may not claim | §2 |
| Identity, devices, admission, recovery, the keypair bundle | §3 |
| Cryptography, key hierarchy, the group and chat envelopes | §4 |
| The protocol: handshake, authorization, signed ops, leases, versioning | §5, and `docs/MESHBAY_NODE_PROTOCOL.md` for the wire format |
| The node, the hub, the clients, the applications | §6, §7, §8, §9 |
| Structural decisions that are not revisited | §14 |
| What is built, what is not, what is open | §15 |
| A reference to a document that no longer exists (`draft-v5 §5.2`, `apps.md §3`, …) — in git history, or in a document outside this repository | §16, the concordance — it maps every one onto its replacement section. The code itself cites `MESHBAY_DESIGN.md` and a section directly |
The documents under `docs/` that this replaced are kept and carry a banner
saying where their content went. **Where any of them disagrees with
`MESHBAY_DESIGN.md`, the design document is right; where either disagrees with
the code, the code is.**
### Working rules that live here, not there
These are about working on the tree rather than about the design:
- **The SPA served in production may be older than this tree.** Check the served
`/a/<hash>/` against `meshbay_hub.api.webapp.ASSET_V` before concluding a fix
is missing. `site/` and the Caddy config have never been deployed
- **A sandbox can refuse a path without saying so, and the browser blames
something else.** `test_sticky_header.py[firefox]` failed with twelve setup
errors whenever the developer's own Firefox was open, and the note that used
to be here said to close it and trust the `[chrome]` half meanwhile. The
cause was not snap's single instance. The harness pointed `HOME` at a
throwaway directory to isolate the profile; snapd sets its own `HOME` inside
the sandbox, so that directory came back **empty on every run** — the tell,
and it was there all along — and Firefox opened the developer's real profile,
which their browser had locked. `--profile` did not help either, for two more
sandbox rules: the snap has a private `/tmp`, so a directory made there is
not the one it sees ("Could not find profile folder"), and its `home`
interface grants no *hidden* directory, so `~/.cache` is refused too. Both
refusals surface as **"Firefox is already running, but is not responding" —
which it also prints when nothing is running at all**, so the message names
neither the real cause nor even the right question. A plain directory under
`$HOME` (the probe prefers `~/snap/firefox/common`) fixes it: 24 passed in
15 s with a Firefox open throughout, against 12 errors in 60 s before. The
rule worth keeping: **when a sandboxed program cannot see a path, find out
what it can see before believing what it says** — and an isolation directory
that stays empty is not isolating anything
- **One UI source.** `packages/meshbay-hub/src/meshbay_hub/static/` is the
interface, for the web and the app alike; `packages/meshbay-client/scripts/
sync-ui.js` copies it (`npm run sync-ui`) and CI fails if the copy drifts —
**never edit `packages/meshbay-client/ui/` by hand**
- **`%(here)s` in `alembic.ini` makes a copy of it correct only where it was
copied from.** The packaged unit ran `alembic -c
/opt/meshbay-hub/migrations/alembic.ini`, a file the build did stage — so the
path existed and the *contents* were wrong: `script_location` resolved to
`…/migrations/src/meshbay_hub/db/migrations`, which nothing installs, because
the migrations ship inside `meshbay_hub` in the shared venv. `ExecStartPre`
failing stops the unit, so **a hub installed from the RPM or the DEB could
not start at all**, and nothing noticed because the one live deployment was
assembled by hand. The same trap had already been found on the server, where
a stray `alembic.ini` pointed at a month-old snapshot. Twice is a trap: the
path is not written down anywhere now — `meshbay-hub migrate` asks the
installed package where its own migrations are, which is right for a
package, a venv and a checkout alike
- **A `DEBIAN/control` file has no comment syntax.** `dpkg-deb --build` refuses
the whole package — `field name '#' must be followed by colon` — so a `#` line
explaining why a dependency is weak turns an installable package into no
package at all. The `.spec` files take comments and the control files do not;
the reason for a `Recommends` goes in `packaging/README.md`. Found by building
a throwaway package, which takes seconds and is the only way to find it: no
test in the tree parses these files
- **`node --check` reports success on a module-syntax error, and a green one
cost a full suite run.** An unclosed `.map(` inside a tagged template came
back clean four times in a row. `test_spa_syntax.py` says this in its own
docstring — copying to `.mjs` first is what forces the module parser — and
it is still the reflex that reaches for `node --check`. Use
`node --input-type=module --eval "$(cat file.js)"`, or just run that test
- **A test fixture narrower than production tests the fixture — and when it
writes down *why* it has to be narrow, that is a bug report nobody filed.**
`test_federation.py` built its MHP envelopes by hand without an `aud`, and
`test_public_groups_toggle.py` signed its own token with a comment saying
`_issue_mhp_token` "binds `_hub_sk_pem` at import time, before the lifespan
loads it, so it cannot be used from a test", and another saying PyJWT
rejects a token carrying `aud` when decode is given none. Both observations
were exactly right. Between them, MHP could not complete a single
authenticated request between two real hubs, and the hub announced itself as
`meshbay.org` whatever it was configured as — for a month, with the suite
green. When a test has to route around the code to run, the thing it routed
around is the finding. Federation's tokens now come from the real issuer
- **A global's state is not a given, in a test least of all.** One assertion —
that a refusal never logs the address — took three attempts: `caplog` saw
nothing because the app configures logging, then a handler on the module's
own logger saw nothing because an earlier test had raised its level, then
because `logger.disabled` was left True. Each version passed alone and
failed in the full run. Pin what you depend on (level, `.disabled`,
`logging.disable`) or assert on a value instead of on a side effect
- **A blocking call in an async handler is the whole instance's problem.**
`mail._send` is `smtplib` with a ten-second timeout, and it was called
straight from four handlers: while the MTA thought about it, nothing else was
served, no node socket read, no offer relayed. It has no symptom a test
catches — everything works, slowly, for everyone, whenever the mail server is
having a bad day. `mail.send_off_loop` is the door, and
`test_no_mail_is_sent_from_the_event_loop` reads the source for direct calls,
because there is nothing else to read. **Argon2 is the same, with a trap
under it**: one derivation is 64 MiB and ~0.1 s on meshbay.org (0.45 s at the
256 MB of `pw_version` 3, still paid once per old account), so it goes through
`auth.hash_password_off_loop` / `verify_password_off_loop` — but **two
concurrent `lanes=4` derivations deadlock inside OpenSSL** and never return,
at no CPU (cryptography 50.0.x, OpenSSL 4.0.x, reproduced locally and on
meshbay.org). Inline on the loop they could never overlap, which hid it. The
executor has **exactly one worker**, not a semaphore around `to_thread`: a
cancelled request releases a permit while its thread is still deriving.
`test_argon2_off_loop.py` holds both halves
- **Before adding an endpoint or a message, ask who pays.** A participant
supplies input; if anyone other than the sender bears the cost, there is a
ceiling to write, and it goes on **every** path that writes the state — the
gap between `_authorize_node_ws` and `update_groups` was one message wide.
`docs/MESHBAY_DESIGN.md` §13.5b is the register; `test_availability_between_
members.py` is where a new case goes, and every test in it is two accounts,
because a one-member test proves a one-member property
- **Never change the KDF parameters in one place.** `keyderive.js`,
`keyderive.py`, the QE harness and `test_bundle_kdf_parity.py` are held
byte-identical by that test, and a mismatch does not look like an error — it
looks like an account nobody can open
- **Raw answer SDP is saved before `setRemoteDescription`** — Chrome strips
sha-256 from a multi-hash SDP, and the fingerprint is the channel binding
- **Upload chunk size is 48 KB**, which is what fits the aiortc SCTP limit after
msgpack overhead
- Throwaway `e2e*` accounts accumulate on the production hub; the operator
deletes them
## Engineering lessons
Each of these cost real time to find. They are kept because they are not
deducible from the design — they are what the code and the platforms actually
do. Read them before writing anything that touches the same mechanism.
- **A content-addressed index cannot represent the same bytes at two paths.**
`GroupIndex` is keyed by blake3, so `clip.mp4` at a root and in `uploads/` with
identical content is **one** entry — which is also why a scan can report ten
files and index nine. Reconciliation compares *paths*, so it decided the
second path was a missed event every 60 s, rewrote the entry, bumped the
version and pushed an index update to every connected peer. Found by watching
a live node, not by a test. Anything comparing disk against index must check
the id, not the path
- **A CLI branch nobody has run is not covered by anything.** `reload` shipped
with `subprocess` unimported and crashed on first use; the module compiles
fine, which is the same "syntax, not names" trap already recorded for the SPA.
`test_cli_dispatch.py` walks every verb with the daemon stubbed, and refuses
to let a verb be added to the parser without an entry there. It also stubs
`os.kill` — the first version of that test SIGHUPed the developer's own
running node
- **Device linking is live (2026-08-18).** `identities` is keyed by
`(user_id, pk_ed25519)`, so one account holds several devices on a node; the
migration rebuilds the table and preserves existing pins. A new device files a
request bound by `sha256(code ‖ its own keys)`, and a key the node already
pinned countersigns it — the hub holds no user keys and so cannot. **The code
never reaches the node**: it lists pending requests with their hashes and the
approver recomputes to find the match, which is what makes a substituted key
impossible rather than merely detectable. `member unpin` still removes every
device; `revoke_device` marks one, because a deleted row is a key the node
would happily pin again
- **`user_devices` on the hub is not the key directory that was H3.** Nothing
reads it but the hub, nothing wraps a group key for it, and it is a *different*
key from the per-node identities. What it does cost is metadata: the hub now
knows how many devices an account has and when each last signed in
- **The desktop client runs** (2026-08-18, Electron 42 / Chromium 148 under
xvfb). Build needs Node ≥ 22 — **Ubuntu 24.04's nodejs 18 cannot install
Electron at all**, its download script `require()`s an ESM module. Node 24 LTS
lives in `/opt/nodejs`, fetched and checksum-verified against nodejs.org.
`test_desktop_shell.py` pins the security contract by reading the source, and
that is still weak evidence — it catches a property being removed. Launching it
is what found the three things below
- **Three things only launching it could find.** (1) A CSP in a `<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 native `confirm()` or `alert()` breaks typing in the desktop client.**
Once the dialog closes `document.hasFocus()` stays false: a click still moves
`activeElement` into a field, and every keystroke goes nowhere until the
window loses and regains focus. It was reported as "the Create group fields are
frozen" after removing a member, and reproduced with real X input under Xvfb.
Events dispatched in the DOM cannot see it, because the focus is lost below
the page. `ask.js` draws both in the page, and
`test_no_native_dialogs_in_the_spa.py` bans all three browser dialogs
- **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. `packages/meshbay-hub/tests/harness/
scroll_probe.py` measures the document against the window and runs the real
`fit()` lifted out of `app.js` — the sizing code is never reimplemented in a
test, or the test outlives the code it was written for
- **A handler bound to the event its own writes produce.** The chat panel's
`fit()` set a height, read `documentElement.scrollHeight` back and subtracted
the overflow — so the document alternately did and did not overflow the
window, the page scrollbar appeared and vanished with it, and `visualViewport`
fired `resize` at every pass. `fit()` listens to that event: it re-entered
itself ~120 times a second for the life of the panel (measured: 240 firings in
2 s on an idle page, against 2 for a bare document). Each pass re-pinned the
list to the bottom, which undid every attempt to scroll up **inside the frame
it happened in** — before the `scroll` event that would have recorded it was
delivered, so `atBottomRef` never went false, no jump button appeared, and the
older messages were unreachable. Every pin in that file reads as correctly
guarded; the reader simply never got to stop being at the bottom. Mutate-then-
measure is a loop wherever layout can raise the event you are handling: learn
the correction once and write nothing in the steady state. And a scroll
position that must survive a gesture has to be released **by the gesture**
(`wheel`/`touchmove`/`pointerdown`), never by the `scroll` event alone
- **A reply that names nothing is routed by luck.** The node answers a chat
message with a bare `{"type": "ack"}` — no request id, no type of its own —
so `_dispatch` had nothing to match it on and fell through to its
arrival-order guess, which hands a reply to whichever request happens to be
oldest. That is wrong the moment anything else this browser asked for is
still waiting, and one *is*: the node refuses an unknown `file_id` with a
bare `error`, which names no request either and so reaches none, leaving the
Videos tab's `media_meta_req` in `_pending` for the full 30 s. The ack went
to that, the send waited out its own timeout, and because the composer is
disabled while a send is in flight, **typing a message froze the Chat tab**:
no click, no keystroke, no message — and the message there all along on the
next visit to the tab, since the node had stored it and answered. Every line
of `chat-app.js` is correct and every routed message in `transport.js` is
routed correctly; the defect is in the seam, which is why
`packages/meshbay-hub/tests/harness/chat_send_probe.py` drives the two together. `ack` was matched
by request type (`chat_msg`, or the keypair-bundle store/delete that name
themselves in `detail`), and that closed the instance — **but it left the
class open, and it came back on 2026-09-06 through the other door.** A
refusal has no type of its own to key on: `_dispatch_message`'s catch-all
answers every unforeseen failure with `{"type": "error", "detail": "Request
failed"}`, and 238 of `webrtc_server.py`'s 240 error sends name nothing
either. So a chat send the node refused was routed by luck all over again —
same frozen composer, same 30 s, and rare enough (it needs an older request
still waiting, which a `music_meta_req` behind a failing third-party lookup
supplies for over a hundred seconds) to look like once every couple of days.
The keys were never the fix, only a workaround for a protocol that carried no
correlation id at all: `_seqId` existed, indexed `_pending`, and was never put
on the wire. It is now (`req_id`, see `protocol.py`) — the node stamps it on
the reply from `_send`, via a ContextVar so a handler's spawned work still
answers under the right id, and never on a broadcast, which answers nothing.
With that, the arrival-order fallback is gone for any node that stamps.
The lesson is not "key the replies": it is that **matching by arrival order
is a guess that fails silently and asymmetrically** — the victim is never
the request that was answered wrongly, it is the unrelated one that now
waits for a reply already delivered elsewhere. A reply needs an identifier
the protocol guarantees, not a field it happens to have
- **One errand, two halves, and the first one's refusal cancelled the second.**
Removing a member is the node half (stop wrapping the group key) then the hub
half (stop them reaching the node at all), in that order because it fails
safe. The node call was not guarded, so anything it refused ended the whole
callback — and it refused the commonest case there is: a person invited to
the wrong group has no member row on the node, only an unredeemed code, so
`set_status` matched nothing and answered "no such member in that group".
The removal never reached the hub. They stayed a member in the list (the hub
answers it), in the owner's other sessions and in the administrator's view,
the code emailed to them stayed live for its seven days, and the interface
offered no second way to take back either — the same button, every time,
reporting that the person it was listing was not there. Two rules, and the
second is the one that generalises: **a half that only ever removes access is
not the half to skip when the other is in doubt** — report the refusal after
doing what can be done, never instead of it; and **an operation that is
refused for the state it was invented to fix is not a refusal, it is a
missing case**. `test_member_removal.py` holds the seam, and the node tests
hold the case
- **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.
**It came back on 2026-09-19, one table along, and the fixture hid most of
it again.** `.admin-table` overflowed a 360px window by 11px — from its
*column heads*, because `white-space: nowrap` met a translated string:
"Username" is one word and "Nom d'utilisateur" is three, uppercased with
letter-spacing on top. **A label's length is not yours to assume once it goes
through `t()`**, and a head that wraps onto two lines is legible where a
table the reader cannot reach the right of is not. The 11px was the whole of
what could be measured, because the probe served no members at all: an empty
table measures its headers and nothing else. With one realistic username in
it — no spaces to break at, a string somebody else chose — the same page
overflowed by **248px**, and at 420px-wide windows as well as 360. Same
lesson as the paragraph above, twice now: the wrapping rule belongs on every
cell that carries a name, and the fixture has to carry the names real data
has or it measures itself.
- **Derived data that nothing persists is re-derived where somebody is
watching.** `video_meta`, `photo_meta` and `thumbs` were durable; the audio
tags were not, so every node start re-read every audio file it serves and,
until that pass landed, served an index with no artist on any track — an
index the Music app cannot group. Nobody noticed for months because the
re-read is cheap and the node is usually up. `media_cache.audio_meta` holds
it now: the pass over a real 6176-file library costs 27.8s cold and 6.4s
cached. **What goes in such a cache is what the file's bytes decided, never
the field as the app finally sees it** — `display_title`, `track_no`, artist
and album can also come from the filename and the folder, and a rename has to
re-derive those; storing the resolved field would hand a renamed file the old
name's answer, which is the fault `_reenrich_renamed_audio_entries` exists to
prevent. The sibling-cover scan stays live for the mirror-image reason: it
reads the *folder*, so caching "no cover" would make a cover dropped in
afterwards unfindable. And a read that *failed* is not an answer — caching
"this file says nothing" for a drive that did not respond makes one bad read
permanent
- **A view that draws one shape of unit, beside an emptiness test that counts
every shape.** The Music grid's unit is an album; a track whose artist tag is
empty belongs to none, so it was drawn nowhere — while `empty` counted it and
therefore stayed false. Neither "no music" nor anything else appeared: a
toolbar over a blank page, with every track one mode-switch away and nothing
saying so. Both halves read as correct alone, which is why no amount of
re-reading the file found it; what found it was rendering the component
against entries with no artist and looking (`music_untagged_probe.py`).
**Whenever a mode filters the units it draws, the emptiness test has to be
asked of that mode, not of the library** — or the two disagree and the
disagreement is silent. Fixed by giving the untagged pile a card of its own,
the same shape the singleton folding already minted, so the two agree by
construction rather than by a second condition someone has to maintain. That
card also stopped a cover lookup going out for a release name the browser
itself wrote: a third-party request, on the operator's connection, that
cannot match anything, seen in a live log naming a placeholder as both artist
and release
- **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
### More of the same, from four rounds of live testing
- **A protocol harness cannot test the SPA.** Any second implementation of the
client is written in the right order by construction, so it proves the protocol
and nothing about `app.js`. Three ordering bugs passed one and failed in a
browser. `test_spa_ordering.py` exists for that class and is worth extending
- **An unbounded `await` on the hub socket makes a node silently unreachable.**
Three instances found in `maintain_ws`: the offer handler awaited inside the read
loop, `ws.recv()` for auth with no timeout, and `return` on auth refusal ending
the task for good. Symptom is always the same — daemon running, logging nothing,
`connected_nodes: 0`, socket in CLOSE-WAIT. Look there first
- **A background task nobody holds can be collected mid-flight.** asyncio keeps
only a *weak* reference to a task, so `asyncio.ensure_future(coro)` with the
result thrown away may be garbage-collected while still running — the loop
logs "Task was destroyed but it is pending!" and nothing else happens. For
`_stream_video` that meant its `async with sem` never reached `__aexit__` and
a transcode slot was lost for good. `WebRTCPeerSession._spawn()` exists for
this; never call `ensure_future` there directly, and `test_task_lifetime.py`
fails the build if anyone does
- **`await proc.wait()` after `kill()` still deadlocks on a full pipe.** ffmpeg
outruns a credit-paced viewer; stop reading its stdout — which is what closing
the player does — and the transport cannot finish closing, SIGKILL or not.
Measured 2026-08-16: closing a viewer after 99 segments held a slot past the
15 s handover timeout, so the next video hung and the one after was refused.
Drain the pipes, then wait with a timeout, and release the slot regardless
- **Losing a peer must *stop* its work, not merely forget it.** The WebRTC
`connectionstatechange` handler popped the session from a dict and nothing
else, so a closed tab went on transcoding for the full 120 s credit timeout.
Anything holding a resource needs `shutdown_tasks()` on the way out
- **A service worker being *active* is not the page being *controlled*.** An
uncontrolled page's requests never reach the worker's fetch handler, so the
streamed-download path handed over its stream and was never asked for it —
`writer.write()` then blocks on backpressure that will never lift, and the
download freezes after exactly one chunk. Require
`navigator.serviceWorker.controller`, and have the worker confirm it actually
served the request before trusting the sink
- **A removed `useState` leaves its setter behind and nothing complains.**
`setActionsOpen` outlived `actionsOpen` and shipped: every action in the Files
panel threw ReferenceError on click. `grep actionsOpen` does not find
`setActionsOpen` — the capital breaks the match, which is exactly how it got
through. `test_transport_contracts.py` compares called setters against
declared ones
- **`node --check` validates syntax, not names.** It caught none of the above.
Neither can `e2e.py`, which is a second implementation of the client. Source-
reading tests are weak evidence and are the only evidence available for the
SPA; prefer ones that re-derive a value from the source over ones that restate
it
- **A test that models a fix agrees with it by construction.** The first
buffer-ceiling test transcribed the player's credit loop into a small model and
passed, while the player it was written for still hung on a phone. The model and
the fix had the same author and the same misunderstanding. `packages/meshbay-hub/tests/harness/
mse_harness.mjs` lifts `bufferedAhead`, `evictBehind`, `flushQueue` and `pump`
out of `app.js` *as text* and executes them; what it models is the browser. When
even that was not enough, a headless Chrome driven against real fragmented MP4
reproduced the defect in one run. Model the environment, never the code under
test
- **A refresh token that rotates must be stored, or it is spent once.** The hub
revokes the refresh token presented, returns a replacement, and treats a
revoked one presented again as theft — revoking the whole family. The SPA kept
only the access token out of that response, so renewal worked once and the
second attempt destroyed the session, which is why signing out and in was the
only cure. Nothing used the path at all: `hubFetch` reported 401 like any
other error, and watching a film is an hour in which the hub hears nothing,
because the video is WebRTC. Renew on a margin, on returning to the tab, and
on a 401 with a replay; coalesce concurrent renewals, or the second presents
what the first just spent and looks exactly like theft.
`packages/meshbay-hub/tests/harness/session_harness.mjs` runs it against a hub that enforces
rotation — a lax stub would pass the broken client
- **An effect keyed on a value that used to be constant.** The WebRTC dial
listed `token` among its dependencies. Harmless while a token only ever
expired; once the session renewed itself the string rotated, and the effect
tore the connection down and rebuilt it — worst at mount, where a stale token
is renewed exactly while ICE is negotiating, so the browser abandoned the
handshake and the node sat in `connecting` for ever. Depend on whether there
is a token, not which one, and read the live one where it is used. Before
making something vary that never varied before, grep the dependency arrays it
appears in — this and the hook-ordering fault above are the same shape: code
that is correct read on its own and wrong against the component lifecycle
- **A stylesheet does not tell you where anything lands.** The responsive tests
pinned numbers out of `style.css` and said in their own docstring that a
layout could not be measured because there was no browser in the suite. There
is one — Chrome, from the video work — and the difference is the transfers
panel: `width: 330px` was never the problem, the problem was that it is
anchored to a button which is not at the right edge of the screen, so it hung
138 px off the left of a 320 px phone and hid the file names. No reading of
the rule would have shown that. `packages/meshbay-hub/tests/harness/layout_probe.py` renders the
real stylesheet at a given width (in an iframe — a headless window will not go
below ~500 px) and returns rectangles; one browser measures every width,
because one apiece put three minutes on the suite. Assert on geometry, and
check the test fails with the fix removed
- **A hook cannot depend on one declared below it.** `const a = useCallback(fn,
[b])` evaluates `[b]` where it is written, so a `b` further down the component
is still in its temporal dead zone: `ReferenceError` on every render, before
anything the component does can run. The component simply does not appear —
clicking a video did nothing at all, with no error on screen and nothing in
the node's log because nothing was ever requested. It reached production.
`node --check` passes; the code is well-formed. Worse, the MSE harness
extracted the player functions in a list order of its own and therefore
*reordered* them, quietly repairing the one class of defect it was best placed
to catch — it now sorts by position in the file. `test_hook_ordering.py`
checks the whole SPA
- **Flow-control accounting comes before every early return.** A segment that
arrived is no longer in flight, whatever is then done with it. Discarding one
before decrementing the in-flight window leaked a slot per discard, and
`reinitAt` is asynchronous, so a seek's whole window could arrive while it
was still awaiting its `updateend`s — the player then believed a full window
was in flight, granted no further credit, and the node waited for ever while
logging a stream it had fed perfectly. A race, so it worked twice and hung on
the third try; "it works now" is not evidence against a race, and
`packages/meshbay-hub/tests/harness/window_leak.mjs` forces the worst case instead
- **A new stream starts from a known state, and that list grows.** `appendingRef`
and `endedRef` were the first two; `awaitingInitRef` and `seekTargetRef`
repeated the same bug a session later, and worse — only `reinitAt` lowers
`awaitingInit`, and the next film never reaches it, so every one of its
segments would have been discarded. Reset at the *start* of the stream, never
in the teardown of the one before, which is skippable
- **`Cache-Control: no-cache` only binds a browser that asks.** One that cached
the SPA before that header existed applies heuristic freshness — a fraction of
the file's age, days for a file dated weeks ago — and does not ask at all. A
fix can be written, tested, deployed, served and still not be what runs, which
is indistinguishable from a fix that does not work. The whole module graph is
now served under `/a/<content-hash>/` so relative imports inherit the prefix and
no cache can serve yesterday's build or half of each. The hash covers every file
under `static/`: it once covered a hand-kept list of top-level modules, the
catalogues were not on it, and a heading rewritten only in `en.js` stayed on a
phone after the deploy — immutable for a year at a URL that had not moved.
`test_asset_versioning.py`
- **Redeploying during someone else's test destroys the evidence.** A node deploy
restarts the daemon, which kills every live WebRTC session — the tester sees
"transport not connected" caused by nothing they did — and `deploy-node.sh`
truncates `/tmp/meshbay-node.log`, taking the reproduction with it. Ask before
deploying while a reproduction is in flight
- **`updateend` fires for `remove()` as well as `appendBuffer()`.** Crediting the
node from that event paid it for the player's own evictions: every time room was
made, more was asked for to fill it. Credit now follows the buffer, decided in
one place, and the append path grants nothing
- **Flow control on a media stream is a window, not a debt.** Granting a credit
per append means pulling at network speed, which for a film is far faster than
watching it and fills the browser's SourceBuffer ceiling; accumulating those
credits and releasing the balance when the buffer finally drains sends the lot
in one burst and then says nothing for forty-six seconds. Bound the read-ahead
by *time past the playhead* and top a small window up as segments land. A viewer
deliberately holding credit must still say so, or the node's stall timeout ends
a film that is merely paused
- **`create_all()` is not a migration.** It creates missing *tables* and never a
missing *column*, so a new column reaches the tests (fresh DB every run) and never
reaches the deployed hub. Symptom: one endpoint answering 500 with an HTML body
while everything else works, and a `psycopg` `UndefinedColumn` in the journal.
`deploy-hub.sh` runs `alembic upgrade head` before restarting the service; a schema
change that skips a migration file used to pass every test there was.
**`test_migrations_reach_head.py` is the one that does not**: it upgrades to head
on SQLite and compares what that built against `Base.metadata`, in both
directions. It could not have existed before 2026-09-13, because
`add_email_verification` wrote PostgreSQL's `(now() at time zone 'utc')` as a
literal server default — a syntax error on SQLite — so the chain could not reach
head on the only database the suite has, and the single test that ran alembic
stopped at the revision before it. The two newest migrations had therefore been
run by exactly one thing: a production deploy. **A server default goes in
`sa.func.now()`**, which the dialect running it renders; a dialect's own SQL
written out by hand is a migration that only one database can apply. What this
still does not check is PostgreSQL-only behaviour — running the chain somewhere
beats running it nowhere and is not the same as running it where it ships
- **Some paths only exist in a browser, and only one browser has them.** The
download-to-disk story is three different mechanisms — File System Access
(Chrome/Edge), a service worker streaming a response (Firefox/Safari), and a
blob as the floor — and no test in this repo exercises any of them.
`test_downloads.py` pins the contracts by reading the source; the behaviour
needs a person with a large file. Confirmed by the operator on 2026-08-15:
Firefox, 180 MB, written to disk. Nothing multi-gigabyte has been measured
- **An empty claim read as a claim on everything, and a list taken as a
ranking.** Two defaults, in two codebases, a month old and harmless
separately. The hub resolved a node's group claim with `set(claimed_groups or
authorized)`, and the node omitted `group_ids` entirely when it hosted
nothing — so "I host no groups" arrived as "I host all of this account's
groups", other members' included. Such a node can serve none of them: it has
no GEK, and its own handshake refuses them. But `/v1/groups/{id}/nodes`
answers in **registration order**, `_node_groups` is in-memory, and
GroupPage took `nodes[0]` with no fallback — so the group opened or did not
depending on who reconnected first after a hub restart. On 2026-09-11 a hub
deploy at 20:14 did exactly that: this node needed 16 s to come back (502
while the hub restarts, then its access token had expired during the outage,
so a refusal and two 5 s backoffs), a second member's unconfigured node won
the race, and a group went dark for everyone with its only real host online
the whole time. **A member could take any of their groups down, by accident,
by leaving an empty node running.**
Four things worth keeping. The node's own logs and audit database were the
evidence that cleared it — every handshake OK, no refusal recorded since
August — which is what said the refusal came from somewhere else entirely;
believing the error message names the machine you are standing on is how an
hour goes. `git log -S` on the three suspect lines dated them all to August,
which is what turned "what did I just break" into "what changed around it":
the answer was a restart, not a commit. The read-only probe that settled it
is four lines — the node's own keystore, a node-scoped token, `/v1/groups/mine`
and `/v1/groups/{id}/nodes` — and node-scoped tokens are accepted by
`get_current_user`, so a node can ask the hub what the hub thinks of it.
And the rule under all of it: **a falsy empty collection must never mean
"unspecified"** — `if gids:` and `or authorized` are the same mistake written
twice, and the ceiling that made C2 hold on connection was stepped over one
message later by `update_groups`, which assigned its list verbatim. A limit
enforced on one path is not enforced
**Corrections that used to live here** — `punch_nat()` is not a traversal stack,
the node keystore's Argon2id parameters, what group chat actually uses, and what
is sealed on the wire — are now design statements in `docs/MESHBAY_DESIGN.md`
(§5.1, §4.6, §4.5, §4.4). They were kept here as a running errata list and had
drifted: one of them asserted a keystore parameter that had been raised months
earlier. An errata list beside a specification is a second specification, and the
older one wins by being read first.
## Known calibration TODOs
- Hub Argon2id verifier: **64 MiB, t=3, lanes=4 in `pw_version` 4** — why not
more is `docs/MESHBAY_DESIGN.md` §4.6. Older `auth_key` hashes (v3, 256 MB)
are rewritten on next sign-in; legacy raw-password hashes stay at v2.
**`hash_password` takes the version being stored** — hashing at one version's
parameters and recording another locks the account out, which went unseen
while v2 and v3 shared parameters. CLI `calibrate` still TODO for per-hardware
tuning.
## NAT traversal
Two residential ISPs validated, both without TURN; the hub relays under a
kilobyte of signaling and the data path is peer to peer. **The measurements are
`docs/MESHBAY_DESIGN.md` §11.1**, and the design that rests on them is §5.1.
## Key modules — where to find what
Locators only. **Why any of this is shaped the way it is, is
`docs/MESHBAY_DESIGN.md`**; the section is named where it is not obvious. Notes
here are kept only where they are a rule about *editing* the code.
### Common
| Need | File | Note |
|---|---|---|
| Chunk encryption | `meshbay_common/crypto.py` | §4.3 |
| GEK wrap/unwrap (ECIES) | `meshbay_common/crypto.py` | §4.2 |
| Sealing a payload under the group key | `meshbay_common/groupbox.py` + `sealGroup`/`openGroup` in `static/crypto.js` | §4.4. **Never reuse the chunk key with a pseudo-file for this** |
| Chat sealing and signing | `meshbay_common/chatbox.py` + `sealChat`/`openChat`/`verifyChatSignature` in `static/crypto.js` | §4.5 |
| Handshake, version range | `meshbay_common/handshake.py` — `MNP_MIN_SUPPORTED`, `check_version` | read by both servers and both clients |
| Wire messages, `req_id`, `IndexEntry` | `meshbay_common/protocol.py` | §5.3 |
| Signed admin transcripts | `meshbay_common/adminop.py`, `join.py`, `device.py` | §5.4 |
| Key derivation from a passphrase | `meshbay_common/keyderive.py` + `static/keyderive.js` | §3.1. **Parity-tested — never change the parameters in one place** |
| Path folding, NFC, long paths, reserved names | `meshbay_common/paths.py` | §10 |
| ~~Double Ratchet / Sender Keys~~ | — | **Deleted.** Both were written and never called. Kept code that nothing calls reads as an alternative somebody may reach for, and its green tests read as evidence of a protection that is not in the product. The reasoning that ruled them out is at the top of `chatbox.py` |
### Node
| Need | File | Note |
|---|---|---|
| Operator operations | `meshbay_node/ops/` (one module per domain, `__init__.py` re-exports every name as `ops.<name>`) | **One implementation, several front doors.** The loopback API, the CLI and the signed MNP handlers all call these |
| Local control API | `meshbay_node/ui/app.py` | JSON only, loopback + per-run token. Each operation endpoint is one `_op(...)` line |
| Roster, identities, devices, invites, group settings | `meshbay_node/roster.py` | §3.3, §3.4 |
| Roots, availability, eject/plug | `meshbay_node/roots.py`, `config.py` | §6.2 |
| Indexing, hashing, enrichment | `meshbay_node/indexer/` — `indexer.py`, `cache.py`, `group_index.py`, `enrich*.py`, `title_parse.py`; `meshbay_node/enrichment.py` — which files each app enriches, and when (NodeDaemon mixin) | §6.3 |
| Transfer leases and queueing | `meshbay_node/transfers.py` | §5.5. Deliberately free of asyncio and of the transport |
| Uploads, resume state | `meshbay_node/uploads.py` | §6.4 |
| WebRTC transport and every MNP handler | `meshbay_node/transport/webrtc_server.py` | the big one; framing, shared limits and one-shot ffmpeg jobs are in `transport/webrtc/`, handlers for one app only in `transport/webrtc/apps/` |
| QUIC transport | `meshbay_node/transport/quic_server.py` | off by default; **must stay at parity with the WebRTC handlers** |
| Background tasks (node) | `transport/webrtc/core.py` — `_spawn()` | the only way to start one; a bare `ensure_future` can be collected |
| Stream handover, backpressure | `transport/webrtc/apps/streaming.py` — `_replace_stream`; `webrtc/files.py` — `DOWNLOAD_BUFFER_HIGH`; `webrtc/core.py` — `shutdown_tasks` | §8.5 |
| Stream diagnosis | `transport/webrtc/apps/streaming.py` — `_do_client_diag` at DEBUG | the player's own view in the node's log; the only window into a phone |
| Chat store and paging | `meshbay_node/chat/store.py` | `get_recent`/`get_before`/`has_before`; `get_messages` pages *forwards* and is not what a chat opens with |
| Chat epochs | `meshbay_node/ops/chat.py` — `open_chat_epoch`, `ensure_chat_epoch`, `chat_epoch_keys` | §4.5 |
| Keystore | `meshbay_node/keystore.py` | §4.6 |
| Bundle store (GEK + keypair bundles) | `meshbay_node/bundle_store.py` | §3.7 |
| Media cache, third-party metadata | `meshbay_node/media_cache.py`, `tmdb.py`, `musicbrainz.py`, `media_probe.py` | §6.5 |
| Link previews | `meshbay_node/linkpreview.py` — `safe_url` | §6.5. SSRF gate |
| Audit log | `meshbay_node/audit.py` | legal compliance |
| Hub socket client | `meshbay_node/hub_client.py` | `login()` (Ed25519) + `maintain_ws()` |
| Daemon, CLI, config | `meshbay_node/daemon.py`, `cli/` (`dispatch.py` → one module per family of verbs; `tests/golden/cli.json` pins their output), `config.py`, `platform.py` | §6.7, §6.8 |
### Hub
| Need | File | Note |
|---|---|---|
| Auth dependencies | `meshbay_hub/api/deps.py` — `require_admin`, `require_moderator`, `require_user_scope` | §7.5 |
| Accounts, devices, recovery | `meshbay_hub/api/users.py` | §3.6 |
| Node auth and registration | `meshbay_hub/api/nodes.py` | §7.2 |
| Signaling relay | `meshbay_hub/api/signaling.py` | §7.2 |
| Groups, membership, presence, public-group quota | `meshbay_hub/api/groups.py` | §7.3 |
| Admin API, instance policy, moderation | `meshbay_hub/api/admin.py`, `hub.py` | §7.4, §7.5 |
| Notifications, federation, relays, reports | `meshbay_hub/api/notifications.py`, `federation.py`, `relay.py`, `moderation.py` | §7.6. **Federation is off**: `federation.FEDERATION_ENABLED` is False and every MHP route answers 503, because no two hubs have ever completed a request between them. Its tests open the gate for themselves. **Relays are closed the same way** (`relay.RELAYS_ENABLED`): nothing in the tree calls them |
| Sign-in lockout | `meshbay_hub/login_throttle.py`, settings in `hub_settings.py` (`login.*`) | §7.7. **Every path that checks a passphrase calls `_take_login_attempt` first** — login, passphrase change, account deletion |
| What answers without an account | `tests/test_unauthenticated_surface.py` — `PUBLIC` | §7.4. A new open route fails the suite until it is listed there with its reason |
| Asset versioning | `meshbay_hub/api/webapp.py` — `_asset_version()` | the whole module graph is served under `/a/<hash>/`, and the hash covers **every file under `static/`**, subdirectories included — nothing to register |
| Token lifetimes | access: `meshbay_hub/config.py` — `[jwt]`; session: `hub_settings.py` — `session.*` | 4 h access token, from `hub.toml`. **The session is an admin setting in the database** (browser idle sign-out, refresh idle, maximum; `static/idle.js` measures the first). A `refresh_token_ttl` still in `hub.toml` is ignored with a warning |
### Browser / desktop UI (`meshbay_hub/static/`)
| Need | File | Note |
|---|---|---|
| Routing and every non-group page | `app.js` | |
| Group shell, index, tabs, modals | `group-page.js` | §9.1 |
| Application registry | `apps.js` | §9.4 — one entry per application |
| Applications | `chat-app.js`, `files-app.js`, `video-app.js`, `music-app.js`, `photos-app.js` (+ `*-app-settings.js`) | §9.5–§9.9 |
| Shared settings widgets | `settings-ui.js`, `folder-tree.js` | **A pane must not import `group-settings.js`** — that is an import cycle, and it fails as a component that silently does not render |
| Transport, handshake, device hello, roster verify | `transport.js` | §5.2, §3.3 |
| Crypto | `crypto.js`, `keyderive.js` | §4 |
| Hub session, token renewal, IndexedDB (keys, playlists) | `hub-client.js` | §3.1. **No group index is stored**: the `group_indexes` store had no reader for weeks and is emptied on start-up (`purgeGroupIndexCache`) |
| Where the hub is | `platform.js` — `hubBase()` | **the only file allowed to decide this** (§8.3) |
| Downloads, decrypt pipeline | `file-utils.js`, `downloads.js`, `sw.js` | §8.5 |
| Video player | `video-player.js` — `pump()` is the only place credit is granted | §8.5 |
| Transfers widget | `transfers.js` | §5.5 |
| Cross-group merge | `source-merge.js`, `group-name.js` | §9.11 |
| Node page | `node-page.js` | §6.7 |
| i18n | `i18n.js`, `locales/*.js` | `en.js` is the source; **ten catalogues, and a new key goes in all ten** |
### Test harnesses that drive the real thing
| Need | File |
|---|---|
| Layout, measured in a browser | `packages/meshbay-hub/tests/harness/layout_probe.py` |
| Chat scrolling / sending, measured | `packages/meshbay-hub/tests/harness/chat_scroll_probe.py`, `chat_send_probe.py` |
| Group landing tab, measured | `packages/meshbay-hub/tests/harness/group_tab_probe.py` |
| Page height against the window | `packages/meshbay-hub/tests/harness/scroll_probe.py` |
| The real player against a fake source buffer | `packages/meshbay-hub/tests/harness/mse_harness.mjs` — **do not write a second model of `pump`/`flushQueue`/`evictBehind`** |
| Flow-control worst case | `packages/meshbay-hub/tests/harness/window_leak.mjs` |
| Session renewal against a hub that enforces rotation | `packages/meshbay-hub/tests/harness/session_harness.mjs` |
| An invitation link opened in the real app, signed out and in | `packages/meshbay-hub/tests/harness/invite_link_probe.py` |
## 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.
|