aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
Commit message (Collapse)AuthorAgeFilesLines
* fix(node,client): query TMDB only once a language is chosen, in that languageHEADmainChristophe Besson8 hours7-1/+262
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | TMDB fiches are fetched lazily, on browse, and the fetch used to run whatever the moment it was first triggered — routinely before the operator had opened settings and picked a language, so it queried in TMDB's English default. Then the fiche was cached by tmdb_id alone, with no note of language and a 30-day TTL, so switching to the intended language afterwards changed nothing: the English fiche was served until it expired. The operator's only recourse was to find and wipe the cache by hand (found live 2026-09-26: a whole library indexed in English although "Français" had been chosen). Two rules now, both there to make the first fetch the right language rather than English-then-corrected, and to stop the doubled requests that eventually get a node rate-limited: - No language configured, no query. media_meta_req/season_meta_req answer confidence 0 and make no TMDB call while tmdb_language is unset; the fetch waits for the operator's choice, so the first (and only) query is in it. English is now a first-class choice (en-US), not the default of skipping the setting. - Changing the language wipes the metadata cache (ops.set_tmdb_config), so the new language takes effect on an already-browsed library. The file->tmdb matches are language-independent and kept. The client refetches on the tmdb_config_ack that carries the new language, so the grid updates without a page reload. docs/MESHBAY_DESIGN.md §9.7 states both rules; tests cover the gate and the cache wipe, and two existing handler harnesses now declare a language. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(node): read the packaged TMDB token in placeChristophe Besson10 hours3-74/+58
| | | | | | | | A node onboarded by the desktop client never ran `init`, so default.env was never copied to node.env; and default.env was 0600 root, unreadable to a per-user node anyway. The daemon now loads it beneath node.env, 0644. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(protocol): bind the MNP token to the node it is for (E10)Christophe Besson20 hours2-0/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(node,client): survive a transient hub state on login, and surface a ↵Christophe Besson27 hours2-0/+126
| | | | | | | | | | | | | | | | | | | | | | | | | | | | failed node-key link Two defensive gaps turned a routine reset-and-reonboard into "impossible de démarrer le node": 1. daemon._login_with_retry retried a 401 (node key not linked yet) but `raise`d on every other status, so a 429 — the daemon's own 5s retries hitting the sign-in rate limit — or a 502/503 while the hub restarts during a deploy killed the process, and systemd crash-looped it. Those statuses (429, 5xx) are now retried with a back-off that respects Retry-After, so a freshly reset node stays alive (the operator needs it up to read its key) instead of dying. A genuine 4xx (400/422) still raises. 2. create-group's linkNodeKey swallowed every error as "already linked or same key" — but PUT /me/node_key is idempotent and returns 200 on a re-link, so there was no benign error to hide: the catch only ever hid a real failure (a rejected session, a bad key), letting the wizard proceed against a node that looked linked but was not, which then could not authenticate. The link failure now surfaces (detectNode shows it). test_login_retry_is_resilient.py holds the retry behaviour (429/5xx retried, Retry-After honoured, 401 stays alive, 400 still raises); red before, green after. common/node/hub suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(protocol): MNP 4.0 flag day for the node-audience token (B2)Christophe Besson29 hours1-56/+56
| | | | | | | | | | | | | | | | | | | | | | | | | | | The node-audience token (previous commit) is a change to what a peer must present, so it is a MAJOR per the versioning rule (§5.6): a pre-4.0 client presents its hub session token and a 4.0 node refuses it, and there is no compatibility branch, because leaving one would keep a hub credential reachable by every node (C6's lesson). So the floor moves with the version. - MNP_VERSION 3.4 -> 4.0 and MNP_MIN_SUPPORTED 3.0 -> 4.0 (meshbay_common); transport.js MNP_V/MNP_V_MIN -> 4.0 to match. - MIN_CLIENT_VERSION 0.13.0 -> 0.16.0 so a stale desktop client is told to update before connecting rather than meeting a handshake refusal it cannot read; the browser reloads this build from the hub. - Regenerate tests/golden/dispatch.json: the only change is the `v` the node stamps on outbound messages, 3.4 -> 4.0 (56 cases, v field only). - Document the split and the flag day: MESHBAY_DESIGN.md §5.2 (the handshake token is the MNP-audience token), §5.6 (the 4.0 flag day), register E10 and decision 23; MESHBAY_NODE_PROTOCOL.md §6.3 (authorize_token binds MNP_AUD) and the wire-version banner. Deploy is coordinated and atomic (common+hub+node+SPA together); a live browser-to-node validation and the deploy itself remain. common (173), node (1489, the pre-existing test_cli_golden argparse/prog artifact aside) and hub (1471) suites all 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 Besson29 hours5-6/+18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* feat: invitation links no longer bound to an e-mail addressChristophe Besson29 hours8-36/+60
| | | | | | | | 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>
* chore: bump version to 0.16.00.16Christophe Besson33 hours2-2/+2
| | | | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): split ops.py into the ops packageChristophe Besson45 hours16-2060/+2285
| | | | | | | | | 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 Besson45 hours2-6/+26
| | | | | | | | 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 Besson2 days5-294/+305
| | | | | | | | 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 Besson2 days17-1427/+1600
| | | | | | | | | | 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 Besson2 days2-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 Besson2 days1-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 Besson2 days9-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 Besson2 days3-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 Besson2 days1-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 Besson2 days3-196/+166
| | | | | | | | | | 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 Besson2 days2-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 Besson2 days3-244/+254
| | | | | | | | 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 Besson2 days3-322/+342
| | | | | | | | 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 Besson2 days2-307/+318
| | | | | | | | | 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 Besson2 days3-368/+383
| | | | | | | | 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 Besson2 days2-563/+588
| | | | | | | | 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 Besson2 days3-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 Besson2 days3-355/+373
| | | | | | | 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 Besson2 days1-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 Besson2 days2-847/+864
| | | | | | | 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 Besson2 days7-416/+422
| | | | | | | 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 Besson2 days3-262/+285
| | | | | | | 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 Besson2 days7-473/+502
| | | | | | | 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 Besson2 days4-654/+670
| | | | | | | | 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 Besson2 days3-244/+262
| | | | | | | BlobsMixin in transport/webrtc/blobs.py, with the blob caps and the kind pattern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(node): move video streaming out of webrtc_serverChristophe Besson2 days12-652/+663
| | | | | | | 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 Besson2 days3-716/+739
| | | | | | | | 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 Besson2 days2-38/+40
| | | | | | 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 Besson2 days3-192/+212
| | | | | | | 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 Besson2 days5-176/+199
| | | | | | | 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 Besson2 days6-343/+383
| | | | | | | 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 Besson2 days2-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>
* test(node): a patched module attribute must be read where it is patchedChristophe Besson2 days1-0/+118
| | | | | | | A function reads its globals from the module that defines it, so patching a name a module only re-exports changes nothing and the test passes anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: read the WebRTC transport's source as a set of filesChristophe Besson2 days11-93/+160
| | | | | | | | | Source-reading tests take their text from node_source (node) and node_tree (hub): webrtc_server.py plus anything under transport/webrtc/, so a check for something's absence keeps reading the code it guards if that code moves. test_node_source_scope holds the boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(node): a connection that ends leaves its group's peer setChristophe Besson2 days2-0/+70
| | | | | | | | | | connectionstatechange dropped a closed or failed session from the transport and stopped its tasks, but only close() took it out of the group's peer set, and nothing on that path called it. Every broadcast then went to closed channels, and each reconnect left a dead session held until restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(hub): offer ceilings that bound a member without failing an ordinary oneChristophe Besson3 days1-2/+3
| | | | | | | | | | | | | | | | | | | | | | | | | | | A phone on 4G found one of five groups missing from Search on half its visits, and all but one on a fifth of them. Every offer that reached the node was answered in about a second; the hub refused the others with 429 — 93 of them in half an hour on meshbay.org, all from that phone, all to nodes that were up. Two ceilings did it. Three pending offers per account was exactly what one Search page dialled at once, so the first tile or reconnection beside the sweep was refused. Thirty offers a minute per address and per node was spent by four or five reloads of a page whose groups share one node. Sized now against an account with twenty groups on three devices: - 32 offers pending per account, across devices and nodes. - A budget per account and per node, a burst of 120 refilled at two a second. This is what bounds a member's cost to one machine (H6), and it leaves their other nodes alone. Counted by account, because a mobile carrier puts many subscribers behind one IPv4 address. - 600 a minute per address and per node, as a coarse guard in front of authentication only. Both refusals carry Retry-After, which the browser now honours. The node's own ceiling on peer sessions is unchanged; its comment no longer quotes the old per-account number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(node): member invite --link and member cancel in the CLIChristophe Besson3 days6-6/+274
| | | | | | | | | The CLI makes both halves itself — the node's code, then the hub's ticket bound to the address — and prints the link; a refused ticket takes the code back, and cancel takes back both. The CLI never asks the hub to mail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: drop the invitation-link plan from the treeChristophe Besson3 days5-5/+5
| | | | | | | | The plan was a working document; what it decided lives in MESHBAY_DESIGN.md and MESHBAY_NODE_PROTOCOL.md. Code and tests now cite those instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(node): invitation links — a code bound to no account until redeemedChristophe Besson3 days7-25/+695
| | | | | | | | | | New invite kind "link": member of one group, once, never operator, not spendable by an active member, capped at 20 per group, cancellable by handle. Signed ops invite_link_create / invite_cancel, loopback routes, and the known-device join path now accepts a link code. Adds the plan, docs/invite-links.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat: the node signs its handshake challenge (MNP 3.4)Christophe Besson3 days4-4/+109
| | | | | | | | | node_pk in handshake_challenge is now signed over the channel binding and both nonces, so a client can check the node key before a join rather than only at the ack. Both transports; the browser and the QUIC client refuse a wrong signature and treat an absent one as an older node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(hub): bound video read-ahead by bytes, not by secondsChristophe Besson6 days1-2/+10
| | | | | | | | | | A browser limits bytes, so a bound in seconds had to be sized for the highest-bitrate file and every ordinary one then held a fraction of what the same buffer would have taken. Floored at the old 90 s so nothing pulls less than before, and walked up rather than declared. A refused append now waits for an eviction instead of retrying on every tick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: removing someone who never redeemed their invitationChristophe Besson6 days4-6/+149
| | | | | | | | | | A member row appears only when a code is consumed, so revoking someone invited to the wrong group was refused for having no row — and the node's refusal aborted the browser's removal before its hub half, leaving them a member everywhere with a live code. Revoking now cancels unredeemed codes for that group, and a node refusal no longer cancels the hub removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>