aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
Commit message (Collapse)AuthorAgeFilesLines
* fix: the two ceilings §13.5b was still missing, as AV27 and AV28Christophe Besson10 days1-0/+67
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **A free-text TMDB search spends the operator's credential.** TMDB rates it, and the automatic matching every member sees runs on the same one, so a member holding down the search box — or a script doing it — degrades the library for everyone and empties a quota the operator pays for. The handler had no ceiling of any kind, where link previews beside it carry two. §6.5's standing rule is a bound and a named adversary in the same commit; this arrived with neither. Per member and not per connection, unlike link previews: three tabs is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the group context, so a reconnect does not reset it — a client that drops its channel between searches would otherwise have no ceiling at all. The node-wide window stays too, because the two answer different questions: one keeps a member from spending everyone's quota, the other keeps a roomful of them from doing it together. Ten a minute each, thirty for the node — a search every six seconds, sustained, is past what anyone types. The refusal is an error rather than an empty list. An empty list is what "no such film" looks like, and telling somebody their film is unknown when the node simply declined to ask is a worse answer than the truth; `video-app.js` already puts `detail` on screen. **How many node keys one account may announce.** Each is a row in `nodes` plus a row in the IP log, and the IP log is kept for a year, so an account in a loop writes a year of storage on the operator's disk having paid only for signatures. M8 settled whose key it is and said nothing about how many. Ten: a node is a machine left running, and an account wanting an eleventh *identity* rather than an eleventh machine is the case this refuses. Counted only where a row is added. Applied to every announce it would freeze the address of every node an account already runs the moment it reached the limit, and a node that cannot re-announce is unreachable after its ISP renumbers it — an outage caused by the protection. There is a test for exactly that. Both tests are two accounts, per §13.5b: a ceiling one person can exhaust for another is not a ceiling but a queue, and a ceiling shared between accounts would let one member stop every other from bringing a machine online. Checked by removing each ceiling: seven tests fail. `test_season_and_search_requests.py` built its session without a `_user_id`, which production guarantees — `_dispatch_message` refuses every message until the handshake settles it. The fixture was narrower than the node, so it could not exercise a per-member bound at all; it has one now. Twelve `test_sticky_header.py[firefox]` setup errors in a full run here: Firefox is open on this machine, the trap CLAUDE.md describes, and its twelve `[chrome]` tests covering the same geometry pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): stop keeping a copy of every group's file listing in the browserChristophe Besson10 days3-6/+107
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `group_indexes` was an IndexedDB store holding a decrypted copy of each group's index — every file's name, path, size, hash and uploader — written on every index and on every delta, from three call sites. It was the cross-group search of Phase 10b: `doSearch` read `getAllCachedIndexes` and searched those records instead of dialling anything. On 2026-08-28 Search began dialling the nodes, and that commit removed the reader and left the writers. Since then the browser has gone on building a cleartext file listing that nothing consulted, that no sign-out removed — the key database is a different one — and that grew with every group ever opened. L7, at rest: kept code that nothing calls does not sit still. Drawing a group's files while its node is unreachable is the only thing such a cache buys, and it is not wanted: a listing that cannot be opened is worse than an honest absence. So there is nothing to read it with, and the writers go. The store stays in the schema and is emptied instead. Dropping it needs a version bump, a version bump is an upgrade another tab can block, and playlists share this database — so the tidier change is the one with a failure mode. `purgeGroupIndexCache` runs once per browser behind a flag, which clears what is already on people's machines; a browser that refuses storage simply runs it again, which is harmless because it is idempotent. Three guards, each checked by reintroducing the fault: only `openDB` and the purge may touch the store, the purge may only clear it, and the purge must actually be called at start-up — a purge nobody calls is the same defect wearing the opposite hat. `test_sticky_header.py[firefox]` reports twelve setup errors in a full run here. A Firefox instance is open on this machine, which is the trap CLAUDE.md describes; the same twelve appear with these changes stashed, and the `[chrome]` half of the same file, covering the same geometry, is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: cite MESHBAY_DESIGN.md and a section instead of the merged notesChristophe Besson10 days15-24/+26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-feature design notes were merged into docs/MESHBAY_DESIGN.md and deleted from the tree on 2026-09-11, but ~230 comments across the three packages still named them — usually written `docs/musicbay.md §3.2`, as though the file were still in docs/. A reader had to know §16 existed to resolve any of them. They now name the section directly. Every mapping comes from §16, the concordance, which already records where each old section landed: musicbay -> §9.8, mediacenter -> §9.7 for the Videos app and §6.5 where the subject is derived data, photos -> §9.9, auth-confirm -> §3.6, refactoring-search -> §9.11, invite-pairing-v1 -> §3.4, per-node-identity-v1 -> §3.2, captcha -> §7.7, chat-sender-keys -> §4.5, apps/refactor-groups -> §9.1–§9.4, desktop-client-v1 per section. Bare citations of the same documents (`draft-v6 §2.11`, `§4.8`, `§3.4`) are retargeted too: those collide with real section numbers in the design document, so leaving them would have been worse than the named form. Four cases the concordance does not cover, each decided rather than guessed: Sub-item references into documents that no longer exist — mediacenter's `§3.3 row 4`, `§3.4b/c`, `apps.md §3b` — name rows and sub-items §9.7 and §9.2 do not reproduce. The module-level citation stays; the sub-item pointer is dropped. The V-findings keep their labels but lose the dead `§10.1/` prefix. §13.8 lists V1–V13 as per-application open items, which is not what the labels mean in these comments, so pointing them at §13.8 would have been a false citation. `apps.md §5`'s virtualization requirement has no counterpart anywhere in the design document. The requirement is stated in the comment itself, so the citation is dropped rather than aimed at a section that does not say it. Comments that attributed a *sentence* to an old note — musicbay's "several thousand files" example, its "what got measured" note, its measured ~11%/~26% cover-art figures, the "original no root, whole shared tree" call — state the fact without attribution now. §9.8 does not contain those sentences and citing it for them would have been wrong. CLAUDE.md's "a reference to a document that no longer exists" row now says the concordance is for git history and out-of-tree material; the code cites sections directly. Verified: 2851 passed, 4 skipped. The 12 errors in the run are the Firefox leg of test_sticky_header.py's browser harness, which is broken at the browser level on this machine — headless Firefox (snap) dies with `[GFX1-]: RenderCompositorSWGL failed mapping default framebuffer`, renders nothing, and the probe exits `{"error": "no measurement"}` after its full 90s wait. Chrome runs the same 12 assertions in 3.2s and passes. Nothing here can affect it: every changed line in style.css is inside a comment. Also checked: ast.parse on every changed .py, `node --check` on every changed .js, the /* */ balance in style.css, and that no changed line exceeds the width its file already used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): a group whose node is down is no longer something the reader waits forChristophe Besson10 days2-0/+318
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A node is a machine in somebody's house, so with a handful of groups one is always off. Search treated that as the exception and charged the reader for it. Measured on twelve groups against a virtual clock, a live group answering in 200 ms and a dead one taking the full deadline: one node down in the first batch put a blank page and a progress bar in front of the reader for **ten seconds**, while two groups of that same batch had answered in two hundred milliseconds and nine others had not been dialled at all. Four down, spread out — the shape a real reader has — was ten seconds to the first result and **forty** to the last. Two causes, neither of them the connection deadline. Results were drawn once a batch was complete rather than as each arrived, so an index already in hand waited on a node that was not answering. And the batches were sequential, so a dead group did not merely cost its own deadline, it postponed every group behind it. So the three is a ceiling on concurrency and never a batch — `inFlight` starts the next group the moment one ends — and each index is drawn when it lands. That alone is not enough, and the tests say where it stops: a ceiling still lets silent nodes hold every place at once, and with four of twelve down the last three live groups still waited out a deadline. So the browser remembers which groups were silent and dials them last, which puts all eight on screen in 600 ms. The list is advisory and rewritten from what each sweep saw: a private window, storage that refuses, a first visit or a node that has come back all fall through to the hub's own order, cost one sweep, and correct themselves. The "n groups unreachable" line also waited for the sweep to finish, which is the one moment it is no longer needed. It now appears as they are found. First result, before and after, twelve groups: one down 10 s → 200 ms; four down 10 s → 200 ms with every reachable group on screen by 600 ms; three down and listed first 10 s → 200 ms from the second visit on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): Search waits on a connection that stalls, not on one that is slowChristophe Besson10 days2-0/+258
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Opening a group from the sidebar has no deadline of its own and gets the transport's: 30 s for the DataChannel, 30 s for each request after it. Search wrapped the same `connect()` in a flat 10 s, and that 10 s had to cover the hub round trip, ICE gathering (capped at 4 s in transport.js), DTLS, the channel opening and the handshake's own round trips. On a phone on 4G the budget was met by luck rather than margin, and the same group then failed in Search while it opened from the sidebar, against the same node. The budgets were inverted: the phase full of round trips had a third of what one request on an open channel got. Raising the number would have been the wrong repair. `fetchAllIndexes` fans out three at a time and waits for the slowest of each batch, so a page of unreachable groups costs batches x the deadline in spinner: a bigger number taxes every dead group for the sake of the live ones. So the deadline measures stalling. A node that is not there reports nothing and still fails in `SEARCH_STALL_MS`, unchanged at 10 s, which is what keeps the fan-out where it was. A node that answers ICE, then opens a channel, buys another window at each step, up to `SEARCH_MAX_MS` — a deadline that only ever resets has none, and a node that answers and then goes quiet would otherwise never be given up on. The transport reports those steps through `onConnectProgress`, set by the one caller that imposes a deadline of its own. `connected`/`completed` is the signal and not `checking`, because the first means a candidate pair answered and the second means this side is still trying addresses that may all be dead. A caller's callback cannot break the connection it is reporting on. The tests run the shipped `connectToGroup`, lifted out as text, against a fake clock — a real one would make each scenario a minute and blur the only thing worth asserting, which is when the deadline fires. Dead node: 10 s. Slow but moving: connects at 20 s where it used to fail at 10. Answers then stops: 18 s. Progress that never finishes: the 30 s ceiling. Two dead nodes: two windows, both transports closed. Checked against the flat deadline, which fails three of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): take the availability poll and every upload write off the loopChristophe Besson11 days1-7/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* feat: decode and re-encode video on the GPU where there is oneChristophe Besson11 days1-0/+49
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* fix(cast): put the relay's progress log behind the same switchChristophe Besson11 days1-18/+37
| | | | | | | | | | | | | | | | | | | | | | | Most of what the relay says repeats without bound — a line every fiftieth fragment for the length of a film, one per dropped fragment whenever a client falls behind — and it lands in the terminal the app was started from. Those go through `debuglog` now, restored with: NODE_DEBUG=cast-relay npm start The loss of fMP4 framing stays on console.warn. It recovers by rescanning, so nothing throws and no other part of the system hears about it; that line is the only trace that the picture on the television is missing a piece. Every other failure here throws, and the renderer already reports the rejection. The logging guard now covers both cast modules, each with the one line it is meant to keep audible. Also corrects the header comment, which still claimed CORS was granted on the subtitle path only. It has been on both since the stream needed it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(cast): keep the Chromecast progress log out of the terminalChristophe Besson11 days1-0/+27
| | | | | | | | | | | | | | | | | | The receiver reports its state on a timer, so `player status: PLAYING` repeats for as long as a film runs and buries everything else in the terminal the app was started from. The progress lines now go through Node's own `debuglog`, which costs nothing when disabled — the message is never formatted — and is restored with: NODE_DEBUG=cast-chromecast npm start The socket error stays on console.error. It fires when the connection to the receiver dies and is the only trace of why a cast stopped; routing it through the same switch would make the one failure worth seeing the one that disappears. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* feat(cast): carry subtitles to a Chromecast, on the relay's clockChristophe Besson11 days2-5/+401
| | | | | | | | | | | | | | | | | | | | | | | | 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
* feat: tell a forced subtitle track from a full oneChristophe Besson12 days1-1/+36
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as "I click a subtitle and nothing appears", on three films. Nothing was broken. The track selected was the container's forced track, which carries signage and foreign dialogue only: measured on the film in question, 30 cues and 77 seconds of text across 2h32 — 0.8% of the running time, against 1559 cues and 41.8% for the full track sitting beside it under the same language tag. At all three positions tested there was genuinely no cue to show; the full track would have shown one at two of them. So the defect is that the menu could not say which was which. The label used the container's title tag, which said "Forced" on that film and says nothing at all on most, and no other field was carried. The disposition is the half that is always there: `probe_video` now reads `forced` and `hearing_impaired`, `stream_init` carries them, and the label states them in the reader's own language rather than repeating an English word a muxer happened to type. The node fixture grows a forced track with no title, because a title would let the old code pass. The label harness's `t` stub took a parameters object unconditionally and threw on a key that has none — a fixture narrower than production, fixed here rather than worked around. Also removes the activeCues probe that found this. It answered its question: mode showing, cues 30, active 0, none due at that instant. 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 Besson12 days2-0/+238
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson12 days2-12/+64
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* auth: a sign-out during a renewal must not write half a sessionChristophe Besson12 days2-0/+216
| | | | | | | | | | | | `{ ..._auth }` after the await spreads a null _auth to {}, so the renewal stored a token with no username and no userId. The app renders the signed-in interface from that and throws on user.username[0] — a blank page on every load, in localStorage, until the site's data is cleared. The sign-out wins the race now, and loadAuth treats an identity-less object as signed out so a browser already holding one heals itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: let the viewer pick the audio trackChristophe Besson12 days1-0/+213
| | | | | | | | | | | | | 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>
* spa: a blank page can never be silent againChristophe Besson12 days2-0/+382
| | | | | | | | | | | | | | boot-guard.js is a classic script loaded before the module graph, so it survives the graph failing to link. If #app is still empty after ten seconds it names what failed and offers a reset of this origin — cache, storage, databases and the service worker, which clearing the cache does not touch. Two real defects found building it: openDB never settled when an upgrade was blocked by another tab, and a connection it gave up on stayed open and squatted the database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: promise the count that holds when nothing compressesChristophe Besson13 days2-7/+50
| | | | | | | | 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 Besson13 days2-0/+98
| | | | | | | | | | | 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>
* menu: give the panel the room that is actually below itChristophe Besson13 days2-2/+142
| | | | | | | | | `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 Besson13 days2-0/+292
| | | | | | | | 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>
* playlists: make the writes actually leave the browserChristophe Besson13 days2-0/+287
| | | | | | | | | | | | | | | | | | | | | | | | | | | Reported from a phone: signing in with the same account showed no playlists. syncWith was called from exactly one place in the interface, so creating a playlist, deleting one, removing a track and saving the queue all wrote to IndexedDB and stopped there. The store pushes itself now, coalesced, so a new mutation cannot forget to. Silence was the real defect. The node audited only successes, so a refusal left no trace and user_blob_list none at all; the background push swallowed its reason; the interface said nothing. All three report now, and "Sync now" says what happened either way. An unreadable blob on a node was treated as a fetch failure and returned before the push — permanent, once the node held anything. It is an absence: the client is the authority, and it gets overwritten. A sign-in reconciles whatever this browser already holds, a pending push is flushed when the page goes away, and a push that did not land is retried once. no_key is spelled out: a client that signs in with its remembered device key only ever has a bundle key persisted before the playlist subkey existed, and an AES handle is non-extractable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* music: pool single-album artists into shared rowsChristophe Besson13 days2-0/+449
| | | | | | | | | | | | | | | An artist with one album got a heading and one cover on a row that fits five, and a library is mostly single-album artists. Consecutive singles share one grid, in place, so the page stays in artist order. Each pooled cover keeps its artist's name above it in the same type as a section heading. Dropping it was the first version and it was wrong: scrolling then alternates between artists written large and small. Measured on the probe's fixture: 4208px to 1895px, and a walk of the page reaches all 21 covers instead of 9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* music: the playlist menusChristophe Besson13 days6-10/+555
| | | | | | | | | | | | | | | | | | | | One button in Music's sticky toolbar — load, create, delete, remove a track, sync now — and "add to playlist" on every cover and row. Both surfaces share one list, read from the manifest, so they open instantly with every node offline and no body is fetched until one is wanted. Submenus expand in place rather than flying out: the account menu's language list already does this, and a flyout has nowhere to go at 400px. The tracklist under "remove a track" loads when it is expanded. A name is typed into a field. Electron has no prompt — it throws. Also splits the two playback failures: a decode failure belongs to that file and keeps the bounded counter, a connection failure belongs to the group and skips all of its queued tracks at once. Six dead tracks are one more than the bound, which is where a playlist would otherwise stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: the store, and putting it on nodesChristophe Besson13 days2-0/+451
| | | | | | | | | | | | | | | | | | playlists.js is IndexedDB, WebCrypto and a transport, and node has no IndexedDB — so it is driven in Chrome against a node stubbed to record what it was handed, which is also how what leaves the browser is checked to be sealed. Sync asks the node what it holds (user_blob_list) rather than comparing against the merged watermark, which says nothing about that node: the first version pushed every body on every sync. A tombstoned playlist's body is deleted as each node is reached, or the quota fills with graves. The database version and its stores stay in hub-client.js — two modules opening one database at versions of their own is a VersionError thrown at whichever runs second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: merge rules, sealing, and the keyChristophe Besson13 days3-0/+874
| | | | | | | | | | | | | | | | | | playlist-merge.js and playlist-crypto.js have no imports and are run by their tests, which is the only real evidence this feature can have. Two revision counters per playlist, not one: a rename on one device and a track added on another both write n+1, and a single counter makes two edits that do not overlap collide. One Argon2 run at sign-in, two handles. The AES handle is imported non-extractably, so nothing can be derived from it — hence a second import of the same bytes as HKDF rather than a derivation. Measured: ~270 bytes a track, deflate worth 4.5x on realistic data, so the 1 MB body cap holds about 17000 tracks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* music: play, play next, add to queueChristophe Besson13 days5-0/+746
| | | | | | | | | | | | | | | | The player's queue could only be replaced: every onPlayQueue reset tracks/order/pos together. It becomes one reducer (queue-ops.js) with an `op`, because two appends batched into one tick cannot both read the track count out of separate useStates. A shared pop-up menu (menu.js) carries the three verbs, on right-click and on a dots button. A track row is now a div holding two buttons: a button cannot contain a button. Found by the browser probe: both music wrappers took two arguments and forwarded two, so every "add to queue" arrived as a plain play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): the Files listing sorts its folders by the chosen column tooChristophe Besson13 days1-0/+84
| | | | | | | | Folders were always ordered by name, whatever the column and direction, so reversing the name sort or sorting by size moved only the files. A folder's size is what it holds and its date its newest file; ties go to the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): drop files and folders onto Files to upload themChristophe Besson13 days2-3/+111
| | | | | | | | | 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 Besson14 days5-5/+95
| | | | | | | | 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>
* fix(ui): a group opened directly lands on the preferred app once preferences ↵0.14Christophe Besson2026-09-152-3/+44
| | | | | | | load Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
* fix(ui): sidebar stays above a phone's address bar and does not scroll the pageChristophe Besson2026-09-152-0/+215
| | | | | Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
* feat(hub): session lifetime is an admin setting, and a browser signs out ↵Christophe Besson2026-09-155-7/+326
| | | | | | | | | | | 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 Besson2026-09-1528-447/+505
| | | | | | | 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
* fix(ui): Search says nothing about groups it leaves outChristophe Besson2026-09-151-1/+0
| | | | | Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
* fix(win): a service-mode daemon can be replaced, and the Node page can link oneChristophe Besson2026-09-141-0/+50
| | | | | | | | | | | | | | | | | | | | | | | | | | | Two live-reproduced bugs in Windows node start/stop, found sideloading the 0.14.0 build: - node:start's crash-recovery step killed a service-mode daemon with taskkill/CTRL_BREAK, both of which fail with "Access is denied" against a process running under the Scheduled Task's own S4U logon session (a different session from the Electron app's). The daemon it was meant to replace just kept running, unreplaced, and schtasks /run on a task Windows still considered Running was then a silent no-op too. Route through winServiceTaskEnd() (schtasks /end) first, the way nodeServiceStop/ nodeServiceRestart already correctly do. service-mode.ps1 also now starts the task right after registering it -- Register-ScheduledTask's own AtStartup trigger does not run it immediately, so nothing was listening until the next reboot. - The Node page's Start button called node.start() with no arguments, so an unlinked node (a fresh install, or one whose hub-side link was lost) could never link on Start alone -- only create-group-page.js's own call passed {hubUrl, username, token}. Reproduced on a fresh non-service install signed in to the real hub: Start hung for ~105s and failed with "could not link", pointing at a "Link Node" control that lives on Settings, not the Node page (that message is fixed too). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: a group can be left out of Search, and Search tries every nodeChristophe Besson2026-09-143-2/+127
| | | | | | | | | | | | | | | | | | | | | | | | | | | `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
* fix(ui): a signed-in person is never shown the sign-in formChristophe Besson2026-09-141-0/+60
| | | | | | | | | | | | | | | | The router rendered `#/login`, `#/register` and `#/reset` before it checked for a user. A browser signed in as one account, opening on a `#/login` left in the address bar, drew the login form (prefilled by the browser with another account) under a navigation bar and a sidebar already showing the first account and its Administration entry. `#/login` and `#/register` now need no one signed in, and a signed-in person landing on either is sent home with `location.replace`, so Back does not lead to the form again. `#/reset` stays reachable: that flow signs in half-way and still has its progress and result to show. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
* feat(ui): an indexing dock above the music bar, on every pageChristophe Besson2026-09-142-5/+231
| | | | | | | | | | | | | | | | | | | | | | | | | Adding a large directory left the operator nothing to look at once they left the Settings panel that started it, and nothing at all when it was added from another machine. A band now sits above the music bar on every page: one row per group with indexing under way, naming the root being walked, percent, bytes and files, and the roots waiting their turn; "indexing finished" for a few seconds at the end. A click opens the group's Settings, and × hides the row until that group is idle. Two sources feed it. On the node's own machine the desktop client polls the loopback `GET /api/index-status` for every group, whatever the route. An operator's group page forwards MNP `index_progress` pushes, resolving the root from the roots table it opened; an ordinary member keeps the sidebar dot only, and a page clears its row when it lets go of the group. Where both describe a group, loopback wins. Reconcile passes and watchdog bursts show only past 1 GB or 5 s, so a single dropped file does not flash a bar. The logic lives in index-dock-model.js, which has no imports and is tested under node. The dock publishes `--index-dock-h` and the sidebar stops above it and the music bar. 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 Besson2026-09-141-0/+91
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson2026-09-144-2/+469
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson2026-09-132-1/+53
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* fix(hub): the migration chain runs, and a test says soChristophe Besson2026-09-131-0/+86
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | `add_email_verification` wrote PostgreSQL's `(now() at time zone 'utc')` as a literal server default, where every other migration in the chain uses `sa.func.now()` and lets the dialect render it. On SQLite that is `sqlite3.OperationalError: near "at": syntax error` — so `meshbay-hub migrate` could not reach head on the database the suite and the documented local-hub workflow both use. Which is how it survived: the only test that ran alembic at all stopped at `c3d4e5f6a7b8`, the revision immediately before it. The two newest migrations — email verification, and the mail quota committed two days ago — had been run by exactly one thing, a production deploy, and the newest by nothing at all. `test_migrations_reach_head.py` upgrades to head and compares what that built against `Base.metadata`, both directions: a column in the models and in no migration never reaches production, and one in the migrations and in no model is a rename abandoned halfway. Both tests fail on the unfixed migration with the error above; the drift half was checked by adding a model column on purpose and watching it be named. The schemas agree today. It still does not check PostgreSQL-only behaviour — a default, an index type or a constraint one dialect accepts and the other refuses. Running the chain somewhere beats running it nowhere, and is not the same as running it where it ships. 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 Besson2026-09-132-0/+135
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(packaging): the hub unit can start, and nothing carries the migration pathChristophe Besson2026-09-121-0/+101
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `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 Besson2026-09-121-0/+75
| | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* fix(hub): the mail allowance is written down, and recovery keeps a shareChristophe Besson2026-09-121-72/+303
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two dicts in `mail.py` held the budget, so every deploy handed out a fresh one — and this hub is deployed several times a day. A bound a restart forgets is not a bound, for the reason the denylist is persisted rather than held in memory (S3). It is a `mail_quota` table now, one row per counter, the recipient hashed so the table does not become a list of plaintext addresses. The counting moves with it, into an async `reserve` that has a session, and `send_off_loop` is the one door it stands in. `_send` keeps the purpose allow-list: that half needs no state, and it is what stops anything which puts a message on the wire from naming a reason this hub does not send for. The caller owns the commit, so a request that fails afterwards is not charged for mail nobody received. `hourly_reserved_for_recovery` is new. A flood of sign-ups used to be able to spend the whole hour and lock out the person waiting on a passphrase reset; registration and address changes may now spend only the unreserved share. Values changed as agreed: 10 messages a day to one recipient, 300 s between two reset codes. The address-change ceiling and its cooldown were two bounds on one thing — 3 a day and 60 s apart — and collapse into one 48-hour delay. Asking again for the address already pending is exempt: it reaches no new recipient, that recipient is bounded anyway, and without the exemption a typo locked the account out of correcting it for two days. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix(hub): MHP binds its audience, and the hub knows its own nameChristophe Besson2026-09-122-17/+72
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Federation has never worked between two hubs, and the tests said so without anyone reading it that way. `federation.py` did `from meshbay_hub.auth import _hub_id, _hub_sk_pem` at import — which is before `load_hub_keypair` runs. So it held the key as `None` and the identity as the module default: `_issue_mhp_token` could only raise, and `/mhp/info`, the directory export and every token announced this instance as `meshbay.org` whatever it was configured as. Read through accessors now, at call time. And `_verify_mhp_token` named no audience while `_issue_mhp_token` sets one. PyJWT refuses a token carrying `aud` when decode is given none, so every token this hub issues was rejected by every hub running this code. Naming the audience fixes that and makes the binding real: a token minted for one peer is refused by another, which is what stops a captured request being replayed at a third hub. The comment claiming audience binding was unavailable because "the sending side is unbuilt" was describing a function four lines below it. Both were already written down. `test_federation.py` built envelopes by hand without an `aud`; `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, and both were treated as facts to route around. When a test has to work around the code to run, the thing it worked around is the finding. Those helpers now go through the real issuer, and two tests pin the identity and the audience refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix(hub): the mail server is not a relayChristophe Besson2026-09-123-3/+326
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit metered the paths that send mail. It was not enough, and saying it was would have been wrong: a 60-second cooldown per account still allows one stranger a minute — 1440 a day — and registration is open, so "per account" is a bound an attacker buys more of. And there was a third door nobody had counted. POST /v1/users/register an address nobody has verified PATCH /v1/users/me an address nobody has verified, signed in POST /v1/users/password/reset only the address already on file POST /v1/groups/{id}/invite-notify only a registered member's address The widest was the register *resend* branch: no token, no captcha, and the username and address are the caller's own from a moment ago — registering a victim's address once bought the right to mail them at the endpoint's rate limit for as long as the account stayed pending. So the bound moves into `mail.py`, where every message passes one function. `purpose` is keyword-required and checked against a closed list, so a helper that names anything else does not send and one that names nothing is a TypeError rather than an unrestricted send. Under it: - a bound per **recipient**, across every purpose, account and endpoint — what a person being mail-bombed actually experiences, and the only bound that describes it. Keyed on a hash, because this would otherwise be the one place in the hub holding plaintext addresses in memory (S2) - an instance-wide hourly ceiling, which cannot be bought with more accounts - a cooldown on the resend branch, a cooldown and a daily ceiling on the address change, and the IP-log entry that endpoint never wrote — alone among the ones that mail The ceiling on address changes counts IP-log rows, not EmailVerification: the handler deletes this account's unverified rows before writing a new one, so counting those counts one, always. Which is what the first version of it did. Refusals never carry the address: that line goes to the journal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix(hub): no mail from the event loop, and a ceiling on every path that sends itChristophe Besson2026-09-121-0/+132
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `users.py` and `admin.py` under the availability lens. `admin.py` needed nothing — its moderator/admin line is drawn explicitly, self-modification is refused, and every list it serves is bounded. `users.py` had four findings and one of them is the worst of this whole pass. AV9 `mail._send` is `smtplib` with a ten-second timeout, called straight from four async handlers. That wait is not one request's, it is the instance's: nothing else served, no node socket read, no WebRTC offer relayed, until the MTA answers. Reachable by any signed-in user at request rate through the endpoint below. It has no symptom a test catches — everything simply works slowly, for everyone, whenever the mail server is having a bad day. AV10 `PATCH /v1/users/me` is the third path that makes the hub send mail and the only one with neither a rate limit nor a captcha, while `register` and `password/reset-request` have both. The address is any string the caller types and the duplicate check only rejects one already held by an account here, so every address *not* registered on this hub was a valid target: a relay for verification codes with the hub's own reputation attached. A rate limit counting by IP bounds a caller and not an inbox, so the floor under it is a cooldown per account — the same for a reset request, whose cost also lands in a mailbox that is not the asker's. AV11 `default_tab:` accepted any suffix on a `{key:path}` route with an unbounded Text value and no cap on rows: one account could write without limit into a table shared with everyone. The suffix is a group id, which is what the SPA writes, so it is checked as one. A key over 64 characters was also a 500 rather than a 400 — the column is String(64), which PostgreSQL enforces and SQLite does not, so it would have appeared in production and in no test. AV12 `/v1/notifications` and `/v1/groups` had no upper bound on `limit` and no floor under `offset`, while every list in `admin.py` carries `le=200`. The group directory takes no authentication at all. Two shapes recur and are now named in §13.5b: a limit written on one of several equivalent paths, and a bound that counts the wrong thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
* fix: bound what one member can cost the othersChristophe Besson2026-09-124-7/+410
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An availability review, prompted by the group claim above: a participant supplies input — who else bears the cost? Six answers where the cost fell on someone other than the sender, and none of them needs an attacker. AV3 `chat_notify` carried a `group_id` the hub believed, so any connected node could write a notification to every member of any group on the hub, carrying a display string of its choosing, with its account having no relation to that group. This is the group claim again, two hundred lines further down the same socket. Gated on what the node is registered for, and metered: the fan-out is one write per member. The budget expires by time rather than on disconnect, or reconnecting would refill it and a node token is good for an hour. AV4 A swarm source named its own `endpoint` as free text documented as "ip:port", so an account could publish a third party's address — H6's `peer_ip` defect, never applied here. Nothing dials a swarm source today, which is the only reason it was not already a reflection primitive. It is a transport and a port now, never a host, and the number of hashes one account may claim is bounded: rows were keyed (hash, account) with no cap at all. AV5 `handle_webrtc_answer` resolved any pending `peer_id` from any node's socket. The answer is the SDP a browser then connects to. That this had not happened rested on a uuid4 being unguessable. AV6 `relay_register` had no authentication of any kind: it compared `pk_relay` against the approved value, which is a *public* key, so anyone who could read it could rewrite where the hub tells nodes to send relayed traffic. The module docstring promised signed JWTs and `jwt` was imported and never used. AV7 The node held unlimited peer connections and kept one that never completed a handshake for the life of the daemon. H6 bounded what one unauthenticated peer costs; the hub's cap is three offers in flight per *account*, a limit on each caller and not on the machine, so an operator's exposure grew with the size of their groups. AV8 `invite-notify` put a request-supplied `group_name` into the subject of an email the hub sends under its own domain, to any account, with no rate limit. The name comes from the group row now. The tests are two accounts each, in one file that says why: a one-member test proves a one-member property, and every finding here needed a second person to exist at all. Each was checked against the unfixed code. Two did not survive that check and were rewritten — one re-enacted the disconnect path instead of running it (hence `forget_node`), the other called the reaper itself and would have passed with the call removed from `handle_offer`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT