summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
Commit message (Collapse)AuthorAgeFilesLines
* fix(node): scope _enriched_attempted by group, not just content hash0.7Christophe Besson2026-08-254-15/+79
| | | | | | | | | | | | | | | | | | | | | | Major finding: entry.id is a content hash, so the exact same physical file — the same MP3, byte-for-byte — indexed into two different groups (a shared library reused across several demo/test groups, or genuinely the same folder shared into two groups) produces the *same id* in both. _enriched_attempted was a single flat set of bare ids shared across every group this node hosts. The moment one group's copy got enriched, every other group's otherwise-identical copy read as "already attempted" and was skipped forever — nothing else ever revisits an id once it's in this set. That group's Music tab (or Videos tab, same bug, same set) showed every affected file at duration 0 with no artist/album/thumbnail, permanently, no matter how long you waited or how many times you reloaded — group A having been enriched first was enough to silently starve every later group of the same content. Now keyed by (group_id, entry.id) throughout — the enrichment gate, the sweep, and the rename re-enrichment path, for both video and audio (they already shared the one set, and the collision risk is identical for both). New regression test constructs two groups with byte-identical audio content and confirms both enrich independently.
* fix(hub): keep an unmatched admin_challenge visible, add trace loggingChristophe Besson2026-08-241-0/+2
| | | | | | | | | | | | | | | | | | A real report showed audio_root timing out with *nothing* logged in between the connection handshake and the timeout — no admin_challenge, no error, nothing. The previous fix made an unmatched admin_challenge return silently (correctly, to stop it stealing an unrelated pending request — see the earlier commit), but that silence is indistinguishable from "the request never reached the node at all", which is exactly the ambiguity blocking this investigation. An unmatched admin_challenge is now logged (op, op_id, and the full set of currently-pending keys) instead of dropped quietly, and setAudioRoot/_authorizeAdminOp trace both hops of the round trip explicitly. Node-side, _do_audio_root gets a debug log at entry — cheap, and the only way to know from server logs alone whether the request was ever received if the client-side trail comes up empty. Diagnostic only: no routing behavior changed from the previous fix, verified against the same reproduction script.
* test(node): a real signed audio_root save through the full challenge pathChristophe Besson2026-08-241-3/+123
| | | | | | | | | | | | | | | | | | | | Every existing audio_root test either called ops.set_audio_root directly or mocked out _issue_admin_challenge — none of them exercised real signature verification, _do_admin_response, or the shared groups_ctx/ roster wiring _run_op depends on. Worth ruling out a break somewhere in that real path specifically: a report described a save that looked like it worked (the Music tab showed content right after) not surviving a reload. Drives the real _do_audio_root -> admin_challenge -> sign -> _do_admin_response -> _admin_exec_audio_root path with a genuine Ed25519 operator key, then opens a *separate* Roster instance against the same db file — the direct question a "worked, then reverted" report raises: does the value actually land durably, in a form any later connection reads back correctly. It does; this passes. The one thing missing from the session fixture to get this far was peer-registry self-registration (a real session adds itself on handshake completion — without it, the final ack has nowhere to go, including back to the requester).
* feat(node): add audio_root, gate Music enrichment on it like video_rootChristophe Besson2026-08-246-40/+421
| | | | | | | | | | | | | | | | | | | | | | | musicbay.md's original call — Music needs no root, tag reads are cheap so just cover the whole shared tree — didn't hold up against a real messy library: everything under every shared folder got mixed together with no way to scope Music down to an actual music collection. This adds an audio_root setting, symmetric to video_root in every respect: signed operator op (audio_root/audio_root_ack, MNP bumped to 0.10), validated against a real directory in the group's own roots before a signature is even asked for, gates tag/cover enrichment exactly like video_root gates ffprobe/TMDB (nothing runs until it's set, only files under it once it is), and a set/change fires a one-off sweep of whatever the folder already contains. The old trigger — sweep everything the instant "music" joins enabled_apps — is gone along with the root-less design it belonged to; setting audio_root is now the trigger, mirroring set_video_root's enrich_video_root_fn exactly. Test coverage mirrors the video_root suite: policy (refuse before a signature round trip, accept/store correctly) and the enrichment gate itself (nothing without a root, only files under it, sweep on set).
* feat(music): transcode WMA/Musepack to AAC so they actually playChristophe Besson2026-08-242-7/+322
| | | | | | | | | | | | | | | | | | | | | Tagging and covers for these two formats landed already, but neither one decodes in any mainstream browser's <audio> element at all — a real library scan turned up 273 such files that would show up correctly in the Music app and then simply fail on click. This closes that gap: the node transcodes to AAC/M4A on request (a one-shot whole-file conversion, not live-piped like video's fMP4 segments — an audio file is small enough that streaming it buys nothing), caches the result under its own content hash the same way a TMDB poster or a MusicBrainz cover is cached, and serves it back through the ordinary file_req/chunk path. That path used to assume anything in the media cache was thumbnail-sized (single chunk, always); generalized it to slice a cached blob the same way a real file on disk gets sliced, since a transcoded track can be several MB. New MNP pair (`audio_transcode_req`/`_resp`, version bump to 0.9), shares its concurrency cap with video's transcode pool rather than getting its own — both are real ffmpeg processes on the same node. Every other audio format is untouched: this only fires for .wma/.mpc, the two extensions that need it.
* fix(node): skip indexing audio files under 50KB, likely-corrupt sourceChristophe Besson2026-08-243-2/+58
| | | | | | | | | | The "P.H. Theme" failure investigated earlier turned out to be a genuinely corrupt 1256-byte source file with no audio stream at all, just an ID3 tag — a real, if rare, corruption pattern worth guarding against directly rather than only handling gracefully at playback time. Scoped to audio only, applied wherever a file actually gets hashed/typed (fresh scan and the cache-miss rehash path alike) — a tiny file of any other type is still indexed normally.
* feat(node): recognize WMA and Musepack as audio, read their real tag keysChristophe Besson2026-08-244-15/+261
| | | | | | | | | | | | | | | | | | | | | | A real-library scan turned up 250 .wma and 23 .mpc files that the indexer was silently classifying as "other" — genuinely lost from the Music app, not a consolidation-rule artifact (checked separately: the grouping logic itself drops nothing). Both are now indexed as audio and tagged properly: - WMA has no mutagen "easy" wrapper, so the generic tag reader was reading nothing from it at all. Reads the real ASF keys directly instead (Title/Author/WM-AlbumTitle/WM-TrackNumber), confirmed against a real sample file before writing the mapping. - Musepack's format auto-detection is unreliable enough (misidentified a real .mpc as MP3 in spot checks) that it now always opens by its own class instead of guessing from content. - Filters out another placeholder value found along the way: a French ripping tool's auto-generated "Album inconnu (<timestamp>)". Neither format decodes natively in a browser's <audio> element, so this gets them correctly visible, tagged, and covered — not yet playable in-browser. That would need server-side transcoding, deliberately left out of this change.
* feat(hub): consolidate loose tracks, "&"/"and" fold, player close/queueChristophe Besson2026-08-242-27/+27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Album-grid readability, part two: - groupMusicEntries (music-app.js): an album bucket left with exactly one track - a real album tag, but only one song from it, not the whole release - clutters the grid the same way an untagged loose track does. Both kinds now fold into one "<artist> - Various" tile per artist, unless there is only one leftover track overall, where relabeling buys nothing and the track keeps its own name (or the generic placeholder, if it never had one). - foldKey also normalizes "&" vs "and" ("Artist & The Band" / "Artist and The Band" is one act, tagged both ways across different rips of the same catalogue) alongside the existing case/whitespace fold. - music-player.js: a close button pauses and tears the player down; an unmount cleanup effect (pause, revoke every cached blob URL) fires either way, whether that's the close button or the shell tearing the bar down on its own. A "current queue" button opens an overlay listing the whole playing queue with the current track highlighted, click any to jump to it - works identically regardless of how the queue was built (an album, the consolidated misc bucket, a single standalone track), since it only ever reads the player's own live tracks/order/pos. - group-page.js: this component is not remounted when switching to a *different* group on the same /group/:id route (only the groupId prop changes) - so without an explicit reset, music from one group would carry into the next one opened. Resets musicQueue to null on groupId change; a tab switch inside one group still leaves it alone. - Scrubbed real artist/band names that had leaked into code comments and test fixtures (enrich_audio.py's docstrings, several test_enrich_audio.py assertions, a music-app.js comment) - replaced with generic placeholders, no behavioural change. - i18n: music.various, music.player_close, music.player_queue, music.queue_title added across all ten locales. Client-side only except none of this touches the node at all. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* fix(node): stop inventing a fake artist from the shared root's nameChristophe Besson2026-08-244-29/+345
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Music grouping was measured against a real ~5700-file library and came back worse than a plain file listing. Root cause: the artist/album ancestor walk always climbed exactly two levels (parent = album, grandparent = artist) with no idea where the group's own shared root was. Any file in a flat top-level folder — common here: bare `Artist/track.mp3`, no album subfolder at all — had its "grandparent" resolve to the root directory's own name, so the artist got replaced by the share's name. Measured: 289 of 5664 tracks across 41 real, unrelated artists (Ben Harper, Dire Straits, Jimi Hendrix, Janis Joplin, ...) collapsed into one fake artist this way — the single biggest bucket in the whole library, ahead of every real one. - `_artist_album_from_ancestors` now takes the entry's own root boundary (daemon.py resolves it via `RootSet.split`) and refuses to read it as a name. A file sitting in a top-level folder — genuinely ambiguous, artist or a standalone album/compilation — is handled by `_split_top_level_folder`: split on "Artist - Album" when the (cleaned) folder name has that shape, otherwise the whole name becomes the artist alone, the more common real case here. - `_clean_tag` treats known tagger placeholders ("No Artist", a French tool's "Nouvel artiste (334)") as absent rather than a real value — they were just as truthy as a real name and were locking out the fallback that would have done better. "Various Artists" is kept, a real compilation credit rather than a placeholder. - A `title` tag that's the bare filename copied verbatim (track number included — found live on a whole CD-single) is stripped through the same prefix rule the filename parser already used (`title_parse.strip_track_prefix`), since a tag normally wins over the parsed title. - Cover art: only 11% of a 400-file sample had embedded art (expected for this era of rip), but 267 loose cover images sit beside the tracks across the library (Windows Media Player's `Folder.jpg`/ `AlbumArt_{guid}_*.jpg`, manual `cover.jpg`) and were never looked at. `_find_sibling_cover` checks the track's own folder before giving up — measured coverage 11% -> 26% on the same library, zero network calls. `_enriched_attempted` is in-memory and resets on restart, so a node restart is enough to re-run enrichment over an already-scanned library with the fixed logic — no rescan flag, no cache to clear by hand. 22 tests in test_enrich_audio.py (11 new), including the exact regression case end to end through the real pool. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(node): Music app node-side — indexing, MusicBrainz enrichment, protocolChristophe Besson2026-08-2414-21/+1537
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Implements the node half of docs/musicbay.md against MNP 0.8: - IndexEntry gains artist/album/track_no (reuses duration/thumb_hash/ display_title, already generic). New musicbrainz_config/_enabled and music_meta_req/_resp message pairs, mirroring the TMDB shape. - title_parse.parse_track_filename: track-number-prefix + title parsing, fallback-only (embedded tags are the primary source, unlike Videos). - indexer.enrich_audio.AudioEnricher: mutagen-based tag/embedded-cover extraction through its own bounded pool (asyncio.to_thread, no subprocess — no ffmpeg-shaped deadlock risk). Gated on "music" in a group's enabled_apps rather than a video_root-style scoped folder. - musicbrainz.py: MusicBrainzClient — no API key (unlike TMDB), just a self-imposed ~1 req/s pace and a configurable, non-default User-Agent contact string; inert (no calls at all) when no contact is configured, never sends an unidentified client. - media_cache.py: file_mbid/mbid_meta tables alongside the existing TMDB ones, cover art reusing the thumbs table via a synthetic musicbrainz:{mbid} id, pruned on file deletion. - roster.py/ops.py/webrtc_server.py: musicbrainz_contact (node-wide) and musicbrainz_enabled (per-group, from the start) as signed operator settings, ALLOWED_APPS gains "music", _do_music_meta_request resolves and caches a release-level MusicBrainz match per (artist, album). - daemon.py: AudioEnricher/MusicBrainzClient wired alongside the video ones; a group's existing library is swept when "music" is newly enabled (no video_root equivalent — see musicbay.md §2.1). 41 new tests (musicbrainz.py against a mocked transport, admin-op policy for both new settings, media_cache round-trip/pruning, enrich_audio end-to-end against real ffmpeg-generated MP3s). Full suite (common + node + hub): 1116 passed, no regressions. Client-side (music-app.js, persistent player bar) not started yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* fix(node,hub): HEVC transcode fallback, live-add progress, per-group TMDB toggleChristophe Besson2026-08-2416-202/+750
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three bugs found live testing the Videos app against a real HEVC/EAC3 show, plus a design change requested afterward: - Streaming always did "-c:v copy", which faithfully reports a source's real hev1 codec string but is unplayable in a browser with no HEVC decoder (most Chrome/Linux builds). The node now transcodes to H264 whenever the probed codec is browser-incompatible (media_probe.py's new BROWSER_INCOMPATIBLE_VIDEO_CODECS), with a `transcode_incompatible_video` node.toml opt-out for operators who know their viewers already decode it. - Dropping a whole season into an already-watched folder gave no scanning indicator and no progress bar: IndexProgress was only ever updated by the two bulk scan paths, never by the real-time per-file watchdog path (_schedule_update/_debounce/_update_entry). That path now accounts a "burst" the same way, without double-counting a file rewritten mid-debounce. - A stray literal "0" rendered in the video detail modal when there was no TMDB match (`meta.confidence` is 0, and `0 && x` renders "0" in JSX/htm, not nothing) — `confident` is now a real boolean. - Whether TMDB is used at all moves from a node-wide setting to per-group (OP_TMDB_ENABLED/tmdb_enabled/tmdb_enabled_ack, scoped like OP_VIDEO_ROOT): an operator running a real media-library group alongside test/demo groups on one node wants outbound TMDB traffic for the one that needs it, not all of them. The custom API token and query language stay node-wide, one shared credential/cache (tmdb_config/OP_TMDB_CONFIG, unchanged reasoning). MNP_VERSION 0.6 -> 0.7, additive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
* feat(node,hub): season-specific overviews, manual TMDB match correction, and ↵Christophe Besson2026-08-249-3/+870
| | | | | | | | | | | | | | | | | | | | | | | | | wizard polish Two operator-facing fixes for a real 3-season show whose automatic TMDB match was wrong at the show level: per-season overview/air_date tabs in the detail modal (falling back to the show-level text when a season's own is empty), and a "Fix match…" search-and-correct affordance that re-resolves every file sharing the corrected show's display_title. New signed op OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp, tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6. Also: the create-group wizard gets a spinning indexing indicator and an app-selection step, group settings default the TMDB language to the operator's own locale (never as a global default), and a file renamed mid-session now re-triggers title parsing instead of being silently skipped by the enrichment dedup guard. Fixes two bugs found during this work: the search overlay's z-index lost to the base video-overlay class and rendered invisibly, and season_meta's own empty overview didn't fall back to the show-level one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
* feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)Christophe Besson2026-08-2422-89/+2712
| | | | | | | | | | | | | | | | | | | | | 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-232-22/+238
| | | | | | | | | | | | | | | | | | | 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-2316-73/+2036
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-232-3/+3
| | | | | | | | | | | | | 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-235-0/+250
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-222-72/+101
| | | | | | | | | | | | | | | | | | | | | 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: first-run wizard reliability and node startup performanceChristophe Besson2026-08-224-46/+78
| | | | | | | | | | | | | 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: update source-reading tests that drifted from the codeChristophe Besson2026-08-221-1/+1
| | | | | | | | | | | | | - 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>
* feat: unified group management, public groups, and activity-based sidebarChristophe Besson2026-08-2010-167/+366
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-207-46/+1334
| | | | | | | | | | | | | | | | | | | | 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-196-34/+72
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* feat(node): the operator can close uploading to everyone but themselvesChristophe Besson2026-08-184-1/+327
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* feat(client): the platform seam, and an Electron shell that has never been runChristophe Besson2026-08-181-0/+108
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-186-24/+1023
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-1823-516/+3152
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* feat(node): seeking, as a stream restarted somewhere elseChristophe Besson2026-08-171-10/+46
| | | | | | | | | | | | | | | | | | | | | | | | | 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.
* chore: release 0.5.0Christophe Besson2026-08-162-3/+3
| | | | | | | | | | | | | | | | | | | | | | | 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>
* 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-163-0/+198
| | | | | | | | | | | | | | | | | | | | | | | | `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>
* chore: release 0.4.00.4Christophe Besson2026-08-162-3/+3
| | | | | | | | | | | | | | | | | | | | | | | | The web client speaks ten languages instead of one: French, Spanish, Brazilian Portuguese, Simplified Chinese, Japanese, German, Italian, Dutch and Polish, all formal, with `hub`, `node` and `GEK` deliberately left in English so the interface still matches the CLI and the docs. Catalogues are fetched per language rather than shipped together, plural forms go through Intl.PluralRules because Polish needs four of them, and locale matching keeps the region so pt-BR and zh-CN resolve to the files written for them. Splitting one module into a loader and ten catalogues gave the SPA a version dependency it did not have before, and the hub was serving static assets with no explicit freshness at all. A browser that cached half a deploy either rendered every string as its own key or, in the other direction, failed to link the module graph and showed nothing. Static responses now carry no-cache, which costs one conditional request and answers 304 with no body. The node and common packages carry no functional change; they move with the version because the three are released together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: release 0.3.00.3Christophe Besson2026-08-152-3/+3
| | | | | | | | | | | | | | | | | | | | | | | Twenty-one commits since 0.2, and enough of them change what the thing does that moving the old tag would have been the wrong description. Node: video streaming paced by the client rather than pushed at it, and a stream that ends when the viewer closes instead of holding a transcode slot for two minutes. An operator can remove an empty directory and revoke a member over MNP. `meshbay-node group add` attaches another hub group without hand-editing node.toml. The node.toml operator key is gone; the roster is the only source of authority. Hub and web client: transfers outlive the page that started them, with a widget that shows the rate and can cancel them; downloads stream to disk in every browser, through the File System Access API where it exists and a service worker where it does not; a folder can be taken as a zip built in the browser. A group owner can remove a member and edit the description. Nodes are recorded at the address their signed announcement arrived from, not the one STUN told them about. Deleted accounts stop being counted while the connection log keeps their name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: stop a stream on close, count only real users, record where a node isChristophe Besson2026-08-151-5/+36
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **Closing the viewer left the node working.** Nothing told it to stop: the player dropped its handlers, which only made the browser deaf. ffmpeg kept running and held one of the node's two transcode slots until the credit timeout expired two minutes later — which is why the next video answered "server busy". `stream_stop` ends it at once, and the viewer also drops its queue, ends the MediaSource and revokes the object URL on the way out, any of which could be holding megabytes of decrypted video. While there: `file_chunk` replies were matched to their requests by arrival order, which was true by luck rather than by construction. The reply now names the file it belongs to and is matched on that and the chunk index; a chunk nobody is waiting for is dropped instead of being handed to whatever request happens to be oldest. **The administration panel counted its own history.** A deleted account is tombstoned so the connection log stays readable, and every count and list treated that row as a user — including a group's member count, and the member list of the group itself. They do not any more. **Where a node is.** `endpoint_hint` is what a node believes its address to be, learned from a STUN server and sent to us: useful for reaching it, and a claim. The announcement that carries it is signed with the node key over a fresh timestamp, so the address that request *arrives from* is the address of whoever holds that key — that is now recorded on the node row and shown in a Nodes tab, next to the hint, with the difference spelled out. Clients get the same treatment: `webrtc_offer` is logged with the address the hub saw when a browser starts a peer connection. Verified against the live deployment: the node's row reads 90.112.206.172 after a restart, and in e2e a stopped stream goes quiet in one message and the next one starts immediately instead of being refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(groups): remove a member, and keep gigabytes out of the tabChristophe Besson2026-08-152-0/+202
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **Removing a member.** The owner can do it from the Members tab, and it is two halves in the order that fails safe: the node stops serving the group key first (an operator-signed request, so a paired browser only), then the hub drops the membership row. The other order would leave someone able to reach a node that still serves them. It is a membership, not an account. The user row is never written: their other groups, their files and their pinned identity survive, because one group's owner must not be able to erase someone from the hub. It is also per group — a node hosting two loses them from one — and it does not take back the key they already unwrapped, which is what rotating the GEK is for. The confirmation and the panel both say so. **Downloads and streaming through the disk, in both browsers.** The audit this started as found two ways to put gigabytes in a tab. Firefox and Safari have no File System Access API, so every download there was collected in memory. A service worker fixes it: the page keeps the writable half of a transferred stream, the worker answers a made-up URL with the readable half and a Content-Disposition header, and the browser writes it to disk as it arrives, with real backpressure. The worker caches nothing and falls through on every request that is not one of these downloads. A zip announces no Content-Length, since the archive is larger than the files in it and a length we miss truncates the file. Video was worse and affected both browsers. The node pushed ffmpeg's whole output as fast as it was produced while the player consumed a segment at a time, so the queue held the film — and appending all of it hit the SourceBuffer's cap, where the handler logged the error and dropped the segment, leaving a hole in the middle of the film with nothing to show for it. Streaming is credit-based now, 24 segments of 256 KB in flight, verified against the live node: three credits, three segments, then silence until more are granted. The player evicts what is more than a minute behind the playhead and retries a refused segment rather than dropping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(files): download a folder as a zip, and remove an empty oneChristophe Besson2026-08-152-0/+179
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two things a Files panel needs and did not have. **Removing a directory** is privileged, where creating one is not: it acts on a name other members are using, on the operator's disk. It is refused unless the directory is empty, and that rule is the safety property — whatever the browser sends, this cannot destroy content. The check runs twice, once before the challenge and once after the signature comes back, because a file can land during the round trip. A file also accepts its uploader's key; a directory has no uploader, so only the operator's key will do. **Downloading a folder** produces a zip built in the browser, written straight to disk as the chunks arrive. An archive of a group folder is routinely tens of gigabytes, so nothing is held: peak memory is one chunk plus a small record per file. The node is not involved at all — it serves the same encrypted chunks as any other download, holds no temporary files, and cannot be asked to compress anything. zipstream.js is store-only. Group content is video and images, already compressed, so deflate would spend CPU on every byte to save nothing, in the thread that is also decrypting. Sizes and CRCs go in a data descriptor after each file because a stream cannot seek back to patch a header, and zip64 kicks in per entry past 4 GiB and for the archive itself. Because none of that can be checked from the Python side of the house, test_zipstream.py runs the real module under Node and reads what it produces with zipfile — CRCs, UTF-8 names, zip64 records and all. The archives also pass `unzip -t`. Firefox and Safari have no File System Access API, so there is nowhere to stream to: the fallback builds the archive in memory and says so, with the size, before starting rather than after failing. One mistake worth recording: the first version of deleteDirectory passed the node's own answer as the value to check the challenge against, which turns the comparison into a tautology. It checks the path we asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node): meshbay-node group add — host another of your groupsChristophe Besson2026-08-154-12/+190
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Attaching a group to a node meant hand-editing node.toml with a UUID copied from a browser URL, restarting, and knowing that gek-init exists. Nothing in the CLI said so, and on a node reached over SSH there is no paste buffer to carry a UUID across in the first place. meshbay-node group add grenet --dir ~/grenet-share The name is resolved against the operator's groups on the hub by the daemon, which is the process holding the session. The [[groups]] block is appended to node.toml as text rather than round-tripped through a TOML writer: the file is hand-written and its comments explain decisions worth keeping. The directory is created, and the command says what remains — restart, then gek-init for that group. It refuses a name it cannot find by printing the groups it can, with their ids. That listing is the useful half of the answer and it was missing everywhere: _daemon_api now renders an `available` list from any endpoint that offers one. The key is per group and pairing is not, which is the part that reads as a gap until it is written down: one paired browser covers every group the node hosts, while each group's key admits only its own members. §4 of the user guide now says all three of those in one place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cli): --group takes a name, and operator pair says it takes noneChristophe Besson2026-08-151-2/+35
| | | | | | | | | | | | | | | | | | Two ways the CLI misled someone attaching a second group to a node. `meshbay-node operator pair --group grenet` accepted the flag and ignored it: pairing is node-wide and always was. That invites exactly the wrong reading — that a code belongs to a group, and that pairing had failed because the group did not change. It now refuses the flag and says one paired browser covers every group the node hosts. `--group` also only ever accepted a UUID. A name went through untouched and the daemon answered as though the group did not exist, which is not what happened. It now resolves a name against node.toml, and when there is no match it prints the groups there are, with their ids — the missing half of the answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(groups): editable description, and one source of operator authorityChristophe Besson2026-08-155-55/+70
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A description could only be set the moment a group was created, so every group made before anyone thought of one stayed blank for good. The owner can now edit it from the group's page, and PATCH /v1/groups/{id} takes it. That endpoint takes the description and nothing else, deliberately. The name, the visibility and the join policy are the terms members joined on; a private group that can quietly become public is not the group they agreed to be in. Changing those needs a decision about who gets told, not a field on a form — there is a test saying so. Separately, the legacy operator key is gone. `admin_pk_ed25519` in node.toml named the operator before the roster existed and was kept so that an existing deployment would keep working; nothing uses it, and a second source of node authority is not something to carry around out of politeness. Authority is the roster, read fresh on every check. It is removed rather than ignored: a config that still names the key gets a warning at startup pointing at the file. Dropping it in silence would refuse invites and file deletion with a signature error that looks like a bug somewhere else — which is exactly how finding M3 presented. Two tests were verifying admin operations by naming a key in the context, which was the legacy path. They now pair an operator into a roster, the way an operator does. The authority test anchored on the deleted function and passed vacuously once it disappeared; it states the invariant against the verifier and the daemon instead. Also defined .btn-secondary, used in four places and styled in none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(logs): keep the username on records the account no longer answers forChristophe Besson2026-08-151-0/+1
| | | | | | | | | | | | | | | | | | | | | The connection log took the name from a join on `users`, and deletion tombstones that row — so every record belonging to a deleted account reported `deleted-3f9a1c`, which is the one answer that helps nobody. The log is kept for a legal retention period precisely so it can say who did what; losing the name at deletion kept the data and lost the point of it. `ip_logs.username` is written as the account is erased, and stays NULL while the account is alive, where the join is better because it cannot go stale. The admin view prefers the stored name when there is one: the join still answers after deletion, just with the tombstone. Releasing the username for re-registration and keeping it in the log are separate things, and the guide now says so. On the node side, the pre-proof audit line records the username the session already knew, instead of leaving the column empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Notifications: one per conversation, none for your own messagesChristophe Besson2026-08-141-0/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Four things were wrong, and they compounded: a busy chat produced one row per message, muting a group did nothing at all, there was no way to clear the list, and the one person guaranteed to know about a message — its author — was told about it. The author bug was a name mismatch across two processes. The node sent chat_notify without saying who wrote the message, so the hub used the node's own token subject, which is the operator's account. The skip therefore matched the operator and no one else: everybody was notified of their own messages, and the operator was notified of nobody's. The node now names the author and the hub reads that field. Muting lived in the browser's localStorage and nothing ever read it, so the checkbox was decoration. It is a column on group_members now, checked where the notification is created — a notification nobody wants is not written at all. Chat keeps a single row per (user, kind, group) whose date moves and whose read flag clears, so a conversation is one line saying when it last spoke. Clicking it opens the group and dismisses it; joining a group dismisses its invitation; and DELETE /v1/notifications clears the lot. The hub deploy now runs alembic. create_all() only creates missing tables, so group_members.muted never arrived on the running hub and /v1/groups/mine answered 500 — worth catching in the script rather than in a browser. Verified end to end against the deployed hub and node: the author receives nothing, the other member receives exactly one, carrying its group_id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(files): one uploads/ directory, for files and chat alikeChristophe Besson2026-08-142-58/+96
| | | | | | | | | | | | | | | | | | | | | | | | | | Correction to the previous commit. Uploads went wherever the member happened to be looking, which spreads chat attachments through the tree and makes the destination a client-supplied path — surface that had to be defended. Everything a member sends now lands in `uploads/` at the root of the shared directory: visible, one place, easy for the operator to look into or empty. Chat attachments go there too, so the separate out-of-tree thumbs directory is not needed and is not built. They were already ordinary uploads; now they are ordinary uploads that land somewhere sensible. The destination is chosen by the node, so a client naming somewhere else changes nothing — the traversal surface simply is not there on this path. safe_subdir() remains for dir_create, where the path genuinely does come from the client, and keeps its tests. One shared directory means name collisions are ordinary rather than adversarial: every camera produces IMG_1234.jpg. The node finds a free name — "IMG_1234 (2).jpg" — and reports it in the ack, because a chat message has to point at the file that was actually written and not at someone else's. Nothing is ever replaced, which is the property the per-user quarantine existed for (C5a) and the one the tests assert; they fail if the free-name search is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(files): upload into the current directory, and create foldersChristophe Besson2026-08-142-29/+184
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-user quarantine is gone. `.uploads/{user_id}/` was the fix for C5a, and it worked, but it made the shared directory something nobody could organise: every file landed under a uuid nobody recognises. Files now go where the member is looking, most often the root. What the quarantine actually bought is kept, and is now what the tests assert rather than the location: - an existing file is never replaced. That was the real defect — overwriting a file also made the attacker its recorded uploader, and therefore able to delete it through the uploader path - the name allowlist is unchanged - the destination is confined under the shared root That last one is new surface: the directory arrives from the client. safe_subdir() is the single place that decides, with two independent guards — every segment against the name allowlist, and the resolved result under the root — because one of them will eventually be refactored by someone who does not know why it is there. Ten traversal cases are covered, and they fail if both guards go. Also adds `dir_create` (any member may organise a shared directory; audited like anything that writes to the operator's disk) and makes the node report its real directory list in index_sync — folders were inferred from file paths, so a new empty one, or one that had been emptied, simply did not exist as far as the UI was concerned. Two C5a tests changed their assertions deliberately, as C5b's did before: they encoded the quarantine path, which is the thing being removed. The property they existed for is asserted more directly than before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: release 0.2.00.2Christophe Besson2026-08-142-3/+3
| | | | | | | | | | | | All three packages together, as the conventions require, plus the RPM and DEB metadata and their changelogs. The tag said 0.2 while every package announced 0.1.0, which would have shipped an RPM claiming to be the reviewed build while containing a different protocol: the hub schema lost the user identity keys, tokens lost pk_user, and gek_bundle_store left the wire. Pre-1.0, a breaking change bumps MINOR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): an operator keeps their role when reconnectingChristophe Besson2026-08-141-1/+5
| | | | | | | | | | | An operator's roster row is node-wide, so looking it up by the group they happen to be opening found nothing and the client was told it had no role on a node it administers. Falls back to the node-wide row. Surfaced by running the live workflow twice: the first pass pins, the second is recognised — and only the second exercised this path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat!: identity keys per node — C4's blast radius drops to one operatorChristophe Besson2026-08-143-8/+66
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | One keypair was copied to every node its owner joined, so cracking the bundle on any single node yielded the identity used on all of them: their content on other operators' machines, and the ability to sign as them anywhere. That lateral reach was the part of C4 worth attacking. Each node now gets its own keypair, generated the first time its owner joins it and left with that node alone. An operator who cracks what sits on their own disk holds a key that is a stranger to every other node — and on their own node, one that unlocks nothing they did not already hold: they serve the content, the index and every byte of it by design. Nothing changes for the user. A first contact with a node already needed that operator's code, and the key is created in the same step; a second browser still recovers it from the node with the passphrase alone. Two operators can also no longer tell they host the same person by comparing keys. BREAKING, and deliberately without a compatibility path — the deployment is wiped for the next demo: - users.pk_ed25519 / pk_x25519 dropped (migration a7c31f9e40b2) - registration no longer sends or stores a key - PUT /v1/users/me/keys and regenerateKeys() gone; rotation is now `member unpin` plus a fresh code, decided on the machine that pinned it - /pubkeys returns an account id and a node's linking key. It was the directory H3 read, and nothing wraps for it any more - the pk_user JWT claim is gone That last one closed a live defect the inventory turned up: the node recorded pk_user as the uploader's identity and authorized deletion against it, so a hub issuing a token naming its own key could delete anyone's uploads on any node. Attribution now uses the key the node itself pinned. A simplification falls out. Registration generates nothing, so a scripted signup is a real account: `demo.py bootstrap` takes a wiped hub and node to a working demo with no browser, which was impossible while keys were born in one. Also fixes, found by running it on a wiped deployment: the key handed back on a join now belongs to the group the connection is for, not the group named in the invitation — an operator pairs node-wide but redeems the code while opening a group, and expects to read it. Tests: 343, including the two that state the property — a key pinned by one node is refused at another, and someone else's code does not admit it. Verified end to end against a wiped hub and node: bootstrap, pair, invite, join, download, stream, second browser, revoke. Design: docs/per-node-identity-v1.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(client): bundle KDF to 128 MB, and derive it once per sign-inChristophe Besson2026-08-141-3/+14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Argon2id memory 64 → 128 MB. Memory is the lever, not time: it caps how many guesses a card can hold at once, so the ceiling on one high-end GPU moves from roughly 4k to roughly 2k guesses/s and its 24 GB fits ~187 lanes instead of ~375. Measured through the vendored build: 640 ms, against 322 ms at 64 MB. While measuring the real cost of a sign-in, found the SPA deriving the bundle key twice — once for the key pair kept for the session, then again inside decryptBundle() for the local bundle. At these parameters that is 0.6 s of pure waste. Measured now, end to end: auth_key (PBKDF2 600k) 239 ms bundle v1 (PBKDF2 600k) 240 ms legacy, until every bundle is upgraded bundle v2 (Argon2id 128MB) 650 ms ----------------------------------- sign-in 1 129 ms (889 ms once no v1 bundles remain) Once per sign-in, and only then: reopening a group, downloading, streaming and reloading the page all reuse the key, which lives in IndexedDB from login. Also bounds two waits in the node's hub WebSocket, found because the node went silent again mid-deploy. It had reconnected after the hub restart, sent its auth frame, and waited for a reply that never came — `ws.recv()` had no timeout, so a hub that accepts a socket and then says nothing for a few seconds while starting up parks the task forever: node running, logging nothing, invisible to everyone. The auth exchange now times out at 15 s, connect at 15 s, and a refused auth retries with a fresh token instead of ending the task for good. QE harness signs in once per account and reuses the token — several clients there stand for several browsers of one person, and what tells them apart is which keys they hold, not which token, while the hub quite rightly rate-limits repeated logins from one address. Tests: 341, plus the live workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(client): make the key backup a choice, and raise the passphrase floorChristophe Besson2026-08-142-0/+39
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two things the multi-browser story made obvious. **The backup is now opt-out.** Keys are kept, encrypted with the passphrase, on every node whose group you join — that is what lets a second browser recover them, and it is finding C4: a PBKDF2-protected blob on other people's disks, attackable offline at the speed of PBKDF2, which is memory-light and therefore cheap on a GPU. Until now everybody paid that cost, including people who will only ever use one browser and get nothing back for it. Settings → "Use this account on other devices". Turning it off does not merely stop future uploads: the next connection to each node withdraws what that node already holds (new keypair_bundle_delete, which only ever deletes the caller's own, taken from the authenticated session and never from the message). The warning says plainly what it costs — clearing the browser then loses everything encrypted for that account, with no recovery, which is the point of choosing it. Default is on. Silent, unrecoverable key loss is worse for an ordinary user than an exposure the roadmap already tracks, but that is a judgement call and it is now visible and reversible instead of implicit. **Passphrase floor 8 → 12 characters, plus a strength estimate** shown while typing, with a refusal below ~60 bits. This number matters more here than in most applications: it is what stands between a node operator and your identity keys. It has to live in the client — with the password split (T1) the hub never sees a password and cannot enforce anything about one — so the UI says why it is asking, rather than nagging. The estimator is deliberately conservative and dependency-free: character classes and length, penalised for repetition and for the handful of patterns everyone tries. Verified against the live deployment: withdrawing the backup leaves a second browser unable to recover anything, which is exactly what it promises, and re-enabling restores it. Tests: 338. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>