aboutsummaryrefslogtreecommitdiffstats
path: root/packages
Commit message (Collapse)AuthorAgeFilesLines
* feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)Christophe Besson2026-08-2446-109/+4233
| | | | | | | | | | | | | | | | | | | | | Implements docs/mediacenter.md: a "Videos" group application built on the existing files index rather than a separate catalogue. On the node side, new indexer enrichment (technical probe, filename/season parsing, thumbnail generation) runs per-file once an operator has chosen a video_root for the group, plus a TMDB client for on-demand poster/metadata lookups (never client-side, thumbnails delivered over the existing chunk path). On the hub side, a new video-app.js renders a lazily-mounted poster grid or a thumbnail-only flat list, with TMDB entirely optional per group. Along the way: the global apps registry now drives Settings' default-tab picker instead of a hardcoded list, and the video_root is configured from group Settings (like uploads) rather than from Files, with the node refusing to run any TMDB/thumbnail work until one is set. Fixes several bugs found via live testing against a real library, notably a race between two effects writing the same "image ready" state that could leave a poster grid spinning forever on a same-tab revisit — see mediacenter.md §5.4 for the full account of each one.
* fix(node,hub): always transcode video audio to stereo AAC, never copyChristophe Besson2026-08-234-33/+298
| | | | | | | | | | | | | | | | | | | MSE only decodes AAC/Opus, so copying a source's real audio codec left non-AAC files silently unplayable in-browser (E-AC-3 additionally made ffmpeg itself refuse to write the fragmented MP4 header). Audio is now always transcoded to AAC and downmixed to stereo — multichannel AAC is accepted by ffprobe/VLC but silently rejected by some browsers' MSE decoder once real fragments are appended, which forces the SourceBuffer out of its MediaSource with no explicit error. Video stays copy-only. Also: report a clear client-side error instead of a bare STREAM_END when ffmpeg exits nonzero before producing any output, add video-element/ MediaSource error logging on the client for the next time this class of bug needs diagnosing, and fix a hub test that had grown too broad a scan window after an earlier, unrelated transport.js change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* feat(node): persistent index cache, visible scan progress, adaptive ↵Christophe Besson2026-08-2334-88/+2645
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | reconcile, and delta sync Indexer performance work, in four parts: - Persistent (path, size, mtime) -> hash cache (indexer/cache.py) so a node restart no longer re-hashes every file — measured at 23 minutes for a 114 GB library on a slow disk before this, near-instant after. Hashing is deliberately kept sequential (max_workers=1): it was never actually concurrent despite the pool size, and two interleaved reads seek-thrash a spinning disk instead of going faster. - Byte-based scan progress (IndexProgress), surfaced via the loopback index-status route, the handshake ack, and a periodic INDEX_PROGRESS push to connected peers — drives a progress bar in the Create Group wizard and "add a directory" in Settings, and an animated presence dot. Guaranteed to settle back to idle via try/finally and a final push on the scanning->false transition. - The reconcile backstop's directory walks now run in the executor instead of blocking the daemon's event loop; its interval defaults to 10 min (was 60s) with adaptive backoff to 2h when nothing changes, reset on a real change or a peer connecting, and is now a per-group operator setting (signed op + group Settings UI). - INDEX_DELTA wired up (protocol support existed, nothing called it): _on_index_change now sends additions/deletions instead of rebuilding the full entries list, coalesced over a short window so a burst of file events produces one push, and the hub swarm registration for public groups only (re-)registers newly added hashes. Also fixes several bugs found while testing the above against real libraries (a 114 GB and a 100+ GB group on a USB HDD): - /api/reload blocked until the reload — including a brand-new group's full initial scan — finished, which the Electron bridge's fixed 30s call timeout turned into a hard failure on any real library. The route now fires the reload without waiting (ops.start_reload), matching add_root/remove_root's existing pattern; the wizard's own step order was fixed to wait for the group to actually appear hosted before the steps that need it (extra roots, GEK), with retries for the residual race between that and the daemon's own bookkeeping. - transport.js's hand-rolled msgpack codec had no case for uint64/int64 (0xcf/0xd3) and crashed decoding any message containing one — hit by IndexProgress.scanned_bytes/total_bytes for any group over ~4.3 GB. Verified against real msgpack-encoded bytes from the Python side. - chat_hist_resp, and this change's own index_progress and set_scan_settings_ack pushes, were not routed by message type and could be handed to an unrelated pending request by the transport's "oldest pending" fallback, stalling it until its own 30s timeout and corrupting whatever received the wrong reply in its place. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* chore: release 0.6.00.6Christophe Besson2026-08-236-9/+13
| | | | | | | | | | | | | The group UI's applications split, and the two missing-import bugs it surfaced and fixed along the way. MNP goes to 0.4: apps_enabled/apps_enabled_ack, and enabled_apps on the handshake ack, for the group-applications registry. Additive — a node that predates it is never sent the op, and a client that predates it never looks for the field. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* feat(hub): split the group UI into a pluggable "applications" architectureChristophe Besson2026-08-2343-3617/+4294
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* feat(node): systemd service panel on the Node page, and a clean CLI restartChristophe Besson2026-08-2217-72/+438
| | | | | | | | | | | | | | | | | | | | | Add a status panel at the top of the Node page — always visible, even before an MNP connection exists — showing the meshbay-node systemd unit's own state (via `systemctl --user show`, main process only) with Start/Stop/Restart controls. This is the piece the rest of the page cannot provide: it has to work while the daemon is stopped or crash- looping, which the MNP-based sections require the daemon to already answer. While touching node lifecycle: `reload` and `restart-daemon` in the CLI shelled out to pgrep + SIGTERM/SIGHUP and respawned the process by hand, logging to a hardcoded /tmp path. That pattern already SIGHUPed a developer's own running node by accident once (see the old test_cli_dispatch.py comment). Both now delegate to `systemctl --user reload|restart meshbay-node`, which the unit already supports correctly (ExecReload=, Restart=on-failure). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* fix(ui): reset Files navigation state when switching groupsChristophe Besson2026-08-221-0/+4
| | | | | | | | | | GroupPage keeps the same component instance across a group switch (no key at the router), so currentPath/selected/filter were leaking from the group just left. A directory that exists in one group but not the other (e.g. "outputs") then showed an empty listing after switching. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* fix: first-run wizard reliability and node startup performanceChristophe Besson2026-08-226-72/+141
| | | | | | | | | | | | | Node daemon no longer blocks startup on slow directory scans — initial indexing runs in the background so the node reaches "running" immediately after transports are up. Fixes the wizard failing to detect the node when large USB/NAS roots take minutes to scan. Also: wizard key-linking deadlock resolved (main.js links during poll), invite form stays in DOM during reconnects (disabled instead of destroyed), pairing code bridges to renderer, and firewall docs for LAN casting added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: find meshbay-node in ~/.local/bin when not in PATHChristophe Besson2026-08-221-8/+17
| | | | | | | | Electron does not run in a login shell, so ~/.local/bin is not in PATH and `which meshbay-node` fails. Check that well-known location first in both node:installed and the node:start fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: node:start auto-provisions config and unlock keyChristophe Besson2026-08-224-7/+40
| | | | | | | | | | | | When the wizard calls node:start with {hubUrl, username}, the handler writes a minimal node.toml and a random unlock.key if they don't exist. The daemon then creates the keystore on first start using the unlock file — fully non-interactive. Existing configs are left untouched: if node.toml or unlock.key already exist, provisioning is skipped and the node starts as before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: update source-reading tests that drifted from the codeChristophe Besson2026-08-225-7/+14
| | | | | | | | | | | | | - test_transport_contracts: CreateGroupPage was refactored into a routing wrapper; assertions now read CreateGroupFormSimple - test_task_lifetime: _spawn now uses an _on_done wrapper instead of a bare self._tasks.discard callback; assertion checks both parts - test_video_buffer_ceiling: target the real updateend handler, not the settled() utility; add awaitingInitRef to the MSE harness scope - test_video_seek: silence debug console.log in window_leak harness so it does not pollute the JSON output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: meshbay-node init creates the keystore, status never doesChristophe Besson2026-08-222-6/+20
| | | | | | | | | | | - `init` now writes config AND creates the keystore (interactive password or unlock.key/env var). Idempotent: skips either step if already done. - `status` uses load_keystore instead of load_or_create_keystore — a read-only command should never silently create identity keys. - Stub getpass in test_cli_dispatch to prevent test hangs when no keystore exists. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: update test_desktop_shell.py for build/ → scripts/ renameChristophe Besson2026-08-221-2/+2
| | | | | | | The test reads index.html and sync-ui.js to verify security properties. Update the paths to match the directory rename from the prior commit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add npm 11 Electron setup steps to meshbay-client READMEChristophe Besson2026-08-221-0/+9
| | | | | | | npm >= 11 blocks install scripts by default. Document the approve-scripts + manual install + chrome-sandbox SUID steps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: move meshbay-client build scripts out of gitignored build/ dirChristophe Besson2026-08-223-1/+74
| | | | | | | | The root .gitignore ignores build/ (Python convention), which silently prevented sync-ui.js and its companion index.html from being committed. Rename to scripts/ so the files are tracked normally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add 82 missing i18n keys to all 9 non-English localesChristophe Besson2026-08-219-0/+901
| | | | | | | | node.*, wizard.*, sidebar.node, settings_node.*, and group.retry were present in en.js but missing from fr/es/de/it/nl/pl/pt-BR/zh-CN/ja. All 10 locales now have identical key sets (test_key_sets_match_english passes). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: delete notifications and detach content_reports before deleting a groupChristophe Besson2026-08-211-2/+7
| | | | | | | | | The DELETE /v1/groups/{id} endpoint only deleted group_members before removing the group row, causing a FK violation (500) when notifications or content_reports referenced the group. Delete notifications outright and nullify group_id on content_reports to preserve moderation history. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: D.6 first-run wizard — detect, start, link, group, gek-init, pairChristophe Besson2026-08-2115-19/+594
| | | | | | | | | | | | | | | | | | | Desktop client guides new users through the full onboarding sequence without a terminal: detect local node → start daemon (systemd with direct-start fallback for dev) → link node key to hub → create group → attach directories → gek-init → operator pair. - node:detect returns configured field to distinguish missing vs stopped - node:start tries systemctl first, checks is-active, falls back to spawning the binary directly when the unit crashes (dev mode) - probeNode() extracts the shared config→token→fetch pattern - SetupWelcome on empty HomePage links to /create-group - CreateGroupWizard step 0 handles node detection and start - platform.js exposes node.installed() and node.start() - 12 setup.* i18n keys added to all 10 locales - Integration test for node:start fallback logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: LAN Wi-Fi casting to Chromecast via local HTTP relayChristophe Besson2026-08-2120-17/+1080
| | | | | | | | | | | | | | | | | | | | | | Re-serve decrypted fMP4 video over HTTP on the LAN so a Chromecast can play the stream. The relay runs in the Electron main process — same trust boundary as downloads and MSE playback. - cast-relay.js: HTTP server with BoxAccumulator (reassembles WebRTC chunks into moof+mdat pairs), ring buffer, backpressure, finish() for clean end-of-stream, fixed port range 19550-19553 - cast-chromecast.js: mDNS discovery (bonjour-service) + CASTV2 protocol (castv2-client), connect/reload/disconnect lifecycle - Seek-aware: relay restarts on every seek, Chromecast reloads new URL; generation counter prevents stale async errors from killing active restarts; landingPlayheadRef suppresses programmatic seeking events - Device picker in video top bar with scan, device selection, copy-URL fallback, and cast status indicator - IPC bridge (main/preload/platform) for start/push/stop/finish/status/ discover/chromecastConnect/chromecastReload/chromecastDisconnect - Phase 3 design doc for DLNA/Smart TV in docs/cast-smart-tv.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: unified group management, public groups, and activity-based sidebarChristophe Besson2026-08-2022-215/+1146
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Create Group wizard (Electron-only) consolidates 6 steps across 4 interfaces into a single multi-step page: group creation on hub, node attachment, root selection via folder picker, GEK initialization, and auto-pairing — all in one flow. Browser SPA keeps its current behavior unchanged. Public group support (Option A — GEK for all groups): - All groups have GEK regardless of visibility; open-join groups auto-admit via TOFU when join_policy is "open" - Key rotation blocked for public groups (API guard + UI hidden) - Hub signaling allows WebRTC offers for nodes hosting open-join groups even when the caller isn't a member yet - attach_group writes join_policy to node.toml - Daemon loads GEK for all groups, not just private ones - Known-device path in join_request now auto-admits to open-join groups Node loopback API bridge (Electron IPC): - node:detect, node:call, node:pairing-code IPC handlers in main process - Renderer never sees tokens, paths, or keys (session token = physical access) - platform.js node namespace for UI consumption - Loopback endpoints: roots CRUD, member-upload toggle, reload Bug fixes: - Root change detection: removed premature ctx["roots"] updates from add_root and remove_root that prevented indexer retarget on reload - Duplicate offline message: global fallback now gated on !group - Signaling membership check: fallback to open-join groups for non-members Sidebar groups sorted by last_activity_at (most recent first): - New Group.last_activity_at column with Alembic migration - POST /v1/groups/{id}/activity endpoint, called on connect and chat send - Client-side sort + throttled hub updates (1/min) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): full Node admin panel — CLI parity, hot-reload, group lifecycleChristophe Besson2026-08-2016-58/+2293
| | | | | | | | | | | | | | | | | | | | Node admin panel (NodePage) now covers every CLI operation over MNP: group attach/detach, roster, member unpin, GEK rotate, denylist, reload. Daemon hot-loads new groups and tears down removed ones on config reload instead of requiring a full restart. Group attach/detach via MNP or local API triggers an automatic reload so the group is live immediately. Fixed GroupPage hang on first visit to a newly created group: the JWT issued at login didn't include the new group, the node rejected with not_a_member, and the token-refresh path returned without re-triggering the connect effect (Boolean(token) didn't change). Now bumps retryKey after a successful refresh so the effect re-runs with the fresh token. NodePage marks groups hosted by the node but absent from the hub with a "not on hub" badge so stale groups are visible and easy to remove. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(ui): 11-point UI overhaul — tabs, transfers, settings, uploadsChristophe Besson2026-08-1924-176/+628
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Hub/UI: - Icon-only group tabs (chat, files, settings) with per-group default tab - Transfer widget: filename becomes a clickable link to open completed downloads - Pulse animation on transfer icon (pale→dark green) while active - Download button feedback in FilePreview (spinner, auto-reset) - Group mute toggle persists across navigation - Login page autofocus, chat refocus after send - Theme toggle closes menu, status badge and duplicate connecting removed - Create-folder restricted to operators, download-path note removed - User preferences API (CRUD) with Alembic migration - Profile: email display/edit via PATCH /v1/users/me - Settings: "Defaults" section for default tab selector - All 10 locale files updated Node: - upload_dir in node.toml: separate filesystem path for uploads - Root.direct flag: uploads land at root path, no subdirectory - CLI --upload-dir flag on `group add` - Admin UI accepts upload_dir Client (Electron): - shell.openPath bridge for opening completed downloads - platform.js passes open callback from native save Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(hub): rewrite initial migration to match the ORM modelsChristophe Besson2026-08-198-386/+206
| | | | | | | | | | | | | | | | Six columns (users.pw_version, users.pk_node_ed25519, users.role, groups.description, refresh_tokens.family_id) and two tables (notifications, hub_peers) were never created by migrations — they relied on create_all(), which creates missing tables but never a missing column. This passed every test (fresh SQLite each run) and broke every deploy to a wiped PostgreSQL database. One migration now creates the complete schema. The seven incremental files are removed; their changes are folded into the initial schema. Safe on a fresh database only, which is the only case that exists after the meshbay.org wipe. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test(hub): add argon2-cffi to dev deps so KDF parity tests runChristophe Besson2026-08-191-1/+1
| | | | | | | Without it the three tests in test_bundle_kdf_parity.py silently skip instead of failing — a coverage gap the test file itself flags. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(ui): the M of the wordmark is the logoChristophe Besson2026-08-194-4/+43
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The stylised M replaces the letter M in "MeshBay" at the top left; `eshBay` beside it stays text. **The source artwork is now in git.** It was in `QE/`, which is gitignored, so it would not have survived a clone — and the tree is about to be moved to another machine. `assets/brand/` holds the original and the `convert` command that derives the 10 KB nav asset from it (trimmed, 72px tall for a picture shown at 30, so it survives a 2× screen). Verified: the documented command reproduces the committed file byte for byte. The derived file is in `_ASSETS`, which is what the `/a/<hash>/` fingerprint is computed from. A file missing from that list is a file whose change never moves the URL, so a browser holding the old one never asks again — the comment on that list says so, and a logo is exactly the kind of asset one would forget. Its URL is resolved from `import.meta.url`, so the hub's fingerprinted path and the application's `app://` scheme both come out right without either being named in the source. **The two sizes are independent, and that took four rounds to get right.** The picture's height was written in `em` — a fraction of the lettering — so every adjustment to the text resized the picture by the same stroke and the ratio between them could never change. The operator spotted the coupling before I did ("si on change la taille du M, tu vas encore tout décaler"). The height is in pixels now: `font-size: 1.375em` (22px) and `height: 30px`, two knobs that move one thing each. The vertical nudge went with it — it was correcting a misalignment I was causing myself. Also: the wordmark blue goes one step down from sky-400 towards sky-500. It sat brighter than the picture next to it, which made the two read as different materials rather than one object. A note on how this went wrong first: I checked the result on a mock HTML page I wrote, not in the application. The picture was loading the whole time, but at 25px against a 29px line box it was *smaller* than the letters, so the wordmark read as unchanged and the report was "none of what I asked for was done". Every size since has been measured in the running window over CDP. 883 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node): the operator can close uploading to everyone but themselvesChristophe Besson2026-08-1819-3/+617
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group where every member may add files stays the default. Some groups want a library the operator curates, and until now the only way to get one was to designate no upload root at all — which refuses the operator too. **The node enforces it; the interface merely stops offering it.** The Upload button in the Files toolbar and the paperclip in the chat composer both disappear, which is a courtesy to the people who are not trying. The control is `_do_file_upload` refusing with `member_upload_off`, so a member on an old tab, or one speaking MNP directly, gets the same answer. There is a test for each, and the enforcement test is in the node package rather than beside the UI one so nobody reads the hidden button as the mechanism. **Changing it is a signed operator instruction** — `OP_MEMBER_UPLOAD`, on the same path as removing a member. An unsigned one would let any member turn it back on and make the setting a suggestion. The transcript's subject is `on` or `off`: what the operator is shown before signing has to name the outcome, not the operation. **It lives on the node**, in a new `group_settings` table in `roster.db`. Not the hub, which has no business deciding who may write to someone else's disk. Not `node.toml` either: that file is hand-written and full of comments recording decisions, `ops.py` appends to it rather than round-tripping it through a writer, and a setting toggled from a panel must not rewrite the operator's file or need a restart. The value is cached in the group context because the upload path is synchronous, and the signed operation updates both — storing it without applying it would make the panel say one thing while the node did another. **Absent means allowed**, at every layer: no row in the table, no key in the context, no field in `handshake_ack`. An older node and an older client both behave exactly as before, and upgrading never silently closes a group. Each of those three has its own test, because they fail independently. The operator is always exempt — otherwise turning it off locks them out of their own node with a config file and a restart as the only way back. `is_node_admin` was being computed in two places by then and is now one function, since two copies of "is this the operator" is how the ack and the gate come to disagree. A change reaches everyone already connected via `member_upload_ack`, so the button goes without a reconnection. That message is both a broadcast and the reply to the request that caused it, which is why the client does not return early on it. Docs updated for a cold start: draft-v6 §2.1b and change 9, a new "Where Phase 13 stands" section in CLAUDE.md recording what is built, deployed and still missing, the module map row, and desktop-client-v1 §10b on the Settings tab and where group settings live. 883 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: the chat tab no longer scrolls, and a group is listed or invite-onlyChristophe Besson2026-08-1816-149/+461
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **The chat tab was 8px too tall, at every window size.** The panel is sized from JS to `viewport - top - 16`, which puts its bottom 16px above the fold — but it sits inside `.main`, which adds 24px of padding below it. Eight pixels of document past the window, whatever the window. Measured at 700, 900 and 1200: `scrollHeight` 708, 908, 1208. This is the second one of these — the sign-in card was `.page-center` and `.layout` each reserving `100vh - 52px` — so it is now measured in the suite rather than reasoned about. `tests/harness/scroll_probe.py` renders the real markup against the real stylesheet and **runs the real `fit()` lifted out of `app.js`**: a copy of the formula in a test would go on passing after the original changed, which is exactly the bug being guarded. The fix does not encode 24 anywhere. The first pass runs as before, then the leftover is measured and taken off, so anything added below the panel later is absorbed the same way. Now `scrollHeight == innerHeight` at all three heights, nothing below the fold, and the panel still fills the room it has — that last one has its own test, because shrinking the chat to 240px would satisfy every other assertion here and be useless. The Settings tab was measured too and is **not** a bug: it fits at 1200px and overflows only when its content is genuinely taller than the window. **Group creation asked one question twice.** Visibility and admission were separate selectors that could only ever be set together — picking Public reached over and set the policy — and two of the four combinations are meaningless. The API already refused public+invite with a 422, so the form could build a request that could not succeed. Private+open was accepted and should not have been: a group anyone may join that nobody can find is a listing with the listing removed, since joining goes through the node and there is no link to pass around. So: one selector, "who can join", and the request derives the rest. The API now refuses the other impossible pair as well, with a message that says which way to resolve it. Six locale strings the visibility box owned are deleted rather than left unread in ten files, and the two surviving descriptions now say what each choice means for who can *find* the group — with the word "public" gone from the page, nothing else would have said it, and someone would publish a group without meaning to. 865 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): name the section after its button, drop the folder slash, colour ↵Christophe Besson2026-08-1814-22/+60
| | | | | | | | | | | | | | | | | | | | | | | | | | | | the widget **"Zone sensible" said nothing.** The heading is now the name of the action in it — "Quitter le groupe", or "Supprimer le groupe" for the owner, who sees a different button. Worth stating because it is not quite what was asked for: a fixed "Quitter le groupe" would have sat above a delete button for whoever owns the group. The red goes with it; only the button is red, which is where the warning belongs. `members.danger_title` is gone from all ten locales rather than left behind unread. **A folder name no longer ends in a slash.** The folder icon in the cell beside it already says what it is. **The transfers widget turns green while transfers run.** The badge counts them, but a count has to be read; colour is what carries from across the room, which is the point of a widget in the nav bar rather than on the page. Derived from the live list on every render, so there is no state that can forget to clear when the last transfer ends. The class is set in `app.js` and coloured in `style.css` — either alone does nothing and neither fails loudly, so there is a test for each half. 848 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: two waits with no deadline, resume positions per account, group ↵Christophe Besson2026-08-1818-153/+838
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | settings tab **Joining a group could hang.** Reported after a first attempt that never finished and a later one that worked — the shape of a network wait with no deadline, and there were two. Signaling here is non-trickle: the offer is not sent until ICE gathering says it is done. A STUN server that is slow, filtered, or resolved through a DNS that is not answering means `icegatheringstatechange` never reaches `complete`, and `connect()` never returns. Same shape as the fullscreen denial fixed yesterday: a promise that never settles leaves no error to find. Gathering now has four seconds, after which the offer goes out with what it has — host candidates are already there, which is enough on a LAN, and giving up instead would turn a slow STUN server into a refusal to connect. The second: `hub:fetch` in the desktop client had no timeout, so a host that accepts a connection and then says nothing holds the request for as long as the OS allows. `hub:probe` had one; the handler that carries signaling did not. Now thirty seconds — longer than the hub's own fifteen-second signaling wait, so it cannot abort a call that was about to succeed — and it says the hub did not answer rather than "fetch failed". **Resume positions belonged to the machine, not the account.** Stored as `mb:pos:<file>`, so a second account signing in on the same computer was offered "resume where you left off" in a film it had never opened. Wrong on its own terms, and a small disclosure of what the other person watches, since the offer only appears for files someone has actually been through. The account is in the key now. Positions written before this are deleted rather than re-keyed: there is no record of whose they were, and guessing hands them to whoever signs in next, which is the bug. **The staggered rules in the members table.** `display: flex` on the actions `<td>` — a flex table cell stops being a table cell, so it no longer stretches to its row and its bottom border is drawn wherever its own content ends. Measured: in a row whose other cells were `top 76, height 40`, that cell was `top 77, height 30`, its rule nine pixels above the rest. It is a table cell again, held open by a zero-width strut so the owner's row — which has no remove button — stays as tall as the others. Every cell now shares its row's top and bottom exactly, at 420px and 900px. **Members became Settings.** It was a list with three unrelated forms stacked above it, laid out with inline styles on whichever element needed them, and the group's own controls somewhere else entirely — leaving or deleting a group sat in the page header beside the title. Now one tab in sections: invitations, operator pairing, your devices on this node, leaving or deleting, and the roster last, since it is the only part with no upper bound. One consequence worth stating: the tab bar no longer waits for the node. Membership is hub-side, and gating it on a live connection would have made "leave this group" unreachable exactly when a node is down — which is when someone most wants it. Files and chat still need the node and say so. **A download button in the viewer**, beside the close button and in the same style, for both the video player and the file preview. 844 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): a film can go full-screen, and automatic saving is automaticChristophe Besson2026-08-183-16/+116
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **Full-screen was denied, and the denial was invisible.** The permission handler was written from a true sentence — nothing here needs a camera, a microphone or a location — and implemented as `callback(false)` for everything. Chromium's own video controls ask for the `fullscreen` permission, so a film could not be watched full-screen. What made it hard to find, and what the test now pins: **a denied `fullscreen` does not reject.** `requestFullscreen()` returns a promise that never settles. No exception, no console message, nothing in the renderer that mentions a permission — the button just does nothing. Measured rather than reasoned: the probe reported `NEVER SETTLED` while the main process, instrumented for one run, logged `PERMISSION ASKED: fullscreen`. After the fix the same probe reports `granted` with `document.fullscreenElement` set. The handler now enumerates what is *granted* — `fullscreen`, and nothing else — so a camera, a microphone, a location, notifications and MIDI are still refused and whatever Chromium adds next arrives refused rather than quietly allowed. `Permissions.query` takes the other handler, so both now answer from the one list instead of eventually disagreeing. The old test asserted `callback(false)`, which is to say it locked in the bug. It is replaced by three: what must stay denied, that `fullscreen` is granted, and that both handlers read the same list. **"Save automatically" opened a dialog.** The automatic path required a folder to have been chosen first, and on a new profile nobody has chosen one — so the very first download fell through to Save As, which is the one thing the setting promises not to do. A browser does not make you pick a folder before it will save a file; the system Downloads folder is the answer when there is no other. Verified on a fresh profile with a home of its own: no dialog, 1024 bytes on disk, destination reported as the default (`/home/…/Téléchargements` on this machine, via the localized XDG directory). A folder that *was* chosen and has since gone still asks. Silently redirecting those files is worse than a dialog: someone who picked an external drive wants to be told it is not there, not to find the film in their home directory a week later. Settings shows the effective destination either way, and offers "forget" only for a folder somebody actually chose. 813 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): downloads stream to disk, and two rough edges on first runChristophe Besson2026-08-1815-38/+200
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **Downloads were going through RAM.** `_openDownloadTarget` tries a granted folder, then a service worker, then its floor: collect the whole file in the page and hand the browser a blob. Both of the first two are absent in the desktop application — `showDirectoryPicker` does not exist, and Chromium refuses a service worker on a custom scheme — so every download under 512 MB took the floor. A gigabyte of film meant a gigabyte of RAM, and the only visible symptom was a Save As dialog at the *end* rather than the start, which is what the operator noticed and asked about. The main process now streams to disk: it honours "save automatically" with a folder chosen once and no dialog, never overwrites (a colliding name gets a suffix), awaits each write so the renderer cannot outrun the disk and queue the file in memory anyway, and unlinks a cancelled download rather than leaving a truncated file that looks complete to whoever opens it next. Settings now offers the native folder picker instead of saying downloads are unsupported. Measured in the running application: the file on disk grows 256 KB → 512 KB → 768 KB → 1 MB as the chunks arrive, and an aborted download leaves nothing behind. **A permanent scrollbar on sign-in.** `.layout` and `.page-center` each reserved `100vh - 52px`, and `.page-center` sits inside `main`'s 24px vertical padding — so the page overflowed by exactly 48px at every window size. Found by measuring in the app rather than reading the stylesheet: `scrollHeight` 819 against a 771 viewport, then the bottom edge of every element. The centring page brings its own padding, so main's is dropped for it and the duplicated arithmetic goes rather than growing a third term. Now `scrollHeight == innerHeight`, no overflowing elements. **The first-run screen was unstyled.** It used a class name I invented (`auth-page`) that appears nowhere in the stylesheet, so it had no card and the button sat against the input. It now uses the same `page-center` + `login-card` markup as sign-in, which is where the 12px gap comes from. The sign-in link in the nav is hidden until a hub is chosen — it led to a page that could not work. 809 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): three defects a real desktop found in ten minutesChristophe Besson2026-08-1816-29/+290
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | All three came from the operator running the application on Ubuntu GNOME. None would have been found by anything already in the suite. **A second copy of the hub address.** `keyderive.js` carried `const HUB = '' // same origin` — true of a page the hub served, false of one loaded from a package, where the origin is `app://meshbay` and `/v1/users/register` resolves against the application's own protocol handler. **Sign-up and sign-in, the first two things anybody does, failed with "Not found."** The seam was changed in `app.js` and in the signalling call and this was missed: the same shape as the duplicate `MNP_VERSION` in `protocol.py`, a second copy of a constant that is harmless until the context changes. `test_hub_address_seam.py` refuses any file that decides where the hub is, and any `fetch('/v1/…')` relative to the page origin. **A window handler reading a variable another path reassigns.** Changing the hub closes one window and opens another; `closed` arrives *after* the replacement is assigned, so the outgoing window nulled the reference to the incoming one and its `ready-to-show` crashed on it — a modal "A JavaScript error occurred in the main process". Every handler now belongs to the window it was created with. The CDP test wrote `config.json` in advance, so it never took the one path that creates a second window; it does now, starting from an empty user-data dir. **A first run that could not be undone.** The hub address was accepted on anything URL-shaped and there was no way to change it afterwards — the prompt only appears when none is set, so a typo meant editing JSON by hand. `https` typed at a hub speaking `http` produced `TypeError: fetch failed`, which names nothing. Now: the address is probed before being written, failures say which URL and why ("does not speak https. If this hub is on your own machine, it is probably http"), Settings can change it, and Electron's "Error invoking remote method" wrapper is stripped from what a person reads. Verified on the operator's desktop: **safeStorage really uses the GNOME keyring** — Settings reports `gnome-libsecret`, and `secrets.bin` is written 0600 with Chromium's `v11` prefix, the marker for keyring-backed encryption (the fixed-key fallback writes `v10`). Headless, the same code reports `unavailable` and refuses to store rather than downgrading in silence, which is now explained in Settings instead of shown as a bare word. Unrelated but found while testing: `test_locales.py` assigned to `globalThis.navigator`, which is read-only from Node 22. The client's build already requires Node 22+, so the first CI machine configured for it would have failed these tests for no visible reason. 809 tests pass on Node 18 and Node 24. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(client): hybrid sign-in — passphrase once, then this device's keyChristophe Besson2026-08-1814-3/+316
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | D4. The passphrase stays the account's credential and its only recovery path; what changes is that it is not asked for on every launch. **The renderer never holds the device key.** It is generated, stored and used entirely in the main process, which signs `meshbay:user_auth:<username>:<ts>` on request. Same rule as the save dialog, for the same reason: the renderer is the part of this application that parses decrypted content from nodes, which is attacker-controlled input. And this key is *not* a per-node identity key — those are generated per node and never leave that relationship, so nothing here correlates a person across operators. First run asks which hub, with no default. A client that picks its own hub is a client that can be pointed at one, and the address is the whole of what this application trusts a hub for — the interface comes from the package. Verified against a hub running this code, not against the deployed one: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → `/v1/users/me` 200 → a stranger's key 401. The signature was also checked directly against the hub's own Python verifier before any of that. Inside the running application, over the debugging protocol: the bridge reaches the main process, the renderer calls the hub **through it** (200 — the CORS fix working end to end), and a call to a host that is not the configured hub is refused. **Not verified:** safeStorage persisting the key. This session has no secret service, and standing one up in xvfb did not succeed. The application behaves correctly there — it *refuses* rather than storing unprotected, and now says so in Settings, which is a real case rather than a hypothetical one since it is exactly what a headless or minimal desktop looks like. Worth remembering for next time: meshbay.org runs whatever was last deployed. It answered 405 on the Stage-C endpoints and reported MNP 0.2 while the tree had 0.3, so a local `uvicorn meshbay_hub.app:create_app --factory` on SQLite is what tests hub changes. Nothing was deployed to production for this. 799 tests pass; e2e.py passes end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): the desktop client runs, and running it corrected three thingsChristophe Besson2026-08-1810-28/+4765
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Electron 42 / Chromium 148, launched under xvfb. The packaged interface mounts over `app://` with a secure context, `crypto.subtle` present, Argon2 WASM loaded, and no console errors. Three statements in the design were wrong, and only launching it found them. **A CSP in a `<meta>` tag silently drops `frame-ancestors`.** Chromium says so in the console. A policy carrying a directive that does nothing is worse than one without it, so the policy is sent as a header by the protocol handler — which is also the only thing serving the interface, so one source instead of two. **`secure: true` is not what makes the service worker register.** Chromium refuses a worker on a custom scheme whatever its privileges: "The URL protocol of the current origin ('app://meshbay') is not supported". The application has no service worker and needs none — it saves through a native dialog, which is the better of the two paths. `sw.js` stays in the package because the same files serve the browser, where it is one of only three ways to write a large file. What `secure: true` is actually for was measured at the same time: without it **the whole of `crypto.subtle` is undefined**. The first probe loaded a `data:` URL and every algorithm failed with TypeError, AES-GCM included — which is why the probe was rewritten before believing its answer. X25519 and Ed25519 are both present on Chromium 148, settling the version floor left open as O6. **The renderer cannot call the hub.** Its origin is `app://meshbay` and CORS refuses it. The hub has *no CORS middleware at all* — its API is reachable from no web origin whatever — and that is worth keeping. Widening it for `app://meshbay` would be worse than it looks: that origin is not a credential, since any Electron application can claim the same scheme and host name. So every hub call leaves from the main process, exactly as saving a file does, and it refuses any origin that is not the hub the user signed in to. `platform.apiFetch()` is `fetch` in a browser and the bridge in the application, so no caller has to know which it got. `transport.js` reaches it through a global because it is a classic script, not a module — the alternative was a second fetch path, which is how two callers of one hub start disagreeing about how to reach it. Verified from inside Electron: the main process gets 200 from /v1/hub/version, the renderer is refused by CORS, and **a script served by the hub is refused by the policy** — T3's mitigation demonstrated rather than asserted. Build note, written into the README because it will bite the next person: **Ubuntu 24.04's nodejs 18 cannot install Electron at all** — the download script `require()`s an ESM module, which Node gained in 22. Node 24 LTS, checksum-verified against nodejs.org, is what this was built with. package-lock.json is committed; builds use `npm ci`, not `npm install`. 799 tests pass, e2e.py still passes end to end. The session harness needed a platform stub: it lifts `hubFetch` out of app.js as text and runs it, so the adapter is now part of the environment it models. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(client): the platform seam, and an Electron shell that has never been runChristophe Besson2026-08-1819-5/+931
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage D, and the honest half of it. D1 — the seam (done, and verified) ---------------------------------- `static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is built with the same base, so one address has one source. In a browser it returns '' and every path stays relative to the origin that served the page — the acceptance criterion for this split was "the browser SPA behaves identically", and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the content hash and a cached browser would never ask for it. D2 — the shell (written, never launched) ----------------------------------------- **There is no npm on this machine. Electron was never installed and `packages/meshbay-client/` has not been run once.** That is stated here rather than discovered later. What is there: a main process serving the packaged interface over a privileged `app://` scheme (`secure` and `standard` are not cosmetic — without them the service worker refuses to register and streamed downloads break silently), a preload exposing an enumerated bridge that never passes a filesystem path, a window with `sandbox`, `contextIsolation` and no node integration, navigation away from the package refused, and a CSP where the hub is reachable over connect-src and is not a script source. The hub address arrives as a process argument because `platform.hubBase()` runs before anything can await. `test_desktop_shell.py` pins each of those by reading the source — the treatment `test_downloads.py` already gives the three browser save paths. It catches a property being removed and proves nothing about the application running. Two were checked by breaking them. The interface is *copied* into the package by `build/sync-ui.js` from the hub's static directory, and `ui/` is gitignored: a silent fork is the only real way to end up maintaining the interface twice. D3 — partial ------------ The bridge, and the part worth having now: safeStorage's backend is reported rather than assumed. On Linux it falls back to a fixed key when no keyring is running, silently — someone who believes the OS is holding their keys is told when it is not. The native key lifecycle belongs with D4 and needs a running application to mean anything. D8 — partial, and a real defect found -------------------------------------- `meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` — into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry `User=`; systemd refuses the file, so the packaged unit could never have started. Nothing noticed because nobody had built and installed the RPM. Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service` that a person enables themselves without a password — which is what lets the desktop client install a node without asking for one. It carries ExecReload, so `meshbay-node reload` does not have to stop a service somebody is streaming from, and documents the drop-in for a drive outside the home, RequiresMountsFor included. 798 tests pass; e2e.py still passes end to end. Nothing here was built or launched: no npm, no rpmbuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: device linking, and signing in to the hub with a device keyChristophe Besson2026-08-1826-25/+2146
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage C. Identity keys are per node, so a browser and a desktop client are two keys on one account there — and the node refused the second where it accepted the first. Without this, an account created natively could never be opened in a browser without an operator code per node, and "a native client must not prevent web use" would have been dead on arrival. Device linking (node) --------------------- `identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The old shape did `INSERT OR REPLACE`, so a second device overwrote the first silently; SQLite cannot change a primary key in place, so the table is rebuilt. Existing pins are carried over — verified against a live roster with 10 of them, nobody re-pairs. A new device files a request bound by `sha256(code ‖ its own keys)`, and a key the node **already pinned** countersigns it. The hub cannot: it has stored no user keys since 2026-08-14, which is what makes this safe to do without an operator in the loop. **The code never reaches the node.** It lists this account's pending requests with their stored hashes; the approver recomputes and keeps the match. A node offering fabricated keys would have to produce a hash over a code it has never seen. Nothing rests on a human comparing digits — that ritual was dropped in 12.1 as "correct, unusable as the default" and must not return by the back door. The design document had the approver look a request up *by* its hash, which is circular: computing it needs the keys being asked about. Corrected in both. Revocation marks rather than deletes, because a deleted row is a key the node would happily pin again — which is the laptop somebody just reported lost. Your last device cannot be revoked: coming back would need an operator's code. Hub — the only change in the whole plan --------------------------------------- `POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as `/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration, because `create_all()` is not one. This is **not** the key directory that was H3, and the tests say so: nothing reads it but the hub, no group key is ever wrapped for one, and it is a different key from the per-node identities. What it does cost is metadata — the hub now knows how many devices an account has and when each last signed in. Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an installed client meets a newer hub the day the interface ships in a package, and that is cheap now and awkward to retrofit. Browser ------- The `key_changed` refusal becomes `unknown_device` and offers a linking code instead of telling someone to find their operator. The Members panel lists this account's devices here, approves one by code, and retires one. 773 tests pass. `e2e.py` gained a step that links a device end to end against the live deployment — file, list, recompute, countersign, then open the group with the new keys and no code — and it also gained `recv_type`, because a step that assumes the next message is its own answer reads an ack left by the step before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node): several named roots per group, and one implementation per operationChristophe Besson2026-08-1840-521/+3489
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage A — a group's content is a set of named roots --------------------------------------------------- `shared_dir` becomes a list of {name, path, kind}. The name is the directory's basename, derived once at add time and *stored*: recomputing it would re-identify a whole library the day someone renames a folder on disk. Duplicate names are refused case-insensitively and no root may contain another — both compared with NFC folding, because most of these directories live on exFAT or NTFS where `Films` and `films` are one directory. Every index path carries its root name, in a one-root group as much as in a five-root one. One path shape has to be got right once; two have to be kept right for ever. **A root that goes away freezes; it never empties.** Unmounting a volume makes watchdog report every file under it as deleted, or presents an empty directory to the next scan. Acting on either propagates deletions for a whole library to every member, as though the owner had erased it. So a deletion is acted on only once its root is confirmed readable, and availability is tracked per root — one unplugged drive leaves the others serving. 12 tests, verified to fail against an indexer without the check. Events are not trusted to be complete either: ReadDirectoryChangesW drops them under load and inotify on a FUSE mount misses changes made outside it. A periodic reconciliation sweep is the only thing that recovers a missed event. MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content hash, a node id and an endpoint — no paths, no filenames — and private groups register nothing (H7). Stage B — one implementation behind every front door ---------------------------------------------------- C1 and C6 were both "a second path into the node with its own weaker handshake". Two implementations of `revoke` with two authorization checks is that shape one size down. `meshbay_node/ops.py` holds each operation once, takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback API is one `_op(...)` line per endpoint; the MNP handlers call the same functions. test_ops.py asserts the shape rather than trusting it. Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload` (SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP operations: rotation is the half of revocation that revocation cannot do, since the ex-member holds the current key, and the node generates the replacement with its own CSPRNG — no key material crosses the wire, which is what the C5b rule is actually about. Two bugs found by running it rather than by testing it ------------------------------------------------------ GroupIndex is keyed by **content hash**, so the same bytes at two paths are one entry — which is also why a scan reports ten files and indexes nine. Reconciliation compared paths, so it decided the second path was a missed event every 60 s, rewrote the entry and pushed an index update to every connected peer. Seen in a live node's log. `meshbay-node reload` crashed on first use with `subprocess` unimported: the module compiles fine, which is the "syntax, not names" trap already recorded for the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be added to the parser without an entry there. Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the wire carried "0.2" — harmless only because nothing imported it. And _do_dir_create/_do_dir_delete referenced an undefined `filename` on their error path. 740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): a session that renews itselfChristophe Besson2026-08-175-19/+582
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported: after an hour of watching a film, every action answers "token expired or invalid", with signing out and back in as the only way on. Reopening the tab the next day did the same. The access token lasts an hour and the refresh token thirty days, and nothing used the second one. `hubFetch` reported a 401 like any other error, and watching a film is precisely an hour in which the hub hears nothing at all, because the video travels over WebRTC. So the token aged out with no request to notice, and a tab reopened the next morning presented a stale token with a perfectly good refresh token sitting beside it in localStorage. Underneath was the reason it could not be recovered from. The hub *rotates*: the refresh endpoint revokes the token presented, returns a replacement, and treats a revoked one presented again as theft, revoking the whole family. The client kept only the access token out of that response. So the refresh token was spent on first use and the second attempt did not merely fail — it destroyed the family. Which is exactly the reported symptom. Renewal now happens on a margin, on returning to the tab, on mount, and on a 401 with the request replayed. Concurrent renewals share one request: two 401s racing would otherwise present the same refresh token twice, and the hub cannot tell that from theft, so the remedy would have been worse than the fault. A refusal signs out cleanly rather than leaving a session that fails every call while looking signed in. The lifetime goes to four hours, which is not what makes long sessions work — renewal is — but is what someone has to notice by if renewal itself breaks. An hour was less than a feature film. Twelve was considered and declined: it widens the window in which a leaked token cannot be turned off, and it lets the renewal path go a whole day between uses, which is how it came to be broken here without anyone noticing. Production sets this in its own hub.toml, so both moved. The tests run the shipped code against a hub that enforces rotation, because a stub that accepted the same refresh token twice would have passed against the broken client. Checked that dropping the rotated token reproduces the revoked family, so the guard is guarding something. Also widens the orphan-setter rule to ignore `setX` functions declared in the module: `setAuth` is not a hook setter, and a rule that cries wolf is one somebody eventually silences. Verified it still catches a real orphan.
* fix(hub): the transfers panel hung off the side of a phoneChristophe Besson2026-08-174-3/+318
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported: on mobile you see only the right-hand edge of the panel, without the content. Measured, before anything was changed: 320 px viewport -> panel at -138..192, 138 px off the left 360 px -> -98..232 412 px -> -46..284 The panel is 330 px wide and anchored to the right edge of its button — but that button is not at the right edge of the screen, since the bell and the user menu come after it. What falls off is the left-hand side, which is where the file names are, so what stayed on screen was a strip of progress bars belonging to nothing. Narrowing it would not have helped: the overflow comes from where the right edge is pinned, not from the width. Below the existing 768 px breakpoint the panel is anchored to the viewport instead, full width on a phone and capped at 420 px on a tablet, where stretching two filenames across 750 px would be silly. Desktop keeps its 330 px against the button. The interesting part is how it was found. The responsive tests read numbers out of the stylesheet and said, in their own docstring, that a layout could not be measured because the suite had no browser. It has one now — Chrome, from the video work — so layout_probe.py renders the real stylesheet at a given width and returns rectangles. `width: 330px` was never the thing worth asserting on. An iframe carries the viewport, because a headless window will not go below about 500 px, and one browser measures every width: launching one per test put three minutes on the suite against twenty-six seconds for all of them. Checked that the new tests fail with the rule removed — three of them do — and that they pass with it back.
* test(hub): a hook that depends on one declared below it never runsChristophe Besson2026-08-174-7/+538
| | | | | | | | | | | | | | | | | | | | | | | `const a = useCallback(fn, [b])` evaluates `[b]` where it is written, so a `b` further down the component is still in its temporal dead zone. ReferenceError on every render, before anything the component does can run — and the symptom is the component simply not appearing. Clicking a video did nothing at all: no picture, no error on screen, nothing in the node's log because nothing was ever requested. It reached production. Nothing caught it. `node --check` passes, the code is well-formed. Worse, the MSE harness extracts the player functions into an order of its own and therefore *reordered* them before running — quietly repairing the one class of defect it was best placed to catch. It sorts by position in the file now, and test_hook_ordering.py checks the property directly across the whole SPA. Both the rule and the harness are checked against the layout that actually shipped. test_video_seek.py covers the rest of seeking, and window_leak.mjs forces the race that made the third seek hang: the whole in-flight window arriving while `reinitAt` is still awaiting. Before, the player is left believing eight segments are in flight and grants nothing; after, the window comes back. A run that happens to work proves nothing about a race, which is the point of forcing the worst case rather than trusting a longer session.
* feat(hub): move about in a film, and pick it up where it was leftChristophe Besson2026-08-1712-22/+372
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking the scrubber now restarts the stream there. A seek inside what is already buffered never reaches the node. Dragging is debounced at 350 ms, because `seeking` fires continuously and each one acted on kills an ffmpeg and spawns another. `SourceBuffer.mode` changes from 'sequence' to 'segments', and each stream sets `timestampOffset` to where the node says it started — otherwise a stream beginning at forty minutes is buffered at zero and the scrubber lies about everything. Proved in Chrome before any of it was written: abort, remove, offset 600, append, and playback resumes at 600 with `readyState` 4. A seek makes the buffer discontinuous, and everything reading `buffered` then has to mean a particular range. "The last one" stops being "the one playing": the read-ahead would report a full buffer across a gap while the player starves, and the eviction would take out everything between the first range and the playhead, including what is showing. Both work from the range around the playhead now. `reinitAt` also clears the buffer outright rather than keeping two ranges, and calls `abort()` first — ffmpeg was killed mid-fragment, so the parser holds half of one and the next header would land on top of it. The position is kept in localStorage, per file: no protocol, no storage anyone else has to keep, and nothing new learns what you watch. Not below thirty seconds, not past 97% of the film, and a pill offers the beginning back. Two things this had to get right and did not at first, both found by running it rather than reading it. Between asking for a seek and its `stream_init`, everything on the channel belongs to the film being left — same file, so `file_id` cannot separate them, but ordering can. And a segment that arrived is no longer in flight whatever is then done with it: discarding one before decrementing the window leaked a slot every time, and `reinitAt` is asynchronous, so a whole window could arrive while it waited. The player then believed eight segments were in flight, granted nothing further, and the node waited for credit that could not come — a race, which is why the same seek worked twice and hung on the third.
* feat(node): seeking, as a stream restarted somewhere elseChristophe Besson2026-08-172-12/+51
| | | | | | | | | | | | | | | | | | | | | | | | | The scrubber was drawn the length of the film — `ms.duration` has always been the real duration — and then `onSeeking` quietly clamped every target back into whatever happened to be buffered. The bar invited a click and refused it. `stream_req` gains a `start`. The session's previous stream is retired by the path that already exists for switching films, and ffmpeg is spawned again with `-ss` **before** `-i`: an index lookup rather than decoding and discarding up to the point, which is milliseconds on a 500 MB film instead of tens of seconds. Measured over real MNP: 0s -> 492 MB, 600s -> 418 MB, 3000s -> 179 MB. A seek at or past the end is pulled back, because ffmpeg would produce nothing and the player would wait for segments that are never coming. `stream_init` reports the position actually used. It has to: ffmpeg restarts its output timestamps at zero however far in it seeks — `-copyts` does not change that for this input, measured — so the client is the one that puts the fragments back on the film's timeline, and it cannot guess by how much. The value is also not what was asked for, since `-c copy` lands on the keyframe at or before it. The diagnostics that found the rest of this are here too: a seek, a first init and a re-init are each one line at INFO, which is rare enough to keep on. The five-second client report stays at DEBUG.
* feat(node): the stream capacity is the operator's to set, and a log that ↵Christophe Besson2026-08-165-25/+325
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | says who stopped Bounding the client's read-ahead changed what a transcode slot is. It used to be a burst — the browser took segments as fast as it could append them, so a slot came back within the minute whatever the length of the film. Now it is held for as long as someone is watching, so the cap counts simultaneous viewers, and two of them meant the third was refused for the next hour and a half. The right number depends on the machine, so it belongs to the operator: `[node] max_concurrent_streams` in node.toml, or MESHBAY_MAX_CONCURRENT_STREAMS. Default 8 — one ffmpeg per viewer, remuxing rather than encoding, idle on a pipe for most of the film. Zero, a negative number, a non-number and a bool are refused with a warning naming the setting: `Semaphore(0)` is not "no limit", it is a node where no video ever plays and nothing says why, and TOML `true` would have become 1 by way of `int()`. A stream also ends on the peer's silence now rather than on its stinginess. A viewer buffered well ahead deliberately grants nothing for minutes, and the old budget accumulated over the whole wait, so a keepalive that granted no credit could not keep a paused film alive. The rest is diagnosis, which is what this cost. `client_diag` carries the player's own view — readyState, refused appends, buffered ranges, the video element's error — into the node's log at DEBUG, next to the node's view of the same stream. It is the only window into a phone, and every field is stringified and cut short because all of it is peer-controlled. The node also logs the first keepalive, which distinguishes a paced client from an unpaced one at a glance, and progress every hundred segments, whose last line says where a stream stopped and which side stopped it.
* fix(hub): serve the SPA under a fingerprint of what it isChristophe Besson2026-08-164-14/+188
| | | | | | | | | | | | | | | | | | | | | | | `Cache-Control: no-cache` requires a browser to revalidate, but it only binds one that asks. A browser that cached app.js before that header existed applies heuristic freshness instead — a fraction of the file's age, which for a file dated weeks ago is days — and never asks. It then runs an old player against a new node. That cost most of a session. A phone kept a player without the read-ahead bound and filled the browser's buffer ceiling at 106 MB, the exact symptom the bound had been written to remove, for an hour after the bounded player went live. A fix that is written, tested, deployed and served, and still not what runs, is indistinguishable from a fix that does not work. The whole module graph now lives under `/a/<content-hash>/`. A path prefix rather than a query string, because relative imports inherit it: `app.js` reaching for `./i18n.js` gets the build it was written against, and never a mixture of two — which does not render a stale page, it fails to link. The URL changes with the content, so those may be cached hard. `sw.js` stays at the root. Its scope is its own path, and under the prefix it would no longer control the pages whose downloads it exists to intercept.
* fix(hub): bound the video read-ahead by the playhead, not by the networkChristophe Besson2026-08-164-10/+565
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A 500 MB film loaded about 100 MB and hung on "buffering" for good. 100 MB is not a number in our code: it is where the browser stops. ffmpeg remuxes with `-c copy`, so the bytes on the wire are the file's own, and credit granted per append meant taking them as fast as the network allowed — which for a film is very much faster than watching it. The SourceBuffer ceiling arrived in the first minute. Past it every append was refused, and the refusal was unrecoverable: a refused append fires no `updateend`, `updateend` was where credit was granted, so the node sent nothing and no segment arrived to retry the append. Every wakeup the pipeline had was downstream of the append that had just failed. Playback continuing — the one thing that frees room — woke nothing at all. Credit now follows the buffer instead of the writes. `pump()` is the only place it is granted, it keeps `STREAM_WINDOW` segments in flight while less than `BUFFER_AHEAD_S` of film is held past the playhead, and it is driven by a one-second clock and by playback progress, never by arriving data. Buffering by time makes a two-hour film cost what a two-minute clip costs. A window rather than a debt, and this took a second measurement to get right: accumulating a credit per append and releasing the balance when the buffer finally drained sent six megabytes in one burst, overshot by a minute of film, then said nothing for forty-six seconds. Measured in Chrome against real fragmented MP4. Two smaller things found on the way. `updateend` fires for `remove()` as well as `appendBuffer()`, so crediting from it paid the node for the player's own evictions. And a viewer that is deliberately far enough ahead grants nothing for minutes, which the node read as a closed tab — it now sends `stream_more` with n=0, which grants no room but proves someone is there. The first version of the test modelled the credit loop and passed while the player still hung: a model written by whoever wrote the fix agrees with it by construction. `tests/harness/mse_harness.mjs` lifts the real functions out of app.js as text and runs them against a SourceBuffer that has a ceiling. What is modelled is the browser.
* chore: release 0.5.0Christophe Besson2026-08-165-7/+7
| | | | | | | | | | | | | | | | | | | | | | | Two rounds of features and one long hunt. The hub gained leaving a group, a cap of ten live public groups per owner, and the rule that a group is listed only once a node has announced it — with `prune-groups` to collect the ones that never got one. Presence rides on the group list, from the registry the hub already keeps for signaling. The web client speaks ten languages, splits Profile from Settings, and reads chat the way it is written: newest first, paging backwards. The rest was one symptom — "close the viewer, the next video hangs" — with three independent causes underneath, none of which the test suite or e2e.py could see. A background task the loop only weakly referenced, collected while it held a transcode slot. A connection-state handler that forgot a peer without stopping it. And `await proc.wait()` deadlocking on ffmpeg's own unread output, which no amount of SIGKILL resolves. Found by instrumenting the node and reading the log, after two confident fixes that addressed real but different bugs. MNP goes to 0.2: PING/PONG and backward chat paging, both additive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): chat, presence, a Profile page, and downloads that do not freezeChristophe Besson2026-08-1620-291/+1693
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Chat opens on the newest hundred messages, loads fifty older on demand with the reading position anchored — the distance from the *bottom*, since everything above the viewport just grew — and follows new messages only when the reader was already at the end. Day separators, sender grouping, an unread marker, and a jump-to-latest pill. Messages are keyed by id: index keys plus prepending makes Preact reuse the wrong bubbles. A presence dot per group in the sidebar, three states, each backed by something: the hub's registry, or a connection this browser made or failed to make. Never colour alone — red and green are the pair colour-blind readers cannot separate — so each dot carries a title and an aria-label. Profile is split out of Settings: identity, node link, pinned node identities and account deletion. Mixing them put an irreversible button two scrolls under a theme picker. The create-group page loses its centred 520 px card, which left 190 px of margin either side, and its two button panels become a radio group — a button conveys no chosen state to a screen reader, and side by side they read as two independent actions rather than one either/or. The Files toolbar shows its actions as icon buttons the moment Select is on, disabled when they do not apply rather than appearing and vanishing. On a phone the right-hand group could not wrap and ran 130 px off the screen. Streamed downloads no longer freeze after one chunk. `registration.active` says a worker exists, not that this page is controlled by it — and an uncontrolled page's requests never reach its fetch handler, so the worker took the stream and was never asked for it, leaving `writer.write()` waiting on backpressure that would never lift. The page now requires control and the worker confirms it actually served the request before the sink is trusted. Fixed on the way: `setActionsOpen` outlived the state it belonged to and threw on every Files action; the chat scrollbar stopped short of the bottom; the owner's row sat lower than the rest; About showed a version hardcoded two releases ago. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): stop losing transcode slots, and reap ffmpeg without deadlockingChristophe Besson2026-08-163-43/+551
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported from a phone: play a video, close the viewer, open another — the second hangs and the third is refused. Three separate causes, found by instrumenting rather than guessing, after two fixes that addressed real but different bugs. A task nobody holds can be collected mid-flight. asyncio keeps only a weak reference, so `ensure_future` with the result discarded may be garbage-collected while running — "Task was destroyed but it is pending!" — and `_stream_video` never reached the exit of its `async with sem`. `_spawn` holds every background task; all nineteen call sites go through it. Losing the peer must stop its work. The connectionstatechange handler popped the session from a dict and nothing else, so a closed tab went on transcoding for the full 120 s credit timeout. Measured in the log: 91 s of ffmpeg after the connection closed. `shutdown_tasks()` now runs on the way out, and the credit wait checks the channel before sleeping and polls in slices instead of once. And `await proc.wait()` after `kill()` still deadlocks. ffmpeg outruns a credit-paced viewer and fills the stdout pipe; stop reading it and the transport cannot finish closing, SIGKILL or not. Measured against the live node with a 169 MB video, closing the viewer after 20 segments and asking for the next one: 15.1 s then "Server busy" before, 0.1 s / 0.0 s / 0.0 s after. Chunk replies wait for room on the channel. Eight megabyte-sized chunks answered as they arrived queued 8 MB with nothing watching — measured at 7.3 MB of bufferedAmount in milliseconds. Fine on a LAN, minutes of head-of-line delay on a busy link. Upload names accept any script. The rule was ASCII-only, so `été.txt` was refused — and so was `rapport (1).pdf`, which is the form `_free_name` produces itself, meaning the node rejected names it had chosen. Widened to Unicode with the C5a and H2 protections intact, plus a refusal of names that lie about themselves: trailing space or dot, and the right-to-left override. Errors now name the file, so one bad name no longer fails every upload in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(common): MNP 0.2 — liveness, and chat history read the way it is writtenChristophe Besson2026-08-165-3/+212
| | | | | | | | | | | | | | | | | | | | | | | | `get_messages` pages forward from the oldest message. That is the right shape for "what happened since I last looked" and the wrong one for opening a conversation, and the browser asked it for `since=0, limit=200` — so a group with more than two hundred messages showed its first two hundred and the exchange anyone came for was unreachable. Demonstrated on 300 messages: the newest was simply absent from the answer. `get_recent` and `get_before` page backwards, cursored on the row id rather than the timestamp. Nothing makes a `time.time()` float unique, and a cursor on a value two rows can share eventually skips a message or repeats it. PING/PONG covers liveness on an already-open channel: a DataChannel whose peer vanished without closing still reads as connected, and nothing noticed until a real request hung. It is not a discovery mechanism — opening a connection to ping costs a full ICE/DTLS handshake, measured at 0.6-7 s across two ISPs — so presence in the group list comes from the hub's registry instead. Both additions are backward compatible: an 0.1 peer sends no `before` and is answered with the newest page, which is what it wanted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): leaving a group, a cap on public ones, and hosting as a preconditionChristophe Besson2026-08-1610-14/+863
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Leaving is its own endpoint rather than a relaxation of the owner's removal check — an authorization rule with an exception in it is the one that gets read wrong later. The owner cannot leave: the group would be left with nobody able to admit, edit or delete it, which is the answer removal and account deletion already give. Public groups are capped at ten live ones per owner. They are the ones that cost other people something — listed in Discover, joinable by anyone — so a script that opens hundreds fills the directory for everybody. Private groups are invisible to non-members and are not capped. Hub staff are exempt; the cap is anti-spam, not a rule about running an instance. Creation is the only place it can be checked, and deliberately so, because PATCH refuses to change visibility at all. A group is now listed only once a node has announced that it hosts it. Before that it has no files, no key and nothing to connect to, so showing it to a member produces a name they cannot open and cannot be told why; its owner still sees it while they set the node up. `meshbay-hub prune-groups` collects the ones that never got a node, meant for cron, with --dry-run. The migration backfills hosted_at from created_at: without that the first run would have deleted every live group. Presence rides on the group list itself, read from the signaling registry the hub already keeps — no poll, no timer. It says a node is connected *to the hub*, which is not a promise that this browser can reach it and not something a dishonest hub could not fake; the client downgrades it on a connection it tried and failed, which is the evidence that concerns the reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>