aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
* feat(hub): give the audio-language button a glyph of its ownChristophe Besson10 days2-1/+10
| | | | | | | | | | | | | | | | The audio-track selector was drawn with `volume` — the same glyph the music player uses for its level control, and the same one the browser draws a few dozen pixels below it, since the video player mounts `<video controls>` and gets the native volume speaker with it. One drawing, two meanings, in one window. `speech-pair` is two offset bubbles: the same line, said twice, which is what the button chooses. Nothing crosses anything — the set is stroked with no fill, and two overlapping outlines read as a tangle at 20 px — and it leaves the conventional subtitle glyph free for the button planned beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* docs(site): the hub's password verifier is 64 MiBChristophe Besson10 days1-1/+1
| | | | Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): the password verifier is Argon2id 64 MiB, and a hash's version ↵Christophe Besson10 days6-27/+116
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson10 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 Besson10 days4-8/+292
| | | | | | | | | | | | | | | -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>
* auth: a sign-out during a renewal must not write half a sessionChristophe Besson10 days3-1/+242
| | | | | | | | | | | | `{ ..._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 Besson10 days22-63/+860
| | | | | | | | | | | | | 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 Besson10 days5-2/+621
| | | | | | | | | | | | | | 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 Besson10 days5-19/+78
| | | | | | | | 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 Besson10 days15-19/+189
| | | | | | | | | | | 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 Besson10 days3-5/+221
| | | | | | | | | 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 Besson10 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 Besson10 days5-23/+215
| | | | | | | | | `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 Besson10 days4-6/+318
| | | | | | | | 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 Besson10 days17-15/+633
| | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson10 days4-18/+549
| | | | | | | | | | | | | | | 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>
* docs: playlists, corrected against what building it neededChristophe Besson11 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>
* music: the playlist menusChristophe Besson11 days23-34/+1248
| | | | | | | | | | | | | | | | | | | | 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 Besson11 days4-1/+952
| | | | | | | | | | | | | | | | | | 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 Besson11 days7-4/+1340
| | | | | | | | | | | | | | | | | | 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>
* mnp 3.1: per-account blobs the node cannot readChristophe Besson11 days8-10/+822
| | | | | | | | | | | | | | | 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>
* music: play, play next, add to queueChristophe Besson11 days24-97/+1431
| | | | | | | | | | | | | | | | 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>
* docs: playlists — storage split, queue ops, and the UI designChristophe Besson11 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>
* fix(hub): the Files listing sorts its folders by the chosen column tooChristophe Besson11 days2-11/+123
| | | | | | | | 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 Besson11 days17-42/+492
| | | | | | | | | 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 Besson11 days25-82/+428
| | | | | | | | 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(node): purge the audit log past its retentionChristophe Besson11 days2-2/+61
| | | | | | | | AuditStore.cleanup was never called, so audit.db kept every entry. The daemon now runs it at start and daily. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
* fix(ui): a group opened directly lands on the preferred app once preferences ↵0.14Christophe Besson12 days3-7/+67
| | | | | | | 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 Besson12 days3-0/+222
| | | | | 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 Besson12 days28-48/+835
| | | | | | | | | | | 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 Besson12 days41-450/+531
| | | | | | | 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 Besson12 days12-24/+5
| | | | | 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 Besson12 days6-11/+182
| | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson12 days27-48/+620
| | | | | | | | | | | | | | | | | | | | | | | | | | | `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 Besson12 days2-1/+70
| | | | | | | | | | | | | | | | 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
* Update spec contact to devel@meshbay.orgChristophe Besson12 days8-8/+8
|
* chore: bump version to 0.14.0Christophe Besson12 days8-8/+8
| | | | | 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 Besson13 days18-12/+797
| | | | | | | | | | | | | | | | | | | | | | | | | 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
* feat(node): progress names the root under way and the roots waitingChristophe Besson13 days9-44/+457
| | | | | | | | | | | | | | | | | | | | | | `IndexProgress` said "scanning, this many bytes of that many" and nothing more. A group's roots are walked one after another, so a second directory added during a large scan showed as the bar jumping back to 0 %. It now also carries the root being walked and its position in the roots table, the kind of walk (scan, rescan, reconcile, watch), file counts, and the roots waiting for the scan lock in order: queued by the initial scan, by a retarget, and by a plug; dropped when a root is removed. `GET /api/index-status` answers for every group at once, including a group still in its initial scan, so a client can show indexing on any page. It names roots: loopback only, like `current_dir`. `index_progress` and the handshake ack gain the same counters, still naming nothing (decision D3): the root is a position in the roots table the member already opened from the sealed index, and the queue is a count. The pusher keeps speaking while a root only waits for the lock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
* fix(node): a scan keeps its progress while a burst or a plug runs beside itChristophe Besson13 days2-23/+224
| | | | | | | | | | | | | | | | A watchdog burst wrote the indexer's single `progress` directly. A file dropped into a folder during a large scan added its size to the scan's total, then cleared `scanning` when its own hash finished, so the progress went blank with hours of hashing left. Bursts now keep their own counters, shown only while no whole-root walk runs. A plug rescan took no scan lock and walked its root beside an added root's scan, both resetting the same counters and reading the drive in turn. It now waits for the lock, and skips the rescan if the root was ejected or removed while it waited, since the rescan drops the entries before it walks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
* fix(node): a reload and a plug rescan outlive the session that askedChristophe Besson13 days3-4/+212
| | | | | | | | | | | | | | | | | | | | | | | | A root added from the client arrives over MNP, and _retarget_indexer started the daemon's reload with the session's own _spawn. When that session closed - a client reconnecting 47 s into the scan of a 900 GB root - shutdown_tasks() cancelled the reload mid-scan, and the reload queued behind it, without a line in the log. The new root was in node.toml and in the indexer's set but never in the group's context; the lock was free and nothing retried, so the node served the old roots table for hours while reconcile hashed the whole drive as missed events. One loopback reload fixed the live node in 9 ms. _reload_config now runs the work in a node-owned task and awaits it through asyncio.shield, so a caller that goes away only stops waiting; a cancelled reload is logged. plug_root does the same for its rescan, which drops the root's entries before walking the disk and so left the root empty when its admin op's session closed. The existing MNP test replaced _spawn with a list and could not cancel anything; the new tests close the session for real and fail without this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
* fix(node): an added root is served before it is scannedChristophe Besson13 days6-8/+343
| | | | | | | | | | | | | | | | | | | | | | | | Adding a large directory to a running group made the reload await the scan of the new root before putting the new RootSet in the group's context, holding _reload_lock the whole time. For the hours a large drive takes to hash, the node served the old set: - a file request under the new root got None from entry_abs_path and the handler died on None.exists() without replying; - a writable/removable toggle answered with the live table, still the old one, so the directory vanished from the operator's settings; - reconcile saw every file the scan had not reached as a missed event and hashed it again on the same executor, rewriting progress under the scan. retarget now applies the set, the roots table and the watcher first, and with wait=False scans the added roots in the background; the daemon swaps ctx["roots"] before calling it. A scan lock shared by the initial scan, added-root scans and reconcile makes the reconcile loop sit out a running scan without backing off. Every transport site that resolves an entry answers ROOT_NOT_SERVED instead of crashing, and a delete keeps the entry. 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 Besson13 days5-14/+150
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson13 days30-41/+1044
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson13 days6-4/+133
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson13 days2-0/+19
| | | | | | | | | | | | | | | | | | | | | | | | §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): the migration chain runs, and a test says soChristophe Besson13 days3-2/+106
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | `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 Besson13 days5-27/+210
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson13 days3-0/+314
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson13 days4-19/+212
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `_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