summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
Commit message (Collapse)AuthorAgeFilesLines
* feat(hub): pin the app controls while a library scrollsChristophe Besson2026-09-101-2/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Files, Videos, Music and Photos are read by scrolling, and everything that steers that reading left with the first screenful. Three bands now pin under the navigation bar, in a group and on the Search page alike: the tab bar (the search field, on Search), the application's own toolbar, and the file table's column heads. The group's name and description still scroll — they say nothing a reader needs while walking a directory, and the height they would cost is height the list does not get. A band's offset is the heights of the bands above it, and those are not constants: the toolbar wraps to three rows on a phone, grows a field while a folder is being named, and loses its filter on Search. So each band measures itself and publishes `--chrome-h` / `--toolbar-h` (static/sticky.js) and the stylesheet does the arithmetic in calc(), rather than a number written down twice — the fault CLAUDE.md already records against this layout twice over. A band publishes height *plus its own bottom margin*, and paints that margin as a ring of page colour, so the pinned layout is pixel-identical to the flow layout and nothing shifts at the moment a band pins. Three overflow faults came out of it, all of the same class and all of them what "the header does not stay" actually meant on Android — a document wider than the screen leaves everything pinned attached to a viewport the reader can no longer see, the navigation bar included: - a directory's name cell was a bare <td>, so an unbreakable folder name (`Rage_Against_The_Machine_Discography_1992-2000_FLAC`) set the column's minimum: a 527px table in a 390px window - Search's group column did the same at 442px with an underscored group name. It also goes entirely below 768px, where there is no room for it and the breadcrumb already names the group - the shared-directories table has four columns of controls with a combined minimum near 440px, none of it compressible. On a phone the row stops being a row: the name and its eject/remove pair on one line, the two switches — each carrying the column head's own string as a label — on the next - and, found by measuring at 360px, the tab bar itself was 19px too wide `.file-table` moves to separated borders: a collapsed border belongs to the table rather than to the cell, so the column heads lost their rule the moment they pinned. Measured, not read. tests/harness/sticky_header_probe.py drives the shipped GroupPage and SearchPage against a stub node, walks to each application, scrolls to the end and reports every rectangle — 11 views x 4 widths x 2 engines. Its fixture says what real data says: the first version used `note-007.txt` and `un groupe`, which fit any screen, and found none of the above. A fixture narrower than real data tests the fixture. Also: `test_desktop_shell` no longer looks for the CSP after the first `-->`, which made it fail on correct markup as soon as a comment was added above it, and `search-page.js` joins test_hook_ordering's file list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tx16FhyD2BUdpooGb5jcyN
* fix: the PDF preview needs object-src and frame-src, in both policiesChristophe Besson2026-09-091-4/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A PDF preview showed the "this browser will not display the PDF inline" fallback everywhere — in the desktop client since its first launch, and in the browser since the hub started sending a CSP on 2026-09-01. It read as a missing native feature because before that commit the hub sent no policy at all, so Chrome had once worked and the application never had. Two directives govern one feature. `files-app.js` decrypts the file in the page and hands it to `<object type="application/pdf">` from a Blob; Chromium loads that as plugin data (`object-src`, absent and therefore falling back to `default-src 'none'`) and then renders it in an internal frame (`frame-src`). Opening either alone changes nothing visible — the second refusal produces the same fallback. `'self'` covers neither: a same-origin `blob:` URL is not matched by it in either directive, measured in Chrome 152 against the deployed page and in Electron 44 against the client's own policy. `plugins` stays at its default `false`: the built-in viewer is not behind that flag on Electron 44, verified by rendering one. Widening `object-src` from `'none'` to `blob:` admits only what page script minted itself, at a type this code sets — PDFium parsing bytes that came from a node, which is what any browser does with the same file once downloaded. Tests: each policy is pinned to carry `blob:` in both directives (each fails if either token is removed), and the two policies are now held identical directive by directive apart from the two deliberate differences — the comment claiming they were the same had already drifted and nothing checked it. The CSP source parser in test_desktop_shell.py read `//` comment lines as directives, which is the "parse directives, not text" mistake this file already records; it skips them now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XauykfBvRrpy6RYbF6F7Wu
* feat: MNP 3.0 — a transfer needs a leaseChristophe Besson2026-09-091-2/+15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage 4 of ~/next/improve-downloads.md, the flag day. Leases become compulsory and a 2.x peer is refused at the handshake. **The bound on leaseless reads (§3.4.1) did not exist, and it is what makes the rest mean anything.** Browsing a group is never subject to a transfer slot — that is an operator decision and a requirement: a member must be able to browse a group at capacity exactly as they browse an idle one. But "not leased" cannot mean "unbounded", or a client that simply omits `tr` transfers outside every cap and the caps are decoration. A session may now read two distinct files at once without a lease: one because a viewer looks at one file, two so that prefetching the next photo stays possible. A count of files and not a byte budget, because a RAW photo is 60-80 MB and is browsing while a 40 MB archive is a download, and no size threshold separates them. Thumbnails, posters and cover art never reach this check at all — they resolve out of the node's own cache. It is a fairness control among cooperating clients, in the company of `max_concurrent_streams`, and is not a defence against a member determined to saturate a node's disk. That member is a member, and the answer to them is `member revoke`. **MNP_VERSION and MNP_MIN_SUPPORTED both move to 3.0**, on both sides. The messages are additive; the requirement is not. An opt-in switch would leave a leaseless branch reachable on every node, which is finding C6's lesson — a transport that accepted a bare JWT — one feature later. **The desktop client now checks before it connects.** The SPA is served by the hub and picks up a new client on reload; the application ships its own interface, so an un-updated one would sign in, list groups, and fail every connection with `version_too_old` — a refusal in a protocol vocabulary with nothing anyone can act on. It asks `/v1/hub/version` for `client.minimum` and says so plainly instead. An unreachable hub is deliberately *not* "too old": a captive portal or a closed laptop must not make starting the application impossible. **Every package is aligned on 0.13.0.** `meshbay-client/package.json` had drifted to 1.0.0 while the Python packages were on 0.12.0 — invisible until something compared those numbers, and then load-bearing: an installed client announcing 1.0.0 sorts above a 0.13.0 minimum and walks through the gate meant to stop it. That is stated in the code rather than left to be rediscovered; it is acceptable exactly once, because the operator is updating every client, node and hub by hand for this flag day. A new test fails if two packages ever disagree again, and another fails if the hub would refuse the client the tree builds. Node suite 1209 passed, hub suite 861 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(hub): let this origin frame its own download URLChristophe Besson2026-09-081-2/+24
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three headers govern whether a page may be framed, and all three had to be wrong for the streamed download to work — so fixing them one at a time cost an afternoon of redeploys and retests. They were visible together in a single `curl -I` against the deployed hub, which is where this should have started. The streamed-download path navigates a hidden iframe to `/_mbdl/<id>` so the service worker is asked for the response it is holding. On Firefox and Safari that is the only way to write a large file to disk: neither has the File System Access API, and OPFS is capped at 10% of the volume's size (measured on Firefox 154: 389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte), which a film exceeds. - `frame-src` was reCAPTCHA's two origins with no `'self'`, so the frame could not be loaded at all. Added when the captcha needed a frame; nobody connected the two. - `frame-ancestors 'none'` forbids all framing, this origin included. - `X-Frame-Options: DENY` says the same in an older dialect. The spec says a browser must ignore it when frame-ancestors is present — relying on that while shipping a header that contradicts our own policy is asking to be surprised, and we were: the CSP was fixed and the download stayed broken. `'self'` and `SAMEORIGIN` refuse every foreign origin exactly as `'none'` and `DENY` do. The clickjacking property is untouched; what they additionally allow is this origin framing itself, which is the only thing the download needed. Pinned three ways: `frame-src` must carry `'self'`, `frame-ancestors` must be `'none'` or `'self'` and never name an origin, and the two framing headers must agree — the defect was the disagreement, and either one read as correct alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat(client): HelloWorld, the reference applicationChristophe Besson2026-09-071-0/+4
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every other test of the plugin architecture reads source for the *absence* of app names. That proves nobody wrote a special case for Videos; it cannot prove a genuinely new application works, because there was no new application. This is one. It stores directories, appears as a tab, has a settings pane and lists files, and the node has never heard its name outside a single allow-list entry. Two files and one registry line, which is the claim `docs/refactor-groups.md` §4.1 makes. It ships hidden behind `?dev=1` (`dev: true` in the registry, the same opt-in shape as transport.js's `?trace=1`). Registering it normally would put a toy app in every operator's group; not registering it would prove nothing, since registration is exactly what is claimed to be sufficient. **Adding it found two places where the claim was nearly true rather than true, and both are fixed by making the code less app-specific:** `group-settings.js` fell back to the whole registry when a group had no `enabled_apps` yet — which would have turned a hidden app on for everyone. It asks `availableApps()` now. `group-page.js` wrote out `videoDirectories` / `musicDirectories` / `photoDirectories` by hand, so a fifth app would have needed that file edited. It derives `<key>Directories` from the registry. Neither was found by reading; both were found by adding the app, which is the whole reason it exists. Verified in a real Electron window as well as by the tests: hidden by default, present with the flag, offered its own settings section, and listing exactly the files under its configured folder and its subfolders — not the ones beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(client): Phase 2 — per-app settings panes, folder tree, multi-directoryChristophe Besson2026-09-061-0/+8
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Each app's settings were inlined in `group-settings.js` — TMDB, MusicBrainz, and one folder picker per app, each with its own draft state and save handler saying the same thing about a different key. They are one file per app now, reached through the `apps.js` registry, and the page that renders them names no application at all: adding one is a registry entry and a settings file. The line between the two is what makes that true. What every app has — folders — the page does generically, through one `saveDirectories` bound to the app. What one app alone has, its pane does itself with the transport it is handed. An app that only needs directories touches neither `group-settings.js` nor `group-page.js`, which is `test_app_settings_plugin.py`'s subject. `settings-ui.js` exists because a pane importing the page that renders it is a cycle, and ES modules answer that with a temporal-dead-zone ReferenceError at first render — a component that silently does not appear, the fault already recorded in CLAUDE.md about hook ordering. The flat depth-indented `<select>` of every folder in the library becomes a modal tree. It asks the node for nothing: the tree is derived from paths the client already holds, so it shows exactly what the group's index contains and adds no folder-browsing protocol. For Chat's attachment folder — the one directory that is written to rather than read — read-only roots are greyed out, so the node's refusal arrives before the operator picks rather than when somebody sends a file. Videos and Music take a list of folders. A library on two drives could not be described before; the only recourse was pointing the app at a parent containing both, which pulls in everything else under it. The scalar shapes survive on the wire alone, for a node speaking MNP 1.0, and the client reads them as a one-element list. Two things the tests caught that I would not have: `test_asset_versioning` — six new modules were missing from `_ASSETS`. Reached through the registry rather than imported by name, they are exactly the files nothing else would notice changing, and a stale one is served from cache with no version bump. And `node --check foo.js` does **not** reliably report a module syntax error: it accepted `${/* ... */''}` — htm template syntax pasted into a plain object literal — and reported success. A `.mjs` copy forces the module parser and reports it. The suite had no syntax check at all, which is how that reached a file; `test_spa_syntax.py` does it for every module now, and pins that the loose path is not what it uses. Suite: 12 failures, all pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(hub): stop the maintenance loop racing the tests, and pin _ASSETSChristophe Besson2026-09-021-0/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two defects found while closing out the Search merge, neither of them in that feature. The maintenance loop. create_app's lifespan starts cleanup_loop as an asyncio task, so every test — each entering that lifespan — ran a purge pass concurrently with its own requests. On SQLite :memory: that is not merely noisy: the engine uses a StaticPool, one connection for the whole process, so the request's session and the cleanup task's session interleave transactions on the same connection. A registration could commit and then be invisible to the login three lines later, surfacing as 401 Invalid credentials for an account created moments before, in roughly one run of test_node_ws_auth.py in four. The purge itself is not at fault and this is not a production condition. A passive SQL listener caught the DELETE removing 0 rows, and the INSERT carrying status='active' — so neither the pending-account mechanism nor the purge filter is involved, and PostgreSQL gives every session its own connection. What the fixture removes is the second user of the shared one. 60 runs of the previously flaky file, 0 failures; reproductions before the fix landed on attempts 4, 6, 13 and 29 of separate loops, so a clean run of 60 has about a 1% chance of being luck. _ASSETS. source-merge.js shipped missing from webapp._ASSETS, the cache-busting hash's input list — exactly the silent failure docs/apps.md §4 step 5 warns about: the file changes, the asset URL does not, and a browser holding the old page keeps the old copy. Harmless this time only because search-page.js changed in the same commit and is listed, which is the worst way for it to go unnoticed. Found by re-reading that checklist for the doc pass, not by any test — so there is a test now, holding _ASSETS to every .js in static/ (sw.js excepted, unversioned on purpose). It was the only one missing. Phase 9 of docs/refactoring-search.md also lands here: mediacenter.md §10.6, musicbay.md §9b, photos.md §10b, apps.md §2b and step 5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* fix(hub): dismissing a notification deletes itChristophe Besson2026-09-021-12/+49
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit filtered the list to unread, which corrected what the reader saw and left every dismissed row in the table, invisible for ever. That is a place to hide the disagreement rather than a resolution, and the operator said so: "elles s'accumulent pour rien." So dismissing drops the row. It is the reasoning `purge_notifications` has carried all along — "these are signals, not a record: the group is still there, the message is still in the chat, the invitation is still an invitation" — applied one at a time instead of only in bulk. - `DELETE /v1/notifications/{id}` is the honest name and what the SPA calls. - `POST /{id}/read` reaches the same handler and now deletes too. It has to keep working: the interface ships inside the desktop package, so a hub is always answering some client older than itself, and giving the old path the new behaviour means those clients stop hoarding as well rather than only the updated ones. - `read-all` deletes rather than marking, which makes it `DELETE ""` under an older name. Marking would have made it the one route still filling the table. Nothing in this repo calls it, but a reachable endpoint is one that can be called. `Notification.read` is now vestigial — nothing stored can be read, because reading it deletes it. It stays because dropping a column is a migration for no gain, and `unread_only` stays because a SPA newer than its hub still needs it to be right. Both are said in the module docstring rather than left to be worked out. Two existing tests encoded the old semantics and now assert the opposite; test_notification_dismissal.py gains one for the old `/read` path, because version skew is the normal case here and not the exception. 617 hub tests pass. docs/USERGUIDE.md's endpoint table updated in both places it lists them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): a desktop solve reports no hostname at all, not "meshbay"Christophe Besson2026-09-021-0/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Registering from the native client failed with `captcha_failed` while the checkbox was green — a worse symptom than the one being fixed, because the widget now looked fine and only the hub's own log said otherwise: captcha solved on an unexpected host ''; allowed: ['localhost', 'meshbay', 'meshbay.org'] The previous commit assumed Google would report the host component of the origin, so `app://meshbay` would come back as `meshbay` and could sit in `allowed_hosts`. It does not. A solve Google cannot attribute to a domain reports an **empty** hostname, and no allowlist entry can match that. An empty entry is not the answer either: a blank in a TOML list is a typo far more often than an intention, and `load_config` drops blanks for that reason — `captcha.allow_unattributed_host` is a named flag instead, so the trade is stated where it is made. What it admits, plainly: every non-web client, not only ours. A file:// page or somebody else's Electron application look identical from here. That is the same bar the client's own origin would have been — main.js already records that `app://meshbay` is not a credential — and it is a bar: the captcha still has to be solved, per token, in something that can render it. What is given up is the origin restriction for non-web clients, not the captcha. Off by default, and a hub without the desktop client should leave it off. The refusal now names which of the two it is, since they need different answers: an unexpected host names the host, an unattributed one says to set the flag. docs/captcha.md §6 said `meshbay` was the value and told operators to add it; it now records what was measured and why the guess was wrong. The packaged example config carries the flag with the same warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): check the captcha's origin here, so the desktop client can pass oneChristophe Besson2026-09-021-0/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported from the native client: the reCAPTCHA box renders "ERROR for site owner: Invalid domain for site key". The web browser is fine. It is not a client restriction, and the CSP was never what refused — the script loads, which is why the widget appears at all to say so. reCAPTCHA validates the hostname of the page the widget is rendered in against the domain list on the site key, and the desktop client's interface ships inside the package and is served from `app://meshbay` (main.js: `win.loadURL`). Not a preference: file:// breaks ES modules and IndexedDB, and the hub must never become the document origin. So the hostname Google sees is `meshbay`, it is not on the key's list, and it never can be — the check runs on Google's servers and nothing client-side reaches it. The fix turns that check off on the key and does it on the hub instead: [captcha] allowed_hosts = ["meshbay.org", "localhost", "meshbay"] `verify_captcha` refuses a solve whose hostname is not in the list. The hostname comes from `siteverify` — what Google observed, not what the caller asserts — so it is a real check against what turning the console setting off opens, which is a bot rendering the public site key on a page of its own. Empty (the default) skips it, so an existing hub upgrades unchanged with reCAPTCHA still doing the origin check. The two settings go together, and docs/captcha.md §6 says so. The `meshbay` entry is the weak one and the doc says that too: any Electron application can claim the same scheme and host, as main.js already records. What it still costs is a captcha solve per token inside a real Chromium instead of a token farmed from any web page. docs/captcha.md §6 replaced. It documented a design that was superseded twice — an `auth_key`-keyed carve-out that turned out to disable the gate for everyone, and "works in the Electron client too, both run Chromium", which is the assumption this bug is made of: reCAPTCHA validates the domain, not the rendering engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* revert(hub): M6 — add_group_member must keep accepting node tokensChristophe Besson2026-09-011-6/+8
| | | | | | | | | | | | | | | | | | | | | | | | | M6 in the third review was a misread. `add_group_member` accepting a node-scoped token is deliberate (commit 0443cf8): the node calls POST /v1/groups/{id}/members/{username} after a CLI `member invite` so the group shows up in the invitee's SPA, authenticating with a node-scoped token. `group.admin_id == caller` is the real guard. An older test (`test_node_scope_blocks_add_member`) asserted the opposite and had been left red on main; the M6 "fix" (commit 6b38704) satisfied that test by switching the dependency to `require_user_scope` — which made `ops.create_invite`'s hub-membership call 403. That exception is swallowed with a log.warning, so an invited user silently never lands in group_members and the group is invisible to them. Reported from live testing (CLI `member invite grenet`, grenet saw nothing). Dependency back to `get_current_user`. The stale test now asserts the intended behaviour: a node token may add a member to its own operator's group (201) but not to a group it does not own (403). Third-review M6 marked WITHDRAWN. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(hub): send a CSP and protective headers on every responseChristophe Besson2026-09-011-2/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The SPA shell and its assets went out with no Content-Security-Policy and no X-Content-Type-Options / Referrer-Policy / X-Frame-Options — so an injection that reached the SPA (rendered third-party OpenGraph data, a federated group name, chat content) had nothing stopping it from loading more code or exfiltrating to any host, and the page could be framed by any site. A middleware in `create_app` now adds all four to every response. `webapp.CSP` is deliberately the *same* policy the desktop client's protocol handler already enforces on these exact UI files, plus the two reCAPTCHA hosts the sign-up widget needs: `default-src 'none'`, `script-src 'self' 'wasm-unsafe-eval' <recaptcha>` (the hub's own origin is not a script source — T3), `style-src 'self' 'unsafe-inline'` (htm/preact inline `style=` only, nothing executes), `connect-src 'self' https: wss:`, `frame-ancestors 'none'`, `base-uri 'none'`, `form-action 'none'`. The shell's dead `<script>window.__MB_ASSET_V = ...</script>` is removed (nothing has ever read it) so `script-src` needs no inline allowance. Needs verification against the running SPA — a mis-tuned CSP shows as a blank page — but it matches a policy already proven with these files under Electron. Second-review L5 / third-review M5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(hub): constrain what a federated peer hub can do (MHP)Christophe Besson2026-09-011-26/+98
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A registered peer was trusted with more than "advertise your own public groups": - `receive_directory` set `source_hub` from `body.hub_id`, so a peer could relay or spoof a third hub's groups into our directory. It is now bound to the token's verified `iss`. The push is also capped (500 groups/request, 2000/peer), rows are type- and length-checked, and a federated id that collides with a local group is refused so it cannot shadow one. - `receive_revocation` forwarded the peer's token to local nodes, which reject a token signed by another hub's key — a silent no-op, and there is no local node hosting a federated group anyway. It now verifies the inner token against the sending peer's key and, for `target == "group"`, prunes our copy of the peer's directory entry when `source_hub` matches. A peer cannot revoke our users or a group it did not advertise. - The state-changing endpoints (`POST /mhp/directory`, `/mhp/revoke`) now reject a replayed `jti` within the token's TTL. Audience binding is unavailable — the sending side that would set `aud` is unbuilt — and this covers the replay concern in its place; the idempotent `GET /mhp/directory` is not affected. Third security review, finding M4. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(hub): require user scope to add group membersChristophe Besson2026-09-011-1/+6
| | | | | | | | | | | | | | | | Every mutating group endpoint depends on require_user_scope except POST /v1/groups/{group_id}/members/{username}, which depended on get_current_user — so a node-scoped daemon token (or a stolen one) whose subject owns the group could add any existing user to it, contradicting NS7 ("operator manages groups from the browser only"). test_node_auth.py::test_node_scope_blocks_add_member already existed and was red on main; it passes now. Third security review, finding M6. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(hub): enforce registration captcha for every clientChristophe Besson2026-09-011-2/+7
| | | | | | | | | | | | | | | | | | | | | | The server only checked the captcha when auth_key was absent — but every real client (browser included, via the password split) sends auth_key, so the check was off for everyone, and a bot skipped it by including the field. The Register form still made humans solve a widget whose token was never transmitted. Gate is now unconditional on captcha.enabled. The web client (registerUser in keyderive.js) forwards captcha.token; RegisterPage resets the (single-use) token on a failed attempt. The desktop client shares this UI source and is Chromium, so it renders the same widget (see the paired meshbay-client commit for the CSP change that allows it). Tests: test_register_captcha.py. Third security review, finding M1 (Option A). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(hub): require auth and distinct reporters for content reportsChristophe Besson2026-09-011-26/+60
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | POST /v1/reports had no authentication and no rate limit, and counted every raw report row toward AUTO_BLOCK_THRESHOLD regardless of who sent it or from where — two anonymous requests naming any blake3 hash added it to the hub-wide content blocklist. A network-wide censorship and DoS primitive for anyone who learns a public file's hash. - require a signed-in account (get_current_user) - rate-limited (10/hour) - threshold now counts DISTINCT reporting accounts (reporter_id), one vote per account per hash; raised 2 -> 3 - refused outright (403) when the hub has public groups switched off: a private-only hub brokers no public content and nothing syncs the blocklist, so the endpoint would be pure abuse surface - admin blocklist management (/v1/admin/blocklist*) is untouched, so a manual block still works regardless of the public-groups setting Noted while fixing: no node currently consumes ContentBlocklist (swarm_register checks the separate CSAM list), so the network-wide block effect was latent — the abuse surface (DB fill, poisoned moderation signal) was live today. Tests rewritten in test_moderation.py. Third security review, finding H2. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(hub): moderator can no longer grant admin or hard-revoke accountsChristophe Besson2026-09-012-5/+33
| | | | | | | | | | | | | | | | | | | | | admin_patch_user was gated by require_moderator but wrote `role` and `status` with no further check. A moderator could promote any account (an accomplice) to admin, demote an existing admin, or set status="revoked" — a straight path from the moderation role to full instance control. Split authorization by field: status between active/suspended stays at require_moderator (reversible content moderation); role changes, status="revoked", and touching an admin's account at all now require user_is_admin(current_user) (new helper in deps.py, alongside the existing require_admin/require_moderator). Regression test: test_moderator_cannot_change_roles_or_revoke. Third security review, finding H1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(node): CLI member invite now registers hub membership and enforces codeChristophe Besson2026-09-011-1/+1
| | | | | | | | | | | | | | | | | | Two bugs fixed: 1. `meshbay-node member invite <user>` created a local roster invite but never told the hub to add the user to group_members, so the group was invisible in the SPA. The node now calls POST /v1/groups/{id}/members/{username} after creating the invite, and the hub endpoint accepts node-scoped tokens (the admin_id check is the real authorization guard). 2. The WebRTC handshake let a previously-pinned user reconnect without a code even when a new invite was pending (e.g. after leave + re-invite). Now any pending invite forces code entry, regardless of existing member/pin status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): reCAPTCHA v2 on Register and Password Reset pagesChristophe Besson2026-09-012-3/+35
| | | | | | | | | | Server-side verification module, CaptchaConfig in hub.toml, captcha_site_key exposed via /v1/hub/info, useCaptcha() hook in the SPA with stable DOM rendering (strength bar always present to avoid Preact re-ordering the captcha widget). Native clients (auth_key path) skip captcha. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: passphrase change and account recovery (auth-confirm)Christophe Besson2026-09-011-3/+223
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The passphrase derives two independent client-side values: auth_key (the hub verifier) and bundle_key (AES-GCM key for the per-node identity bundles, which live on nodes and never on the hub). Changing or recovering a passphrase is therefore two operations — swap the hub verifier, and re-wrap every reachable node's identity bundle. Flow A — change a known passphrase (Profile page) - POST /v1/users/password re-proves the current passphrase, swaps pw_hash/salt/version, revokes every refresh token and returns a fresh pair so the tab that made the change stays signed in. - MeshBayTransport.rewrapAllNodes: for every group's online node, connect with the old key, read the identity off the handshake, store it back under the new key. Returns updated / unreachable / failed so the UI can point at the operator-unpin fallback for the gaps. Always-shown confirmation dialog listing reachable and unreachable groups. Recovery key - keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>). - Every per-node identity gets a second copy wrapped under the recovery key: keypair_bundles.bundle_enc_recovery (node-only column, added in _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs), carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive. - session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded on connect, so a group joined in any later session still leaves a recovery copy. - Shown once at registration; optionally folded into the verification e-mail as a pass-through the hub never stores or logs, with an opt-out. - Profile -> Recovery key re-loads R and backfills every reachable node via rewrapAllNodes in bundleKey mode (no passphrase re-entry). Flow B — recover a lost passphrase (#/reset, linked from sign-in) - POST /v1/users/password/reset-request {username, email}: both must be the pair on file, checked against the blind email_hash (never decrypted). A mismatch — wrong e-mail, unknown username, non-active account — takes the identical no-op path (no code, no mail, same 200), so it reveals nothing and cannot be used to spray reset mail from a username alone. 5/min, 1-hour single-use code. - POST /v1/users/password/reset {username, code, new_auth_key}: same expiry / attempts / single-use checks as e-mail verification; revokes every session and deletes every registered device key so a stored one cannot sign back in past the reset. - ResetPasswordPage: request code -> code + optional recovery key + new passphrase -> reset + sign-in -> fan-out. connect() falls back to the recovery-wrapped copy when the passphrase key cannot open bundle_enc. Without a recovery key: sign-in is restored and each group needs the operator-unpin fallback. Supporting fixes (found in live testing) - member unpin now also deletes the keypair bundle; connect() mints a fresh identity when handed a bundle it cannot open (unless _rewrapOnly, set by rewrapAllNodes), so a rejoin completes instead of dead-ending before the invite-code prompt. - A browser with no bundle key gets a passphrase prompt on the group page instead of a "go back to the browser you registered on" message. - RegisterPage / LoginPage / ResetPasswordPage trim the username so every key derivation matches the hub's stored form. Docs: docs/auth-confirm.md. Locale keys across all ten catalogues. Tests: test_password_change, test_password_reset, test_recovery_email, test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492 passed; node suite 741 passed (the lone test_packaging_units failure is a pre-existing RPM-spec flake, reproducible on main). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
* feat: email verification for registration, email change, and invitationsChristophe Besson2026-08-312-25/+263
| | | | | | | | | | | | | | | | | | Registration now creates a pending account and sends a 6-digit code via email; the account activates only after verification. Email changes on the profile page follow the same flow. Group invitations send a notification email to the invitee (without revealing their address to the inviter) containing the invite code and hub link. Backend: blind HMAC-SHA256 email index for uniqueness without decryption, mail.py for localhost Postfix delivery, verification endpoints, cleanup of expired codes and stale pending accounts, startup backfill of email_hash for existing users. Frontend: 3-phase register page, inline email change verification on profile, invite-notify call with status display. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(ui): extract Explore, Login, Register and CreateGroup from app.jsChristophe Besson2026-08-311-1/+6
| | | | | | | | | | | | | ExplorePage → explore-page.js (static import), LoginPage/RegisterPage/ FirstRunPage → auth-page.js (static import, LoginPage receives onLogin as a prop), CreateGroupPage/wizard → create-group-page.js (lazy-loaded via dynamic import(), same pattern as AdminPage/NodePage). app.js goes from 1803 to 914 lines. webapp.py _ASSETS extended with the three new files and the previously missing extracted pages. Test fixtures updated to follow the moved components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contactChristophe Besson2026-08-291-0/+11
| | | | | | | | | | | | | | | | | | | | | | | | Wizard (Electron): - Auto-provisions node config (hub URL + username) from logged-in user - node:start handles both cold start and restart of misconfigured daemon - Waits for daemon to reach 'running', auto-links node key on hub - probeNode accepts intermediate states for wizard progress feedback Reset (meshbay-node reset): - Unlinks node key from hub (DELETE /me/node_key, best-effort) - Stops and disables daemon (systemctl --user disable --now) - Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay MusicBrainz contact: - Resolved from owner's hub email instead of per-node roster config - Removed musicbrainz_contact UI and WebRTC handshake field - Removed set_musicbrainz_contact/musicbrainz_contact from roster Node pairing: - Added operator pairing banner on NodePage - Added operator_paired flag to list_groups Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): group names unique per owner, shown as name@ownerChristophe Besson2026-08-282-13/+55
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group's identity stays its UUID. What changes is that "the name is unique" — until now an unenforced expectation — becomes real, scoped to the owner account, and the owner's username is surfaced so two groups called "photos" on different nodes can be told apart. Hub: - `groups` gains a functional unique index `uq_groups_owner_name` on `(admin_id, lower(name))` (model + migration c3d4e5f6a7b8). The migration pre-flights: if the data already clashes it aborts and lists the offending (admin_id, name) pairs rather than renaming anyone's group. meshbay.org checked clean. - `create_group` trims the name, rejects blank (422) and an owner-scoped case-insensitive clash (409), with an IntegrityError backstop for the race, and returns `owner_username`. - `owner_username` added to `/v1/groups/mine`, `GET /v1/groups` (local rows), `POST /v1/groups/{id}/join`, `GET /v1/admin/groups`. SPA: - new `static/group-name.js` — `<GroupName name owner [inline]>` renders the name with the `@owner` handle on a smaller grey line under it. - used in the sidebar, the group-page header, Explore cards, the Admin groups table, and cross-group Search (via a widened `cacheGroupIndex` carrying the owner). Delete/leave confirmations show `name@owner` inline. - federated Explore rows show `@<source_hub>` instead of an account. Design record and the locked decisions: ~/next/groupnames.md (out of repo). MNP unchanged. Tests: test_group_name_unique.py, test_group_name_migration.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(hub): let a hub admin disable public groups instance-wideChristophe Besson2026-08-285-21/+125
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A new General tab in Administration carries one switch, allow_public_groups, stored in a hub_settings key/value table (runtime-editable, unlike hub.toml). Default is on; an absent row means on, so an upgrade changes nothing. Enforcement is server-side on every hub-mediated path, not just the SPA: - create_group refuses visibility=public (403), staff included - list_public_groups the directory returns nothing (local + federated) - join_group open-joining a public group is refused - group_online_nodes a non-member of a public group is handed no node - signaling.webrtc_offer drops the "node hosts an open group" fallback - federation.export_directory advertises nothing to peer hubs The switch is read live, so flipping it back restores every path. Existing members of a group that predates the switch keep their membership row and their access — this is plan A, not a purge. GET /v1/hub/info exposes the flag (unauthenticated) so the create-group form and the sidebar's "Public groups" link render correctly. Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets status=revoked and broadcasts a signed revocation every node enforces (denylist + dropped live sessions). It is confirm-guarded and names the group. And a message fix the revoke work surfaced: group_online_nodes, join_group and webrtc_offer answered "Group is suspended" for any non-active status. They now report the real state, so a member of a revoked group is told "Group is revoked" rather than something reversible-sounding. Tests: test_public_groups_toggle.py (10) covers the switch end to end and the five enforcement paths; test_revocation.py gains the status-message assertion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(music): network-adaptive prefetch depth, opt-in keep-screen-on toggleChristophe Besson2026-08-261-0/+1
| | | | | | | | | | | | | | | | | Prefetch depth (music-player.js): 5 tracks ahead on Wi-Fi, 3 on cellular — more runway through a screen-lock network gap when the connection is cheap and fast, less when it's metered. navigator.connection is Chromium-only; Firefox/Safari (where it's undefined) get the same conservative tier as an unrecognized connection type, never assumed fast. MAX_CACHED_BLOBS raised to 6 to hold the largest case (current + 5). Keep-screen-on-during-audio (new user preference, off by default): a Settings toggle, backed by a new whitelisted key on /v1/users/me/preferences (music_keep_screen_on). music-player.js holds a Screen Wake Lock only while a track is playing and only when the user has opted in — unlike the video player's unconditional lock, this must not fight the ordinary expectation (matching Spotify/Deezer) that the phone locks on its own while listening.
* fix(hub): no-store on the SPA HTML shell — pull-to-refresh wasn't enoughChristophe Besson2026-08-261-3/+15
| | | | | | | | | | | Likely root cause of a very confusing test result: the /app HTML response had no Cache-Control at all, so a browser that decided to cache it heuristically could keep re-serving the SAME old page (old ASSET_V, old JS) indefinitely — a normal reload, pull-to-refresh included, has no reason to override a cache entry it still considers fresh. Every static asset already gets a fresh URL from ASSET_V precisely so a change is visible, but that only matters if the HTML naming that URL is itself refetched. no-store forces every navigation here to hit the network.
* 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
* feat(hub): Music app client — album grid, flat list, persistent playerChristophe Besson2026-08-241-0/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Implements the client half of docs/musicbay.md against MNP 0.8: - music-app.js: album grid (grouped by artist -> album, from index-time artist/album fields) or flat folder view, per-group localStorage toggle like Videos. MusicBrainz (music_meta_req) is only looked up when a track has no embedded cover at all — well-tagged files never trigger a network call, unlike Videos where TMDB is unconditional. Reuses video-app.js's MediaThumb/LazyTile (now exported) rather than duplicating the chunk-path thumbnail decode + virtualization. - music-player.js: the persistent player bar — queue, shuffle (Fisher- Yates, keeps the current track in place when toggled), repeat (off/ all/one), volume (localStorage), prev/next, a one-track prefetch cache. No MSE, no node-side streaming: a track is downloaded and decrypted once via file-utils.js's pipelinedDownload, same chunk pipeline Files already uses, then played from a blob URL. - group-page.js: owns musicQueue/musicbrainzConfig state and renders MusicPlayerBar outside the tab-switched area — deliberately, so playback survives navigating to Chat/Files, the same reasoning the video/preview modals are shell-owned rather than app-owned. - apps.js: registers "music". transport.js: fetchMusicMeta (keyed by path, same reordering-hazard fix as fetchMediaMeta), setMusicbrainzConfig/setMusicbrainzEnabled (signed ops, mirroring TMDB's), and the three new ack handlers. group-settings.js: a MusicBrainz settings section (contact string, per-group toggle) — the existing Applications checklist already picks up "music" for free, per apps.md's own claim. - icon.js: music/pause/skip-next/skip-prev/shuffle/repeat/volume, drawn in the same stroked style as the existing set. - i18n: group.tab_music, the music.* and settings_node.musicbrainz_* keys, translated (not just copied) across all ten locales, Polish carrying full one/few/many/other plural forms for music.n_tracks. - webapp.py's _ASSETS, test_hook_ordering.py's STATIC_FILES and test_transport_contracts.py's SPLIT_FILES gain the two new files. Full suite (common + hub + node): 1116 passed, no regressions. `npm run sync-ui` in meshbay-client confirmed both files copied. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)Christophe Besson2026-08-241-1/+1
| | | | | | | | | | | | | | | | | | | | | 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.
* feat(hub): split the group UI into a pluggable "applications" architectureChristophe Besson2026-08-231-1/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* fix: delete notifications and detach content_reports before deleting a groupChristophe Besson2026-08-211-2/+7
| | | | | | | | | The DELETE /v1/groups/{id} endpoint only deleted group_members before removing the group row, causing a FK violation (500) when notifications or content_reports referenced the group. Delete notifications outright and nullify group_id on content_reports to preserve moderation history. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: unified group management, public groups, and activity-based sidebarChristophe Besson2026-08-203-9/+47
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(ui): 11-point UI overhaul — tabs, transfers, settings, uploadsChristophe Besson2026-08-191-1/+121
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(ui): the M of the wordmark is the logoChristophe Besson2026-08-191-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The stylised M replaces the letter M in "MeshBay" at the top left; `eshBay` beside it stays text. **The source artwork is now in git.** It was in `QE/`, which is gitignored, so it would not have survived a clone — and the tree is about to be moved to another machine. `assets/brand/` holds the original and the `convert` command that derives the 10 KB nav asset from it (trimmed, 72px tall for a picture shown at 30, so it survives a 2× screen). Verified: the documented command reproduces the committed file byte for byte. The derived file is in `_ASSETS`, which is what the `/a/<hash>/` fingerprint is computed from. A file missing from that list is a file whose change never moves the URL, so a browser holding the old one never asks again — the comment on that list says so, and a logo is exactly the kind of asset one would forget. Its URL is resolved from `import.meta.url`, so the hub's fingerprinted path and the application's `app://` scheme both come out right without either being named in the source. **The two sizes are independent, and that took four rounds to get right.** The picture's height was written in `em` — a fraction of the lettering — so every adjustment to the text resized the picture by the same stroke and the ratio between them could never change. The operator spotted the coupling before I did ("si on change la taille du M, tu vas encore tout décaler"). The height is in pixels now: `font-size: 1.375em` (22px) and `height: 30px`, two knobs that move one thing each. The vertical nudge went with it — it was correcting a misalignment I was causing myself. Also: the wordmark blue goes one step down from sky-400 towards sky-500. It sat brighter than the picture next to it, which made the two read as different materials rather than one object. A note on how this went wrong first: I checked the result on a mock HTML page I wrote, not in the application. The picture was loading the whole time, but at 25px against a 29px line box it was *smaller* than the letters, so the wordmark read as unchanged and the report was "none of what I asked for was done". Every size since has been measured in the running window over CDP. 883 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: the chat tab no longer scrolls, and a group is listed or invite-onlyChristophe Besson2026-08-181-10/+23
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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>
* feat(client): the platform seam, and an Electron shell that has never been runChristophe Besson2026-08-181-1/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-182-1/+206
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* fix(hub): serve the SPA under a fingerprint of what it isChristophe Besson2026-08-161-7/+42
| | | | | | | | | | | | | | | | | | | | | | | `Cache-Control: no-cache` requires a browser to revalidate, but it only binds one that asks. A browser that cached app.js before that header existed applies heuristic freshness instead — a fraction of the file's age, which for a file dated weeks ago is days — and never asks. It then runs an old player against a new node. That cost most of a session. A phone kept a player without the read-ahead bound and filled the browser's buffer ceiling at 106 MB, the exact symptom the bound had been written to remove, for an hour after the bounded player went live. A fix that is written, tested, deployed and served, and still not what runs, is indistinguishable from a fix that does not work. The whole module graph now lives under `/a/<content-hash>/`. A path prefix rather than a query string, because relative imports inherit it: `app.js` reaching for `./i18n.js` gets the build it was written against, and never a mixture of two — which does not render a stale page, it fails to link. The URL changes with the content, so those may be cached hard. `sw.js` stays at the root. Its scope is its own path, and under the prefix it would no longer control the pages whose downloads it exists to intercept.
* feat(hub): leaving a group, a cap on public ones, and hosting as a preconditionChristophe Besson2026-08-162-5/+159
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Leaving is its own endpoint rather than a relaxation of the owner's removal check — an authorization rule with an exception in it is the one that gets read wrong later. The owner cannot leave: the group would be left with nobody able to admit, edit or delete it, which is the answer removal and account deletion already give. Public groups are capped at ten live ones per owner. They are the ones that cost other people something — listed in Discover, joinable by anyone — so a script that opens hundreds fills the directory for everybody. Private groups are invisible to non-members and are not capped. Hub staff are exempt; the cap is anti-spam, not a rule about running an instance. Creation is the only place it can be checked, and deliberately so, because PATCH refuses to change visibility at all. A group is now listed only once a node has announced that it hosts it. Before that it has no files, no key and nothing to connect to, so showing it to a member produces a name they cannot open and cannot be told why; its owner still sees it while they set the node up. `meshbay-hub prune-groups` collects the ones that never got a node, meant for cron, with --dry-run. The migration backfills hosted_at from created_at: without that the first run would have deleted every live group. Presence rides on the group list itself, read from the signaling registry the hub already keeps — no poll, no timer. It says a node is connected *to the hub*, which is not a promise that this browser can reach it and not something a dishonest hub could not fake; the client downgrades it on a connection it tried and failed, which is the evidence that concerns the reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: stop a stream on close, count only real users, record where a node isChristophe Besson2026-08-155-10/+88
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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-151-0/+48
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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(groups): editable description, and one source of operator authorityChristophe Besson2026-08-151-0/+32
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-152-2/+10
| | | | | | | | | | | | | | | | | | | | | 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-143-4/+106
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(account): a user can delete their own account, an admin can delete oneChristophe Besson2026-08-142-3/+110
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Both go through the same erasure, so there is one description of what happens rather than two that drift. Gone: credentials, email, node key, group memberships, notifications, refresh tokens, node registrations. The username is released. Kept, on purpose and stated in the UI: the row itself, emptied, and the IP log that points at it. Those logs exist for a year to answer legal requests, and a log that can no longer say whose connection it recorded keeps the data while losing the only thing it is for. So the account becomes a tombstone rather than a hole in the table. Out of reach, also stated: files uploaded to nodes, and the identity keys nodes pinned. Those are on machines the hub does not command, and only their operators can remove them — `member unpin` and a delete on their own disk. Saying so in the confirmation matters more than the button. Owning groups blocks deletion, with the list. Cascading would delete other people's groups out from under them; the account holder can hand them over or delete them first, deliberately. Self-deletion re-checks the passphrase. A live token may be a borrowed laptop or a tab left open, and it is not consent to something irreversible. Admin deletion requires admin rather than moderator: suspension is the reversible moderation tool and stays one click away. A deleted account's access token stops working at once — the status check already refuses anything but "active", which the tests now pin down, because refresh tokens being gone would otherwise leave up to an hour of usable session. Tests: 8 covering what survives and what does not, plus a db_session fixture for assertions that cannot honestly be made through the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat!: identity keys per node — C4's blast radius drops to one operatorChristophe Besson2026-08-142-46/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* feat(client): Argon2id for the keypair bundle, and remove the backup toggleChristophe Besson2026-08-141-0/+4
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two corrections to yesterday's judgement, in the order they matter. **The toggle is gone.** Asked to make the remote key backup optional, I shipped a setting whose "off" position meant: no second browser, ever, and clearing your storage destroys the account. I wrote the warning that says so without drawing the conclusion. A control whose only effect is to break the ordinary case is not a control, and removing an exposure by removing the feature is not a fix. Every browser backs its keys up again, unconditionally. **The exposure is fixed where it actually lives: the KDF.** The keypair bundle rests on every node whose group its owner joins, protected by the passphrase alone (finding C4). It used PBKDF2-SHA512 at 600k — compute-only, which is exactly what a GPU eats. Measured on this machine: PBKDF2 600k costs 241 ms and Argon2id 64 MB/t=3 costs 322 ms, near enough the same honest work, except only one of them forces an attacker to find 64 MB per guess. So the bundle key is now Argon2id 64 MB / t=3 / p=1, via a vendored WebAssembly build (no external host — the CSP forbids one, and 12.2 will tighten it further). Parameters chosen by measurement through that build: 19 MB is OWASP's floor at 118 ms, 256 MB is 1.3 s and too slow for a phone, 64 MB sits where a login should. What this buys, stated honestly: cracking a bundle yields the owner's identity keys, and with them content on OTHER nodes and the ability to sign as them — not the content on the operator's own node, which they host in the clear by design. Argon2id raises that price steeply; it does not remove it, and a weak passphrase still loses. Hence the floor raised to 12 characters and ~60 bits in the same breath, which can only be enforced client-side: with the password split (T1) the hub never sees a passphrase. Migration is automatic and invisible. Bundles carry an "MBK2" marker; the old form is still readable, and is re-encrypted the first time a browser backs it up. Both keys are derived at sign-in, because which one a bundle needs is only known once it is read and the passphrase is deliberately not kept around. Two implementations of the KDF now exist — the browser's WASM and argon2-cffi in QE — so a parity test holds them byte-identical. A disagreement would not look like an error; it would look like an account nobody can open. keypair_bundle_delete stays, without a UI. It is the mechanism behind withdrawing your data from a node, exercised end to end, and it will belong to a deliberate "forget me on this node" action rather than a setting that quietly disables multi-device. Verified against the live deployment: the full workflow passes, including recovering keys on a second client from the passphrase alone. Tests: 341. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): require proof of possession on node announce — closes M8Christophe Besson2026-08-131-0/+44
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5.10. POST /v1/nodes/announce accepted any pk_node with no proof the announcer held the matching private key, so a user could register a node record carrying someone else's node key, and records accumulated without limit. The announcer now signs a domain-separated message binding the key to their account — meshbay:node_announce:{user_id}:{pk_node}:{timestamp} — reusing the shape already proven by /v1/nodes/auth, so a signature for one can never satisfy the other. Same 60-second window. Re-announcing the same key now updates the existing record in place instead of creating a new row. Three test helpers had to be taught to sign, which is the useful part: nothing in the suite had ever exercised announce with an attacker's key. The new tests cover the missing proof, a foreign key, a stale timestamp, and idempotence. Note for the record: the node key is independent of the user's identity key. Two hub tests asserted the announced pk_node equalled the user's pk_ed, which happened to be true only because the daemon announces its keystore key. They now assert against the announced key itself. Tests: 157 hub+common, node suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: resource limits, signaling authz, node admin UI tokenChristophe Besson2026-08-132-4/+70
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5 — findings H6, C4 (partial), and milestone 11.5.3. H6 — resource exhaustion. Several paths let one peer degrade or stall a node: * the DataChannel frame limit was a flat 64 MB applied BEFORE authentication, so an unauthenticated peer could announce a huge frame and dribble bytes into it. Unauthenticated peers now get 64 KB; the large budget is granted only after the GEK proof, where it is needed for uploads. * _do_stream_segment ran subprocess.run(..., timeout=30) directly in the event loop, stalling the entire daemon — every peer, every group — for up to thirty seconds per request. Now async, with a timeout and process kill. * ffmpeg was spawned per stream request with no cap. Both streaming paths now share a transport-wide semaphore. * POST /v1/nodes/{id}/webrtc/offer was reachable by any authenticated user for any node, with no membership check and no rate limit, making the target node allocate an aiortc PeerConnection and gather ICE on demand — remote resource exhaustion against a third party's machine. Now rate limited, capped per user, SDP size bounded, and the caller must share an active group with the node. That also closes the H4 gap where signaling ignored group status. * POST /v1/nodes/{id}/incoming took peer_ip verbatim, so any user could make an arbitrary node emit UDP packets to an address of their choosing. The probe target must now match the caller's own source address. C4 (partial) — the pre-proof bundle window. GEK and keypair bundle fetches are served before the GEK proof by necessity: the client needs its wrapped bundle in order to compute the proof. That window is a disclosure surface a hub can reach by forging a JWT. Bounded to 4 fetches per session and audited as "pre_proof_fetch". The real fix is removing remote keypair bundles entirely, which belongs to the native client (Phase 13.3). 11.5.3 — the node admin UI was unauthenticated because it binds loopback. But any local process can reach it, and so can a page in the operator's browser via DNS rebinding — and this API re-initialises group keys and reads the audit log. H2 showed script execution there equals full control. Now gated by a per-run token, printed at startup, accepted as ?t= or X-MeshBay-Token. One test needed rewriting rather than adding: the first version asserted "subprocess.run(" was absent from the source, which also matched the comment documenting the old behaviour. It now parses the AST and checks the property. Tests: 121 node, 142 hub+common. Regression suite 47 node + 10 hub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>