aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
* fix(hub): present a node-audience token in the handshake, not the hub ↵Christophe Besson16 hours15-38/+284
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson16 hours4-12/+110
| | | | | | | | | | | | | | | | | | | | | | | 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 Besson16 hours2-0/+206
| | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson16 hours2-2/+140
| | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson16 hours2-5/+192
| | | | | | | | | | | | | | | | | | | | | | | 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 Besson16 hours31-205/+317
| | | | | | | | 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 Besson17 hours23-177/+605
| | | | | | | | | | | 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>
* chore: bump version to 0.16.00.16Christophe Besson20 hours8-8/+8
| | | | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(readme): the repository itself is published read-only, not a mirrorChristophe Besson20 hours1-1/+1
| | | | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: add a README at the repository rootChristophe Besson21 hours2-1/+68
| | | | | | | Also list fcgiwrap, which serves cgit on git.meshbay.org, among the legitimate services of meshbay.org in CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(client): split transport.js into classic scriptsChristophe Besson21 hours14-2147/+2234
| | | | | | | | | | | | transport.js keeps the core (connection, reconnect, leases, dispatch). Chat, media, admin, upload and device methods move, cut as text, into transport-*.js scripts that hand a class of their own to extendTransport, which copies each method onto MeshBayTransport.prototype; the codec, roster checks, node pins and the rewrap fan-out move as they were. Both shells load them after transport.js. Every prototype member, class property and top-level function has the same source text as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(hub): read the transport wherever it is splitChristophe Besson21 hours27-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>
* docs(claude): translate the remaining French passages into EnglishChristophe Besson21 hours1-12/+12
| | | | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(claude): translate the Python environment section into EnglishChristophe Besson21 hours1-19/+19
| | | | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(client): fetch the media apps, the player, settings and search on first useChristophe Besson23 hours8-17/+160
| | | | | | | | | | | | 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>
* refactor(client): move the connection pool and the media tiles to modulesChristophe Besson23 hours7-369/+392
| | | | | | | | | ConnectionPool (with connectToGroup and its limits) leaves search-page.js for connection-pool.js, and LazyTile/MediaThumb leave video-app.js for media-tiles.js, cut as text. The shell and the Music and Photos apps now reach them without importing the search page or the Videos app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(hub): read the search code wherever it is splitChristophe Besson23 hours8-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>
* refactor(node): split ops.py into the ops packageChristophe Besson32 hours20-2073/+2298
| | | | | | | | | Each section of ops.py becomes a module of meshbay_node/ops/ (core, node_toml, members, chat, groups, roots, files, settings, apps), cut as text; ops/__init__.py keeps the docstring and re-exports every name, so `ops.<name>` is unchanged for every caller. Logger name unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(node): read ops wherever it is splitChristophe Besson32 hours4-10/+44
| | | | | | | | The tests that read ops.py for an absence (fastapi, a TOML path without as_posix, the reference app's name) and the one counting operations by module now take every file of ops.py or the ops package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move per-app enrichment out of NodeDaemonChristophe Besson39 hours8-297/+308
| | | | | | | | The _enrich_*/_reenrich_* methods and _on_enriched become EnrichmentMixin in meshbay_node/enrichment.py, with the two directory helpers only they use. APP_DIR_KEYS stays on NodeDaemon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the CLI out of daemon.py into cli/Christophe Besson39 hours18-1428/+1601
| | | | | | | | | | Each `if args.command == ...` branch of main() becomes a function in cli/ (one module per family of verbs); the parser, the process setup and a VERBS table go to cli/parser.py and cli/dispatch.py. daemon.main runs the verb or starts the daemon. Tests patch the CLI through conftest.patch_cli; the CLI golden is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(node): record what every CLI verb printsChristophe Besson39 hours2-0/+1001
| | | | | | | | A golden master of `meshbay-node`: help text, every verb, the prompts answered "no", each verb's usage fallback. Exit code, output, loopback calls with their bodies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(node): keep the CLI dispatch test off the developer's nodeChristophe Besson39 hours1-0/+8
| | | | | | | | The stub left HOME and config.DEFAULT_CONFIG_PATH real: every run wrote TEST-CODE over ~/.local/share/meshbay/invite-code and pair-code, and `status` read the real keystore and asked the running node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(node): read the daemon's source wherever it is splitChristophe Besson39 hours9-59/+138
| | | | | | | | Tests that read daemon.py now take their text from node_source (daemon.py, NodeDaemon's bases, the cli package once it exists). The CLI tests that sliced main() run the CLI instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(node): Peers tab answered 500 after the webrtc splitChristophe Besson39 hours3-1/+70
| | | | | | | | /api/peers imported _get_remote_ip, inside the function, from webrtc_server, which no longer has it. A test now resolves every meshbay import in the node's source, function bodies included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): run signed operations through a table of executorsChristophe Besson41 hours1-85/+37
| | | | | | | | _do_admin_response looked each operation up in a 28-branch elif, every branch the same call. The admin cases of the dispatch golden are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): dispatch MNP messages through a tableChristophe Besson41 hours4-197/+167
| | | | | | | | | | The pre-authentication guards stay explicit code, in the same order and text. After the handshake, a table maps each type to its handler and to whether it runs as a task, the choice each branch made; the three inline blocks become StreamingMixin methods, unchanged. The dispatch golden is identical, including types that are not strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(node): the dispatch golden covers a type field that is not a stringChristophe Besson41 hours2-0/+258
| | | | | | | | Missing, None, an int, a list, a dict, bytes: recorded on the elif chain before it becomes a table, since a dict lookup would raise on the unhashable ones where the chain found no match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move _dispatch_message out of webrtc_server, unchangedChristophe Besson41 hours5-246/+256
| | | | | | | | DispatchMixin in transport/webrtc/dispatch.py, with the pre-proof fetch bound it enforces. The elif chain moves as it is; turning it into a table is the next commit, on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the session core out of webrtc_serverChristophe Besson41 hours6-330/+351
| | | | | | | | SessionCore in transport/webrtc/core.py, last among the bases: state, the data channel, task ownership, the peer registry, sending and teardown. The facade keeps _dispatch_message and WebRTCTransport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move operator authority and the admin response out of ↵Christophe Besson41 hours3-309/+320
| | | | | | | | | webrtc_server AdminMixin in transport/webrtc/admin.py: who the operator is, the signed challenge, signature checks, and _do_admin_response moved unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the operator's group controls out of webrtc_serverChristophe Besson41 hours4-369/+384
| | | | | | | | GroupOpsMixin in transport/webrtc/group_ops.py: member revocation and unpinning, the group key rotation, apps and their directories with the allow-list, and Search listing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the operator's node controls out of webrtc_serverChristophe Besson41 hours3-564/+589
| | | | | | | | NodeOpsMixin in transport/webrtc/node_ops.py: status and settings, roster and denylist, roots, hosted groups, reload, scan pacing and transfer limits, with their class-level bounds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): section headers follow the code they introduceChristophe Besson41 hours3-21/+16
| | | | | | | | Three headers stayed in webrtc_server when their sections moved; they now sit above the same methods in blobs.py and admission.py. Two that were only a title over what is now a whole module are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the MNP handshake out of webrtc_serverChristophe Besson41 hours5-357/+375
| | | | | | | HandshakeMixin in transport/webrtc/handshake.py: challenge, proof, channel binding, and the sealed configuration a peer receives once admitted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(node): two stale comments in webrtc_serverChristophe Besson41 hours1-6/+2
| | | | | | | | The peer-session ceiling no longer quotes the hub's old per-account number, and the note on a single uploads/ directory goes: its constant left with f3fb449, when an upload started landing in the folder it was sent to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move invitations, joins and device linking out of webrtc_serverChristophe Besson41 hours3-848/+865
| | | | | | | AdmissionMixin in transport/webrtc/admission.py, with the invite handle pattern and the join attempt bounds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move uploads out of webrtc_serverChristophe Besson41 hours9-418/+424
| | | | | | | UploadMixin in transport/webrtc/upload_handlers.py, with the upload cap; _upload_chunk moved unchanged, _append_chunk joins webrtc/disk.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move transfer slots and leases out of webrtc_serverChristophe Besson41 hours4-263/+286
| | | | | | | TransferMixin in transport/webrtc/transfer_handlers.py, with the sweep interval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move file browsing and folder/file ops out of webrtc_serverChristophe Besson41 hours11-481/+510
| | | | | | | FilesMixin in transport/webrtc/files.py; the blocking disk helpers join _locate in webrtc/disk.py, and the lease states go to webrtc/limits.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move group chat out of webrtc_serverChristophe Besson41 hours5-655/+671
| | | | | | | | ChatMixin in transport/webrtc/chat.py: sealed messages, history, epoch keys, link previews with their cache and rate bounds, and the operator's chat ops. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move per-account blobs and key bundles out of webrtc_serverChristophe Besson41 hours4-246/+264
| | | | | | | BlobsMixin in transport/webrtc/blobs.py, with the blob caps and the kind pattern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: the transcoded-seek test passes without transcodingChristophe Besson41 hours1-0/+1
| | | | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move video streaming out of webrtc_serverChristophe Besson41 hours15-658/+669
| | | | | | | StreamingMixin in transport/webrtc/apps/streaming.py: stream credit, handover, the transcode slots and _stream_video_inner, moved unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the Videos catalogue handlers out of webrtc_serverChristophe Besson41 hours4-721/+744
| | | | | | | | VideoMetaMixin in transport/webrtc/apps/video_meta.py: media and season meta, TMDB search and its per-member bound, posters, and the operator's TMDB ops. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the MusicBrainz switch to the Music mixinChristophe Besson41 hours3-39/+41
| | | | | | An operator op that exists for one app lives with that app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the Music handlers out of webrtc_serverChristophe Besson41 hours4-193/+213
| | | | | | | MusicMixin in transport/webrtc/apps/music.py: tags and cover art, and the audio transcode with the extension list that gates it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move the subtitle handler out of webrtc_serverChristophe Besson41 hours6-177/+200
| | | | | | | SubtitlesMixin in transport/webrtc/apps/subtitles.py; _locate, which it shares with the files, music and streaming handlers, in webrtc/disk.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move framing, shared limits and ffmpeg jobs out of webrtc_serverChristophe Besson41 hours8-347/+387
| | | | | | | transport/webrtc/channel.py, limits.py and media_tools.py, cut from webrtc_server.py as text; the facade imports them back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(node): record what the session does with every message it can be sentChristophe Besson41 hours2-0/+24943
| | | | | | | | A characterisation test over every MNP type, peer state and message shape, and every signed operation's answer: the reply, the audit, the handler started. It pins dispatch order and the admin table so they can be moved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>