aboutsummaryrefslogtreecommitdiffstats
path: root/docs/USERGUIDE.md
Commit message (Collapse)AuthorAgeFilesLines
* docs: sessions renew themselves, and two faults of the same shape0.5Christophe Besson2026-08-171-9/+28
| | | | | | | | | | | | | | | | | | | | | USERGUIDE said an hour in five places and presented renewal as something the reader does with curl. Both are now wrong: it is four hours, the web app renews for itself, and the endpoint rotates — so anyone driving it by hand has to store the refresh token that comes back, or their next call revokes the family. Also corrects what the token's life actually bounds. It is not how long a revocation takes: the hub reloads the account on every request and refuses a suspended one at once, and it pushes signed revocations to nodes. What remains is a leaked token on an account still in good standing, which is the reason to keep the number small. Two lessons in CLAUDE.md. A rotated refresh token has to be stored or it is spent once. And an effect keyed on a value that used to be constant: the WebRTC dial listed `token` among its dependencies, harmless while a token only ever expired, fatal once the session renewed itself — it tore the connection down mid-handshake and the node waited for ever. That and the hook declared after its own dependency are the same shape, and worth naming as one: code that reads correctly on its own and is wrong against the component lifecycle.
* docs: what this session cost to findChristophe Besson2026-08-161-12/+46
| | | | | | | | | | | | | | | | | Five lessons, and the first is the expensive one: a test that models a fix agrees with it by construction. The buffer-ceiling test passed against a player that still hung, because the model and the fix had the same author and the same misunderstanding. Also: `no-cache` only binds a browser that asks; redeploying during someone else's test kills their session and truncates the log holding the reproduction; `updateend` fires for `remove()`; and flow control on a media stream is a window, not a debt. USERGUIDE section 7 rewritten — it still described 24 segments in flight and two transcode slots, and said "transcode" where ffmpeg does a `-c copy` remux, which is exactly why a slot costs little and why 500 MB really does go on the wire.
* docs: record which download paths have actually been runChristophe Besson2026-08-151-0/+15
| | | | | | | | | | | | | | | | The download-to-disk story is three mechanisms — File System Access in Chrome, a service worker streaming a response in Firefox and Safari, a blob as the floor — and no test in this repository exercises any of them. test_downloads.py pins their contracts by reading the source; whether a browser really writes to disk needs a person with a large file. One now has: Firefox, 180 MB, written to disk rather than assembled in the tab. That is the path worth confirming, since it is the only one Firefox has and it was written blind. It is also not the scale it exists for, and the guide says which rows of that table are measured and which are still only designed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: stop a stream on close, count only real users, record where a node isChristophe Besson2026-08-151-0/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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-4/+45
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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>
* fix(downloads): automatic really is automatic, and a selection downloads all ↵Christophe Besson2026-08-151-0/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | of it Two bugs in what shipped last, and both were mine. Automatic mode still opened Save As, because with no folder granted the code fell through to the file picker — while the documentation said it would use the browser's own download folder. It does that now. Over 512 MB it still asks, since getting there means holding the file in memory and a tab will not survive a 40 GB blob; Settings is where to stop it asking again. Selecting two files downloaded one. They were started without awaiting, so each asked the browser for a save dialog at once, and a browser allows exactly one — the rest were rejected and the errors went nowhere. They are awaited one at a time now, which serializes the dialogs and not the transfers: each call returns as soon as its transfer is registered. Then the adjustments. The transfers widget offers Open on a finished download that went into a granted folder — the bytes go to a new tab, and that is the whole of what a page can do: no browser lets one start a desktop application or show a file manager, so the folder half of that request cannot be built and the guide says so. The Files toolbar was four controls of three different heights in a row. It is three groups now — what you can add, where you are, what you can do with what is here — on one baseline, with icons from the set and a gap between the dots and the word Actions. Chat comes first among the tabs and is the one you land on. The three Discover entries in the sidebar have icons. And a link in a chat message becomes a link: built as an element and never as markup, http and https only, so `javascript:` is not one message away from running here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(settings): choose between Save As and saving into a folderChristophe Besson2026-08-151-0/+18
| | | | | | | | | | | | | | | | | | | | | | | | | | Downloading a selection of twenty files meant twenty Save As dialogs, which is the wrong answer for the feature that had just been built. Settings → Downloads now offers saving automatically, and that is the default; asking every time stays available for people who want it. The correction worth recording: a web page cannot be given a filesystem path and cannot read one either. There is no ~/Downloads to configure and nothing to type, on any operating system — which is also why none of this will need changing on Windows. What a browser grants is a handle to a folder the user picked in a dialog, so that is what the setting keeps: picked once, stored in IndexedDB, re-confirmed once a session because the grant comes back as a claim rather than a permission. Where no folder has been granted, and in Firefox and Safari where none can be, files go to the browser's own download folder — which on most machines is the folder that was meant all along. Automatic saving has one risk a dialog does not: it can silently replace a file. It does not — a taken name gets a suffix before the extension, `clip (2).mp4`, so a download folder does not fill up with files the system no longer recognises. That, and the default, are what test_downloads.py pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(files): transfers that outlive the page, and selection instead of ↵Christophe Besson2026-08-151-0/+23
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | per-row menus Downloads and uploads were state inside GroupPage. Leaving a group unmounted the component, its cleanup closed the DataChannel, and a half-written file was all you had — which is also why only one thing could be in flight at a time. They live in a module-level store now. A group page hands its transport over on the way out rather than closing it, and the last transfer using it closes it; signing out is the one thing that cancels everything, because those transfers are moving data on a token about to stop being ours. The store is plain JavaScript with no browser globals, so test_transfers.py runs it under Node and pins the parts that are timing and lifetime rather than markup: that a cancel stops the work instead of greying out a row, that a stalled transfer reads as stalled rather than reporting its own historical average, and that a released transport is closed by the last transfer and not before. The widget by the bell shows each transfer with its rate and a cancel button, so the Files panel no longer carries progress bars — you can watch a 40 GB archive from the chat, or from another group. Selection replaces the per-row menu: a Select toggle puts checkboxes on files and folders, and ⋮ Actions acts on what is ticked. Ticks survive walking into another folder, so a selection can span directories. Downloads start together and run together. Videos offer Play only — View did the same thing, which is the sort of duplication that makes people wonder what the difference is. Uploads had to become parallel-safe for any of this to mean anything: their acks were matched by arrival order, so two at once credited each other's progress. The node names the file in every ack, so they are keyed by name now — with the same file twice refused, since the node keys its own upload state that way too. Two mistakes worth recording. The selection column went into the body rows and not the header, because that edit matched nothing and I had not made it assert; the columns were misaligned until a screenshot showed it. And the Actions menu opened leftwards from a button at the right edge of the toolbar, half of it off-screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(files): download a folder as a zip, and remove an empty oneChristophe Besson2026-08-151-0/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two things a Files panel needs and did not have. **Removing a directory** is privileged, where creating one is not: it acts on a name other members are using, on the operator's disk. It is refused unless the directory is empty, and that rule is the safety property — whatever the browser sends, this cannot destroy content. The check runs twice, once before the challenge and once after the signature comes back, because a file can land during the round trip. A file also accepts its uploader's key; a directory has no uploader, so only the operator's key will do. **Downloading a folder** produces a zip built in the browser, written straight to disk as the chunks arrive. An archive of a group folder is routinely tens of gigabytes, so nothing is held: peak memory is one chunk plus a small record per file. The node is not involved at all — it serves the same encrypted chunks as any other download, holds no temporary files, and cannot be asked to compress anything. zipstream.js is store-only. Group content is video and images, already compressed, so deflate would spend CPU on every byte to save nothing, in the thread that is also decrypting. Sizes and CRCs go in a data descriptor after each file because a stream cannot seek back to patch a header, and zip64 kicks in per entry past 4 GiB and for the archive itself. Because none of that can be checked from the Python side of the house, test_zipstream.py runs the real module under Node and reads what it produces with zipfile — CRCs, UTF-8 names, zip64 records and all. The archives also pass `unzip -t`. Firefox and Safari have no File System Access API, so there is nowhere to stream to: the fallback builds the archive in memory and says so, with the size, before starting rather than after failing. One mistake worth recording: the first version of deleteDirectory passed the node's own answer as the value to check the challenge against, which turns the comparison into a tautology. It checks the path we asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node): meshbay-node group add — host another of your groupsChristophe Besson2026-08-151-0/+34
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Attaching a group to a node meant hand-editing node.toml with a UUID copied from a browser URL, restarting, and knowing that gek-init exists. Nothing in the CLI said so, and on a node reached over SSH there is no paste buffer to carry a UUID across in the first place. meshbay-node group add grenet --dir ~/grenet-share The name is resolved against the operator's groups on the hub by the daemon, which is the process holding the session. The [[groups]] block is appended to node.toml as text rather than round-tripped through a TOML writer: the file is hand-written and its comments explain decisions worth keeping. The directory is created, and the command says what remains — restart, then gek-init for that group. It refuses a name it cannot find by printing the groups it can, with their ids. That listing is the useful half of the answer and it was missing everywhere: _daemon_api now renders an `available` list from any endpoint that offers one. The key is per group and pairing is not, which is the part that reads as a gap until it is written down: one paired browser covers every group the node hosts, while each group's key admits only its own members. §4 of the user guide now says all three of those in one place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(groups): editable description, and one source of operator authorityChristophe Besson2026-08-151-0/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A description could only be set the moment a group was created, so every group made before anyone thought of one stayed blank for good. The owner can now edit it from the group's page, and PATCH /v1/groups/{id} takes it. That endpoint takes the description and nothing else, deliberately. The name, the visibility and the join policy are the terms members joined on; a private group that can quietly become public is not the group they agreed to be in. Changing those needs a decision about who gets told, not a field on a form — there is a test saying so. Separately, the legacy operator key is gone. `admin_pk_ed25519` in node.toml named the operator before the roster existed and was kept so that an existing deployment would keep working; nothing uses it, and a second source of node authority is not something to carry around out of politeness. Authority is the roster, read fresh on every check. It is removed rather than ignored: a config that still names the key gets a warning at startup pointing at the file. Dropping it in silence would refuse invites and file deletion with a signature error that looks like a bug somewhere else — which is exactly how finding M3 presented. Two tests were verifying admin operations by naming a key in the context, which was the legacy path. They now pair an operator into a roster, the way an operator does. The authority test anchored on the deleted function and passed vacuously once it disappeared; it states the invariant against the verifier and the daemon instead. Also defined .btn-secondary, used in four places and styled in none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(logs): keep the username on records the account no longer answers forChristophe Besson2026-08-151-1/+4
| | | | | | | | | | | | | | | | | | | | | 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>
* docs: account deletion, notifications, and the APIs that no longer existChristophe Besson2026-08-151-110/+181
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Account deletion is the headline, in the user guide and in draft-v5 §6.1, and the important half is what deletion does *not* do. It releases the username, clears the email and password hash, drops memberships, notifications, refresh tokens and node registrations, and refuses any access token still inside its hour. It does not touch a node: files, the pinned identity and the keypair bundle stay on machines the hub does not command, which is the same sovereignty §5.5 relies on — so deleting a hub account is not an erasure request to the operators hosting you. The IP log survives too, attributable, for its legal retention period. The claims table in §2 gets a row saying exactly this, adversary by adversary. Notifications get a section: one entry per conversation rather than per message, never one for your own message, invitations that clear when you join, muting that lives on the hub so it works from any browser. Then the corrections, which is most of the diff. The guide still described a node HTTP API — `GET /index`, `GET /file/{id}`, an HLS playlist, and a `player.js` that does not exist — with curl examples inviting the reader to expose port 19001. That surface was removed in 0.2.0 as findings C1 and C6, precisely because it served files outside the handshake that decides what a peer may see. Sections 6, 7 and the API reference now describe MNP message pairs, and the quickstart says the same in French. Also corrected: the JWT table advertised a `pk_user` claim that no longer exists (it was what let the token issuer decide who could delete a file), `/pubkeys` no longer returns identity keys, and the GEK-distribution endpoints are gone entirely rather than merely unused. draft-v5 §5.2 had uploads landing in `.uploads/{user_id}/`; they land in `uploads/`, chat attachments included. §6.1 now says the hub learns the author's user_id from chat_notify — a stable identifier, and a metadata leak worth naming rather than leaving as "by whom". CLAUDE.md records why the deployed hub broke this week: create_all() creates missing tables, never missing columns, so a schema change passes every test (fresh DB per run) and never reaches production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: user guide and conventions catch up with per-node identityChristophe Besson2026-08-141-17/+10
| | | | | | | | | | | | | | | | | | | USERGUIDE said registration submits your public keys "so other members can wrap GEK bundles for you". Both halves are wrong now: registration creates an account and nothing else, and nobody wraps anything for a key fetched from the hub. The API reference and the register body followed the same correction. CLAUDE.md gains the block a future session needs before touching registration or anything shaped like a user's public key: keys are born at first contact with a node and stay there, the hub publishes none, tokens carry no pk_user, and a scripted signup is now a real account. Left alone deliberately: first-review.md, docs/poc-v1*.md and poc/spike-results.md still describe the old JWT and registration. They are records of what was true on their date, like second-review's verdict table, and draft-v5 is what states the present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: record the invite redesign — H3 and M3 closedChristophe Besson2026-08-141-40/+30
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | draft-v5 §2: against an active hub, reading content moves from "❌ H3" to "❌ T3 (browser) · ✅ native". The defensible sentence becomes "the hub cannot read your content unless it ships you malicious client code" — T3 is now the only path, it is an artifact rather than a silent directory lie, and it does not exist for a native client. New §5.5 describes admission and key delivery, with the four properties that carry it and the one exception (open-join groups, where the hub can walk in the front door — a property of open joining, and the setting is read from node.toml). Corrected while writing it: §5.1 said the C5b fix stopped a group admin who does not run the node from inviting, and that the redesign reverses this. It does not, because delegation was deferred. What changed is the timing — the operator issues a code and is then out of the loop. devel-phases-next: 12.1 is done and NOT as written. The plan was key transparency plus safety numbers; what shipped removes the directory read instead. Safety numbers make substitution detectable by a human who checks, at first contact, when there is nothing to check against. 12.2 (served-SPA integrity) is now the highest-value item in that phase. Phase 14 marked for what landed. second-review: H3 and M3 annotated closed at the finding, with what actually closed them. The §7 verdict table is left intact — it is the record of an audit on a date, and falsifying it would be worse than leaving it — with a note pointing at draft-v5 §2 for current state. CLAUDE.md matters most here, being loaded every session: NS4 read "admin_pk_ed25519 auto-pinned from keystore ✅ DONE", which is M3 described as a feature. Rewritten, with the two fixes that must never be attempted (auto-pin, hub lookup). QE/deploy/README.md: set-admin-pk retired from the walkthrough; the regression checklist now exercises pairing, joining by code, recognition without a code, and revocation. USERGUIDE.md is beyond the invite work but was actively wrong: it told users to POST GEK bundles to a hub endpoint deleted in Phase 12, and to re-wrap for every remaining member on revocation. Both replaced with what the code does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: 4 corrections — streaming hash, watchdog bug, cipher doc, depsChristophe Besson2026-08-091-1/+12
| | | | | | | | | | | | | | | | | | | 1. indexer.py: streaming blake3 (8MB chunks) instead of read_bytes(). Large files (initrd.img, ISOs, VM images) no longer load into RAM. 2. QE/demo-v1/run_node.py: call indexer.start() not initial_scan(). initial_scan() alone never starts the watchdog observer — files added after startup were silently ignored. Added indexer.stop() on shutdown. 3. USERGUIDE.md §8: clarify symmetric vs asymmetric. Ed25519/X25519 = asymmetric (key pairs). ChaCha20-Poly1305 and AES-256-GCM = symmetric AEAD 256-bit (content encryption). ChaCha20 is PRIMARY; AES-GCM is optional browser-compat variant only. 4. pyproject.toml: aioquic, websockets, aiosqlite, slowapi added to proper package deps (were installed manually, now declared). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* docs: update all pointers after keyderive + QE restructureChristophe Besson2026-08-091-8/+31
| | | | | | | | | | | | | | | | | | | | | | CLAUDE.md: add QE/ to structure, key modules table, server state reference, security rule updated (QE/ not keypair files), meshbay.org inventory pointer. devel-phases.md: add milestones 6.6-6.9 (keyderive, bundle, demo scripts, QUICKSTART rewrite). 81/81 tests. docs/meshbay-draft-v3.md §6.1.1: new section documenting 3 key generation strategies (Argon2id CLI, WebCrypto browser+bundle, keystore file) and the algorithm mismatch caveat between CLI and web registration paths. docs/USERGUIDE.md §2 Register+Login: replace "generate and persist before registering" warning with the two clean strategies (derive_keys_from_password for CLI, keyderive.js + keypair_bundle for browser). Login response updated with keypair_bundle field. hub/models.py + users.py + Alembic migration: keypair_bundle column on User, stored at registration, returned at login (web clients only). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* docs: add QUICKSTART.md and USERGUIDE.mdChristophe Besson2026-08-091-0/+785
QUICKSTART (434 lines): 6-step guide tested against live https://meshbay.org — demo accounts alice_test/bob_test, real transfer of README.txt (23ms) and 1MB chunk (275ms recv, 2.4ms decrypt), exact Python commands with measured output. USERGUIDE (785 lines): 11-section reference — architecture, account management, group/node config, file sharing, HLS streaming, security model, moderation/CSAM, troubleshooting, full API table. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>