summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
Commit message (Collapse)AuthorAgeFilesLines
* fix(hub): say so when a lazily loaded view cannot be fetched0.16Christophe Besson9 hours13-5/+66
| | | | | | | | A tab opened before a hub deploy got 404 for every module it had not loaded yet, and lazy.js kept its spinner for good. It now shows a notice with a Reload button and logs the failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): keep a show's detail modal open under the playerChristophe Besson10 hours4-4/+33
| | | | | | | Closing the player lands back on the season being watched, with the episode just started marked. A film's modal still closes on Play. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): the sidebar's Node section follows the node linkChristophe Besson23 hours2-8/+35
| | | | | | | | | | | | | | | | | | Reported on a real install: after the first click on Create group, the Node section (Node, Create group) disappeared from the sidebar until a reload, although the group was created and the node ran. hasNodeKey was read once per session change and never again, so a node the wizard linked stayed out of the sidebar; and that read swallowed its errors, so a session blip (a refused renewal, then the desktop app's silent device sign-in) followed by one failed request hid the section for good. The wizard now tells the app when it has linked or started a node, the app asks again then and after a group is created, and a failed read is retried -- never applied to a session that has changed meanwhile. The wizard also starts the node on its own for a node in waiting_for_hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): say "No operator paired" only when the node says soChristophe Besson23 hours1-4/+12
| | | | | | | | | The Node page showed the banner whenever operator_paired was not true, so a node that had not yet read its roster -- one still signing in to the hub -- was reported unpaired while its pairing was intact. The node now answers null until it knows; the page shows the banner for false only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: Windows installer and desktop app start and stop the node one wayChristophe Besson23 hours10-10/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A 0.16 upgrade in service mode left the previous node running: setup's unelevated taskkill cannot reach session 0, and it ran in customInstall, which electron-builder inserts after the files are copied. The locked exe was not replaced, and the new app talked to the old node ("started but could not link", "No operator paired"). Installer (build/installer.nsh, build/stop-node.ps1): - customCheckAppRunning, which runs before uninstallOldVersion and extraction, stops the node with an embedded stop-node.ps1: control API, then schtasks /end, then Stop-Process, and refuses to half-upgrade if one survives. - An upgrade keeps the mode it finds (task, launcher, previous install), restores the sign-in launcher the old uninstaller deletes, and restarts the node the way that mode runs it. A silent upgrade of an "at sign-in" install used to end with no autostart and no node. - The uninstaller removes the task and firewall rules only on a real uninstall, not on an update. Desktop app (src/main.js): - Start, Stop, Restart and node:start go through the CLI's lifecycle verbs instead of a second implementation; a child spawned by Electron also held Electron's sockets after the app quit. - "Only while MeshBay is open" is a real mode: the app starts a provisioned node at launch and stops the one it started when it quits. - Switching modes stops the node first -- deleting a task does not end its instance, and a new service found the port taken -- keeps the firewall rules every mode needs, and starts the node again. A declined or unanswered UAC prompt restores the node instead of leaving it stopped, and says that nothing changed. - waiting_for_hub counts as a node that is up; linking waits for a node that answers, with a longer deadline, and reports a version mismatch. Packaging (packaging/win): - The service task gets no 72-hour limit, runs on battery and ignores a second start; service.ps1 status reports a stale registration so setup re-registers it; remove ends the running instance before deleting the task. - build-node-runtime.ps1 starts the frozen daemon in a throwaway profile (smoke-node-runtime.ps1) instead of only asking for --help. The mode that was "Off (start manually)" is labelled "Only while MeshBay is open" in all ten catalogues. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(node,client): query TMDB only once a language is chosen, in that languageChristophe Besson2 days1-0/+16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | TMDB fiches are fetched lazily, on browse, and the fetch used to run whatever the moment it was first triggered — routinely before the operator had opened settings and picked a language, so it queried in TMDB's English default. Then the fiche was cached by tmdb_id alone, with no note of language and a 30-day TTL, so switching to the intended language afterwards changed nothing: the English fiche was served until it expired. The operator's only recourse was to find and wipe the cache by hand (found live 2026-09-26: a whole library indexed in English although "Français" had been chosen). Two rules now, both there to make the first fetch the right language rather than English-then-corrected, and to stop the doubled requests that eventually get a node rate-limited: - No language configured, no query. media_meta_req/season_meta_req answer confidence 0 and make no TMDB call while tmdb_language is unset; the fetch waits for the operator's choice, so the first (and only) query is in it. English is now a first-class choice (en-US), not the default of skipping the setting. - Changing the language wipes the metadata cache (ops.set_tmdb_config), so the new language takes effect on an already-browsed library. The file->tmdb matches are language-independent and kept. The client refetches on the tmdb_config_ack that carries the new language, so the grid updates without a page reload. docs/MESHBAY_DESIGN.md §9.7 states both rules; tests cover the gate and the cache wipe, and two existing handler harnesses now declare a language. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(hub): default email opt-outs and match Register to Login stylingChristophe Besson2 days3-14/+34
| | | | | | | | | | | | | | - Invitation-by-email and recovery-key-by-email boxes now start unchecked; mailing a code/key is opt-in. The invite choice still remembers itself per account once set. - Align the two invite-email checkboxes with their label (center, not flex-start). - Register (and its verify/recovery/done steps) now sits on the same dark gradient backdrop and frosted card as Login, via a shared AuthShell. - Make the gradient's top-left corner very slightly less bright (#86a3c4 -> #809cbc), on both auth pages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(protocol): bind the MNP token to the node it is for (E10)Christophe Besson3 days4-7/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The audience split stopped a member's node credential from opening the hub API. It did not stop the credential being *replayed to another node*: the MNP token carried the member's whole group set and named no node, so a token handed to node A's operator could be presented to node B the member also belongs to. That does not read content on B — the handshake still requires proving node B's group key, which the operator lacks — but it reaches B's pre-proof window and fetches the member's *encrypted* keypair bundle for B (offline-attackable, bounded, audited): a disclosure §2.4 says should not follow from hosting a member on A. The token now names the node it is minted for (a `node` claim = that node's Ed25519 key), and authorize_token refuses one that names a different key. The client already knows the target node's key (from /v1/groups/{id}/nodes) and asks for a token bound to it: POST /v1/nodes/mnp-token takes node_pk, and transport.connect threads it (group-page, the connection pool and rewrap pass n.pk_node; reconnect preserves it). A token that names no node is still accepted, because the hub only mints one for the authenticated requester, so an unbound token grants nothing across accounts — which also keeps non-binding callers working with no churn. Done before deploy, so it folds into the MNP 4.0 flag day rather than needing its own. Docs: §5.2, register E10, MESHBAY_NODE_PROTOCOL.md §6.3. test_handshake.py and test_mnp_token.py hold the binding (a token for node A is refused by node B, accepted by node A; an unbound token still works); red before, green after. common/node/hub suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(node,client): survive a transient hub state on login, and surface a ↵Christophe Besson3 days1-5/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | failed node-key link Two defensive gaps turned a routine reset-and-reonboard into "impossible de démarrer le node": 1. daemon._login_with_retry retried a 401 (node key not linked yet) but `raise`d on every other status, so a 429 — the daemon's own 5s retries hitting the sign-in rate limit — or a 502/503 while the hub restarts during a deploy killed the process, and systemd crash-looped it. Those statuses (429, 5xx) are now retried with a back-off that respects Retry-After, so a freshly reset node stays alive (the operator needs it up to read its key) instead of dying. A genuine 4xx (400/422) still raises. 2. create-group's linkNodeKey swallowed every error as "already linked or same key" — but PUT /me/node_key is idempotent and returns 200 on a re-link, so there was no benign error to hide: the catch only ever hid a real failure (a rejected session, a bad key), letting the wizard proceed against a node that looked linked but was not, which then could not authenticate. The link failure now surfaces (detectNode shows it). test_login_retry_is_resilient.py holds the retry behaviour (429/5xx retried, Retry-After honoured, 401 stays alive, 400 still raises); red before, green after. common/node/hub suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(protocol): MNP 4.0 flag day for the node-audience token (B2)Christophe Besson3 days1-10/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | The node-audience token (previous commit) is a change to what a peer must present, so it is a MAJOR per the versioning rule (§5.6): a pre-4.0 client presents its hub session token and a 4.0 node refuses it, and there is no compatibility branch, because leaving one would keep a hub credential reachable by every node (C6's lesson). So the floor moves with the version. - MNP_VERSION 3.4 -> 4.0 and MNP_MIN_SUPPORTED 3.0 -> 4.0 (meshbay_common); transport.js MNP_V/MNP_V_MIN -> 4.0 to match. - MIN_CLIENT_VERSION 0.13.0 -> 0.16.0 so a stale desktop client is told to update before connecting rather than meeting a handshake refusal it cannot read; the browser reloads this build from the hub. - Regenerate tests/golden/dispatch.json: the only change is the `v` the node stamps on outbound messages, 3.4 -> 4.0 (56 cases, v field only). - Document the split and the flag day: MESHBAY_DESIGN.md §5.2 (the handshake token is the MNP-audience token), §5.6 (the 4.0 flag day), register E10 and decision 23; MESHBAY_NODE_PROTOCOL.md §6.3 (authorize_token binds MNP_AUD) and the wire-version banner. Deploy is coordinated and atomic (common+hub+node+SPA together); a live browser-to-node validation and the deploy itself remain. common (173), node (1489, the pre-existing test_cli_golden argparse/prog artifact aside) and hub (1471) suites all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hub): present a node-audience token in the handshake, not the hub ↵Christophe Besson3 days1-1/+30
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | session token A member authenticated to a node in the MNP handshake with its hub *session* token — scope=user, valid at the hub API for hours. A node operator is in the threat model, so this handed them a live hub credential for the member: enough to enumerate the member's other groups, act as them, and (before the previous commit closed it) take the account over. The node genuinely needs a hub-signed membership assertion, so the fix is to make that a separate credential that opens nothing at the hub API. Two audiences signed by the one hub key (meshbay_common/tokens.py): - HUB_API_AUD — session tokens (login, device-auth, node-auth, refresh), used for hub calls and signaling. decode_access_token now binds this audience, so an MNP token cannot be replayed against the hub API. - MNP_AUD — a short-lived token a member presents to a node and nothing else, from POST /v1/nodes/mnp-token. authorize_token now binds this audience, so a session token presented to a node is refused. This closes the disclosure. The node's own self-decode (hub_client.py) reads its node token with audience=HUB_API_AUD. The client fetches the MNP token inside transport.connect() (and on every reconnect) using the session token, so callers are unchanged and signaling keeps using the session token. No regression to a long session: the MNP token is checked once, at the handshake, before any proof — a film already playing is not re-authenticated, so a 15-minute token does not interrupt a 4-hour film; reconnects refetch a fresh one. Denylist and membership checks are unchanged (the MNP token carries sub/jti/groups). Tests: authorize_token refuses a session/no-audience token and accepts an MNP token; the hub API refuses an MNP token; POST /v1/nodes/mnp-token is minted only for a member's own session. Verified red-before/green-after; common, node and hub suites green (the pre-existing test_cli_golden failure is an argparse/pytest prog artifact unrelated to this change). Still to do before deploy (B2): bump the MNP version and client.minimum so a stale desktop client is told to update rather than getting a handshake refusal, update docs/MESHBAY_DESIGN.md and MESHBAY_NODE_PROTOCOL.md, and validate against a real node locally, then deploy hub+node+SPA atomically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hub): require the passphrase to change the e-mail on fileChristophe Besson3 days1-6/+22
| | | | | | | | | | | | | | | | | | | | | | | A member hands its hub access token to every node it connects to (the MNP handshake), so a node operator holds a live bearer token for that member. PATCH /v1/users/me {email} needed only that token, and the confirmation code goes to the new address — so an operator could point the account's e-mail at their own inbox, confirm it, and then use the passphrase-reset path to take the account over. This is the immediate mitigation of that chain; the full fix (a node-audience token distinct from the API session token) follows. Changing the address now requires the passphrase-derived auth_key, verified through the same throttle as a passphrase change or an account deletion — the hub still never sees the passphrase. A PATCH that does not change the address is unaffected. The profile page prompts for the passphrase and derives auth_key with the existing MeshBayKeys.deriveAuthKey, as the delete and change-password flows already do. test_email_change_requires_passphrase.py: refused without / with a wrong passphrase, proceeds with the right one, and a no-email PATCH still works; red before, green after. test_mail_is_not_a_relay.py updated to pass the auth_key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: invitation links no longer bound to an e-mail addressChristophe Besson3 days12-71/+97
| | | | | | | | A link is redeemable by whoever opens it first, so it can be sent by any messaging app. The address is optional (mail + label only); a link lives 7 days, fixed. Adds a Share button; see MESHBAY_DESIGN.md §3.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): friendlier welcome page, link previews, robots.txt and faviconChristophe Besson3 days16-162/+438
| | | | | | | | | | | Welcome page: privacy said once, a three-step "how it works", a documentation box, download (green) and legal links under the sign-in form, on a dark gradient backdrop covering the whole page. Link previews: Open Graph tags in the app shell, rendered for identity.id, with the square icon as image. robots.txt, favicon and touch icon served at the origin root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(client): split transport.js into classic scriptsChristophe Besson3 days10-2145/+2214
| | | | | | | | | | | | transport.js keeps the core (connection, reconnect, leases, dispatch). Chat, media, admin, upload and device methods move, cut as text, into transport-*.js scripts that hand a class of their own to extendTransport, which copies each method onto MeshBayTransport.prototype; the codec, roster checks, node pins and the rewrap fan-out move as they were. Both shells load them after transport.js. Every prototype member, class property and top-level function has the same source text as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(client): fetch the media apps, the player, settings and search on first useChristophe Besson3 days4-12/+69
| | | | | | | | | | | | apps.js registers Videos, Music, Photos and every settings pane through lazy.js; the group page does the same for the video player and the settings panel, and the shell for the search page. The first download goes from 49 modules / 386 KB gzip to 37 / 289 KB; opening Music now fetches music-app.js and media-tiles.js and nothing of Videos. test_first_load_is_lean holds the eager graph; test_spa_imports checks that every on-demand load names an export that exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(client): move the connection pool and the media tiles to modulesChristophe Besson3 days7-369/+392
| | | | | | | | | ConnectionPool (with connectToGroup and its limits) leaves search-page.js for connection-pool.js, and LazyTile/MediaThumb leave video-app.js for media-tiles.js, cut as text. The shell and the Music and Photos apps now reach them without importing the search page or the Videos app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move per-app enrichment out of NodeDaemonChristophe Besson4 days2-2/+2
| | | | | | | | The _enrich_*/_reenrich_* methods and _on_enriched become EnrichmentMixin in meshbay_node/enrichment.py, with the two directory helpers only they use. APP_DIR_KEYS stays on NodeDaemon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move operator authority and the admin response out of ↵Christophe Besson4 days1-2/+2
| | | | | | | | | webrtc_server AdminMixin in transport/webrtc/admin.py: who the operator is, the signed challenge, signature checks, and _do_admin_response moved unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the operator's group controls out of webrtc_serverChristophe Besson4 days1-1/+1
| | | | | | | | GroupOpsMixin in transport/webrtc/group_ops.py: member revocation and unpinning, the group key rotation, apps and their directories with the allow-list, and Search listing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the MNP handshake out of webrtc_serverChristophe Besson4 days1-1/+1
| | | | | | | HandshakeMixin in transport/webrtc/handshake.py: challenge, proof, channel binding, and the sealed configuration a peer receives once admitted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the Videos catalogue handlers out of webrtc_serverChristophe Besson4 days1-5/+5
| | | | | | | | VideoMetaMixin in transport/webrtc/apps/video_meta.py: media and season meta, TMDB search and its per-member bound, posters, and the operator's TMDB ops. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the MusicBrainz switch to the Music mixinChristophe Besson4 days1-1/+1
| | | | | | An operator op that exists for one app lives with that app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the Music handlers out of webrtc_serverChristophe Besson4 days1-1/+1
| | | | | | | MusicMixin in transport/webrtc/apps/music.py: tags and cover art, and the audio transcode with the extension list that gates it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): Search reaches each group with one offer, and a busy hub is not a ↵0.15Christophe Besson5 days2-67/+246
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | dead node The other half of the 4G failure. The page negotiated every group twice: the sweep opened a connection, read the index and closed it, then the warm-up opened the same group again. The sweep, the warm-up and the tiles each had a concurrency ceiling of their own, and together they went past what the hub admits per account. Whatever the hub refused was then reported as "node unreachable" and remembered as down, which put that group last next time. - Every connection goes through ConnectionPool, which holds the page's one ceiling (six at once, sized for twenty groups on a phone) and keeps what the sweep opened for the tiles. A visit costs one offer per group. A refresh costs none for a connection that answers a four-second ping, and a connection that died while the phone slept is replaced, not waited on. - A connection whose index is being read is held against eviction. With more groups than the pool keeps, it was otherwise the least recently used one. - Negotiations still under way when the page closes close what they get, and a sweep cut short that way remembers nobody as down. - transport.js sends an offer again on 429, 502 or 503, honouring Retry-After, with jittered waits of about twenty seconds at worst. Search counts each retry as progress. A 404, 403 or 504 still fails at once, so a dead node costs no time. The fan-out tests assumed a ceiling of three and were re-measured: four dead groups of twelve now hold nothing back, even on a first visit. The pool and the retry run as shipped code, lifted as text, against a fake clock. Each guard was checked by removing it and seeing its test fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): a redeemed invitation link leaves the owner's listChristophe Besson5 days11-17/+9
| | | | | | | | | | | | | | | | | | | | 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>
* feat(hub): open, create and join invitation links in the interfaceChristophe Besson5 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 Besson5 days11-0/+11
| | | | | | | | | | | 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 Besson5 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 Besson5 days11-17/+62
| | | | | | | | 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 Besson5 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 Besson7 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 Besson8 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 Besson8 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 Besson8 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 Besson8 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 Besson8 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>
* feat(hub): show the node's public key in Node → OverviewChristophe Besson9 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 Besson9 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>
* fix(hub): a track with no artist tag reaches the Music gridChristophe Besson9 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 Besson9 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 Besson9 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 Besson10 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 Besson9 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(hub): stop keeping a copy of every group's file listing in the browserChristophe Besson9 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 Besson10 days24-109/+117
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-feature design notes were merged into docs/MESHBAY_DESIGN.md and deleted from the tree on 2026-09-11, but ~230 comments across the three packages still named them — usually written `docs/musicbay.md §3.2`, as though the file were still in docs/. A reader had to know §16 existed to resolve any of them. They now name the section directly. Every mapping comes from §16, the concordance, which already records where each old section landed: musicbay -> §9.8, mediacenter -> §9.7 for the Videos app and §6.5 where the subject is derived data, photos -> §9.9, auth-confirm -> §3.6, refactoring-search -> §9.11, invite-pairing-v1 -> §3.4, per-node-identity-v1 -> §3.2, captcha -> §7.7, chat-sender-keys -> §4.5, apps/refactor-groups -> §9.1–§9.4, desktop-client-v1 per section. Bare citations of the same documents (`draft-v6 §2.11`, `§4.8`, `§3.4`) are retargeted too: those collide with real section numbers in the design document, so leaving them would have been worse than the named form. Four cases the concordance does not cover, each decided rather than guessed: Sub-item references into documents that no longer exist — mediacenter's `§3.3 row 4`, `§3.4b/c`, `apps.md §3b` — name rows and sub-items §9.7 and §9.2 do not reproduce. The module-level citation stays; the sub-item pointer is dropped. The V-findings keep their labels but lose the dead `§10.1/` prefix. §13.8 lists V1–V13 as per-application open items, which is not what the labels mean in these comments, so pointing them at §13.8 would have been a false citation. `apps.md §5`'s virtualization requirement has no counterpart anywhere in the design document. The requirement is stated in the comment itself, so the citation is dropped rather than aimed at a section that does not say it. Comments that attributed a *sentence* to an old note — musicbay's "several thousand files" example, its "what got measured" note, its measured ~11%/~26% cover-art figures, the "original no root, whole shared tree" call — state the fact without attribution now. §9.8 does not contain those sentences and citing it for them would have been wrong. CLAUDE.md's "a reference to a document that no longer exists" row now says the concordance is for git history and out-of-tree material; the code cites sections directly. Verified: 2851 passed, 4 skipped. The 12 errors in the run are the Firefox leg of test_sticky_header.py's browser harness, which is broken at the browser level on this machine — headless Firefox (snap) dies with `[GFX1-]: RenderCompositorSWGL failed mapping default framebuffer`, renders nothing, and the probe exits `{"error": "no measurement"}` after its full 90s wait. Chrome runs the same 12 assertions in 3.2s and passes. Nothing here can affect it: every changed line in style.css is inside a comment. Also checked: ast.parse on every changed .py, `node --check` on every changed .js, the /* */ balance in style.css, and that no changed line exceeds the width its file already used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): a group whose node is down is no longer something the reader waits forChristophe Besson10 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 Besson10 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 Besson10 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 Besson11 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