aboutsummaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
Commit message (Collapse)AuthorAgeFilesLines
* feat!: MNP 1.0 — seal index and handshake_ack under the group keyChristophe Besson2026-09-031-2/+31
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
* docs: Windows port audit and sender key distribution decisionChristophe Besson2026-09-031-3/+7
| | | | | | | | | | | | Add docs/WINDOWS-PORT.md with the full portability audit (what is already portable, what blocks, implementation plan W1-W7). Reverse structural decision 20: sender keys are distributed GEK-wrapped, not pairwise to identity keys. The GEK is the group secret; files and chat share the same access boundary. Per-device chains (15.0b) remain required for correctness. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(hub): route the chat ack to the request that asked for itChristophe Besson2026-09-031-0/+21
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Typing a message froze the Chat tab: the composer stopped taking clicks and keystrokes, the message never appeared, and it was there all along on the next visit to the tab. The node answers a chat message with a bare {"type": "ack"} -- no request id, no type of its own -- so _dispatch had nothing to match it on and left it to the arrival-order guess at the end of the function. That guess is wrong the moment anything else this browser asked for is still waiting: the ack went to *that* request, and the chat send waited out _sendAndWait's own 30s timeout. Since the composer is disabled while a send is in flight, that reads as a frozen tab; the node had stored the message and answered, into somebody else's promise. An outstanding request is the ordinary case, not a rare one. The node refuses an unknown file_id with a bare `error`, which names no request either and so reaches none, leaving the Videos tab's media_meta_req in _pending for the full 30s. That is the one that was live when this was found. - `ack` is now matched by request type: chat_msg, or the keypair-bundle store and delete, which name themselves in `detail`. A node naming neither still has its reply placed rather than dropped. Every line of chat-app.js is correct and every routed message in transport.js is routed correctly -- the defect is in the seam, so tests/harness/ chat_send_probe.py drives the two together: the real ChatPanel over the real MeshBayTransport, with only the DataChannel replaced by a stand-in answering what the node answers. test_chat_send.py asserts against it, and with the fix reverted all three of its tests fail on the three visible halves of the defect -- the composer still disabled, the message absent, and the ack resolving the unrelated request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFF4BL8VSKrghkSLzCrTVs
* fix(hub): fall back to the group's first app when the landing tab is absentChristophe Besson2026-09-021-0/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group could open on a tab that rendered nothing: no panel, no tab shown active, and nothing on screen to explain it. The landing tab is chosen at mount from a preference -- default_tab for the group, else the account-wide one, else 'chat'. Which applications the group runs comes from the node, in the handshake ack, several awaits later. A preference is a preference, not a promise that the app exists here, so the two disagree in two ordinary cases: the group has Chat disabled while 'chat' is everyone's default, or the reader prefers an app this group does not run. `apps.map(a => tab === a.key && ...)` then matches nothing. The first app the group does offer answers both. Two more cases come free: a preference naming an app that no longer exists, and an operator disabling the app someone is currently looking at -- enabledApps changes live over apps_enabled, and being moved to a working tab beats staring at an empty panel. Settings is exempt: it is not an application, and the create-group wizard lands on it deliberately. `const apps` moves above the effect that reads it; a const further down would be in its temporal dead zone, which is the hook-ordering trap already recorded in CLAUDE.md. tests/harness/group_tab_probe.py renders the real GroupPage against a stub node answering a chosen enabled_apps and reads the tab bar back, over five cases. With the fix reverted the three fallback cases report no active tab at all and four of the six tests fail; the two that pass either way are the ones that must not change -- a group running everything, and a preference the group does honour (Videos stays selected, so the fallback has not become "always the first app"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
* fix(hub): let the reader scroll up in the chat againChristophe Besson2026-09-011-0/+17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The chat could not be read back: any wheel gesture was undone in the frame it happened in, and the "jump to latest" button never appeared. None of the pins in ChatPanel are at fault -- every one of them is guarded by "only if the reader is at the bottom". The reader never got to stop being at the bottom. fit() set the panel's height, read documentElement.scrollHeight back and subtracted the overflow, so the document alternately did and did not overflow the window. The page scrollbar appeared and vanished with it and visualViewport fired resize at every pass -- the event fit() is bound to. It therefore re-entered itself for the life of the panel: measured at 240 firings in two seconds on a page nobody was touching, against 2 for a bare document. Each pass ran fitAndPin, which re-pinned the list to the bottom before the scroll event that would have recorded the gesture was delivered a frame later, so atBottomRef never went false. - fit() learns the space below the panel once and remembers it on the element instead of re-deriving it by writing and measuring back. At the steady state it writes nothing, so it produces no resize. A real window resize or an orientation change forgets the learnt value and measures again (the page under the panel may have reflowed); visualViewport deliberately does not, since a phone fires it constantly. - The scroll-to-bottom is now scoped to *arrival*, which is all it was ever for: opening the group, or coming back to the Chat tab, including the thumbnails and link-preview cards that keep growing the list for a second afterwards. It ends when the reader takes hold of the scroll, and the ResizeObserver disconnects there. - That release is recorded from the gesture (wheel/touchmove/pointerdown/ keydown), not from the scroll event, which arrives too late to protect anything. Unchanged: landing on the newest message, following new messages while already at the bottom, the "load older" anchor and the unread marker. tests/harness/chat_scroll_probe.py mounts the real ChatPanel in a browser and reads a conversation back; test_chat_scroll_up.py asserts against it. With the fix reverted, five of its six tests fail and the sixth -- landing on the newest message -- still passes, which is the property that must not have been traded away. A structural test cannot see any of this, which is why it is measured. test_layout_responsive.py pinned the listener's name and follows the rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
* docs: move root docs into docs/ and archive superseded draftsChristophe Besson2026-09-011-4/+4
| | | | | | | | | | | | | | | | | | Move the remaining root-level .md files (except CLAUDE.md) into docs/: devel-phases.md, devel-phases-next.md, first-review.md, second-review.md, tmp-decisions.md. Update all inbound references in CLAUDE.md (now docs/-prefixed) and strip the now-redundant docs/ prefix from links inside the moved files. Consolidate the superseded material into docs/old-draft.md: architecture drafts v1-v4, POC v1, and the Phase 1-12 development log, each under an ARCHIVED banner with a preamble pointing at the current specs. Delete the merged originals plus the unreferenced French translations (v1-fr, v2-fr, poc-v1-fr). Repoint the surviving file-links in first-review.md, second-review.md and meshbay-draft-v5.md at old-draft.md; prose "draft-v3 §x" mentions are left as-is since the content now lives in the archive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J74kj44q6REczub8XR3DRy
* refactor(node): JSON-only control API, Node page absorbs the admin dashboardChristophe Besson2026-09-011-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Remove the node daemon's server-rendered admin UI (GET / and /audit, the _render_* helpers and inline templates) and the `meshbay-node ui` CLI verb. The loopback control API stays; it is now JSON only, ruff-clean, and 453 lines (was 1074). Also drop three never-wired endpoints (/api/config, /api/chat/history, /ws/chat, plus broadcast_chat) and the pointless 18000/tcp firewall profiles. The desktop client's Node page (static/node-page.js) takes over what the dashboard showed, reorganised into six tabs (Overview, Groups, Roster, Peers, Audit, Settings): - Overview: version, node id, QUIC port, hub, index-cache maintenance - Roster: node-wide view with unpin - Peers and Audit: auto-load on open, no Load button - Audit: real usernames and group names (resolved from the roster and node.toml), Previous/Next pagination newest-first, Export CSV of every matching row - Settings: node settings, STUN, ICE, denylist, then Unlink from hub Backend: audit.get_entries gains `offset`; /api/audit and /api/peers resolve ids to names via a new _display_names helper; CSP tightened to default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and 2.12 corrected -- the Node page uses the loopback API, not MNP. One capability is intentionally dropped: browser-based admin on a headless server. The CLI covers every operation there. See docs/refactor-node-ui.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
* chore: scrub copyrighted names from tests, comments and docsChristophe Besson2026-08-301-0/+10
| | | | | | | | | | | | | | Real franchise / show / release-group names had crept back into test fixtures, code comments, a docstring and docs/mediacenter.md while fixing the saga-match and misclassification bugs. Replace them all with invented placeholders ("Some Saga", "A Different Show") and shape descriptions ("a franchise-origin film", "a 3-season show"). Behaviour and assertions unchanged; 738 node tests still pass. Record the rule in CLAUDE.md so it stops recurring. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat: add Photos group appChristophe Besson2026-08-251-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A new group application (docs/apps.md's plug-in mechanism), following the plan in docs/photos.md. Unlike Videos/Music: several photo roots per group instead of one (photo_roots is a set, one signed op replaces it whole), a single album-grid view with no third-party matching step, and per-photo info read from the file's own EXIF at index time — no metadata service, no credential, no outbound network call at all. Protocol (meshbay-common, MNP 0.10 -> 0.11, additive): `taken_at`/`camera` on IndexEntry; `photo_roots`/`photo_roots_ack`; `OP_PHOTO_ROOTS`. Node: roster.py stores photo_roots as a group_settings entry (JSON list, same shape as enabled_apps); ops.py/webrtc_server.py validate and sign the whole set in one op, same pattern as apps_enabled; a new PhotoEnricher (indexer/enrich_photo.py) runs Pillow in its own small bounded pool, separate from the video/audio pools, producing a resized thumbnail plus the two EXIF fields — never GPS, checked by a grep-based regression test. Client: photos-app.js — one album card per directory containing images, a per-album photo grid, and a lightbox with next/previous (keyboard and buttons), zoom in/out/fit/100% starting from the actual on-screen fit percentage, and a "zip this album" button reusing files-app.js's own zip mechanism (lifted into file-utils.js's downloadDirectory so both call the same implementation). group-settings.js gets an add/remove multi-root picker, distinct from Videos/Music's single-value one. Bugs found and fixed before this ever shipped, worth keeping the story of: - enrich_photo.py read width/height from the raw image *before* applying EXIF orientation correction, and read DateTimeOriginal off the plain 0th-IFD Exif object — a real camera stores it in the Exif sub-IFD, which Pillow only exposes via get_ifd(Exif). A flat, hand-built EXIF dict round-trips through Pillow either way, which is exactly what would have hidden both bugs; the regression test builds EXIF with piexif instead, matching what real hardware produces. - photos-app.js's album grouping stripped a trailing path segment from entry.path under the assumption it still carried a filename — it doesn't (files-app.js's own convention: e.path is already the containing directory), so every album collapsed one level into its parent. Found live against a real multi-folder library. - transport.js's ADMIN_OP_TYPES allowlist (already the fix for an identical bug on video_root/apps_enabled, see 4783d81) was missing photo_roots: its admin_challenge matched no pending request and was silently dropped, so saving a photo root just timed out after 30s with no error. - daemon.py pruned a thumbnail when its file left the index (root removed or reconfigured) but never forgot the content hash was "already attempted" — the same bytes reappearing under a renamed/relocated root (an operator's real workflow) were then permanently skipped, forever, with nothing to indicate why. Discarding the attempt alongside the cache entry on prune is what makes pruning actually reversible. - packages/meshbay-client's app:// protocol handler served every file with no Cache-Control header, so Chromium was free to serve a stale cached copy indefinitely — none of several `npm run sync-ui` + reload cycles during development actually picked up the new code until the renderer's disk cache was cleared by hand. Now sends Cache-Control: no-store. - the lightbox's zoomed image used flex centering (align-items/ justify-content: center) combined with overflow: auto — a well-known trap where the browser centers overflowing content by shifting it, and the leading half of that overflow (here, the top of a zoomed photo) sits outside what the scrollport can actually reach. Reported live as "unusable". Fixed by switching to top/left alignment once zoomed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL
* docs: plan the Music group app (musicbay.md)Christophe Besson2026-08-241-1/+1
| | | | | | | | | | | | Proposal only, not implemented. Same plug-in mechanism as Videos (apps.md), but no new streaming path — a track is downloaded and decrypted like any other file, not transcoded/remuxed like a film. Metadata: local tags first (mutagen), MusicBrainz/Cover Art Archive as node-side fallback enrichment, no API key needed (unlike TMDB) — just a rate-limited, self-identifying client. Player state (queue, shuffle, repeat) moves up into the group-page shell so playback survives a tab switch, mirroring how the video/preview modal is already shell-owned.
* feat(node,hub): season-specific overviews, manual TMDB match correction, and ↵Christophe Besson2026-08-241-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | 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(hub): split the group UI into a pluggable "applications" architectureChristophe Besson2026-08-231-10/+14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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): the operator can close uploading to everyone but themselvesChristophe Besson2026-08-181-0/+23
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-181-0/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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(client): a film can go full-screen, and automatic saving is automaticChristophe Besson2026-08-181-0/+8
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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-181-0/+17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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-181-0/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-181-0/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-181-8/+21
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-181-0/+18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-181-0/+15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* docs: settle the desktop client, and draft v6Christophe Besson2026-08-181-4/+95
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A design discussion on 2026-08-17 settled Phase 13 and, in doing so, changed four things the spec states. v6 restates only those; v5 stays authoritative for everything it does not touch, per the convention v5 itself used with v4. What changed: * The native shell is **Electron**, not pywebview — structural decision 18 reversed. The SPA depends on Chromium-class APIs (WebRTC, WebCrypto X25519/Ed25519, MSE, Service Workers), so keeping Chromium keeps transport.js, crypto.js, keyderive.js, downloads.js and sw.js *as the client*. A system webview meant reimplementing ~2500-3000 lines. The old "69 % reused" figure was measured against an app.js of ~2600 lines; it is 4586. * A group's content is **several named roots**, not one directory, because the planned video and audio libraries will not live in one folder on one disk. * **Device linking**: one person may hold several devices on a node, admitted by a key the node already pinned and bound by a one-time code the new device generates. Without it a native client is refused where a browser is not, and an account created natively could never be opened in a browser. * **Authorship is authenticated, not asserted** — chat senders sign, uploads have a provable owner, and delete authorization moves from the uploading key to the account. And one rule v5 assumed without writing down: **group-related server state lives on the node.** Verified for multi-root — SwarmSource carries hashes and endpoints, no paths. Also here: the Caddy configuration, which was a snippet in the roadmap that would have broken the SPA (it predates /a/<hash>/ asset versioning and would have 404ed /sw.js, silently killing streamed downloads on Firefox and Safari); and downloads.html, which becomes a security page once a release key exists. Phase 15 was re-read against device linking and is wrong as written: a sender key must be per **device**, never per person, or two devices sharing a chain produce key and nonce reuse — C1 again, one level down. senderkeys.py already fails this silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: sessions renew themselves, and two faults of the same shape0.5Christophe Besson2026-08-171-0/+26
| | | | | | | | | | | | | | | | | | | | | USERGUIDE said an hour in five places and presented renewal as something the reader does with curl. Both are now wrong: it is four hours, the web app renews for itself, and the endpoint rotates — so anyone driving it by hand has to store the refresh token that comes back, or their next call revokes the family. Also corrects what the token's life actually bounds. It is not how long a revocation takes: the hub reloads the account on every request and refuses a suspended one at once, and it pushes signed revocations to nodes. What remains is a leaked token on an account still in good standing, which is the reason to keep the number small. Two lessons in CLAUDE.md. A rotated refresh token has to be stored or it is spent once. And an effect keyed on a value that used to be constant: the WebRTC dial listed `token` among its dependencies, harmless while a token only ever expired, fatal once the session renewed itself — it tore the connection down mid-handshake and the node waited for ever. That and the hook declared after its own dependency are the same shape, and worth naming as one: code that reads correctly on its own and is wrong against the component lifecycle.
* fix(hub): the transfers panel hung off the side of a phoneChristophe Besson2026-08-171-0/+14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-171-1/+33
| | | | | | | | | | | | | | | | | | | | | | | `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.
* docs: what this session cost to findChristophe Besson2026-08-161-0/+44
| | | | | | | | | | | | | | | | | Five lessons, and the first is the expensive one: a test that models a fix agrees with it by construction. The buffer-ceiling test passed against a player that still hung, because the model and the fix had the same author and the same misunderstanding. Also: `no-cache` only binds a browser that asks; redeploying during someone else's test kills their session and truncates the log holding the reproduction; `updateend` fires for `remove()`; and flow control on a media stream is a window, not a debt. USERGUIDE section 7 rewritten — it still described 24 segments in flight and two transcode slots, and said "transcode" where ffmpeg does a `-c copy` remux, which is exactly why a slot costs little and why 500 MB really does go on the wire.
* docs: record what this session cost to findChristophe Besson2026-08-161-1/+56
| | | | | | | | | | | | | | | | Six lessons, each of which took a deployment and a user's patience to locate. Five are the same shape: something that fails silently and looks like something else. The asyncio task nobody holds, `proc.wait()` deadlocking on a pipe it will not read, a peer whose loss is noted but not acted on, a service worker that is active without controlling the page, and a React setter that outlives its state. The sixth is about method: `node --check` validates syntax and not names, and `e2e.py` is a second implementation of the client rather than a test of this one. Neither caught any of the above. Source-reading tests are weak evidence and are the only evidence available for the SPA — so prefer ones that re-derive a value from the source over ones that restate it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): translate the web client into nine more languagesChristophe Besson2026-08-161-1/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | French, Spanish, Brazilian Portuguese, Simplified Chinese, Japanese, German, Italian, Dutch and Polish, all in the formal register. `hub`, `node` and `GEK` stay in English: they name the CLI, node.toml and the docs, and translating them would cut the interface off from everything an operator reads and types. Catalogues move out of i18n.js into locales/, one file per language, fetched with a dynamic import. A visitor downloads their language plus English as a fallback — about 36 KB rather than the ~180 KB that ten inlined catalogues would have cost everyone. i18n.js keeps only the loader, so the first render now waits for initLocale(). Three things the old code got wrong, none of them visible until there was a second language: - Resolution trimmed a tag to its base before matching, so a browser reporting pt-BR looked for a `pt` catalogue that does not exist and fell back to English. Matching is now exact first, then by base language. - Counted strings were single strings, so Polish could not express 1 plik / 2 pliki / 5 plików at all. t() selects through Intl.PluralRules; en.js gains the same treatment, which incidentally fixes "1 files". - Interpolation used String.replace, which reads `$&` in the replacement. A file named rap$&sody.mp3 rendered corrupted in its own delete dialog. Five strings were still hardcoded in app.js — the group name placeholder and the four visibility/join-policy descriptions — and are now keyed. test_locales.py holds the nine translations to the shape of en.js: same keys, a counted string stays counted everywhere, every plural entry covers each category Intl actually produces for that language, and the {placeholders} survive translation. Verified failing first, against a catalogue with a key removed, a placeholder dropped and the Polish `few` form deleted. The language menu also grew from one entry to ten, which overran a short viewport inside a dropdown that clipped instead of scrolling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: record which download paths have actually been runChristophe Besson2026-08-151-0/+8
| | | | | | | | | | | | | | | | The download-to-disk story is three mechanisms — File System Access in Chrome, a service worker streaming a response in Firefox and Safari, a blob as the floor — and no test in this repository exercises any of them. test_downloads.py pins their contracts by reading the source; whether a browser really writes to disk needs a person with a large file. One now has: Firefox, 180 MB, written to disk rather than assembled in the tab. That is the path worth confirming, since it is the only one Firefox has and it was written blind. It is also not the scale it exists for, and the guide says which rows of that table are measured and which are still only designed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(groups): editable description, and one source of operator authorityChristophe Besson2026-08-151-2/+4
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* docs: account deletion, notifications, and the APIs that no longer existChristophe Besson2026-08-151-0/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Account deletion is the headline, in the user guide and in draft-v5 §6.1, and the important half is what deletion does *not* do. It releases the username, clears the email and password hash, drops memberships, notifications, refresh tokens and node registrations, and refuses any access token still inside its hour. It does not touch a node: files, the pinned identity and the keypair bundle stay on machines the hub does not command, which is the same sovereignty §5.5 relies on — so deleting a hub account is not an erasure request to the operators hosting you. The IP log survives too, attributable, for its legal retention period. The claims table in §2 gets a row saying exactly this, adversary by adversary. Notifications get a section: one entry per conversation rather than per message, never one for your own message, invitations that clear when you join, muting that lives on the hub so it works from any browser. Then the corrections, which is most of the diff. The guide still described a node HTTP API — `GET /index`, `GET /file/{id}`, an HLS playlist, and a `player.js` that does not exist — with curl examples inviting the reader to expose port 19001. That surface was removed in 0.2.0 as findings C1 and C6, precisely because it served files outside the handshake that decides what a peer may see. Sections 6, 7 and the API reference now describe MNP message pairs, and the quickstart says the same in French. Also corrected: the JWT table advertised a `pk_user` claim that no longer exists (it was what let the token issuer decide who could delete a file), `/pubkeys` no longer returns identity keys, and the GEK-distribution endpoints are gone entirely rather than merely unused. draft-v5 §5.2 had uploads landing in `.uploads/{user_id}/`; they land in `uploads/`, chat attachments included. §6.1 now says the hub learns the author's user_id from chat_notify — a stable identifier, and a metadata leak worth naming rather than leaving as "by whom". CLAUDE.md records why the deployed hub broke this week: create_all() creates missing tables, never missing columns, so a schema change passes every test (fresh DB per run) and never reaches production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: user guide and conventions catch up with per-node identityChristophe Besson2026-08-141-1/+22
| | | | | | | | | | | | | | | | | | | USERGUIDE said registration submits your public keys "so other members can wrap GEK bundles for you". Both halves are wrong now: registration creates an account and nothing else, and nobody wraps anything for a key fetched from the hub. The API reference and the register body followed the same correction. CLAUDE.md gains the block a future session needs before touching registration or anything shaped like a user's public key: keys are born at first contact with a node and stay there, the hub publishes none, tokens carry no pk_user, and a scripted signup is now a real account. Left alone deliberately: first-review.md, docs/poc-v1*.md and poc/spike-results.md still describe the old JWT and registration. They are records of what was true on their date, like second-review's verdict table, and draft-v5 is what states the present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: Argon2id, the multi-browser property, and what a browser foundChristophe Besson2026-08-141-0/+31
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | draft-v5 §7 rewritten around the keypair bundle, because that is where the last open finding actually lives. New §7.1 states the adversary (an operator holding their own node's disk), what cracking a bundle yields (identity keys, hence content on *other* nodes and the ability to sign as that user — not the content they host in the clear by design), and the measured numbers rather than adjectives: PBKDF2 241 ms vs Argon2id 88 ms natively, a GPU ceiling moving from ~8k to ~2k guesses/s, six days for a 10⁹ dictionary run, four random words outlasting the sun. The honest summary is in there too — a factor of four on one card, not a thousand; what it buys is the cost of scale. §2 gains the row the table never had: **your identity keys stay yours**, ⚠️ against a malicious node operator. An operator hosts your content by design, and that was documented; that they can also try to become *you* was not. That is the difference between reading what they host and reading what other operators host. §4 records that the challenge now carries `node_pk`, why (a first-time member signs a transcript naming the node and has no GEK to complete a handshake with), and that it is checked against the ack rather than trusted. Also that refusals carry a code, and what `not_a_member` usually means. §8.1 states the multi-browser property plainly — one identity across browsers, recovered with the passphrase, no second code — together with its cost, since it is the same mechanism as C4. invite-pairing-v1 is no longer "a proposal": it shipped. §9bis gains the four browser-found failures and their common thread — e2e.py is a second implementation of the client, written in the right order by construction, so it proves the protocol and nothing about app.js. CLAUDE.md gets the two things a future session must not rediscover the hard way: the KDF parameters live in three places held identical by a parity test, and an unbounded await on the hub socket makes a node silently unreachable (three found). second-review: C4 marked reduced, not closed. devel-phases-next: 12.2's CSP must keep `wasm-unsafe-eval`, or the strict policy locks every user out of their keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: record the invite redesign — H3 and M3 closedChristophe Besson2026-08-141-2/+30
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | draft-v5 §2: against an active hub, reading content moves from "❌ H3" to "❌ T3 (browser) · ✅ native". The defensible sentence becomes "the hub cannot read your content unless it ships you malicious client code" — T3 is now the only path, it is an artifact rather than a silent directory lie, and it does not exist for a native client. New §5.5 describes admission and key delivery, with the four properties that carry it and the one exception (open-join groups, where the hub can walk in the front door — a property of open joining, and the setting is read from node.toml). Corrected while writing it: §5.1 said the C5b fix stopped a group admin who does not run the node from inviting, and that the redesign reverses this. It does not, because delegation was deferred. What changed is the timing — the operator issues a code and is then out of the loop. devel-phases-next: 12.1 is done and NOT as written. The plan was key transparency plus safety numbers; what shipped removes the directory read instead. Safety numbers make substitution detectable by a human who checks, at first contact, when there is nothing to check against. 12.2 (served-SPA integrity) is now the highest-value item in that phase. Phase 14 marked for what landed. second-review: H3 and M3 annotated closed at the finding, with what actually closed them. The §7 verdict table is left intact — it is the record of an audit on a date, and falsifying it would be worse than leaving it — with a note pointing at draft-v5 §2 for current state. CLAUDE.md matters most here, being loaded every session: NS4 read "admin_pk_ed25519 auto-pinned from keystore ✅ DONE", which is M3 described as a feature. Rewritten, with the two fixes that must never be attempted (auto-pin, hub lookup). QE/deploy/README.md: set-admin-pk retired from the walkthrough; the regression checklist now exercises pairing, joining by code, recognition without a code, and revocation. USERGUIDE.md is beyond the invite work but was actively wrong: it told users to POST GEK bundles to a hub endpoint deleted in Phase 12, and to re-wrap for every remaining member on revocation. Both replaced with what the code does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: draft-v5 architecture specChristophe Besson2026-08-131-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Supersedes draft-v4, which described a system the code did not implement and made several claims that were simply wrong — "ALL operations require the GEK proof" (true on one of four transports), "Argon2id 256 MB" (hub only), "hub stores no content metadata" (private file hashes were registered with it). Written as a delta over v4: sections not restated are unchanged. Carries an explicit rule — a claim must name the adversary it holds against — and a per-adversary table replacing v4's informal assurances. Records the decisions: transport (aiortc primary, QUIC retained, TCP and the node HTTP API removed), unified handshake with mutual authentication, admin operation transcripts, node authority over GEK storage and activation, upload confinement, hub node-registration and signaling authorization, and the client architecture — hub keeps serving the web SPA, native client offered alongside, hub minimization deferred. States plainly what is NOT true. The defensible claim is "the hub cannot read your content unless it actively attacks you", not "unreadable by other parties, even the hub": H3 (hub is the key directory and can substitute a key at invite time) is open until Phase 12.1, and T3 (hub serves the SPA) is accepted permanently by decision. Content is also readable by every group member and by the node operator, so "end-to-end" here means client-to-node, never client-to-client. Corrects the v4 NAT traversal account: punch_nat() is a single UDP probe with no STUN, no candidate gathering and no fallback, validated on one ISP. ICE is the traversal path, including for native clients. Open items listed with status, including C6 on the QUIC path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: second security review + roadmap rewrite0.1Christophe Besson2026-08-131-6/+79
| | | | | | | | | | | | | | | | | | Second architecture and security review (second-review.md): 6 critical and 7 high findings against the Phase 12 implementation, plus an assessment of whether the system meets its end-to-end confidentiality claim. Roadmap rewritten against those findings (devel-phases-next.md): new blocking Phase 11.5 (security remediation), Phase 12 (hub minimization), Phase 13 (native desktop client). Old phases 12-17 renumbered to 14-19. tmp-decisions.md records two open decisions: whether the hub keeps serving the web UI, and browser extension vs native desktop client vs both. CLAUDE.md and devel-phases-next.md also carry pre-existing Phase 12 edits from the working tree that could not be cleanly separated from the review changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node): Phase 11 — production-ready daemon with WebRTC, WS, chat, HTTPChristophe Besson2026-08-111-0/+4
| | | | | | | | | | | | | | | | | | The node daemon was previously a skeleton that only started QUIC/TCP servers and the local web UI. All browser-facing functionality (WebRTC, hub WebSocket, chat store, HTTP file API) lived in QE demo scripts. This rewrites daemon.py to be fully self-contained: - WebRTC transport for browser clients (aiortc DataChannel) - Hub WebSocket task (signaling, revocations, WebRTC offers) - ChatStore per group (SQLite in ~/.local/share/meshbay/) - HTTP file API per group (create_http_app on configured port) - Graceful shutdown (all transports, stores, tasks) - hub_client: _ws tracking + send_ws() for chat notifications - config: data_dir field for persistent state - systemd: security hardening (ProtectSystem, StateDirectory) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 10c — MSE video streaming (real-time playback)Christophe Besson2026-08-111-0/+3
| | | | | | | | | Replace download-then-play VideoPlayer with MSE (MediaSource Extensions) streaming. Node remuxes to fMP4 via ffmpeg, probes codecs with ffprobe, and sends encrypted segments over DataChannel. Browser decrypts and appends to SourceBuffer — playback starts within seconds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 10b — Self-service UI (group create/join, upload, IndexedDB, ↵Christophe Besson2026-08-111-0/+6
| | | | | | | | | | | | | | | | search) Six self-service features for the web SPA: - Group creation UI with GEK auto-generation (AES-256-GCM ECIES) - Member management + invite by username (GEK wrapping for invitee) - Open group self-join flow (POST /v1/groups/{id}/join) - File upload client→node (FILE_UPLOAD MNP type, .uploads/ staging) - IndexedDB caching of group file indexes (instant display on revisit) - Cross-group file search (SearchPage, pure client-side on cached indexes) 11 new tests (166 total): 8 group self-service + 3 AES GEK wrap/unwrap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): Phase 10.5–10.8, 10.10 — notifications, settings, search, versionChristophe Besson2026-08-111-0/+2
| | | | | | | | | | | | - 10.5: Notification model + CRUD API (list, mark read, mark all read) Triggered on: group invite, role change, suspend/unsuspend - 10.6: SettingsPage shows role, per-group notification mute (localStorage) - 10.7: GET /v1/groups?q= search filter (ilike on name) - 10.8: NotificationFeed on home page + bell with unread badge in navbar - 10.10: GET /v1/hub/version endpoint for client update checks - 8 new tests (test_notifications.py), 155 total Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update Phase 10 commit hash, API reference, key modulesChristophe Besson2026-08-111-0/+4
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 9 — Web client SPA with WebRTC P2P transportChristophe Besson2026-08-111-2/+13
| | | | | | | | | | | | | | | | Complete browser-based client: Preact SPA with login, group file browser, encrypted download, video playback, group chat, i18n, and dark/light theme. Browser connects P2P to nodes behind residential NAT via WebRTC DataChannel (aiortc). Hub handles signaling only — all data flows E2E. Performance: pipelined downloads (8-chunk sliding window), binary msgpack wire format (no base64), redundant I/O elimination. Large file downloads stream to disk via File System Access API (showSaveFilePicker). Validated on SFR + Orange residential NATs, Chrome + Firefox, IPv4/IPv6. 132 tests passing. Deployed to meshbay.org + Orange node. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 9.1–9.5 — WebRTC DataChannel transport for browser P2PChristophe Besson2026-08-101-19/+34
| | | | | | | | | | | | | | | | | | | | | | | | Browser clients can now connect P2P to nodes behind residential NAT via WebRTC DataChannel with ICE/STUN. Validated on SFR Port-Restricted Cone NAT + 4G CGNAT across three scenarios (WiFi LAN, 4G IPv6, 4G IPv4 STUN). No TURN relay needed. Hub serves only as signaling relay (<1 KB). New files: - webrtc_server.py: aiortc-based WebRTC transport (node side) - signaling.py: SDP/ICE relay endpoint (hub side) - transport.js: browser WebRTC client with msgpack framing - webrtc-test.html: spike test page for browser→NAT→node validation - test_webrtc_transport.py: 4 tests (handshake, file transfer, auth, guard) - meshbay-draft-v4.md: architecture spec updated for web client Modified: - hub_client.py: WebRTC offer handling via hub WebSocket - revocation.py: node_id from WS auth + webrtc_answer routing - pyproject.toml: aiortc>=1.9 dependency 123 tests passing (117 existing + 6 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): Phase 8 — Hub v2 security hardening + production readinessChristophe Besson2026-08-101-5/+6
| | | | | | | | | | | | | | | | | | | | 8.1 Config-based admin authz (require_admin on all admin endpoints) 8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key) 8.3 Refresh token rotation with family-based reuse detection 8.4 Federation persistence (HubPeer model replaces in-memory dict) 8.5 Federation token verification now async (DB-backed) 8.6 CSAM hash check wired into swarm registration flow 8.7 Rate limiting on auth endpoints (5/10/20 per minute) 8.8 Healthcheck endpoint (GET /v1/health, no auth) 8.9 IP log cleanup background task (365-day retention) 8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login) Deployed to meshbay.org — schema migrated, existing emails encrypted. 117 tests pass (29 hub, 88 common+node). Resolves security review items S1, S2, S5. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 7 — Node v2 (multi-group, Sender Keys, 0-RTT, chat, denylist)Christophe Besson2026-08-101-1/+25
| | | | | | | | | | | | | | | | | | | | | | | | | | | Implements all 8 milestones (7.0-7.7): - 7.0: JWT carries `groups` claim; node verifies group membership at MNP handshake (QUIC + TCP+TLS). Resolves security review C2. - 7.1: QUIC 0-RTT session resumption via stored session tickets (17-21ms reconnect vs 47ms cold). - 7.2: Hub→node WebSocket signaling for NAT punch coordination (`client_incoming`/`punch_ready`) + jti denylist push. Denylist class blocks revoked users/jtis at handshake. - 7.3: Multi-group daemon — one QUIC port serves N groups with per-group GEK, shared_root, and index routing. - 7.4: HLS streaming via QUIC (STREAM_SEGMENT message type, ffmpeg segment extraction). - 7.5: Sender Keys protocol for group chat (Signal Groups approach). Each member has own sending chain key, HKDF chain ratchet, AES-256-GCM encryption, Ed25519 signing. Resolves security review C1. - 7.6: Chat store (SQLite via aiosqlite), CHAT_MESSAGE MNP wire type with peer broadcast, web UI with WebSocket push. - 7.7: Argon2id calibration CLI. First security review included (first-review.md). 109 tests, demo-v3 validated against meshbay.org production hub. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add demo-v2 NAT findings to CLAUDE.mdChristophe Besson2026-08-101-0/+14
| | | | | | Port-Restricted Cone SFR, punch_nat() mechanism, 12.7s handshake note. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* fix: complete pyproject.toml deps + graceful QUIC fallbackChristophe Besson2026-08-091-3/+10
| | | | | | | | | | | | | | | | | | | | meshbay-node/pyproject.toml: add aioquic>=1.0 (was commented 'v2'), websockets>=12.0 (revocation push). Both are production code since Phase 5. meshbay-hub/pyproject.toml: add aiosqlite (tests without PostgreSQL), slowapi (rate limiting), websockets (revocation push), PyJWT (explicit). transport/__init__.py: QUIC imports wrapped in try/except — node works without aioquic (TCP+TLS + HTTP fallback). QUIC_AVAILABLE flag exported. QUICKSTART.md: replace manual pip list with 'pip install -e' that pulls all deps from pyproject.toml automatically. Add dependency table. CLAUDE.md: clarify that all deps go in pyproject.toml, not manual installs. 81/81 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* fix: venv --clear required when copying repo across OS (Fedora→Ubuntu)Christophe Besson2026-08-091-4/+3
| | | | | | | | | | | | | Root cause: certifi.where() in the Fedora venv points to /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem which does not exist on Ubuntu. 'python3 -m venv .venv' without --clear keeps the Fedora certifi paths. Fix: always use --clear when recreating a venv on a different OS. Documented in QUICKSTART.md and CLAUDE.md. rsync command updated to exclude .venv/ (in QE/server-state, not versioned). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* fix: document pip SSL_CERT_FILE workaround for Python 3.14 on Ubuntu/FedoraChristophe Besson2026-08-091-10/+12
| | | | | | | | pip + Python 3.14 fails with FileNotFoundError in certifi.where() on fresh venvs (truststore bug). Fix: SSL_CERT_FILE pointing to system CA bundle. Documented in CLAUDE.md (Python environment section) and QUICKSTART.md. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* docs: update all pointers after keyderive + QE restructureChristophe Besson2026-08-091-8/+31
| | | | | | | | | | | | | | | | | | | | | | CLAUDE.md: add QE/ to structure, key modules table, server state reference, security rule updated (QE/ not keypair files), meshbay.org inventory pointer. devel-phases.md: add milestones 6.6-6.9 (keyderive, bundle, demo scripts, QUICKSTART rewrite). 81/81 tests. docs/meshbay-draft-v3.md §6.1.1: new section documenting 3 key generation strategies (Argon2id CLI, WebCrypto browser+bundle, keystore file) and the algorithm mismatch caveat between CLI and web registration paths. docs/USERGUIDE.md §2 Register+Login: replace "generate and persist before registering" warning with the two clean strategies (derive_keys_from_password for CLI, keyderive.js + keypair_bundle for browser). Login response updated with keypair_bundle field. hub/models.py + users.py + Alembic migration: keypair_bundle column on User, stored at registration, returned at login (web clients only). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>