aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
Commit message (Collapse)AuthorAgeFilesLines
* fix(hub): drop the flag emoji from the language menuChristophe Besson2026-09-081-10/+17
| | | | | | | | | | | | | | | | | | | The flags were regional-indicator pairs, which Windows has never shipped a glyph for: Segoe UI Emoji renders the pair as its two letters, so the menu read "GB", "FR", "NL" down the left-hand side instead of a flag. It only ever looked right on a system with an emoji font that draws them, such as GNOME's. Names only, rather than vendoring ten flag images. A flag is a country and not a language anyway — none of them is the right answer for "Português (Brasil)" — and each name is already written in its own language, which is the self-identifying signal the flag was standing in for. The `flag` field is removed from LOCALES rather than left unread; app.js's menu was its only consumer. The submenu keeps its indent from .user-menu-sub's padding, not from the icon box that is now gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(hub): translate the web client into nine more languagesChristophe Besson2026-08-161-386/+123
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | French, Spanish, Brazilian Portuguese, Simplified Chinese, Japanese, German, Italian, Dutch and Polish, all in the formal register. `hub`, `node` and `GEK` stay in English: they name the CLI, node.toml and the docs, and translating them would cut the interface off from everything an operator reads and types. Catalogues move out of i18n.js into locales/, one file per language, fetched with a dynamic import. A visitor downloads their language plus English as a fallback — about 36 KB rather than the ~180 KB that ten inlined catalogues would have cost everyone. i18n.js keeps only the loader, so the first render now waits for initLocale(). Three things the old code got wrong, none of them visible until there was a second language: - Resolution trimmed a tag to its base before matching, so a browser reporting pt-BR looked for a `pt` catalogue that does not exist and fell back to English. Matching is now exact first, then by base language. - Counted strings were single strings, so Polish could not express 1 plik / 2 pliki / 5 plików at all. t() selects through Intl.PluralRules; en.js gains the same treatment, which incidentally fixes "1 files". - Interpolation used String.replace, which reads `$&` in the replacement. A file named rap$&sody.mp3 rendered corrupted in its own delete dialog. Five strings were still hardcoded in app.js — the group name placeholder and the four visibility/join-policy descriptions — and are now keyed. test_locales.py holds the nine translations to the shape of en.js: same keys, a counted string stays counted everywhere, every plural entry covers each category Intl actually produces for that language, and the {placeholders} survive translation. Verified failing first, against a catalogue with a key removed, a placeholder dropped and the Polish `few` form deleted. The language menu also grew from one entry to ten, which overran a short viewport inside a dropdown that clipped instead of scrolling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: stop a stream on close, count only real users, record where a node isChristophe Besson2026-08-151-0/+12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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/+19
| | | | | | | | | | | | | | | | | | | | | | | | | | 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-2/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(groups): editable description, and one source of operator authorityChristophe Besson2026-08-151-0/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* The Files panel stops guessing, and search says how old its answer isChristophe Besson2026-08-151-1/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Four small things, two of which were the same bug wearing different hats. The index cache seeded the Files panel and then raced the live index: IndexedDB is async, so a fast node could hand you the real list and have it overwritten a moment later by the cached one. That is the "choses bizarres". The panel now shows what the node says, or says it cannot reach the node — no third state that looks like data but is memory. The cache stays, written on every sync, and the search page is the only thing that reads it. Search across groups has no other source: it cannot connect to every node to answer a keystroke. So it now says how stale each hit is — "synced 2 hours ago", per group — and a line under the results explains that opening a group refreshes what search knows about it. Deleting a file also rewrites the cache now; it used to refresh the table and leave the cache holding a file that no longer existed, which is why search kept offering it. The invite form is shown only once this browser holds an operator key. Invites are signed with it and the node checks the signature against its roster, so an unpaired browser could fill the form in and fail on submit. An owner who is not the node's operator is told to ask the one who is. Group descriptions now show on the home cards, as they already did in Explore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(members): restore the member list and invite form, and retire the ↵Christophe Besson2026-08-151-2/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | pairing form Moving the invite form above the member list cut both out of MembersPanel and pasted them into AdminPage, where `doInvite`, `members`, `adminId` and `inviteCode` do not exist. A standard member saw an empty Members tab, the group owner saw only a pairing form, and the hub's own Users tab referenced four undefined names. The pairing form outstaying its welcome is a second bug and an older one. `is_node_admin` compares the connecting account with the account that owns the node — it says nothing about whether *this browser's key* was ever paired, which is the thing pairing changes and the thing that lets you sign an invite. So the form showed for an operator who paired months ago, accepted a fresh code, reported success, and stayed exactly where it was. The node already reports the roster role in `join_result`; the transport keeps it, and the form appears only when this identity is not an operator key yet. Also dropped a clause from the pairing hint: the code never passing through the hub is worth saying, the theory behind it is not. test_spa_ordering.py gets three checks for this class of bug — a cut-and-paste between components is invisible to every other test we have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Notifications: one per conversation, none for your own messagesChristophe Besson2026-08-141-0/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-141-0/+15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(files): upload into the current directory, and create foldersChristophe Besson2026-08-141-0/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-user quarantine is gone. `.uploads/{user_id}/` was the fix for C5a, and it worked, but it made the shared directory something nobody could organise: every file landed under a uuid nobody recognises. Files now go where the member is looking, most often the root. What the quarantine actually bought is kept, and is now what the tests assert rather than the location: - an existing file is never replaced. That was the real defect — overwriting a file also made the attacker its recorded uploader, and therefore able to delete it through the uploader path - the name allowlist is unchanged - the destination is confined under the shared root That last one is new surface: the directory arrives from the client. safe_subdir() is the single place that decides, with two independent guards — every segment against the name allowlist, and the resolved result under the root — because one of them will eventually be refactored by someone who does not know why it is there. Ten traversal cases are covered, and they fail if both guards go. Also adds `dir_create` (any member may organise a shared directory; audited like anything that writes to the operator's disk) and makes the node report its real directory list in index_sync — folders were inferred from file paths, so a new empty one, or one that had been emptied, simply did not exist as far as the UI was concerned. Two C5a tests changed their assertions deliberately, as C5b's did before: they encoded the quarantine path, which is the thing being removed. The property they existed for is asserted more directly than before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(ui): upload progress, PDF preview, invite form above the member listChristophe Besson2026-08-141-0/+2
| | | | | | | | | | | | | | | | | | | Three of the ten UI items, the ones that needed no decision. Upload showed a disabled button and nothing else — on a large file that is indistinguishable from a hang. It now has the same bar downloads have, filled from chunks the node has acknowledged rather than bytes read locally, and says "indexing" for the pause at the end: the node re-indexes on a filesystem event, so there is a real wait there with nothing to poll. PDFs open in the overlay through the browser's own viewer. The file is decrypted in the page as any other preview is, and shown from a blob: URL — nothing leaves the tab, and no external viewer is involved. The invite form sits above the member list, where the action is rather than after the thing it acts on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(client): Argon2id for the keypair bundle, and remove the backup toggleChristophe Besson2026-08-141-13/+0
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* feat(client): make the key backup a choice, and raise the passphrase floorChristophe Besson2026-08-141-2/+19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two things the multi-browser story made obvious. **The backup is now opt-out.** Keys are kept, encrypted with the passphrase, on every node whose group you join — that is what lets a second browser recover them, and it is finding C4: a PBKDF2-protected blob on other people's disks, attackable offline at the speed of PBKDF2, which is memory-light and therefore cheap on a GPU. Until now everybody paid that cost, including people who will only ever use one browser and get nothing back for it. Settings → "Use this account on other devices". Turning it off does not merely stop future uploads: the next connection to each node withdraws what that node already holds (new keypair_bundle_delete, which only ever deletes the caller's own, taken from the authenticated session and never from the message). The warning says plainly what it costs — clearing the browser then loses everything encrypted for that account, with no recovery, which is the point of choosing it. Default is on. Silent, unrecoverable key loss is worse for an ordinary user than an exposure the roadmap already tracks, but that is a judgement call and it is now visible and reversible instead of implicit. **Passphrase floor 8 → 12 characters, plus a strength estimate** shown while typing, with a refusal below ~60 bits. This number matters more here than in most applications: it is what stands between a node operator and your identity keys. It has to live in the client — with the password split (T1) the hub never sees a password and cannot enforce anything about one — so the UI says why it is asking, rather than nagging. The estimator is deliberately conservative and dependency-free: character classes and length, penalised for repetition and for the handful of patterns everyone tries. Verified against the live deployment: withdrawing the backup leaves a second browser unable to recover anything, which is exactly what it promises, and re-enabling restores it. Tests: 338. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node)!: the node wraps the group key — closes H3 and M3Christophe Besson2026-08-141-0/+16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The invite flow fetched the invitee's pk_x25519 from the hub and wrapped the GEK for whatever came back (app.js:1466, and gek-init did the same server-side). The hub is the key directory, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. No forgery, no injection, nothing for the client to notice. That was H3. The fix is not safety numbers. Nobody reads the directory any more: - the node holds the GEK and wraps it itself, on every connection, for the X25519 key the joiner signed with their Ed25519 identity in one transcript (meshbay:join:v1), so the identity key vouches for the encryption key; - identities are bound to accounts by a one-time code the hub never sees — 40 bits, single use, one account, bounded per connection AND node-wide; - the node's own roster decides who may receive the key. Hub membership lets someone reach a node; it no longer gets them anything. A hub that invents an account and mints it a token is answered not_authorized_for_group. Safety numbers would have made substitution detectable by a human who checks, at the moment there is nothing to check against — first contact. Removing the lookup makes it impossible, and costs the user one code to pass along. M3 falls out of the same work. The daemon auto-pinned its own keystore key as admin_pk_ed25519 while the browser signs with the user identity key, so every privileged operation failed closed with a signature error that looked like a bug somewhere else; the demo only worked because a deploy script overwrote the value. Authority now comes from the roster, established locally by `operator pair`. Asking the hub for the operator's key — the obvious-looking fix — would have let the hub install itself as node administrator. BREAKING: gek_bundle_store is deleted, not gated. No member hands the node key material at all, so C5b becomes structural rather than an authorization to check. Existing stored bundles are still served, so current deployments keep working. Also: - join_policy (invite|open) is read from node.toml, never from the hub — a hub able to declare a group open would be handed its key. Unknown group ⇒ invite. - admin signatures are verified against the roster on every check, so unpinning takes effect without a restart. admin_pk_ed25519 stays readable as legacy. - two C5b tests were rewritten, deliberately: they asserted that gek_bundle_store demanded an operator signature, and the message is gone. They now assert the stronger property. The file says not to fix these tests, so this is the record of why they changed. - a slice-1 bug found while writing slice 2: connect() never passed skEdB64, so pairing would have failed at runtime with no test able to catch it. Tests: 152 node+common here, including an end-to-end DataChannel run where a member who has never held the group key redeems a code in the pre-proof window and receives the key wrapped for a key only they can open. Design: docs/invite-pairing-v1.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(client): pin node identities on first use — closes 11.5.8Christophe Besson2026-08-131-0/+4
| | | | | | | | | | | | | | | | | | | | | | | | | The client verified the node's Ed25519 signature but did not remember which key it had seen, so a substituted node was caught only by its lack of the GEK. Trust On First Use: the node's public key is recorded per node_id on the first successful handshake and compared on every later one. A change is refused outright — strict, per operator decision. A warning users can click through is decorative, and this is the SSH known-hosts tradeoff taken deliberately. Scope, stated honestly: with C6 closed this is defence in depth, not the primary control. A substituted node already fails the GEK proof. Pinning covers the case where an attacker HAS the group key — an ex-member, or a leaked GEK — and swaps the node underneath, which the proof alone cannot distinguish from the real one. Strict refusal needs an escape hatch or it is a dead end: a node operator who reinstalls and loses their keystore generates a new pk_node and would otherwise lock out every member. Settings gains a "Node identities" section showing the pin count and clearing them, with copy telling the user to verify out of band first. Also exposed as MeshBayTransport.clearNodePin() for the native client. Tests: hub+common green; all five static JS files syntax-checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: Phase 12 — P2P crypto material, password split, node Ed25519 authChristophe Besson2026-08-131-1/+18
| | | | | | | | | | | | | | | | 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>
* feat: Phase 10c — MSE video streaming (real-time playback)Christophe Besson2026-08-111-0/+2
| | | | | | | | | Replace download-then-play VideoPlayer with MSE (MediaSource Extensions) streaming. Node remuxes to fMP4 via ffmpeg, probes codecs with ffprobe, and sends encrypted segments over DataChannel. Browser decrypts and appends to SourceBuffer — playback starts within seconds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(ui): upload chunk size, cached file display, chat names, file delete, ↵Christophe Besson2026-08-111-0/+4
| | | | | | | | | | | | | | | | inline thumbnails - Upload chunks capped at 64KB to avoid WebRTC DataChannel max-message-size - Show cached files immediately while WebRTC connects (tabs visible during connection) - Persist sender_name in chat store (SQLite) — no more UUID display in history - File delete action in menu (node admin only, enforced server-side) - FILE_DELETE / FILE_DELETE_ACK MNP message types - Inline image thumbnails in chat attachments (download+decrypt, Signal-style) - Member panel: "Owner" label instead of "Group admin" to avoid hub/group admin confusion - Create group page: hint about needing a node - Refresh index after chat file attachment upload Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(ui): group creation error, chat UUIDs, file preview, action menuChristophe Besson2026-08-111-0/+4
| | | | | | | | | | | | | | | | | | | Bug fixes: - Group creation "[object Object]" error: removed dead pkcs8 import code that threw before GEK wrapping, added array detail handling in hubFetch - Chat shows usernames instead of UUIDs (sender_name passed through node) - Join button: navigate to group on "Already a member" instead of error UI improvements: - Loading spinner animation for async states (connecting, fetching) - File action menu (3-dot dropdown: View, Download, Play) - Click filename to preview inline (images, text/code files) - FilePreview overlay for images and text files - Chat file attachment button (upload to node + structured message) - Chat attachment display (icon, filename, size) - Member panel: "Group admin" badge instead of plain "Admin" text 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/+41
| | | | | | | | | | | | | | | | search) Six self-service features for the web SPA: - Group creation UI with GEK auto-generation (AES-256-GCM ECIES) - Member management + invite by username (GEK wrapping for invitee) - Open group self-join flow (POST /v1/groups/{id}/join) - File upload client→node (FILE_UPLOAD MNP type, .uploads/ staging) - IndexedDB caching of group file indexes (instant display on revisit) - Cross-group file search (SearchPage, pure client-side on cached indexes) 11 new tests (166 total): 8 group self-service + 3 AES GEK wrap/unwrap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): Phase 10.5–10.8, 10.10 — notifications, settings, search, versionChristophe Besson2026-08-111-0/+9
| | | | | | | | | | | | - 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-111-0/+60
| | | | | | | | | | | | | | - 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-111-0/+172
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>