aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
Commit message (Collapse)AuthorAgeFilesLines
* test(hub): wrap a docstring ruff flagged0.17Christophe Besson9 hours1-3/+3
| | | | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(client): rotating a group key asks nothingChristophe Besson9 hours1-3/+2
| | | | | | | Rotation narrows rather than widens: members still connected receive the new key, and nothing already shared changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): a username is unique whatever its caseChristophe Besson12 hours1-0/+24
| | | | | | | | Registration refuses a name that differs from an existing one only by case; accounts that already do keep their names, and a pending retry needs the exact name (F-26). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): what an offer or a node message costs the hub is boundedChristophe Besson12 hours1-0/+51
| | | | | | | | | An offer's IP-log row (kept a year) was written before any check, for any string named as a node; it is written once the offer goes to a node. The ICE list is capped (64 candidates, 32 KiB). A node's update_groups, a database read each, is budgeted like chat_notify and claims at most 1000 groups (F-22). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(client): the audit export never hands a spreadsheet a formulaChristophe Besson12 hours1-0/+32
| | | | | | | A cell starting with = + - @ (or a tab or carriage return) gets a leading apostrophe; the export carries text members chose (F-29). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: an MBK2 bundle is opened once and stored again as MBK3Christophe Besson12 hours2-2/+80
| | | | | | | | | | | | Transitional. The Argon2 run that makes M makes A, the key MBK2 bundles were sealed under; a session keeps it as a decrypt-only key (IndexedDB in a browser, the key storage in the desktop app). A client meeting an MBK2 bundle opens it — or its recovery copy — and stores the same identity as MBK3 once connected; the desktop app reseals or withdraws it as browser access says. A session without A asks for the passphrase once. Older formats stay refused by name. Replaces the unpin-and-reinvite step the 0.17 flag day required on every node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: downloads are marked and keep their extension; Explorer files are refusedChristophe Besson13 hours1-0/+23
| | | | | | | | | | | The desktop app writes the Mark-of-the-Web on each file it saves on Windows, as a browser does. Bidirectional controls are reserved characters in a saved name (portable-name.js and paths.sanitize_for_download, and again in the main process), so a name cannot display one extension and carry another. The node refuses uploads of files Windows Explorer acts on by itself: desktop.ini, .lnk, .url, .scf, .library-ms, .searchConnector-ms (F-19). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): a stranger who knows your name locks only browsers you never usedChristophe Besson13 hours2-4/+114
| | | | | | | | | | | A sign-in from a browser that presented no token is answered with one (known_browser, kept hashed, twenty per account); a later sign-in presenting it counts failures on its own row, which nobody else can spend. Passphrase checks inside an open session (change, e-mail, deletion, device, pepper) count on the account's own row, so a locked name no longer stops its owner there either; /me reports that row. Reset and erasure forget the browsers (F-15). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): a group name fits its column and carries no control charactersChristophe Besson15 hours1-0/+33
| | | | | | | | Over 128 characters was a 500 on PostgreSQL; line breaks, C0/C1 controls and bidi overrides are refused (joiners stay, for emoji). The creation form caps the field at 128 (F-13, what remains of it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: an identity signs a named kind, and a device approval answers a requestChristophe Besson28 hours2-8/+102
| | | | | | | | | | | The desktop main process builds every transcript itself from fields (transcripts.js) and signs no raw bytes; the page's identity has the same contract (crypto.js transcriptFor). The keyring seals no bundle while browser access is off. On the node, device_add must redeem a pending request filed by the same keys, and device_revoke is signed under its own prefix (meshbay:device_revoke:v1), so a retirement signature admits nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): the pepper and a device key take the passphrase, not a tokenChristophe Besson28 hours4-12/+67
| | | | | | | | | POST /me/bundle-pepper (was GET) and POST /users/devices require auth_key. A refreshed or lifted token could otherwise fetch the pepper, or register a device whose every sign-in carries it. Both callers have just been given the passphrase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat: browser access, decided in the desktop applicationChristophe Besson32 hours2-0/+37
| | | | | | | | | Off for an account made there: its identities stay on the device and nothing is left on nodes. Turned on from the Profile page behind a native confirmation; each node is settled when its group next opens. The hub keeps a mirror a browser reads to say why a group will not open; it grants nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(client): the desktop application keeps M and every node identity in its ↵Christophe Besson32 hours2-0/+196
| | | | | | | | | | | | main process keyring.js derives, opens, mints, seals, signs and agrees there; the page gets public keys and a handle. Argon2 comes from the page's own WebAssembly build (Electron's crypto has none). Without OS key storage the page keeps its keys as a browser does. A node's bundle is settled after connecting, re-sealed when the key changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(hub): the transport holds an identity, never a private keyChristophe Besson33 hours3-3/+53
| | | | | | | | Two public keys, sign() and shared(); the apps take transport.signFn. What holds the keys (this page, or the desktop main process) is the identity's business alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat: bundles sealed per node under the passphrase and the hub's pepperChristophe Besson34 hours10-276/+406
| | | | | | | | | The session key is M = HKDF(Argon2(passphrase) || pepper, account id); each node's bundle key and the playlist key derive from it. Bundles are MBK3, bound to account and node; MBK1/MBK2 are refused by name, never replaced silently. Playlists move to key v2 and are re-sealed over unreadable node copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): a bundle pepper per account, handed only to a proven sessionChristophe Besson35 hours1-0/+181
| | | | | | | | Sealed at rest and bound to the account; returned by sign-in, device sign-in, a passphrase change and GET /me/bundle-pepper, never by a refresh, to a node token, in a token or in a log. Erasure clears it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): a downloaded file opens in a tab only under a type that runs nothingChristophe Besson36 hours2-4/+55
| | | | | | | Open is offered for PDFs, raster images, audio, video and plain text, typed from the name; HTML, SVG and the rest are not opened in the hub's origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: the page connects to its own origin and reCAPTCHA, nowhere elseChristophe Besson36 hours1-7/+10
| | | | | | | | connect-src drops https: and wss: in both policies. Checked against Google's reCAPTCHA test keys: in Chrome widget, token and registration unchanged; in Firefox the widget loads; no violation reported in either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(client): the page names node operations, and the app confirms what ↵Christophe Besson36 hours3-7/+82
| | | | | | | | | | | widens the node node:call is replaced by named operations with checked arguments; hosting a group, sharing an unpicked folder, key rotation, denylist clearing and a change of node account are confirmed by a native dialog. Every channel checks its sender, secrets:get/set/clear are gone, node:start writes the app's own hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): the admin allow-list grants an account, not a usernameChristophe Besson37 hours1-0/+121
| | | | | | | Each name in admin_usernames is pinned to the first active account seen holding it (admin_pins), so a name freed by a deletion grants nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: only the owner decides who hosts a group, and nobody is made a member ↵Christophe Besson37 hours17-48/+787
| | | | | | | | | | | | | | | | | | | | | | | | | | unasked - hub: a node may host a group only if its account owns it or the owner approved that node (new `group_hosts`). Membership was the ceiling, and every member holds the group key, so any member's node could register as a host and be the one clients kept. A node claiming a group it may not host is recorded as a request; the owner is notified once and approves or refuses it (GET/POST/DELETE /v1/groups/{id}/hosts[/{node_id}]), which takes effect on a connected node at once. - hub: an owner adding a username creates an invitation (new `group_invitations`), accepted or declined by the invitee (/v1/groups/invitations, /{id}/invitation/accept|decline). Until then the group is not listed, not dialled, not searched and not in any token. Invitation links, open joins and group creation still make members directly: they are the account's own act. - hub: the MNP token names only the group it is minted for (group_id is now required), so a node operator no longer learns a member's other groups. - SPA: invitations on the home page; invited people and host requests in the group's settings; the transport sends group_id. Ten catalogues. - Browser probes for both screens, run in Chrome and Firefox. - Design §5.2, §7.2, §7.3, AV32, AV33; protocol §6.3; user guide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: a member can no longer lock a node, crash it with a link, or stop hub ↵Christophe Besson38 hours1-0/+103
| | | | | | | | | | | | | | | | | | | cleanup - node: only a wrong code counts towards the join lock, now per account (5) as well as node-wide (20), and it is consulted only when a code is tried. Every member reconnecting gets the group key through join_request, so a lock checked before recognition let one member refuse it to everyone. - node: link previews read the body as a stream and stop at the cap, counted on decoded bytes; a declared oversized image is not read; 15 s total deadline; image decoding off the loop. `client.get` had buffered the whole (decompressed) response before the caps looked at it. - hub: the daily purge of never-verified accounts detaches their IP-log rows (keeping the name) and clears every other reference first, and each cleanup step runs on its own. On PostgreSQL the bare DELETE violated the ip_logs foreign key and stopped every purge behind it for good. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): search title, description, canonical and noscript in the shellChristophe Besson38 hours2-1/+37
| | | | | | | | | A descriptive <title>, a two-line meta description apart from the short one messengers get, "/" canonical for every shell path, and a <noscript> pitch with the download and doc links for crawlers that do not run app.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): sitemap.xml, named by robots.txtChristophe Besson39 hours1-1/+15
| | | | | | | Home, downloads, and the repository's about page and docs on git.meshbay.org, spelled as the welcome page links them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(hub): remove the closed relay registryChristophe Besson3 days2-91/+0
| | | | | | | Every /v1/relays route answered 503 and nothing called them; no TURN relay is needed. The proof-of-possession rule it carried stays as AV6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(client): save files under a name every platform can writeChristophe Besson3 days1-0/+42
| | | | | | | | | A node serves the name its disk gave a file; the client now makes it portable at save time (single file, zip entries, zip name) and says so on the transfer row. Same rule as paths.sanitize_for_download, held by a parity test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): reports from public-group members, decided by an administratorChristophe Besson3 days1-42/+163
| | | | | | | | | | A report needs a person's account at least a day old, membership of the public group, and fits a daily allowance per account. Past the threshold a hash is queued and administrators are notified; blocking without review is an instance setting, off by default. Report menu item in public groups, Reports tab and settings in the admin panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat: nodes apply the content blocklist in their public groupsChristophe Besson3 days2-11/+94
| | | | | | | | | A node hosting a public group syncs the hub's blocklist on every connection (paged, node token only) and applies pushed changes. A blocked file leaves the index and is refused (content_blocked); private groups are untouched. The unused per-hash check route is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor: remove the public-content swarmChristophe Besson3 days2-67/+3
| | | | | | | | Nodes registered the hashes of their public groups on the hub and nothing ever read them back. Routes, model and node registration removed; a migration drops swarm_sources. No node sends the hub a content hash now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor: remove dead code across packagesChristophe Besson3 days1-1/+0
| | | | | | | | | Unused modules, functions, constants and client helpers with no caller, the unreachable hub:probe IPC handler, and the CSAM hash matching. Behaviour unchanged; the dispatch golden loses only the two removed message types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: refuse an unsigned handshake challengeChristophe Besson3 days2-7/+6
| | | | | | | | Every node the 4.0 floor admits signs its challenge, and one without a channel binding could not complete the proof anyway, so a missing signature is refused like a wrong one (browser and QUIC client). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): say so when a lazily loaded view cannot be fetched0.16Christophe Besson4 days2-0/+211
| | | | | | | | A tab opened before a hub deploy got 404 for every module it had not loaded yet, and lazy.js kept its spinner for good. It now shows a notice with a Reload button and logs the failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): keep a show's detail modal open under the playerChristophe Besson4 days2-0/+340
| | | | | | | Closing the player lands back on the season being watched, with the episode just started marked. A film's modal still closes on Play. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: make both suites pass on WindowsChristophe Besson4 days65-217/+219
| | | | | | | | | | | | | | | | | | | | | | | | | | | Most of these failed on Windows for reasons that had nothing to do with the code under test, which is how real Windows defects hid among them: - Read and write files as UTF-8, and talk to Node in UTF-8. read_text(), write_text() and subprocess text=True use the locale codepage, cp1252 on Windows: "é", "—" and "→" arrived as "?" or crashed, some sixty tests. Calls to PowerShell and schtasks are left alone -- they answer in the console codepage. - Import ESM harness modules by file URL (as_uri): a raw "C:\..." path is not a module specifier. - test_cli_golden: mask the tmp path in its JSON-escaped form, spell it the POSIX way, record on Linux, mask the protocol version (the recording had failed everywhere since the MNP 4.0 bump) and argparse's version-dependent quoting; point USERPROFILE at the tmp home, or `member invite` and `operator pair` wrote their codes into the developer's profile. - test_disk_io_off_loop: expect what a free loop can reach on the platform's timer, 15.6 ms on Windows, not an assumed 5 ms. - test_root_paths_are_operator_only: expect the OS's spelling of the path. - test_audio_meta_cache: find ffprobe with shutil.which. Node suite on Windows: 1489 passed, none failed. Hub suite: 3 failures left, all older than this change (two SQLite concurrency tests, one transfer resume). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): the sidebar's Node section follows the node linkChristophe Besson4 days1-0/+55
| | | | | | | | | | | | | | | | | | Reported on a real install: after the first click on Create group, the Node section (Node, Create group) disappeared from the sidebar until a reload, although the group was created and the node ran. hasNodeKey was read once per session change and never again, so a node the wizard linked stayed out of the sidebar; and that read swallowed its errors, so a session blip (a refused renewal, then the desktop app's silent device sign-in) followed by one failed request hid the section for good. The wizard now tells the app when it has linked or started a node, the app asks again then and after a group is created, and a failed read is retried -- never applied to a session that has changed meanwhile. The wizard also starts the node on its own for a node in waiting_for_hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): say "No operator paired" only when the node says soChristophe Besson4 days1-0/+48
| | | | | | | | | The Node page showed the banner whenever operator_paired was not true, so a node that had not yet read its roster -- one still signing in to the hub -- was reported unpaired while its pairing was intact. The node now answers null until it knows; the page shows the banner for false only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): rate-limit per client, not per proxyChristophe Besson4 days1-0/+48
| | | | | | | | | | | Behind Caddy every request's TCP peer is loopback, and the limiter was keyed on that peer (slowapi's get_remote_address), so each limit was one bucket for the whole internet: ten node sign-ins a minute shared by every node. A node starting while others signed in got 429 and sat in waiting_for_hub, which the desktop app took for no node at all. Key it on client_ip, which already resolves X-Forwarded-For from a trusted proxy and was written for this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): default email opt-outs and match Register to Login stylingChristophe Besson6 days1-2/+3
| | | | | | | | | | | | | | - Invitation-by-email and recovery-key-by-email boxes now start unchecked; mailing a code/key is opt-in. The invite choice still remembers itself per account once set. - Align the two invite-email checkboxes with their label (center, not flex-start). - Register (and its verify/recovery/done steps) now sits on the same dark gradient backdrop and frosted card as Login, via a shared AuthShell. - Make the gradient's top-left corner very slightly less bright (#86a3c4 -> #809cbc), on both auth pages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hub): the NAT-punch signal needs a shared active group, like the offer relayChristophe Besson6 days1-0/+76
| | | | | | | | | | | | | | | | | POST /v1/nodes/{id}/incoming checked only the caller's own address, then revealed whether the node was connected (404 vs 504) and, with QUIC on, made it punch — so any authenticated account could poll it for a node's liveness or make a stranger's node emit a UDP probe. The membership gate webrtc_offer did inline is now require_shared_active_group() in api/signaling.py, called by both routes; in notify_incoming it runs before anything depends on the node's connection state, so a non-member gets one uniform 403 whether the node is up or not. test_incoming_membership.py holds it (a non-member is refused with a membership 403 whether the node is connected or not; a member passes the gate); red before, green after. The offer relay is unchanged in behaviour (it now calls the shared helper); signaling/availability suites pass. Design §7.2 updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(protocol): bind the MNP token to the node it is for (E10)Christophe Besson6 days1-0/+21
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The audience split stopped a member's node credential from opening the hub API. It did not stop the credential being *replayed to another node*: the MNP token carried the member's whole group set and named no node, so a token handed to node A's operator could be presented to node B the member also belongs to. That does not read content on B — the handshake still requires proving node B's group key, which the operator lacks — but it reaches B's pre-proof window and fetches the member's *encrypted* keypair bundle for B (offline-attackable, bounded, audited): a disclosure §2.4 says should not follow from hosting a member on A. The token now names the node it is minted for (a `node` claim = that node's Ed25519 key), and authorize_token refuses one that names a different key. The client already knows the target node's key (from /v1/groups/{id}/nodes) and asks for a token bound to it: POST /v1/nodes/mnp-token takes node_pk, and transport.connect threads it (group-page, the connection pool and rewrap pass n.pk_node; reconnect preserves it). A token that names no node is still accepted, because the hub only mints one for the authenticated requester, so an unbound token grants nothing across accounts — which also keeps non-binding callers working with no churn. Done before deploy, so it folds into the MNP 4.0 flag day rather than needing its own. Docs: §5.2, register E10, MESHBAY_NODE_PROTOCOL.md §6.3. test_handshake.py and test_mnp_token.py hold the binding (a token for node A is refused by node B, accepted by node A; an unbound token still works); red before, green after. common/node/hub suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hub): present a node-audience token in the handshake, not the hub ↵Christophe Besson6 days4-10/+97
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | session token A member authenticated to a node in the MNP handshake with its hub *session* token — scope=user, valid at the hub API for hours. A node operator is in the threat model, so this handed them a live hub credential for the member: enough to enumerate the member's other groups, act as them, and (before the previous commit closed it) take the account over. The node genuinely needs a hub-signed membership assertion, so the fix is to make that a separate credential that opens nothing at the hub API. Two audiences signed by the one hub key (meshbay_common/tokens.py): - HUB_API_AUD — session tokens (login, device-auth, node-auth, refresh), used for hub calls and signaling. decode_access_token now binds this audience, so an MNP token cannot be replayed against the hub API. - MNP_AUD — a short-lived token a member presents to a node and nothing else, from POST /v1/nodes/mnp-token. authorize_token now binds this audience, so a session token presented to a node is refused. This closes the disclosure. The node's own self-decode (hub_client.py) reads its node token with audience=HUB_API_AUD. The client fetches the MNP token inside transport.connect() (and on every reconnect) using the session token, so callers are unchanged and signaling keeps using the session token. No regression to a long session: the MNP token is checked once, at the handshake, before any proof — a film already playing is not re-authenticated, so a 15-minute token does not interrupt a 4-hour film; reconnects refetch a fresh one. Denylist and membership checks are unchanged (the MNP token carries sub/jti/groups). Tests: authorize_token refuses a session/no-audience token and accepts an MNP token; the hub API refuses an MNP token; POST /v1/nodes/mnp-token is minted only for a member's own session. Verified red-before/green-after; common, node and hub suites green (the pre-existing test_cli_golden failure is an argparse/pytest prog artifact unrelated to this change). Still to do before deploy (B2): bump the MNP version and client.minimum so a stale desktop client is told to update rather than getting a handshake refusal, update docs/MESHBAY_DESIGN.md and MESHBAY_NODE_PROTOCOL.md, and validate against a real node locally, then deploy hub+node+SPA atomically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hub): require the passphrase to change the e-mail on fileChristophe Besson6 days2-6/+64
| | | | | | | | | | | | | | | | | | | | | | | A member hands its hub access token to every node it connects to (the MNP handshake), so a node operator holds a live bearer token for that member. PATCH /v1/users/me {email} needed only that token, and the confirmation code goes to the new address — so an operator could point the account's e-mail at their own inbox, confirm it, and then use the passphrase-reset path to take the account over. This is the immediate mitigation of that chain; the full fix (a node-audience token distinct from the API session token) follows. Changing the address now requires the passphrase-derived auth_key, verified through the same throttle as a passphrase change or an account deletion — the hub still never sees the passphrase. A PATCH that does not change the address is unaffected. The profile page prompts for the passphrase and derives auth_key with the existing MeshBayKeys.deriveAuthKey, as the delete and change-password flows already do. test_email_change_requires_passphrase.py: refused without / with a wrong passphrase, proceeds with the right one, and a no-email PATCH still works; red before, green after. test_mail_is_not_a_relay.py updated to pass the auth_key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hub): make group revoke admin-only and route it through the broadcasting ↵Christophe Besson6 days1-0/+164
| | | | | | | | | | | | | | | | | | | | | | | | | | path Two coupled gaps in the moderation surface (docs/MESHBAY_DESIGN.md §7.5): admin_patch_group let a moderator set a group to "revoked", while the user handler makes revoke admin-only; and a "revoked" set through either PATCH was never broadcast to nodes — unlike POST /v1/admin/revoke and account deletion — so it behaved like "suspended" on nodes while claiming to be the signed, node-enforced state H4 promises. PATCH now refuses "revoked" on both users and groups (400, pointing at POST /v1/admin/revoke, which signs and broadcasts), a moderator setting a group to revoked is refused with 403 as on the user handler, and moving an entity out of "revoked" requires admin — a moderator flipping the hub row back to active would only disagree with the nodes still enforcing the broadcast. Revoke has one door and it broadcasts. The admin SPA already revokes through POST /v1/admin/revoke, so this is not a UI-visible change. test_revoke_is_one_path.py holds the refusals and the working path; verified red against the pre-fix admin.py and green after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hub): require exp, sub and a known scope when decoding access tokensChristophe Besson6 days1-0/+114
| | | | | | | | | | | | | | | | | | | | | | | | | | | One Ed25519 key signs four kinds of token — user access, node access, revocation broadcasts (no exp, no sub, and returned in the body of POST /v1/admin/revoke and pushed to every node) and MHP federation tokens (aud, sub=hub_id, no scope). decode_access_token required none of these, so a hub-signed token with no exp was accepted and separation between the types rested only on which fields each consumer happened to read. Require exp, sub and scope, and reject a scope that is not one of the two access scopes. A revocation or MHP token can no longer be mistaken for a session, and no hub-signed token without an expiry is honoured. A full RFC 5987 aud binding is deliberately not used: the node decodes its own hub-issued token without passing audience, so adding aud would make every already-deployed node reject its own token (InvalidAudienceError) — a coordinated, node-breaking change. scope gives the same purpose separation among the hub's own token types without it. This is non-breaking: every real access token already carries exp, sub and scope, so no session is forced to re-authenticate. test_token_hardening.py holds the refusals (no exp, no scope, unknown scope, a revocation token as bearer) and the paths that must keep working (a real login token, a node token); verified red against the pre-fix auth.py and green after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hub): refuse node-scoped tokens on the admin and moderator APIChristophe Besson6 days1-0/+159
| | | | | | | | | | | | | | | | | | | | | | | A node's authority and a hub role are different notions: what a node may do is decided by its operator's roster pin on the node (NS4), while admin and moderator are hub roles on a person's account, exercised from a browser with a user-scoped token. The scope refusal was wired only onto require_user_scope (group mutation), so require_admin and require_moderator accepted a scope:"node" daemon token whenever the underlying account also held a hub role. On a deployment where the operator is a hub admin and runs a node, the daemon's in-memory token was therefore a full hub-admin credential — able to revoke accounts and groups (signed, broadcast to every node), change instance policy, and read the IP audit log. Factor the refusal into _reject_node_scope(payload) and call it from require_user_scope, require_moderator and require_admin alike, so a node-scoped token is turned away with 403 on every privileged route. test_node_scope_not_admin.py holds seven refusals, each asserting the same account's user-scoped token still gets in; verified red against the pre-fix deps.py and green after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: invitation links no longer bound to an e-mail addressChristophe Besson6 days3-18/+33
| | | | | | | | A link is redeemable by whoever opens it first, so it can be sent by any messaging app. The address is optional (mail + label only); a link lives 7 days, fixed. Adds a Share button; see MESHBAY_DESIGN.md §3.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): friendlier welcome page, link previews, robots.txt and faviconChristophe Besson6 days2-12/+117
| | | | | | | | | | | Welcome page: privacy said once, a three-step "how it works", a documentation box, download (green) and legal links under the sign-in form, on a dark gradient backdrop covering the whole page. Link previews: Open Graph tags in the app shell, rendered for identity.id, with the square icon as image. robots.txt, favicon and touch icon served at the origin root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(hub): read the transport wherever it is splitChristophe Besson7 days27-36/+118
| | | | | | | | | spa_source.transport_files() takes the classic transport*.js scripts from the hub's shell, in load order. Every test that read transport.js reads them all, the Node harnesses run them joined as one scope, the chat probe loads each, and the desktop shell must load them in order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(client): fetch the media apps, the player, settings and search on first useChristophe Besson7 days2-3/+84
| | | | | | | | | | | | apps.js registers Videos, Music, Photos and every settings pane through lazy.js; the group page does the same for the video player and the settings panel, and the shell for the search page. The first download goes from 49 modules / 386 KB gzip to 37 / 289 KB; opening Music now fetches music-app.js and media-tiles.js and nothing of Videos. test_first_load_is_lean holds the eager graph; test_spa_imports checks that every on-demand load names an export that exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(hub): read the search code wherever it is splitChristophe Besson7 days8-9/+50
| | | | | | | | The Node harnesses take one file or several, and the tests that lift connectToGroup and the pool out of search-page.js as text take them from spa_source, which also reads connection-pool.js once it exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>