aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
Commit message (Collapse)AuthorAgeFilesLines
* fix(hub): offer ceilings that bound a member without failing an ordinary oneChristophe Besson46 hours1-3/+61
| | | | | | | | | | | | | | | | | | | | | | | | | | | A phone on 4G found one of five groups missing from Search on half its visits, and all but one on a fifth of them. Every offer that reached the node was answered in about a second; the hub refused the others with 429 — 93 of them in half an hour on meshbay.org, all from that phone, all to nodes that were up. Two ceilings did it. Three pending offers per account was exactly what one Search page dialled at once, so the first tile or reconnection beside the sweep was refused. Thirty offers a minute per address and per node was spent by four or five reloads of a page whose groups share one node. Sized now against an account with twenty groups on three devices: - 32 offers pending per account, across devices and nodes. - A budget per account and per node, a burst of 120 refilled at two a second. This is what bounds a member's cost to one machine (H6), and it leaves their other nodes alone. Counted by account, because a mobile carrier puts many subscribers behind one IPv4 address. - 600 a minute per address and per node, as a coarse guard in front of authentication only. Both refusals carry Retry-After, which the browser now honours. The node's own ceiling on peer sessions is unchanged; its comment no longer quotes the old per-account number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): a redeemed invitation link leaves the owner's listChristophe Besson2 days12-27/+26
| | | | | | | | | | | | | | | | | | | | The list under "Invite by link" answered every ticket the group had ever minted, so a link that somebody had already used sat there saying "used by <name>" for the thirty days of KEEP_REDEEMED — beside the member row it had just produced, and above the links that still wait for somebody, which are the only ones there is anything to do about. The node's own `member list` had never shown them: it selects `used_at IS NULL`. The listing now selects `redeemed_by IS NULL`, and drops the `redeemed` status and the `redeemed_by` field with it. The row itself still lives for KEEP_REDEEMED, which is what lets a reload or a second tab of the invitation page be answered rather than refused; its comment says that now instead of naming a list it is no longer in. The SPA filters too, because the desktop client's copy of this interface can be newer than the hub it is signed into. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): an invitation no longer holds back its invitee's sign-up codeChristophe Besson2 days1-5/+23
| | | | | | | | | The per-recipient cooldown was shared by every purpose, so the code a person asked for by registering within two minutes of an invitation link was refused, silently. The cooldown is now per family (invitations vs the account's own steps); the daily cap still counts everything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): open, create and join invitation links in the interfaceChristophe Besson2 days18-9/+919
| | | | | | | | | | | #/invite takes the link out of the address on load and keeps it in the tab through registration and sign-in; joining is one click, only the ticket goes to the hub, and the code goes only to the node the link names once it has signed its challenge. Members tab gains "Invite by link" (shared e-mail box, pending list, cancel both halves); home page takes a pasted link. Browser probe drives the real app, signed out and in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): invitation-link tickets bound to a verified addressChristophe Besson2 days20-8/+507
| | | | | | | | | | | group_invite_links holds sha256(ticket) and the invitee's address blind index; redeeming grants membership to that account only. Owner-only create/list/cancel (a node token may create, never mail), 20 outstanding per group, optional mail written by the hub itself and capped at 10 per sender per day (mail.invite_link_daily_cap). MESHBAY_DESIGN.md §3.4 now carries the whole link design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat: the node signs its handshake challenge (MNP 3.4)Christophe Besson2 days2-3/+48
| | | | | | | | | node_pk in handshake_challenge is now signed over the channel binding and both nonces, so a client can check the node key before a join rather than only at the ack. Both transports; the browser and the QUIC client refuse a wrong signature and treat an absent one as an older node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): make mailing an invitation a remembered choiceChristophe Besson2 days12-17/+65
| | | | | | | | A "Send the invitation by e-mail" box under the Invite member field, checked by default and stored as the invite_email preference. Unchecked, invite-notify is never called and the hub never sees the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): right-click menu in the Files tabChristophe Besson2 days11-64/+97
| | | | | | | | The toolbar's actions on the row under the pointer, sharing one action list with the toolbar — which keeps showing what does not apply, disabled, while the menu leaves it out. A count only where more than one item is concerned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: ask in the page instead of native confirm/alertChristophe Besson4 days21-30/+152
| | | | | | | | A native confirm() or alert() leaves the desktop client unable to type until the window is refocused. ask.js draws both in the page; the SPA test now bans all three browser dialogs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): removing from a group no longer unpins the accountChristophe Besson5 days1-4/+16
| | | | | | | | | | The node half called `unpin` after `revoke`, and an unpin takes no group: it deletes the identity and every member row the account holds on this node, and drops the stored keypair bundle with them. Taking somebody out of one group took them out of all of them, silently. `revoke` is group-scoped and is what withdraws the key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): keep the video read-ahead across a reconnectionChristophe Besson5 days1-9/+164
| | | | | | | | | | | A reconnection restarted the stream at the playhead, and that empties the source buffer — spending the whole read-ahead at the one moment it was the thing carrying the film. It carries on from the end of the buffer instead, except where the buffer is short, the stream already ended, or a cast is active: the receiver is fed from the wire and never had that buffer, so resuming there would skip it forward by the lot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(hub): bound video read-ahead by bytes, not by secondsChristophe Besson5 days1-8/+145
| | | | | | | | | | A browser limits bytes, so a bound in seconds had to be sized for the highest-bitrate file and every ordinary one then held a fraction of what the same buffer would have taken. Floored at the old 90 s so nothing pulls less than before, and walked up rather than declared. A refused append now waits for an eviction instead of retrying on every tick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: removing someone who never redeemed their invitationChristophe Besson5 days1-2/+17
| | | | | | | | | | A member row appears only when a code is consumed, so revoking someone invited to the wrong group was refused for having no row — and the node's refusal aborted the browser's removal before its hub half, leaving them a member everywhere with a live code. Revoking now cancels unredeemed codes for that group, and a node refusal no longer cancels the hub removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): reveal button in the sign-in and registration passphrase fieldsChristophe Besson5 days13-9/+90
| | | | | | | One PasswordInput for all three fields, with an eye toggle inside the box. Out of the tab order, type="button" so a click cannot submit the form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: bump version to 0.15.0Christophe Besson6 days1-1/+1
| | | | Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): show the node's public key in Node → OverviewChristophe Besson6 days12-17/+84
| | | | | | | | | | | | | It was only the Node ID row's fallback, so it vanished the moment the node announced itself — and was labelled Node ID when it did show. Own row, copy button, and the profile's instructions no longer name the node dashboard removed on 2026-09-01. `.node-key` wraps: 44 base64 characters with no break opportunity pushed a 412px document to 417px, which unpins every sticky element on the page. The width probe now measures the Overview table with a real key in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): the members table stops hanging off the side of a phoneChristophe Besson6 days1-1/+16
| | | | | | | | | | | | | | | `.admin-table` column heads are `white-space: nowrap`, which meets a translated string: "Username" is one word, "Nom d'utilisateur" is three, uppercased with letter-spacing on top. The heads alone overflowed a 360px window by 11px, and on Android an overflowing document takes every sticky element with it. 11px was only what could be measured: the probe served no members, so the table measured its headers and nothing else. With one realistic username in it the same page overflowed by 248px, at 420px-wide windows too. The cells get `word-break: break-word`, as `.file-name` already had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Merge origin/main: the tree-wide ruff pass beside the Music reconnect workChristophe Besson6 days30-176/+204
|\
| * style: the 98 ruff could not fix, so the linter is a signal againChristophe Besson6 days7-18/+33
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The pass before this applied ruff's own fixes. These are the ones needing a decision, and the point of doing them is that `ruff check .` now passes: a linter reporting 98 known-acceptable findings reports nothing, because the next real one arrives invisible. **Lines over 100 (70).** Mostly wrapped where they stood. Two exceptions: the aligned trailing comments in `protocol.py`'s message table were shortened rather than wrapped, because wrapping one row of a table breaks the table; and in `models.py` the column comments moved above their columns for the same reason. **Imports below the first statement (14).** `csam.py` kept its FastAPI imports under a section header halfway down the file; two node tests had a constant and a `pytestmark` wedged between two import blocks. Moved, not suppressed. **Bindings nothing reads (4).** Three in tests, where the call stays and only the name goes — `_user(client, "listener")` is there to create the user, not to return one. The fourth was in `revocation.py` and was not a lint finding at all: `_connect_and_listen` opened an httpx stream to the WebSocket URL, did `pass`, and then opened the real connection through the `websockets` library. One pointless request per connect, left over from before that library was used directly. Removed, and `httpx` with it. **`l` as a name (4)**, **semicolons (6)** in the POC spikes, and the rest. 2893 passed, the same count as the two commits before it. `meshbay_node/revocation.py` is worth a decision separately: 154 lines that nothing imports, superseded by `hub_client.maintain_ws`'s `on_revocation`. This commit only stopped it failing the linter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| * style: ruff's own fixes, mechanically appliedChristophe Besson6 days28-160/+173
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `ruff check .` had gone unrun long enough to report 568 errors, which is the same as having no linter: the next real finding would have been invisible in the noise. This is the 521 it fixes by itself, in 173 files, and nothing else — the 98 it cannot fix are the next commit. What actually changed: import sorting (225), imports nobody used (87, none of them a re-export — no `__init__.py` is touched, which was the one way this could have broken an import elsewhere), `datetime.timezone.utc` to `datetime.UTC` (69) and `asyncio.TimeoutError` to `TimeoutError` (18), both plain aliases on the 3.12 this project requires, `Optional[X]` to `X | None` (24), and f-strings with nothing to interpolate (19). Checked rather than assumed: every module in the three packages still imports, and the suite is 2893 passed — the same count, test for test, as the merge before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* | fix(hub): a track with no artist tag reaches the Music gridChristophe Besson6 days11-4/+45
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The grid's unit is an album, so a track whose artist tag is empty was drawn nowhere — while `empty` counted it and stayed false, so no message appeared either. An untagged library rendered a toolbar over a blank page, with every track one mode-switch away and nothing saying so. It gets a card, the same shape the singleton folding already mints. No cover is looked up for it, or for any album this file invented: the release name is one the browser wrote, and the request cannot match. music_untagged_probe.py renders the real grid and reads the page back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* | fix(hub): a reconnect re-reads the index, not just the handshakeChristophe Besson6 days3-42/+123
|/ | | | | | | | | | | | | | | | | _reconnectLoop re-did the handshake and nothing else, so a page kept whatever it last saw until someone reloaded it. That is invisible until the node restarts: it rebuilds its index from index_cache.db, which holds no enrichment, and Music is the one app whose enrichment is persisted nowhere — for the length of the re-read pass it serves tracks with no artist, and the album grid drew nothing. onReconnected was one slot the video player took on open and cleared on close; it is a listener set now, and carries the fresh ack. docs/MESHBAY_DESIGN.md §15.3 records the two defects found alongside and not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Merge origin/main: the operator's upload ceiling beside the disk-thread workChristophe Besson6 days11-0/+28
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | One conflict, in §15.3's open list, and it was two changes agreeing rather than disagreeing: this side removed the rows for the third-party search bound and the node-announcement bound because both are now built (AV27, AV28), while the other side kept them and added a new one. Resolved by keeping what is genuinely still open — per-device revocation having no CLI — and leaving the two closed. `webrtc_server.py` merged without conflict but the two sides met inside one function: `_upload_chunk` gained the operator's `max_upload_gb` ceiling from there and the per-group lock and `off_disk` calls from here. Read back rather than trusted: the operator's ceiling now sits inside the critical section that keeps chunk ordering, and the unlink beside it goes to the disk thread with everything else. 2893 passed. The twelve `test_sticky_header.py[firefox]` setup errors are the open Firefox on this machine, as before.
| * feat(node): per-file upload ceiling is an operator setting, default 8 GBChristophe Besson7 days11-0/+28
| | | | | | | | | | | | | | | | | | | | | | Was a 4 GB constant in webrtc_server.py, the same on a small board and on a machine holding a library. Now max_upload_gb in node.toml, on the Node page and via `meshbay-node transfers max-size`, read from the transport context per chunk so a change reaches an upload already running. MESHBAY_DESIGN.md §6.4; §15.3 records a defect found beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* | fix(node): the node decides which files it will transcode, not the playerChristophe Besson6 days1-3/+6
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `BROWSER_INCOMPATIBLE_AUDIO_EXTS` named the two formats no browser decodes and was read by nothing. The player asked for `.wma` and `.mpc` and no others, and `music-player.js` called itself "kept in sync with the node's" constant — so the entire restriction lived in the caller, and a member's own message is not the caller. What that let through: this converts a whole file and holds a transcode slot shared with video streaming while it runs. One message naming a two-hour film spends minutes of the operator's CPU and a slot every other viewer is queued behind. `AUDIO_TRANSCODE_MAX_BYTES` catches the result, after the work is done; only the extension catches the work. The client comment now says what it is — an optimisation that saves asking for a refusal — rather than implying it is the rule. Two tests: a film is refused before ffmpeg is reached, and a WMA still passes, because a gate that refuses everything is not a gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* | fix: the two ceilings §13.5b was still missing, as AV27 and AV28Christophe Besson6 days1-1/+32
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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 Besson6 days4-49/+40
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `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 Besson7 days28-116/+124
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson7 days1-33/+95
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson7 days2-5/+59
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* feat(cast): carry subtitles to a Chromecast, on the relay's clockChristophe Besson7 days2-1/+127
| | | | | | | | | | | | | | | | | | | | | | | | The relay forwards the node's fragments untouched, and those begin at zero at the seek point. The player never notices because its SourceBuffer is given `timestampOffset = start`; a receiver has no equivalent, so the cues are shifted by `-start` before they leave, recomputed at every restart of the relay. Sent as they are, a subtitle would be out by the whole seek. The document is served from the relay's own port at /subs.vtt, behind the same token as the stream and with CORS: a receiver fetches a side-loaded track with XHR from its own origin, and without the headers it fails as a network error with nothing on screen to say so. The URL carries a version because a track is cached by address — changing the cues behind a fixed URL leaves the previous language showing. Cues that end before the stream begins are dropped rather than clamped, so a line from before the seek cannot appear over the first frames after it. The relay is plain Node, so the tests start it and fetch from it rather than reading its source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(node): size the subtitle budget to the file, and extract each track onceChristophe Besson8 days1-1/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Verifying the feature across a library turned up two faults, one of which broke it outright. **The extraction budget was a constant and the cost is not.** Extracting demuxes the whole container, so the file sets the price: measured at 9.8 s per GB on a library held on an external disk — 36 s for a 3.9 GB title, 71 s for a 7.3 GB one. Against a flat 60 s that worked on most of a library and failed on the big films, which to whoever is watching one is indistinguishable from a broken feature. `Avatar.The.Way.of.Water` timed out every time. The budget is now three times the measured rate per gigabyte, floored at the old 60 s and capped so no container can pin a transcode slot indefinitely. The client's own timeout follows it: the node always answers, so that one is a backstop against a silent peer rather than a deadline for the work. **Two clicks ran two extractions.** The cache is consulted on the way in, so a second request arriving while the first was still running missed it and ran its own — seen in the log as two identical extractions of one 4.3 GB file overlapping, each holding a transcode slot and reading the file end to end. Latecomers now wait on the answer the first is producing. The in-flight entry is registered *before* the first await, not after. The first version registered it after the probe, two concurrent requests both got past the check while neither had registered, and the test for it failed — which is the only reason this note can be written from the right side. 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 Besson8 days11-28/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* fix(hub): sample activeCues, the number that decides whether a subtitle showsChristophe Besson8 days1-0/+27
| | | | | | | | | | | | | | | | | | | Attach-time state — one track, showing, cues parsed — was all correct while nothing appeared on screen, so it was measuring the wrong thing. A re-render can replace the <track> and reset a mode nothing sets again, and a cue list that does not cover the playhead looks identical to one that does. The probe samples activeCues for ten seconds alongside the playhead, the mode, and the cue that ought to be on screen at that instant, so "no cue is active" and "a cue is active and is not painted" stop being the same observation. Verified beforehand that the mechanism itself is sound: a real Chrome driven over CDP, fed through MediaSource with timestampOffset 2690 and given a track appended after playback started, reports activeCues 1 on the cue bracketing the playhead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(hub): trace the subtitle path end to end in the playerChristophe Besson8 days1-2/+24
| | | | | | | | | | | | | | | | A subtitle button that spins for ever and never shows a track could not be told apart from a node that answered, a blob that never arrived, or a track attached with no cues in it: the whole path was silent. Every step of it happens on someone else's machine, over a link, against a file that may be gigabytes, so the only question worth asking when it does not finish is which step did not — and nothing recorded that. It now logs the request, the node's answer with hash, size and elapsed time, the fetched blob and its chunk count, the attachment, and the live TextTrack's mode and cue count. A failure says how long it took before failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* docs(hub): say what the subtitle track's mode effect was measured to doChristophe Besson8 days1-5/+9
| | | | | | | | | | | | | | The comment claimed a <track> appended after playback started is not shown by its `default` attribute. Chrome shows it: in a headless run the TextTrack read back "showing" before the effect had touched it, with its cues already parsed. The effect stays, for the two reasons that are true — `default` says nothing about turning subtitles off again, and an explicitly assigned mode means the same thing in every engine — but the reason written beside it was not one of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* feat: embedded subtitles in the video player (MNP 3.3)Christophe Besson8 days13-4/+254
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* feat(hub): give the audio-language button a glyph of its ownChristophe Besson8 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
* fix(hub): the password verifier is Argon2id 64 MiB, and a hash's version ↵Christophe Besson8 days2-10/+25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson8 days1-1/+26
| | | | | | | | | | | | `{ ..._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 Besson8 days12-6/+149
| | | | | | | | | | | | | 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 Besson9 days3-2/+239
| | | | | | | | | | | | | | 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 Besson9 days2-4/+9
| | | | | | | | The ceiling is 1200 tracks on a library that repeats and 660 on one that never does; the message said 1000, which would send a reader back into the wall at 660. It says 500, measured at both ends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: refuse a body no frame can carry, and say which oneChristophe Besson9 days12-9/+72
| | | | | | | | | | | 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 Besson9 days2-19/+56
| | | | | | | | | `max-height: calc(100vh - 16px)` says how tall the menu may be and nothing about where its bottom lands, so one opened partway down the window ran past it and its last rows scrolled out of reach. Measure from where the panel was placed, and re-measure when a submenu opens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* menu: do not close on the panel's own scrollingChristophe Besson9 days1-2/+15
| | | | | | | | 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 Besson9 days13-7/+295
| | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson9 days2-18/+100
| | | | | | | | | | | | | | | 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 Besson9 days17-24/+693
| | | | | | | | | | | | | | | | | | | | 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 Besson9 days2-1/+501
| | | | | | | | | | | | | | | | | | 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>