aboutsummaryrefslogtreecommitdiffstats
path: root/docs
Commit message (Collapse)AuthorAgeFilesLines
...
* fix(node): take the availability poll and every upload write off the loopChristophe Besson7 days1-2/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The rest of AV9's disk half. Serving a file left the loop in the commit before this one; two paths were still on it. **The availability poll.** `RootSet.refresh_availability` stats every root, and eleven call sites reached it from `async def` — the reconcile loop among them, on a timer. On a sleeping disk that is a stall once per tick, and the stat is also what keeps the disk awake, so a node paid spin-up for a library nobody was reading. All eleven now go through `off_disk`, `Root.is_live` included. **The upload write.** `open`/`write`, and the resolve, the stat, the free-name search, the rename and the unlink around it. This one could not simply be awaited: the handler was synchronous, so nothing could come between the `chunk_index != state.next_index` check and the `advance` that answers it, and that is the whole of the chunk-ordering rule. Awaiting the write opens the gap — chunk 1 arriving while chunk 0 is in the disk thread reads a position that has not moved and is refused as out of order, so an upload would fail on a slow disk and nowhere else. Verified, not assumed: without the lock the new ordering test refuses three chunks of four. So the check, the write and the advance are one critical section again, under a lock held **per group**. Not per session: `partial_uploads` lives in the group context so a reconnecting client finds its upload where it left it, which means two sessions of one member share the position of one `.part` file. Arrival order is preserved by construction — the dispatcher creates one task per message as it arrives, tasks start in creation order, and the lock is the first thing each one waits on, so its waiters queue in arrival order too. `_do_file_upload` is a coroutine now, which is why forty-two test call sites gain an `await`. Their outcomes are unchanged, file by file, against the run before the change. `test_ops.py` asked which public coroutines `ops` exposes and got `off_disk`, imported rather than defined there. It now asks for the ones written in the module, which is what its own docstring means; all forty-three operations are still checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: state what is built, and what the packaged install has answeredChristophe Besson7 days2-14/+15
| | | | | | | | | | | | | | | | | | | | | | | | | | | The two documents disagreed with the code they describe. §15.2 still listed playlists and Chromecast subtitles as not built, both shipped and tested; the protocol document's header said wire version 3.2 beside its own §13 saying 3.3, which is §12's "a number restated away from its definition is a number that will drift" happening to the document that states the rule. The packaged install is no longer an open question: the artefacts have been taken to a running hub and node on Ubuntu 26.04 (.deb), Fedora 44 (.rpm) and Windows 11 (.exe). What is left of D11 is signing the binary, which is a different question from whether it installs. The migration row overstated its own risk. Nothing replays the revisions behind the first packaged release — they were development no installation ever ran — so the exposure is the next migration, not the chain. The disk-I/O row gains the consequence that was measured rather than reasoned: a root waking from standby stalls the loop long enough for a client's WebRTC connection to time out, on one node with one member. `refresh_availability` is named beside the two chunk paths, because a stat on a sleeping disk is what wakes it while reading nothing. A blank line had also split §15.3's table in two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: decode and re-encode video on the GPU where there is oneChristophe Besson7 days1-0/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A 1080p film is decoded by whoever watches it and re-encoded by the node when no browser can decode the source. Both were on the CPU, and on an Atom or Celeron mini-PC neither reaches real time — which is what `transcode_incompatible_video` exists to refuse. This adds the mechanism that makes refusing it unnecessary. Node — `hwaccel.py`: VA-API on Linux, Quick Sync or NVENC on Windows, established by encoding 1080p and reading the file back with ffprobe. Nothing is accepted that does not produce the exact profile and level `stream_init` announces, since the client checks that string before it trusts a byte: an encoder that wrote another level would make the node's own codec string a lie, and ffmpeg takes `-level 4.1` and `-level 41` from h264_qsv without saying which it understood. Three modes per stream — hardware decode and encode, hardware encode alone, libx264 — demoted per source codec, because a GPU that decodes HEVC may have no decoder for MPEG-4 Part 2 and only asking it finds out. A mode that fails is detected on an empty stdout before `stream_init` goes out, so the viewer sees one working stream and never an error. Client — Chromium ships VA-API off on Linux. It is enabled where a render node and a driver are present, then verified through `navigator.mediaCapabilities`: a no moves to the next GL backend on the next launch and an exhausted list drops the switches, so a renamed feature cannot pass for a feature that is on and `--ignore-gpu-blocklist` cannot survive on a machine it did not help. Feature lists now merge rather than overwrite — `appendSwitch` replaces the value, and a second caller would have silently cancelled the mDNS switch aiortc depends on. Packaging — the drivers are weak dependencies on all four formats, so a machine without a GPU installs exactly as before. `dpkg -i` and `rpm -ivh` ignore weak deps; `packaging/README.md` now says so. Windows needs no driver: the bundled ffmpeg already carries h264_qsv and h264_nvenc, and a re-pin that dropped them would cost every low-power Windows node its hardware encoding silently. AMD on Windows (AMF) and macOS (VideoToolbox) are named gaps, not oversights: neither could be tried anywhere in this project, and both re-encode in software as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(cast): carry subtitles to a Chromecast, on the relay's clockChristophe Besson7 days1-0/+19
| | | | | | | | | | | | | | | | | | | | | | | | The relay forwards the node's fragments untouched, and those begin at zero at the seek point. The player never notices because its SourceBuffer is given `timestampOffset = start`; a receiver has no equivalent, so the cues are shifted by `-start` before they leave, recomputed at every restart of the relay. Sent as they are, a subtitle would be out by the whole seek. The document is served from the relay's own port at /subs.vtt, behind the same token as the stream and with CORS: a receiver fetches a side-loaded track with XHR from its own origin, and without the headers it fails as a network error with nothing on screen to say so. The URL carries a version because a track is cached by address — changing the cues behind a fixed URL leaves the previous language showing. Cues that end before the stream begins are dropped rather than clamped, so a line from before the seek cannot appear over the first frames after it. The relay is plain Node, so the tests start it and fetch from it rather than reading its source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(node): measure where a seek lands instead of predicting itChristophe Besson8 days2-9/+14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous fix read ffprobe's key frames and took the last one at or before the request. It was wrong twice, and a viewer felt the difference: subtitles went from 5 s early to 2–3 s late. Matroska's Cues index only some keyframes, so an index seek backs off to an indexed one that the frame list does not single out. And the landing point moves with **which streams are mapped**, because the container is positioned where every mapped stream has data — on the reported title, a seek to 4913.7 s landed at 4909.863 with video alone and at 4907.236 with the second audio track mapped beside it. The frame scan gave the first number; the stream delivered the second; the gap was 2.65 s, and the measured audio displacement in the served stream was 2.65 s. So the node asks ffmpeg instead: the same seek, the same mapping, one copied frame under `-copyts`, and the answer read back off the result. 0.06–0.07 s, cheaper than the scan it replaces. The `-ss` argument stays at the request, so the bytes served are exactly the ones served before — only the number naming them changes. The probe runs after the audio track is resolved, because it cannot be right before that is known. An answer after the request, or further before it than any real keyframe gap, is discarded in favour of the old label: a number wrong by seconds beats a fabricated one. Found by decoding the served stream and locating its first frame in the source, which put it at 4907.213 s against an announced 4909.863 s. The test does the same thing rather than comparing the announced number against a second reading of the same probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(node): a seek reports where the picture begins, not where it was askedChristophe Besson8 days2-2/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Copied video can only start on a keyframe, so `-ss t` with `-c:v copy` delivers the keyframe at or before `t`. The node reported `t` anyway and the client builds `SourceBuffer.timestampOffset` out of that number, so everything downstream believed the picture stood a few seconds further along than it did. That was a wrong label while only the scrubber read it — it is recorded as such, low priority, in the design document. Subtitles made it a wrong answer: their cues carry the source's own absolute timestamps, so the mismatch put every line on screen before it was spoken. Reported from real use on an H264 title, where seeking to 600 s, 2650 s and 5000 s lands on keyframes 0.82 s, 1.56 s and 4.64 s earlier. The copy path now resolves the request to that keyframe, seeks to it, and reports it. The bytes delivered do not change — ffmpeg lands on the same frame either way — only the number that names them. The look-up reads the thirty seconds before the request and cost 0.12–0.51 s on that title, which is the price already costed in §15.3 and never paid. Re-encoded video is untouched: it can begin exactly where it is asked to, and does. Two details worth their lines. The keyframe is passed back to ffmpeg at six decimals, because rounding a keyframe's own timestamp down puts it before the frame it names and selects the previous one — the same fault again, smaller. And the ffprobe window ends past the request, since an interval whose end is the request never emits a keyframe sitting exactly on it: the resume position is the one place a viewer asks for the same instant twice, and it would have been answered a whole GOP early. The test decodes the first frame served and matches it against the source frame at the position announced, rather than comparing `start` to an expected number — both sides of that comparison would be reading the same ffprobe and would agree by construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* feat: embedded subtitles in the video player (MNP 3.3)Christophe Besson8 days2-7/+21
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | MSE decodes no in-band text track, so a subtitle cannot ride inside the fragmented MP4 the player is fed. The node extracts one track whole, converts it to WebVTT and caches it under its own hash; the client pulls that blob through the ordinary file_req/chunk path and hangs a <track> on the video element — the same indirection as a TMDB poster or an audio transcode, which is what makes a film's subtitles extracted once in the life of the file rather than once per viewing. Whole-file also makes the cues absolute, so a seek and an audio-language change both leave the track untouched. **The ordinal counts every subtitle stream, including the ones never listed.** Only text codecs are offered: a bitmap track (PGS, VOBSUB — about a fifth of a real library) has no path to WebVTT without OCR, and one extracted anyway yields a header with no cues, which is a menu entry that shows nothing and reports no error. Numbering the survivors of that filter would give a PGS/SRT/SRT file the ordinals 0 and 1 for its text tracks and `-map 0:s:0` would then extract the PGS — the same trap `AudioTrack.ordinal` exists for, one level deeper. A fixture whose first subtitle stream cannot be decoded pins it, and the handler checks membership of the probed list, never a range. Additive and MINOR: the selector is drawn from `subtitle_tracks` in the node's own `stream_init` and from no version number, so `subtitle_req` is never sent to a peer that would not answer it. The floor stays at 3.0. Also here: a failed extraction never touches playback, a superseded reply cannot install its blob over a newer choice, and `_languageName` is shared with the audio labels — lifted by both label harnesses, since a lift that names one function stops covering the rule the moment logic moves out of it. Tests: 9 node (tracks told apart by the words in the extracted cues, not by tags), 10 client. Full suite green: 1545 node/common, 1252 hub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(hub): the password verifier is Argon2id 64 MiB, and a hash's version ↵Christophe Besson8 days1-1/+18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | names its parameters `pw_version` 4: Argon2id 64 MiB, t=3, lanes=4 — RFC 9106's second recommended setting. A v3 hash (256 MB) still verifies at its own parameters and is rewritten at the new ones on the next sign-in, through the rehash path that already existed. Why not more. The verifier matters against an offline attacker holding the database; online guessing is bounded by the sign-in lockout. That attacker pays the client's 600 000 PBKDF2-SHA512 iterations and the hub's Argon2id per guess, since `auth_key` is 256 bits and cannot be searched directly. Memory above 64 MiB multiplies that cost by a constant — at most 16 at 256 MB, less with PBKDF2 counted — while the hub pays the same memory at every sign-in, one derivation at a time. Measured on meshbay.org: 450 ms at 256 MB, 105 ms at 64 MiB, so a burst of sign-ins clears about four times faster. Changing the current version exposed a latent lockout. `hash_password` always used the current version's parameters, while the raw-password scheme recorded `pw_version = 2` — harmless while versions 2 and 3 shared their parameters, and with version 4 every legacy registration and v1→v2 rehash would have stored a 64 MiB hash labelled 256 MB, which nothing could then verify. Seventeen tests caught it. `hash_password` now takes the version it is hashing for. The OpenSSL deadlock between two concurrent `lanes=4` derivations is the same at 64 MiB, so Argon2 stays on its single worker. The loop-stall test measures against a v3 hash, because half of a 45 ms inline derivation is too close to scheduling noise to be a reliable bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
* docs: record the seek label mismatch as openChristophe Besson8 days1-0/+1
| | | | | | | | A seek now begins picture and sound together at the keyframe, but stream_init still reports the position that was asked for. §15.3 says what the fix costs and why it has not been made. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): a seek left the audio a GOP behind the pictureChristophe Besson8 days2-1/+19
| | | | | | | | | | | | | | | -ss before -i cannot trim copied video, which must begin on a keyframe, but accurate_seek did trim the re-encoded audio to the exact request. Every seek on a copied stream therefore opened with a GOP-wide hole in the audio and ran a GOP out of sync afterwards — 9.979s on a real film with a 10s keyframe interval. Accurate seeking is now off wherever video is copied, and stays on where it is re-encoded, which is the only path that could already begin where it was asked to. Every timestamp was correct throughout, which is why nothing caught it; the tests assert on decoded audio and on frames compared against the source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: let the viewer pick the audio trackChristophe Besson8 days2-15/+52
| | | | | | | | | | | | | The streaming path mapped 0:a:0 unconditionally, so a dubbed film played in whichever language was muxed first and the others were unreachable. The node now enumerates the tracks in stream_init and honours audio_track in stream_req; switching is the seek path, since one ffmpeg carries one track. MNP 3.2, additive: the player draws its selector from the node's own list and never from a version number, so an older node is never asked for a track it would ignore. MNP_MIN_SUPPORTED does not move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: promise the count that holds when nothing compressesChristophe Besson9 days1-8/+19
| | | | | | | | The ceiling is 1200 tracks on a library that repeats and 660 on one that never does; the message said 1000, which would send a reader back into the wall at 660. It says 500, measured at both ends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: refuse a body no frame can carry, and say which oneChristophe Besson9 days1-10/+19
| | | | | | | | | | | A DataChannel send() throws above the max-message-size the node advertises (aiortc: 65536), so the 1 MB body cap was unreachable and each body push swallowed the difference in a bare catch. Cap at 62 KB after sealing, name the playlist in the sync result, keep syncing the rest. Measured: 1000 tracks seal to 53 276 bytes, so the ceiling is ~1200. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: drive the blob ops over a real DataChannelChristophe Besson9 days2-5/+42
| | | | | | | | | The six MNP 3.1 ops were the only new ones never crossing a channel in a test. Two cases on the existing aiortc harness: a round trip read back on a second connection, and a 256 KB body. Found that the 1 MB body cap is unreachable from a browser — docs/playlists.md §15.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: playlists §15 was out of date on where sync livesChristophe Besson9 days1-4/+10
| | | | | | | Sync moved to the shell; the Search-page gap it lists is the bug that was fixed. And two devices converging has now been seen working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* menu: give the panel the room that is actually below itChristophe Besson9 days1-2/+17
| | | | | | | | | `max-height: calc(100vh - 16px)` says how tall the menu may be and nothing about where its bottom lands, so one opened partway down the window ran past it and its last rows scrolled out of reach. Measure from where the panel was placed, and re-measure when a submenu opens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* menu: do not close on the panel's own scrollingChristophe Besson9 days1-4/+11
| | | | | | | | The dismiss-on-scroll listener is on the capture phase, because `scroll` does not bubble — so it also heard the menu scrolling itself, and a long tracklist closed the moment it was wheeled. Filter on the event's origin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: playlists, corrected against what building it neededChristophe Besson9 days2-119/+293
| | | | | | | | | | | | | | | | | Fourteen places the proposal was wrong, listed rather than edited away: the MNP bump was 2.1 and is 3.1; the kind pattern refused the one playlist every account has; confirm() is the house style and prompt() is what throws; submenus expand in place; deflate is 4.5x and the first fixture's 23x was a property of the fixture. Adds the hazard the proposal missed entirely: two wrappers that take two arguments and forward two, so every "add to queue" arrived as a plain play. The acceptance section now says what is checked and what is not — no real daemon round trip, no two-browser convergence, no Firefox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* mnp 3.1: per-account blobs the node cannot readChristophe Besson9 days1-4/+48
| | | | | | | | | | | | | | | One row per playlist plus a manifest, so starring a track rewrites that playlist rather than the whole collection. blob_enc is a BLOB, not base64 TEXT: these run to hundreds of kilobytes. user_id comes from the session and never from the message; kind is validated against a pattern; every cap refuses with a stated reason rather than truncating. Additive, so MNP_MIN_SUPPORTED does not move — a 3.0 node answers "unknown message type" and the client writes to the next one it reaches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: playlists — storage split, queue ops, and the UI designChristophe Besson9 days2-290/+831
| | | | | | | | | | | | | The 256 KB cap contradicted its own worked example; entries were also missing `size`/`name`, without which a stored track cannot be fetched at all. Storage becomes a manifest plus one blob per playlist, compressed, so starring a track no longer rewrites the collection. Adds the UI pass that was missing: the queue verbs, the menus, and the one real code change behind them — the player's queue can today only be replaced, not appended to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): drop files and folders onto Files to upload themChristophe Besson10 days1-0/+12
| | | | | | | | | Into the folder on screen, under the Upload button's rule. A name already there, or one the node would refuse, cancels the whole drop with a message. Folders are recreated level by level; files go out a few at a time. The in-flight upload guard is keyed by folder and name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): Videos and Music list their cards a page at a timeChristophe Besson10 days1-0/+9
| | | | | | | | Previous/next arrows in the pinned toolbar, on group pages and in Search. Page size is an account preference (Settings → Defaults), 50 by default, 10 to 200 in steps of 10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): session lifetime is an admin setting, and a browser signs out ↵Christophe Besson11 days1-0/+15
| | | | | | | | | | | when idle Browser idle sign-out (media playback counts as activity; not the desktop app), refresh idle window and maximum session length, in hours. Sign-out now revokes on the hub, and the profile has "sign out everywhere". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
* feat(hub): usernames are at least 8 characters at registrationChristophe Besson11 days1-1/+2
| | | | | | | Existing shorter accounts keep signing in. Test usernames padded to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
* feat: a group can be left out of Search, and Search tries every nodeChristophe Besson11 days2-0/+17
| | | | | | | | | | | | | | | | | | | | | | | | | | | `search_listed` is a per-group setting on the node, changed by a signed operator op and carried in the sealed handshake ack. Search reads it after the handshake and stops there: no index is fetched, cached or merged, in any of the four views, and the page says how many groups it left out. The switch is a "Search" section in the group's settings, shown to the operator. Absent means listed, at every layer: roster default, ack default, and the client only drops a group on an explicit `false` — so an upgrade or an older node removes nothing from anyone's Search. It is a listing preference and protects nothing: the node serves the same index to Search and to the group page and cannot tell them apart, every member lists the group by opening it, and a client that ignores the flag lists it in Search too. Design §9.11 says so, so it is never described as private. The cost is one handshake per unlisted group, because only the node knows the setting. Search also took `nodes[0]` twice — for the index and for the pooled connection — the defect 4cce50f fixed on the group page only. One `connectToGroup` now walks the list the same way: a refusal about this browser stops, `not_hosted` or a failed connection moves on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
* chore: bump version to 0.14.0Christophe Besson11 days1-1/+1
| | | | | Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
* fix(hub): Argon2 runs off the event loop, on exactly one workerChristophe Besson12 days1-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | One derivation is 256 MB and a quarter to half a second of CPU (240 ms here, 485 ms on meshbay.org). All eleven call sites — sign-in, registration, the two rehashes, passphrase change, reset and account deletion — ran it inline in an async handler, so every one stopped the whole hub for that long: no request served, no node socket read, no offer relayed. Measured on a local hub during eight concurrent sign-ins, the worst `/v1/health` response went from 232 ms to 10 ms; the sign-ins themselves take the same time. It could not simply go to a thread pool. Two concurrent `lanes=4` derivations deadlock inside OpenSSL and never return, at no CPU — reproduced on cryptography 50.0.x / OpenSSL 4.0.x both locally and on meshbay.org, while `lanes=1` does not. `lanes` is part of every stored hash, so it is not ours to change, and inline on the loop two derivations could never overlap, which is the only reason production never hung. So `auth.hash_password_off_loop` / `verify_password_off_loop` hand the work to a dedicated executor with exactly one worker. Not a semaphore around `to_thread`: a cancelled request would release its permit while its thread was still deriving, and the next derivation would start beside it. One worker also bounds Argon2's memory to one derivation whatever the number of callers. `test_argon2_off_loop.py` reads every module for a direct call, pins the single worker, runs four derivations and four sign-ins concurrently to completion, and checks the loop keeps turning during a derivation; each fails with its guard removed. CLAUDE.md and AV9 state the rule and the trap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
* fix(hub): a per-account sign-in lockout, and a reviewed unauthenticated surfaceChristophe Besson12 days1-1/+44
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Passphrase sign-in locks per username: after `login.max_failures` wrong passphrases (default 4) the name is refused with `429 account_locked` and a `Retry-After` for `login.lockout_minutes` (default 60), without the passphrase being checked. Both numbers are instance policy an admin sets from the panel; zero failures turns it off. The per-IP limit bounds one address, and IPv6 gives every subscriber a /64 of them — an online guess targets an account, so the account is what is counted. - Counted by the name as typed, existing or not, so `login` stays uniform (M1). The key is a hash: people type passphrases into the username field. - The attempt is taken before the check in one `INSERT … ON CONFLICT DO UPDATE … WHERE … RETURNING`, so a concurrent burst gets no more than the limit. - Sign-in, passphrase change and account deletion count on the same row; the last had no rate limit at all. - A lockout refuses passphrase sign-in and nothing else: sessions, renewal and device sign-in continue, and a reset code clears it (AV26). A session learns its own lockout from `/v1/users/me`, and the passphrase change checks it before re-wrapping any node's bundle — the hub accepts the new passphrase only after the nodes have it. The SPA now shows what the hub said. `loginAndRecover` threw "Login failed: {json}", so `email_verification_required` never matched and was never shown; the passphrase-change form rendered no error at all in its first phase. The unauthenticated surface, reviewed route by route: - No `/docs`, `/redoc` or `/openapi.json`, in the code. The Caddyfile hid them on meshbay.org only; a packaged hub behind any other proxy published all three. - The node socket's first message must arrive within ten seconds. It is accepted before anyone is known, and an unbounded read is a connection any stranger holds for free. - `/v1/relays` answers 503 behind `relay.RELAYS_ENABLED`, as federation does: nothing in the tree calls it and two of its routes take no account. - `test_unauthenticated_surface.py` walks every route and fails on one without an authentication dependency that is not listed with its reason. Verified in Chrome against a local hub: the lockout and wrong-passphrase messages, the admin section saving both lockout and mail limits, and the passphrase change refused while locked. Not verified in Firefox (a running instance blocks the headless one), nor the upsert's concurrency on PostgreSQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
* fix(hub): federation is closed until two hubs have run itChristophe Besson12 days1-1/+24
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every MHP route answers a stated 503. `federation.FEDERATION_ENABLED` is the only thing that decides it, applied as a dependency on the router so the six routes that exist and any added later are covered by construction — a gate you have to remember to write in each handler is the shape C6 is the standing lesson about. The protocol is not what is wrong with it. What is wrong is that nothing has ever run it: two hubs have never completed one authenticated request between them. AV14 was two defects in the same path — an issuer signing with a key bound before it was loaded, naming itself after the reference deployment whatever the instance was called, and a verifier naming no audience for the `aud` the issuer sets, which PyJWT refuses outright. Both stood for a month behind a green suite, and both were found by reading rather than by running, because a second implementation of a peer proves the protocol and nothing about two machines. Four of the six routes carry no authentication of their own — the MHP token is the authentication — and two of those write, a directory push and a revocation. That is the surface being closed until somebody stands up a second hub. A constant and not a `hub_settings` row, deliberately: a switch in the admin panel invites an operator to turn on a feature that has never worked between two machines, where this takes an edit, a deploy, and reading the comment above it. `/v1/hub/info` reports the state, because the `mhp_version` beside it would otherwise be a claim the hub does not honour. The protocol tests open the gate for their own duration and say why; the one that runs with the flag as it ships asserts all six routes refuse. §7.6 states the closure, §15.2 carries federation between two hubs as not built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
* docs: what this week's review left open, and a harness that liesChristophe Besson12 days1-0/+7
| | | | | | | | | | | | | | | | | | | | | | | | §15.3 gains the items the review found and did not close, each as what it is rather than as a plan: the packaged install that has never been installed, QUIC serving content outside every cap it does not implement, a relay registry nothing calls, an unmetered third-party search, disk I/O on the node's event loop, unbounded node announcements, and a migration chain exercised on a database it does not ship on. They are in the design rather than in a tracker because each is a sentence about what the system currently is, and §15.3 is where the document already keeps those. The seven that were closed this week are in §13.5b as AV20–AV25 and in the sections they belong to. CLAUDE.md gains the Firefox harness, which is the other kind of entry: eleven errors at setup that look exactly like a regression, are not, and cost a full diagnosis to place. `HOME` isolates the profile and not snap's single instance, so any running Firefox blocks the headless probe, `--no-remote` and `--new-instance` do not help, and a failed run leaves a process that blocks the next. The chrome half covers the same geometry meanwhile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
* fix(hub): a node that hosts nothing is not a free target, and a private ↵Christophe Besson12 days1-0/+19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | group's hosts are its members' Two checks that were not performed, in the same shape: something true of the public case, written so it did not apply to the rest. **Signaling.** The membership check read `if node_group_ids:`, so an empty set skipped it — membership, group status and the public-group gate together — and the offer was relayed. Since AV1 made an empty claim mean "no groups" rather than "all of my owner's", that is the *normal* registration of a node hosting nothing: the unconfigured node left running, the machine in this register's founding incident, and the one least able to absorb the work. Each offer makes it allocate an RTCPeerConnection and gather ICE, 30 a minute, which is H6 restored in exactly the case AV1 made common. It is refused now. Nothing legitimate is lost: such a node refuses the handshake anyway — `group_id` is mandatory (M1) and a node with no group key refuses (NS8) — so this declines work the node would decline one step later at its own expense. **The node list.** `GET /v1/groups/{id}/nodes` checked membership only for a public group with public groups switched off. A private one answered any authenticated account that knew the id — which an ex-member knows for ever — with the ids and public keys of the machines hosting it. §7.4 already stated the property for the public case. Membership is required now unless the group is public and public groups are on. Nothing needs the list before joining: an open join writes the membership row first, and an invitation registers the invitee's when the code is created, both checked before making the change. `test_webrtc_signaling_roundtrip` had to be repaired, and is the finding restated: it registered a node in `_connected_nodes` and never in `_node_groups`, so it drove the relay through the hole rather than through the door — the check it routed around was the defect. It now registers the group a real node registers. §7.2 and §7.3 gain the rules, the register AV24 and AV25, and test_availability_between_members.py two cases that fail on the property against the previous source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
* fix(node): chat is bounded in size and in rateChristophe Besson12 days1-0/+26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A chat message is the plainest member-supplied write there is: the node stores it in `chat.db`, where nothing expires it — retention is a manual command (§6.6) — relays it to every other connected member, and has the hub write a notification for every member of the group. Nothing bounded any of it. The only ceiling was the DataChannel frame, 64 MB once the handshake is done, so one member in a loop filled the operator's disk and saturated everyone else's connection, and the node's answer to each message was `ack`. Uploads, the other member-supplied write, have carried a filename allowlist, strict chunk ordering, a no-overwrite rule and a 4 GB cap since C5a — because somebody asked what one member costs the others on that path. Nobody had asked it on this one. Two bounds, for the two halves of the question: **64 KB of ciphertext** for what one message may cost, and **60 a minute per account per group** for how often one member may impose it. Both are checked before anything is stored or relayed; a refusal names itself and is audited, so "why is my disk full" has an answer. The rate is keyed by account, not by connection: a second tab does not make anyone type faster, and keying on the session would hand a script one budget per socket it opens. No node-wide ceiling beside it, deliberately. The link-preview limiter has one because a preview spends the *node's* egress and its third-party quota, which is one shared thing; a chat message spends the sender's own group, and a node-wide ceiling would let a busy group silence a quiet one — this same defect one level up. The last test in the new file is that property: a member at their limit has not spent anybody else's. Two things stay open on purpose and are named rather than quietly done: retention still keeps everything, because a default that deletes people's history is not a review's call; and the composer still offers to send an oversized message, so this is §6.4's pattern with only the node half built. §6.6 gains the rule, §13.5b the label — AV20, with AV21–AV23 registering the three fixes this week that closed the same kind of gap elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
* fix(node): the node's own controls take no authority from a hub tokenChristophe Besson12 days1-0/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `_is_node_admin()` is `self._user_id == node_user_id`, and `_user_id` is the `sub` of a JWT the hub issued. Six node-wide controls were gated on that alone: `node_status` — which lists every group on the machine with each root's **absolute path** — plus `node_settings_set`, `roster_read`, `denylist_read`, `denylist_clear` and `node_reload`. So the answer to "are you the operator of this node" was "the hub says so", which NS4 and M3 rule out in as many words: operator authority comes from the node's roster and from nowhere else, and asking the hub is how the hub installs itself as node administrator. The reach is bounded — a completed handshake also needs the group key — but an active hub obtains one legitimately in an open-join group, which §3.5 concedes, and from there it could read the operator's directory layout or clear the denylist, which is the persisted revocation H4 exists to keep. `_operator_device()` requires both halves now: the account is the one the node belongs to, *and* the device on this connection has proved a key the roster holds as an operator. `device_hello` is signed over a transcript naming the node, the group and this connection's nonce, and `operator_pks()` is rebuilt from the roster on each call, so an unpinned browser and a revoked one are both refused at once. The hub holds no user keys and cannot countersign a device. Keeping the account check as well is deliberate: dropping it would widen these node-wide controls to any paired operator of any group on the machine, which is a separate decision. `_is_node_admin()` stays as what it is in the handshake ack — a hint telling a client whether to offer the Node page — and says so. Nothing changes for a paired operator: `device_hello` runs unconditionally after the ack, and anyone using the Node page's controls is already paired, since `root_add` and every other signed op has always verified against `operator_pks()`. A browser that never paired now reads nothing there, which is the state in which it could already write nothing. test_node_status.py's fixture set the account and not the device, which is how it went on passing; it now wires the device the way `device_hello` leaves it. The adversary itself is in test_security_regressions.py — a token naming the owner's account with no proved device, which the previous source answered with `node_status_ack`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
* fix(node): a transfer id names a lease, or it names nothingChristophe Besson12 days1-0/+12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `_do_file_request` read `tr` as a boolean. Present meant "this is a leased transfer, skip the leaseless ceiling", and nothing asked whether this node had ever granted such a lease — `slots.touch(tr)` was called beside it and its answer, `False` if it is not granted, was discarded. So any non-empty string bought the whole library with no ceiling of any kind: not the per-member cap, not the node-wide one, not the leaseless bound that exists to bound a client claiming to be browsing. The queue held only the clients that chose to wait. `_lease_of` decides it now, and the three answers differ on purpose: - **granted**, and of *this* session — served, and touched so the sweeper does not reclaim a transfer that is plainly moving. The session is checked as well as the id, because touching another connection's lease refreshed its idle timer. - **queued** — refused with `lease_not_granted`, on the upload path too, before anything reaches the operator's disk. A member reading while queued is the cap not applying. - **unknown** — bounded by the leaseless ceiling rather than refused. That is also what a reconnect looks like from here, where the session's leases died with the old connection and the client is re-opening them, and it leaves the residual §5.5 already states: a client that lies gets that bound's worth of files at a time, not the group. Noted once per connection so the residual is visible rather than merely documented. Nothing changes for the shipped client: the transfer store awaits `lease.acquire()` before it reads a byte, so the refused case is one it never enters. §5.5 gains a paragraph saying the node decides which of the two a request is — the document described the accounting without ever saying it was enforced, which is how it came not to be. `test_lease_enforcement.py` drives the real handlers over a real index; six of its nine cases fail against the previous source, each on the property. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
* fix(node): an uploaded file records who sent itChristophe Besson12 days1-6/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `_register_uploader` walked the index for the entry it had just written, at a moment when no such entry can exist: the file was a `.part` until the rename on the line above, which is not indexable, and the watchdog that will index it debounces for two seconds and then hashes. The walk matched nothing, silently, so every uploaded file in every group was owned by nobody — and `file_delete` refuses a caller with no admin authority when the entry records no uploader, so a member could not delete what they had just sent. MESHBAY_DESIGN.md §5.4 grants that to any non-revoked device of the uploading account. The record is now written when the last chunk lands (`indexer.record_upload`) and the entry is stamped from it in `_hash_or_cached`, the one funnel every entry passes through — initial scan, watchdog, reconcile and replug alike. It lives in the index cache rather than on the entry alone, because the index is rebuilt from disk at every start and an owner the node forgets on restart is a right quietly taken away. It is validated against a live `stat()`, so whatever later occupies that path inherits nothing; and `_rescan_root`'s carry-over no longer copies over it, or memory would beat the durable record. §5.4 also claimed ownership was *provable* — a transcript the uploader signs, stored with the entry. No such signature has ever existed; `meshbay:upload:v1` in the code is the groupbox purpose that seals the envelope. The section now states what the code does, and the transcript is an open item in §15.3. `test_upload_attribution.py` drives the real handler and a real indexer across that seam. Against the previous source its two positive cases fail on the property, not on a missing method — an upload, then a rebuild from disk, then a different file at the same path inheriting nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
* fix(packaging): the hub unit can start, and nothing carries the migration pathChristophe Besson13 days1-0/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `ExecStartPre` ran `alembic -c /opt/meshbay-hub/migrations/alembic.ini upgrade head`. The build does stage that file, so the path existed and the contents were wrong: `alembic.ini` resolves `script_location` with `%(here)s`, so the copy pointed at `/opt/meshbay-hub/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. 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 shape as the node unit that carried `User=` into the user unit directory. The same `%(here)s` trap was already found once on the server, where a stray `alembic.ini` resolved to a month-old snapshot of the tree. Twice is a trap rather than an accident, so the fix is that the path is no longer written down anywhere: `meshbay-hub migrate` asks the installed package where its own migrations are, which is correct for the RPM, the DEB, a venv and a checkout. The build stages no `alembic.ini`; the repo keeps its own for `alembic revision` and for deploy scripts that already work. `env.py` now prefers a URL the caller resolved over re-reading the environment itself, so `migrate --config` connects with exactly the string the server will — one resolution, not two that agree until they do not. Six tests, three of which fail against the unit as it was. They read the directives rather than the file, because searching the whole thing finds the comment explaining a directive and calls that the directive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* feat(hub): the mail state is a panel section, and a ceiling falling is an eventChristophe Besson13 days1-1/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | | The figure was a line beside the settings form, which is where it is changed and not where it is watched. It sits with the other live figures under Statistics now — four cards and, above them, a banner saying which of the two ceilings has fallen. The two states are not the same to whoever is reading: one means newcomers are turned away, the other means somebody locked out of their account cannot get back in. The settings block keeps a line pointing at it. And an operator no longer has to be looking. When a global ceiling is reached the administrators are notified — in `mail.py`, in its own session, never raising, because this runs while a request is being refused and an alert that fails must not turn a refusal into a 500. Once per hour, keyed on a row rather than a flag in memory: a flood is what spends the budget, so one alert per refusal would bury the message under its own cause, and a hub that is refusing mail is a hub somebody is about to restart. `/v1/admin/mail` gains `general_exhausted` and `all_exhausted` rather than leaving the panel to compare two numbers. Labels in all ten catalogues; `.warn-msg` for the middle state, on the `--warn` token both themes already define. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* docs: AV16–AV17, and why node --check is not the checkChristophe Besson13 days1-0/+2
| | | | | | | | | | | | | | AV16 the allowance that survives a restart and that an operator can see and change; AV17 the single worker. And a lesson that cost a full suite run: `node --check` reports success on a module-syntax error. An unclosed `.map(` inside a tagged template came back clean four times. `test_spa_syntax.py` says exactly this in its own docstring — copying to `.mjs` is what forces the module parser — and the reflex still reached for `node --check`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* docs: AV13–AV15, and two lessons from how they were foundChristophe Besson13 days1-2/+15
| | | | | | | | | | | | | | | | | | | | | | | AV13 the mail gate, AV14 the MHP audience and the hub's own identity, AV15 the shape check on the unauthenticated blocklist endpoints. §13.5b also records the auto-block threshold as an open decision rather than a defect. Two lessons, both about tests. A 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. Two test helpers described the federation defects accurately, in comments, while routing around them. And 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, each passing alone and failing in the full run, for three different reasons: caplog saw nothing because the app configures logging, then a handler saw nothing because an earlier test raised the logger's level, then because `disabled` was left True. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* docs: AV9–AV12, and the two shapes that keep producing themChristophe Besson13 days1-1/+21
| | | | | | | | | | | | | | | | The register grows by the four findings from users.py, and gains the pattern underneath several of them: a limit written on one of several equivalent paths (three endpoints send mail, one had no limit; every list in admin.py is bounded, the two outside it were not; the group claim was bounded at registration and not on reload), and a bound that counts the wrong thing (a per-IP rate limit bounds a caller, never the mailbox that receives what they cause). Also records that admin.py was read under the lens and needed nothing, so the next reader does not re-derive that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* docs: availability between members is a finding categoryChristophe Besson13 days1-0/+29
| | | | | | | | | | | | | | | | | | | | | | | | The first three reviews asked who can read what, who can impersonate whom, and what a hostile node can forge. None asked what a legitimate but misconfigured or careless member costs everyone else — which is the question a group platform lives on, because every member was invited by someone who trusted them and none of them is an attacker. C2 had asked "can a node claim a group its owner is not in?" and the answer was correctly no. Nobody had asked what happens when a node claims one its owner *is* in but does not host, which is how a group went dark for all of its members with its real host online throughout. §13.5b is the register, AV1 to AV8. The lens, for anything reviewed from here: 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. CLAUDE.md gets the working rule and the incident as a lesson, and the stale path to sync-ui.js corrected — it lives under scripts/, not build/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix: an empty group claim is a claim on nothingChristophe Besson13 days2-2/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A node that hosts no groups sends no `group_ids` on its hub socket, and the hub resolved the claim with `set(claimed_groups or authorized)` — so "I host nothing" arrived as "I host every group this account belongs to", other members' included. Such a node can serve none of them: it holds no GEK, and its own handshake refuses them with "Group not hosted on this node". `/v1/groups/{id}/nodes` answers in registration order and `_node_groups` is in-memory, so which node a client was sent to depended on who reconnected first after a hub restart. GroupPage took `nodes[0]` with no fallback. On 2026-09-11 a hub deploy at 20:14 reshuffled the registry, a second member's unconfigured node won the race, and a group stopped opening for everyone in it with its only real host online throughout. Any member could take one of their groups down, by accident, by leaving an empty node running. Four changes, because no one of them is sufficient: - the hub never widens an absent claim, and `update_groups` goes through the same ceiling as registration — it assigned its list verbatim, so the bound that makes C2 hold at authentication was one message wide - the node states the empty set rather than omitting the field - the refusal carries `not_hosted`, so a client can tell "try the next node" from "you, here, must do something first" - GroupPage walks the list instead of indexing into it The three lines involved date from 13, 20 and 23 August and each is defensible alone. The defect is in the seam, which is where the last two also were: a falsy empty collection must never mean "unspecified". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix(hub): an administrator can erase an account that owns groupsChristophe Besson2026-09-111-5/+19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An administrator's deletion answered 409 for any account owning a group, so an erasure ordered by an authority had to wait on the person it was about. It now deletes the account's groups with it, then pushes a signed revocation for the account and for each group to every connected node: an access token already issued stays valid on a node until it expires, and the revocation is what makes the nodes refuse the account and close the groups' sessions now. The action is written to the IP log, and the confirmation dialog says the groups go too, in all ten catalogues. The owner's own deletion is unchanged: refused while they own groups, which they can hand over first (CGU 3.4, privacy statement). Deleting a group had three partial cascades. The owner's route left email_verifications behind, and the cleanup of unhosted groups left notifications, invitations and reports - each an IntegrityError on PostgreSQL, invisible on SQLite, which does not enforce foreign keys by default. db/purge.py is now the one implementation: it finds every table referencing groups.id from the schema, deletes the group's rows and detaches content reports, which are evidence and outlive the group. test_group_purge.py turns foreign-key enforcement on for its connection, seeds every referencing table, and fails without the fix on all three routes. MESHBAY_DESIGN.md 7.7 states the rule, and now lists the device keys and swarm sources that e3c68b3 erases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
* fix(hub): the asset fingerprint covers every file under static/Christophe Besson2026-09-112-4/+6
| | | | | | | | | | | | | | | | | | | Everything under static/ is served at /a/<hash>/ with a year's `immutable`, but the hash was computed from a hand-kept list of 43 top-level modules. The ten catalogues and vendor/ were not on it, nor was anything the guarding test could see: it globbed *.js at the top level only. A change confined to the catalogues therefore kept the hash, and a phone went on showing a heading that had been rewritten and deployed - pull-to-refresh fetched the no-store shell, which was current, and never refetched en.js at a URL that had not moved. The fingerprint now hashes every file under static/, path and content, so a change, a rename or a new file moves the version with nothing to register. _ASSETS is gone, and CLAUDE.md, MESHBAY_DESIGN.md 9.4 step 6, assets/brand/README.md and docs/playlists.md no longer ask for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
* docs: remove the documents MESHBAY_DESIGN.md replacesChristophe Besson2026-09-1127-19148/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Twenty-four files, about 17 000 lines: the two architecture drafts, the three security reviews, eleven design notes, the roadmap, the decisions file, the v1–v4 archive, the deprecated user guide and the stale quickstart. Their content is in MESHBAY_DESIGN.md, and git history holds the originals. The reason to delete rather than keep bannered: a document that is superseded but present still gets read, and a reader cannot always tell which of two accounts of one mechanism is the live one. That was the argument for retiring the user guide rather than repairing it, and it applies to the whole set. What made this safe is the concordance. Roughly 290 comments and docstrings cite these files by section — `musicbay.md §6`, `mediacenter.md §5.5`, `draft-v6 §2.11` — and section 16 maps every one onto its replacement, so not a single comment needs editing to stay followable. It now says plainly that the files are gone and where to recover them, and it gained rows for the three reviews (their findings are section 13), and for the two guides. Four kept documents pointed into the set and were repointed first: `playlists.md` (nine references — it is a live proposal and must not dangle), `WINDOWS-PORT.md`, and CLAUDE.md's example. No dangling reference remains outside section 16. Two files were dropped from the list after checking what they hold. `HTTPS.md` is an operational runbook — Caddy, certificate renewal, DNS, troubleshooting — and MESHBAY_DESIGN.md deliberately covers no operations, so nothing would replace it; the versioned Caddyfile is the config, not the procedure. `cast-smart-tv.md` is the plan for the unbuilt DLNA phase of a feature whose first two phases ship, and section 11.4 summarises it in four lines rather than carrying the SSDP/UPnP work. There is no user guide now, and section 0.1 says so rather than leaving a reader to discover it. Suites green: 2258 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
* docs(guide): mark USERGUIDE.md deprecatedChristophe Besson2026-09-102-1/+30
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | It is too far out of date to be worth repairing. It describes identity keys derived from a username and password, one `shared_dir` per group with an `uploads/` quarantine, ChaCha20 as the content cipher, a hub that stores users' public keys and the wrapped group keys, and a member wrapping that key for another member — which is finding H3, in the section that explains why the hub cannot read your files. The banner lists each of those against what is actually true, so that no section below it is mistaken for current, and points at MESHBAY_DESIGN.md and MESHBAY_NODE_PROTOCOL.md instead. It also records what the document predates entirely: encrypted chat, the sealed index and upload path, transfer leases, device linking, the application framework. Repairing it section by section is refused deliberately. Enough of it is wrong that a reader cannot tell the sound parts from the stale ones, which is worse than having no guide, and fixing one section leaves exactly that problem in place. The previous commit — which translated two French passages and corrected the errors immediately around them — is dropped for the same reason: it made a small part of a misleading document accurate, which makes the whole harder to distrust, not easier. There is no replacement user guide today. That gap is real and is better stated than papered over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
* fix(node): the handshake ack dropped one app's directoriesChristophe Besson2026-09-101-1/+23
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The ack was assembled from its own tuple of application names, a copy of the daemon's `APP_DIR_KEYS`, and the two had drifted: the copy was missing `helloworld`. So the reference application — the one that exists to prove a new application needs no special-casing — was the single application whose configured folders never reached a client, which made the plugin claim false exactly where it is demonstrated. Fixed by removing the copy rather than syncing it. The ack now emits whatever `<app>_directories` the group context carries, and `_app_directories_ctx` is the only thing that puts one there, so the two cannot disagree again. The transport names an application in one place, `ALLOWED_APPS`, which is enforcement rather than a directory list. The client had the same fault one layer up: `group-page.js` read three names by hand from the ack while the live-update path beside it was already generic. It derives the map from the ack's own keys now, so the fix reaches the settings pane instead of stopping at the wire. A first attempt moved the list to `roster.py`, where directory *storage* lives, and `test_helloworld_proves_the_plugin_claim.py` refused it: the roster, the ops, the config and the root set must name no application at all. That test is the architecture's own guard and it was right — the list belongs on the daemon, which is what wires a group's context, and everything downstream is derived from it. Two new tests, both verified to fail against the previous shape: the ack carries an application the node names nowhere else, and the ack keeps no list of its own. `test_the_lists_are_read_under_one_name_each` now asserts the shell names no application rather than that it names exactly three. Two stale comments went with it — the ack's, which described scalars removed in 07ff8b4, and the client's, which said those scalars still rode the wire for MNP 1.0 peers that can no longer connect. Full suite: 2258 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
* docs: point the superseded drafts at the design documentChristophe Besson2026-09-1023-10/+255
| | | | | | | | | | | | | | | | | | | | Twenty-three documents that MESHBAY_DESIGN.md absorbs gain a header saying so and naming the sections their content went to. None is deleted: code comments, tests and the documents themselves cite their sections and their labels, and each records reasoning a synthesis compresses. The header states the precedence, because two documents describing one system will disagree eventually: where a draft disagrees with MESHBAY_DESIGN.md the design document is right, and where either disagrees with the code the code is. Seven status lines were corrected on the way through, all of them claiming less than the truth. Videos, Music, Photos, partial-read hashing and account recovery were headed "proposal, not implemented" months after they shipped; the desktop client said "nothing here is implemented" with stages A through D running; draft v6 still called itself the current specification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
* docs(mnp): the protocol reference, brought to the cleanup pushChristophe Besson2026-09-102-0/+2713
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | MESHBAY_NODE_PROTOCOL.md and playlists.md move into the repository, where a reader can follow them. The protocol reference was written against the tree before the cleanup landed and described three things that no longer exist. Corrected here rather than left to be discovered: * member_upload is gone from the handshake ack and from the message catalogue. Whether a member may write is a property of each root, and a summary field beside the authoritative one is a second source for one question — whichever the reader consults first decides it. * video_root, audio_root and photo_roots are gone from the ack, the signed-op table and the message reference. app_directories is not "the general form" of three narrower ops any more; it is the only one. What survives is their storage key on the node, because that is a key on an operator's disk rather than on the wire, and a node upgraded into this has to find its own configuration. * The sender-key implementation the chat section pointed at has been deleted, along with the ratchet. The argument for deriving a key per device stands on its own now instead of pointing at a module to compare against. playlists.md is added as written — a design for a feature that is not built, and the first one to need per-account state spanning several groups on several nodes. Its decided shape is summarised in MESHBAY_DESIGN.md section 9.10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
* docs: MESHBAY_DESIGN.md — one reference for the designChristophe Besson2026-09-102-0/+3787
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Thirty documents under docs/ described this system between them: two architecture drafts, three security reviews, eleven design notes, a roadmap and a decisions file. Reading any one of them meant following cross-references into four others, and several were flatly wrong — the keystore KDF, the protocol version, and four features whose headers still said "not implemented" months after they shipped. This is the synthesis. It states design rather than history: a section says why the node wraps the group key itself, not which finding made it necessary. Development history, spikes and reversed directions are gone. The security findings survive as section 13, where each label names the invariant it stands for today rather than the defect it was reported as. Two things it is careful about, because hundreds of code comments depend on them. Every short label — C1, H3, NS6, T3, C5b, W2, E9, F1 — is defined in section 13, including the three colliding namespaces (each review numbered its findings from C1, and the code means the second review's). And section 16 maps every "<doc> section n" reference the code makes onto its replacement, so no comment has to be edited to stay resolvable. transfers-v1.md comes in from outside the tree with it. The lease design is section 5.5; what a synthesis cannot carry is that document's failure-mode analysis — every way a slot can be lost, every way a client can be left waiting — and what a live pass found after the work was called done. Every claim was checked against the code rather than the drafts. Suites green: 2256 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7