summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
Commit message (Collapse)AuthorAgeFilesLines
* 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>
* fix: swarm privacy, revocation persistence, keystore KDF, audit integrityChristophe Besson2026-08-135-40/+98
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5 hardening batch — H7, H4, M2, M6, M7, L1, L3, L6. H7 — private content hashes leaked to the hub. The daemon registered blake3 hashes for every group it hosted, private ones included, giving the hub a content fingerprint of every private file and letting anyone confirm whether a known file exists in the network. The leak was dormant only because the routes were declared on the groups router with a full path and mounted at /v1/groups/v1/swarm/* — the node's calls 404'd into a swallowed exception. Fixing the path alone would have activated the leak, so both land together: registration is gated on group visibility, the routes moved to a real /v1/swarm router, and the lookup now requires authentication. H4 — revocation was advisory. Group revocations were signed and broadcast by the hub and then dropped by the node, whose handler understood only "user" and "jti", so "suspend a group" enforced nothing. The denylist was also in-memory only, so a restart silently un-revoked everyone. Now persisted to data_dir/denylist.json, group targets honoured on both transports, and live sessions for a revoked group are closed. M2 — the node keystore, which protects the node's Ed25519 and X25519 private keys, was still deriving at 64 MB long after the hub's password verifier moved to 256 MB; the docs recorded the bump as done, true for the hub only. Raising the constant alone would have made every existing keystore permanently undecryptable, so envelopes now record the parameters they were written with and pre-M2 files continue to open under the legacy profile. M6 — registration inserted its audit row with a NULL user_id and then ran UPDATE ip_logs SET user_id=<new> WHERE user_id IS NULL, claiming every unattributed row in the table: failed logins for other usernames, concurrent registrations. In logs retained a year for legal requests, that attributed other people's connections to the wrong account. M7 — X-Forwarded-For was trusted unconditionally at four call sites, so anyone could forge the IP written to the compliance log and evade per-IP rate limits. New netutil.client_ip honours the header only from a trusted proxy and takes the rightmost hop (the one our proxy appended); no direct header reads remain. L1 dead GEK_REQUEST/GEK_RESPONSE constants removed; L3 peer errors no longer echo exception text (paths, internal state); L6 email sanity-checked instead of accepting any string — deliberately not RFC 5322, to avoid a new dependency. test_daemon_index_change_pushes_to_peers asserted that a PRIVATE group's hashes are registered with the hub. Split: private asserts not-called (index push to members still asserted), and a new test proves public groups still register. That is the fourth pre-existing test found asserting a vulnerability as intended behaviour, after gek auto-activation, the transport-wide chat_store and the blind admin challenge. Tests: 116 node, 132 hub+common. Regression suite now 43. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): authenticate node WebSocket registrationChristophe Besson2026-08-131-13/+84
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 11.5 — finding C2 (see second-review.md). /v1/nodes/ws took node_id and group_ids straight from the client's first message with no ownership check: node_id = msg.get("node_id") or decoded.get("sub", "unknown") _connected_nodes[node_id] = ws Any registered user could connect with an ordinary browser token, claim a victim node's id and overwrite its entry. Every WebRTC offer for that node was then relayed to the attacker, who answered with their own SDP — full node impersonation. The DTLS channel binding does not help, because the attacker is the endpoint rather than a relay: the browser sends its GEK proof to the attacker, who ignores it and replies handshake_ack. The attacker received the victim's encrypted keypair bundle, chat and uploads, and could serve a forged index. Registration now requires scope == "node", verifies Node.user_id against the token subject, checks the account is active, and refuses to displace a live registration instead of silently overwriting it. group_ids are intersected with the operator's actual membership: a node may narrow the set to what it hosts but cannot widen it, so it cannot advertise itself as an online source for arbitrary groups. Authorization uses a short-lived session rather than Depends(get_db): a node WebSocket lives for hours and a request-scoped dependency would pin a PostgreSQL connection for its whole lifetime. BEHAVIOUR: a node hosting a group whose hub membership was never recorded for the operator's account will stop appearing in GET /v1/groups/{id}/nodes. Adds tests/test_node_ws_auth.py (7 tests). The node WebSocket had no test coverage at all, which is why this went unnoticed. Tests: 109 node, 139 hub+common. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: Phase 12 — P2P crypto material, password split, node Ed25519 authChristophe Besson2026-08-135-84/+256
| | | | | | | | | | | | | | | | Baseline commit capturing in-progress Phase 12 work that was already present in the working tree (uncommitted) before the Phase 11.5 security remediation begins. Committed as-is, without review or modification, so that remediation changes arrive as a separable diff. Contents: BundleStore (P2P GEK + keypair bundles), password split (auth_key / bundle_key), node Ed25519 auth (POST /v1/nodes/auth, node-scoped JWT), GEK-HMAC handshake proof with DTLS channel binding, Ed25519 admin challenge-response, node local admin UI rewrite, browser key persistence. Not authored in this session — captured to establish a baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): move _handle_chat_notify before @router.websocket decoratorChristophe Besson2026-08-111-1/+1
| | | | | | | The function was placed between the decorator and node_websocket, breaking the WebSocket endpoint registration (403 on all WS connects). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: chat notifications via hub, file delete, upload fix, cached display, ↵Christophe Besson2026-08-111-0/+36
| | | | | | | | | | | | | | | | | | | | | inline thumbnails Backend: - Chat store persists sender_name (SQLite migration, no more UUID display) - FILE_DELETE / FILE_DELETE_ACK MNP types — node admin can delete files - Node sends chat_notify to hub WS — hub creates notifications for offline members - Hub revocation.py handles chat_notify, creates per-member notifications Frontend: - Upload chunk size 64KB (was 1MB) — fixes WebRTC DataChannel max-message-size - Show cached files immediately while WebRTC connects - ChatImage component — inline image thumbnails in chat (download+decrypt) - File delete action in menu (group admin, with confirm dialog) - Member panel: "Owner" label instead of confusing "Group admin" - Create group page: hint about needing a node - Refresh index after chat attachment upload Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 10b — Self-service UI (group create/join, upload, IndexedDB, ↵Christophe Besson2026-08-111-0/+53
| | | | | | | | | | | | | | | | search) Six self-service features for the web SPA: - Group creation UI with GEK auto-generation (AES-256-GCM ECIES) - Member management + invite by username (GEK wrapping for invitee) - Open group self-join flow (POST /v1/groups/{id}/join) - File upload client→node (FILE_UPLOAD MNP type, .uploads/ staging) - IndexedDB caching of group file indexes (instant display on revisit) - Cross-group file search (SearchPage, pure client-side on cached indexes) 11 new tests (166 total): 8 group self-service + 3 AES GEK wrap/unwrap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): Phase 10.5–10.8, 10.10 — notifications, settings, search, versionChristophe Besson2026-08-114-8/+139
| | | | | | | | | | | | - 10.5: Notification model + CRUD API (list, mark read, mark all read) Triggered on: group invite, role change, suspend/unsuspend - 10.6: SettingsPage shows role, per-group notification mute (localStorage) - 10.7: GET /v1/groups?q= search filter (ilike on name) - 10.8: NotificationFeed on home page + bell with unread badge in navbar - 10.10: GET /v1/hub/version endpoint for client update checks - 8 new tests (test_notifications.py), 155 total Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): Phase 10.1–10.4 — Site overlay + admin/moderation UIChristophe Besson2026-08-114-1/+307
| | | | | | | | | | | | | | - Site overlay: landing page, /about, /downloads (dark/light, responsive) - User role column (user/moderator/admin) with config-based admin sync - require_moderator dependency + admin API (8 endpoints: stats, users, groups, audit logs) - Admin SPA panel at #/admin with 5 tabs (stats, users, groups, logs, blocklist) — visible only to moderators/admins - SPA also served at /app/ for Caddy site overlay integration - GET /v1/users/me returns current user role - 15 new tests, 147 total passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 9 — Web client SPA with WebRTC P2P transportChristophe Besson2026-08-113-59/+76
| | | | | | | | | | | | | | | | Complete browser-based client: Preact SPA with login, group file browser, encrypted download, video playback, group chat, i18n, and dark/light theme. Browser connects P2P to nodes behind residential NAT via WebRTC DataChannel (aiortc). Hub handles signaling only — all data flows E2E. Performance: pipelined downloads (8-chunk sliding window), binary msgpack wire format (no base64), redundant I/O elimination. Large file downloads stream to disk via File System Access API (showSaveFilePicker). Validated on SFR + Orange residential NATs, Chrome + Firefox, IPv4/IPv6. 132 tests passing. Deployed to meshbay.org + Orange node. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 9.1–9.5 — WebRTC DataChannel transport for browser P2PChristophe Besson2026-08-103-1/+121
| | | | | | | | | | | | | | | | | | | | | | | | Browser clients can now connect P2P to nodes behind residential NAT via WebRTC DataChannel with ICE/STUN. Validated on SFR Port-Restricted Cone NAT + 4G CGNAT across three scenarios (WiFi LAN, 4G IPv6, 4G IPv4 STUN). No TURN relay needed. Hub serves only as signaling relay (<1 KB). New files: - webrtc_server.py: aiortc-based WebRTC transport (node side) - signaling.py: SDP/ICE relay endpoint (hub side) - transport.js: browser WebRTC client with msgpack framing - webrtc-test.html: spike test page for browser→NAT→node validation - test_webrtc_transport.py: 4 tests (handshake, file transfer, auth, guard) - meshbay-draft-v4.md: architecture spec updated for web client Modified: - hub_client.py: WebRTC offer handling via hub WebSocket - revocation.py: node_id from WS auth + webrtc_answer routing - pyproject.toml: aiortc>=1.9 dependency 123 tests passing (117 existing + 6 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): Phase 8 — Hub v2 security hardening + production readinessChristophe Besson2026-08-108-69/+150
| | | | | | | | | | | | | | | | | | | | 8.1 Config-based admin authz (require_admin on all admin endpoints) 8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key) 8.3 Refresh token rotation with family-based reuse detection 8.4 Federation persistence (HubPeer model replaces in-memory dict) 8.5 Federation token verification now async (DB-backed) 8.6 CSAM hash check wired into swarm registration flow 8.7 Rate limiting on auth endpoints (5/10/20 per minute) 8.8 Healthcheck endpoint (GET /v1/health, no auth) 8.9 IP log cleanup background task (365-day retention) 8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login) Deployed to meshbay.org — schema migrated, existing emails encrypted. 117 tests pass (29 hub, 88 common+node). Resolves security review items S1, S2, S5. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Phase 7 — Node v2 (multi-group, Sender Keys, 0-RTT, chat, denylist)Christophe Besson2026-08-102-4/+56
| | | | | | | | | | | | | | | | | | | | | | | | | | | Implements all 8 milestones (7.0-7.7): - 7.0: JWT carries `groups` claim; node verifies group membership at MNP handshake (QUIC + TCP+TLS). Resolves security review C2. - 7.1: QUIC 0-RTT session resumption via stored session tickets (17-21ms reconnect vs 47ms cold). - 7.2: Hub→node WebSocket signaling for NAT punch coordination (`client_incoming`/`punch_ready`) + jti denylist push. Denylist class blocks revoked users/jtis at handshake. - 7.3: Multi-group daemon — one QUIC port serves N groups with per-group GEK, shared_root, and index routing. - 7.4: HLS streaming via QUIC (STREAM_SEGMENT message type, ffmpeg segment extraction). - 7.5: Sender Keys protocol for group chat (Signal Groups approach). Each member has own sending chain key, HKDF chain ratchet, AES-256-GCM encryption, Ed25519 signing. Resolves security review C1. - 7.6: Chat store (SQLite via aiosqlite), CHAT_MESSAGE MNP wire type with peer broadcast, web UI with WebSocket push. - 7.7: Argon2id calibration CLI. First security review included (first-review.md). 109 tests, demo-v3 validated against meshbay.org production hub. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: password-based key derivation + operational QUICKSTARTChristophe Besson2026-08-091-3/+8
| | | | | | | | | | | | | | | | | | | | | keyderive.py: derive Ed25519+X25519 from username+password via Argon2id. Same credentials → same keys on any device. Encrypt/decrypt keypair bundle (AES-256-GCM) for hub storage (web clients). 7/7 tests. Full suite: 81/81. keyderive.js: browser counterpart using PBKDF2-SHA512 + random keypairs encrypted for hub storage. Avoids algorithm mismatch with Python. hub/models.py + users.py: keypair_bundle field added to User, stored on registration, returned in login response for web client key recovery. QUICKSTART.md: fully rewritten. 3 operational scripts in QE/demo-v1/: setup_demo.py — create accounts, group, distribute GEK run_node.py — start HTTP node (watches shared/ directory) download.py — bob login → GEK fetch → decrypt → save All tested locally end-to-end. No invented URLs. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat: Phase 6 complete — chat, multi-group, federation, replication, webcryptoChristophe Besson2026-08-092-19/+106
| | | | | | | | | | | | | | | | | | | | | | | | | | 6.1 Double Ratchet (meshbay_common/ratchet.py): Forward secrecy, break-in recovery, out-of-order delivery. Signal-spec KDF_RK/KDF_CK via HKDF-SHA256. 11/11 tests. 6.2 Multi-group node (config.py): [[groups]] TOML array, per-group ports, back-compat [group]. 6.3 MHP federation persistence (db/models.py FederatedGroup + SwarmSource): receive_directory() now persists to federated_groups table. list_public_groups() includes federated results with source attribution. 6.4 Content replication (node/replication.py + hub SwarmSource): ContentReplicator: fetch-index, download, hash-verify, register-swarm. Hub: POST /v1/swarm/register, GET /v1/swarm/{hash} for multi-source. 6.5 Browser private group (webcrypto.py + static/crypto.js): AES-256-GCM variant of GEK for WebCrypto-compatible groups. crypto.js: SubtleCrypto importGEK + deriveChunkKey + decryptChunk. Keys distinct from ChaCha20 via :aes HKDF info suffix. 4/4 tests. 74/74 tests total. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(hub): add MHP federation + Mesh Relay registration — 5.2 + 5.3Christophe Besson2026-08-092-0/+330
| | | | | | | | | | | | | | Federation (MHP 0.1): /mhp/info, /mhp/directory (GET=export, POST=receive), /mhp/revoke (propagation), /mhp/peers (admin registration). Peer auth: JWT EdDSA signed by requesting hub's key. Explicit peer allowlist — no auto-discovery. Relay (5.3): /v1/relays (GET=list), /v1/relays/register (relay keepalive), /v1/relays/approve (admin pre-approval). Relays pre-approved by admin, then self-register with signed endpoint. Nodes query when hole punch fails. 59/59 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(hub): add moderation — content blocklist + reports — 5.9Christophe Besson2026-08-091-0/+200
| | | | | | | | | | | DB: ContentReport + ContentBlocklist tables. POST /v1/reports: public endpoint, auto-blocks after 2 reports. GET /v1/blocklist/check: node sync check before serving public content. GET /v1/blocklist: full list for node startup sync. GET|POST|DELETE /v1/admin/blocklist: admin management. 6/6 tests. Full suite: 59/59. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat: add revocation push (WebSocket hub→node) — 5.7Christophe Besson2026-08-091-0/+194
| | | | | | | | | | | | | Hub: /v1/nodes/ws WebSocket endpoint for persistent node connections. /v1/admin/revoke marks user/group revoked in DB, signs JWT revocation token (EdDSA), broadcasts to all connected nodes. Node: RevocationSubscriber maintains WS connection, verifies incoming revocation tokens offline (hub Ed25519 PK), adds to local blocklist (_revoked_users/_revoked_groups sets). 53/53 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(hub): add web app + public group listing endpointChristophe Besson2026-08-092-0/+103
| | | | | | | | | | webapp.py: serves / (HTML SPA) and /app.js (JS client). app.js: login, hub API calls, node HTTP API integration, file browser, HLS video streaming via <video> tag. groups.py: GET /v1/groups — public group listing (no auth). Deployed on https://meshbay.org — 200 OK. 47/47 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(hub): add production hub — config, auth, API routers, testsChristophe Besson2026-08-096-0/+469
| | | | | | | | | | | | | config.py: TOML + env var priority. auth.py: Argon2id passwords, JWT EdDSA with jti, refresh token hashed (blake3). Routers: hub (info/pubkey), users (register/login/refresh/pubkeys), nodes (announce/get), groups (create/gek-bundle/gek-retrieve). Rate limiting via slowapi. app.py factory with lifespan. All 40 tests pass (SQLite in-memory, no PostgreSQL required). Fix: remove tests/__init__.py to resolve namespace conflicts. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* chore: initialize monorepo structure for MeshBayChristophe Besson2026-08-091-0/+0
3-package layout: meshbay-common (shared crypto/protocol), meshbay-hub (FastAPI server), meshbay-node (local daemon). Includes validated POC spikes 1-6 in poc/, architecture drafts v1/v2 in docs/, and CLAUDE.md project conventions. All cryptographic primitives extracted from POC into meshbay_common/crypto.py (GEK wrap/unwrap, chunk key derivation, keystore encryption, chunk signing). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>