<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-node/tests, branch 0.2</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.2</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.2'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-14T15:51:48Z</updated>
<entry>
<title>feat!: identity keys per node — C4's blast radius drops to one operator</title>
<updated>2026-08-14T15:51:48Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T15:51:48Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f0984e86d9cb596a282ce6feb7cfc2f075b2794b'/>
<id>urn:sha1:f0984e86d9cb596a282ce6feb7cfc2f075b2794b</id>
<content type='text'>
One keypair was copied to every node its owner joined, so cracking the bundle on
any single node yielded the identity used on all of them: their content on other
operators' machines, and the ability to sign as them anywhere. That lateral reach
was the part of C4 worth attacking.

Each node now gets its own keypair, generated the first time its owner joins it
and left with that node alone. An operator who cracks what sits on their own disk
holds a key that is a stranger to every other node — and on their own node, one
that unlocks nothing they did not already hold: they serve the content, the index
and every byte of it by design.

Nothing changes for the user. A first contact with a node already needed that
operator's code, and the key is created in the same step; a second browser still
recovers it from the node with the passphrase alone. Two operators can also no
longer tell they host the same person by comparing keys.

BREAKING, and deliberately without a compatibility path — the deployment is wiped
for the next demo:

  - users.pk_ed25519 / pk_x25519 dropped (migration a7c31f9e40b2)
  - registration no longer sends or stores a key
  - PUT /v1/users/me/keys and regenerateKeys() gone; rotation is now
    `member unpin` plus a fresh code, decided on the machine that pinned it
  - /pubkeys returns an account id and a node's linking key. It was the directory
    H3 read, and nothing wraps for it any more
  - the pk_user JWT claim is gone

That last one closed a live defect the inventory turned up: the node recorded
pk_user as the uploader's identity and authorized deletion against it, so a hub
issuing a token naming its own key could delete anyone's uploads on any node.
Attribution now uses the key the node itself pinned.

A simplification falls out. Registration generates nothing, so a scripted signup
is a real account: `demo.py bootstrap` takes a wiped hub and node to a working
demo with no browser, which was impossible while keys were born in one.

Also fixes, found by running it on a wiped deployment: the key handed back on a
join now belongs to the group the connection is for, not the group named in the
invitation — an operator pairs node-wide but redeems the code while opening a
group, and expects to read it.

Tests: 343, including the two that state the property — a key pinned by one node
is refused at another, and someone else's code does not admit it. Verified end to
end against a wiped hub and node: bootstrap, pair, invite, join, download,
stream, second browser, revoke.

Design: docs/per-node-identity-v1.md

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(node): announce the node key in the challenge, and keep names in the roster</title>
<updated>2026-08-14T00:18:07Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T00:17:45Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=71df5857b213be893025c562977558ba79009c09'/>
<id>urn:sha1:71df5857b213be893025c562977558ba79009c09</id>
<content type='text'>
Both found by deploying the thing and running the workflow end to end. Neither
was reachable from the test suite, for the same reason in each case: the tests
knew something a real client cannot.

1. A first-time joiner had no way to learn node_pk.

   join_request signs a transcript naming the node, and the node key was only
   sent in handshake_ack — which an invited member cannot reach, having no GEK to
   prove. joinGroup() therefore threw "handshake incomplete" and the browser path
   for an invited member was broken. Every test built the transcript from a node
   key it already had, so nothing noticed.

   The challenge now carries node_pk. It is unverified at that point and never a
   substitute for the ack: the ack still proves possession and signs the
   transcript, the client checks the two values match and refuses a peer that
   changed identity mid-handshake, and TOFU pinning is unchanged. A wrong value
   only makes our own verification fail.

   test_invite_then_join_delivers_the_gek now takes the key from the challenge
   instead of from sk_node, so it proves a real client can learn it.

2. The roster pinned everyone without a name.

   `_do_join_request` took the username from the session, which takes it from the
   JWT — and the hub puts no username claim in a token. So identities were pinned
   with an empty name and `member revoke &lt;name&gt;` could never match: the live node
   answered "known: , ,". Invitations now carry the name (new invites.username
   column, with a migration for the roster DBs already out there), and the CLI
   resolves a name through the daemon: its own roster first, the hub as fallback
   for identities pinned before this.

The harness that found them is QE/deploy/e2e.py — gitignored with the rest of
QE/, so it is not in this commit. It does the SPA's job in Python against the
live deployment: hub login, WebRTC via hub signaling, the unified handshake,
joining with a code, index, chunk download and MSE segments.

Verified against meshbay.org and the local node: an account registered from
scratch is invited by code, receives the group key wrapped for a key it proved it
holds, downloads and decrypts a file, streams 5 encrypted fMP4 segments,
reconnects with no code, and is refused after `member revoke`. The node audit log
shows invite_create → join_pinned(via=code) → gek_wrapped → handshake, then
join_no_gek once revoked.

Tests: 232 node+common.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node): operator surface — member list, invite, revoke, unpin over SSH</title>
<updated>2026-08-13T23:27:57Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T23:27:57Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8f6e2f724fd24a077de11d4a3b3ae069d369324d'/>
<id>urn:sha1:8f6e2f724fd24a077de11d4a3b3ae069d369324d</id>
<content type='text'>
A node admits people from its own roster, and until now a headless operator had
no way to put anyone on it: pairing worked from the CLI, everything else needed a
browser on a machine that does not have one. Absorbs milestones 14.3/14.4.

  member list                 who is admitted, role, status, when and how pinned
  member invite &lt;username&gt;     one-time code; the node wraps the key when they
                               connect, so nobody has to be online then
  member revoke &lt;username&gt;     stop serving them the key
  member unpin &lt;username&gt;      forget the pin so they can pair again after a reset

All of it goes through the daemon's loopback API with the per-run session token
(11.5.3) — _daemon_api() in daemon.py, which also replaced three hand-rolled
urllib blocks. `status` deliberately still reads the keystore, config and roster
directly, so it works while the daemon is stopped.

Two things the commands say out loud, because getting them wrong is silent:

  - revoke ends by telling the operator to rotate the key. The ex-member stops
    receiving it on their next connection, but they hold the current one, and
    "revoked" reads like it took the key back.
  - revoke/unpin refuse a username the roster does not know instead of acting on
    nobody. A typo must not look like success.

Code lifetimes now differ by what the act is: 7 days for an invitation, which
crosses a human conversation and gets answered whenever someone reads their
messages, and 24 h for operator pairing, which is typed during the SSH session
that printed it. Both configurable ([node] invite_ttl_hours, pair_ttl_hours). A
day was long enough for the second and not for the first — a code that dies over
a weekend means finding a browser to issue another one.

The roster is also in the local admin UI, escaped: usernames come from the hub
and land on the page that can re-key groups and read the audit log, so H2's rule
covers them exactly as it covers filenames.

Verified by driving the real CLI against a stub daemon over a socket, which is
how the "known: &lt;nothing&gt;" bug in the not-found path turned up.

Tests: 89 node here (roster, endpoints, CLI routing, TTL config).

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node)!: the node wraps the group key — closes H3 and M3</title>
<updated>2026-08-13T23:27:21Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T23:27:21Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f15efd23f66c521ca9206789482bb38e7326eeb4'/>
<id>urn:sha1:f15efd23f66c521ca9206789482bb38e7326eeb4</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>test(node): allocate daemon test ports instead of hardcoding 28000</title>
<updated>2026-08-13T23:25:10Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T23:25:10Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=aab4bc98a3361d9f23e048a52705baa2f4a4a078'/>
<id>urn:sha1:aab4bc98a3361d9f23e048a52705baa2f4a4a078</id>
<content type='text'>
test_daemon.py started the real admin UI on a fixed port, so every test file
that also brought up a node collided with it. Each file passed on its own and
the full node suite failed with EADDRINUSE on test_daemon_creates_chat_store —
which reads as a flaky regression rather than a test-isolation bug.

Confirmed against a clean worktree at HEAD before touching anything: the
failure predates the invite work.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(quic): GEK proof and mutual authentication — closes C6</title>
<updated>2026-08-13T10:24:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T10:24:54Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=146a6759fa73386e9b59570956aeedd7e1cfd978'/>
<id>urn:sha1:146a6759fa73386e9b59570956aeedd7e1cfd978</id>
<content type='text'>
Phase 11.5.4/5/6 — finding C6, the last open critical finding.

QUIC ran a JWT-only handshake: a forged or stolen token reached the node and
could inject chat without ever holding the group key. It now runs the same
challenge/response as WebRTC through meshbay_common.handshake — client nonce,
role-bound length-prefixed transcript, GEK proof, and the node proving itself
with a GEK proof plus an Ed25519 signature over the transcript (C3).

11.5.6 channel binding, resolved by spike and then by two findings the spike
could not predict:

  * aioquic 1.3.0 exposes no RFC 5705 exporter, and the peer certificate only
    via a private attribute. The server reads its own certificate from disk, so
    no internals are touched on that side; the client's access is guarded and
    fails loudly if an upgrade moves it.
  * A RESUMED TLS session carries no certificate — aioquic does not re-send it,
    so there is nothing live to bind to. The anchor therefore travels with the
    session ticket, which is sound because the ticket is cryptographically
    derived from the handshake where that certificate was presented.
  * The anchor had to travel with the ticket rather than live on the client
    object: resumption constructs a fresh client, so an instance-level cache
    was silently useless. Caught by the resumption test, not by inspection.

Both paths refuse rather than degrade. No certificate and no cached anchor
means the handshake fails; it never falls back to an unbound proof, which would
silently drop MitM detection (L4).

QuicChunkClient gains a peer_cert_der property and constructor argument,
mirroring how session_ticket is already carried by the caller.

Tests: 9 quic/multi-group, full node+common suite green.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>refactor(quic): share the unified handshake authorization</title>
<updated>2026-08-13T09:56:04Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T09:56:04Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=be1ff89465c9878f2fbd641fb0d4eba729439ac8'/>
<id>urn:sha1:be1ff89465c9878f2fbd641fb0d4eba729439ac8</id>
<content type='text'>
Phase 11.5.4 — QUIC half. Findings M1, M9 on this transport.

quic_server._do_handshake_sync was a second, weaker copy of the WebRTC logic:
group_id was optional, so omitting it skipped the membership check entirely and
fell back to the node's first group (M1); node-scoped daemon tokens were
accepted as client tokens (M9); and the checks could drift from the WebRTC path
independently, which is how they diverged in the first place. Authorization now
comes from meshbay_common.handshake, shared with WebRTC.

C6 IS STILL OPEN ON THIS TRANSPORT. There is no GEK proof here yet: a forged or
stolen token still reaches the node over QUIC and can inject chat without
holding the group key. What remains is the challenge/response and the mutual
node proof — quic_binding() is written and unit-tested for exactly this, and
11.5.6 (whether a certificate hash is the right anchor, or an RFC 5705 exporter
is reachable from aioquic) is still unproven. This commit narrows the gap to
the proof itself; it does not close the finding.

QUIC tests updated: default tokens are members of the test group, and clients
pass group_id, since it is mandatory now.

Tests: 9 quic/multi-group, full node+common suite green.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(mnp): unified handshake with mutual authentication</title>
<updated>2026-08-13T09:46:12Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T09:46:12Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e13659f8f3166b5a9a4155314941bc149fec2721'/>
<id>urn:sha1:e13659f8f3166b5a9a4155314941bc149fec2721</id>
<content type='text'>
Phase 11.5.4/5/7/8 — findings C6 (WebRTC half), C3, L4, M1, M9.

New meshbay_common/handshake.py is the single implementation of authorization
and proof: JWT verify, scope, denylist, mandatory group_id, membership, hosting.
The handshake previously existed three times over and only the newest copy
enforced the GEK proof.

C3 — mutual authentication. Authentication ran one way: the client proved
itself, the node proved nothing. handshake_ack.node_pk was never verified
against anything and per-chunk signatures had been dropped in Phase 9.15, so a
peer that had hijacked signaling (C2) or been substituted by the hub could
accept the client's proof, ignore it, and serve a forged index, forged chat
history and a forged is_node_admin flag. The client now sends a nonce; the node
answers with its own GEK proof over that nonce AND an Ed25519 signature over
the transcript; the browser verifies both and refuses otherwise. It also
refuses an unchallenged handshake_ack, which previously let a peer skip proving
anything at all.

L4 — the proof was nonce ‖ offer_fp ‖ answer_fp: bare concatenation, and a
missing fingerprint silently degraded it to nonce-only, dropping MitM detection
(NS5). Every field is now length-prefixed and domain-separated, the role is
bound so a client proof cannot be replayed as a node proof, and an absent
channel binding is refused rather than tolerated.

M1 — group_id was optional; omitting it skipped the membership check entirely
and fell back to the node's first group. Now mandatory.
M9 — node-scoped daemon tokens are refused on the client path.

NOT DONE: quic_server.py still runs its own JWT-only handshake, so C6 remains
open — a forged or stolen token reaches a node over QUIC and can inject chat
without holding the GEK. quic_binding() is written and unit-tested but unwired.
11.5.6 (whether the certificate-hash anchor works with aioquic, or an RFC 5705
exporter is reachable) is unproven. 11.5.8 TOFU pinning of pk_node is not done:
the client verifies the node's signature but does not yet remember which key it
saw last.

Adds packages/meshbay-common/tests/test_handshake.py (18 tests) covering the
properties every transport must inherit. WebRTC test helpers rewritten around
the shared module; _make_jwt now defaults to the test group, since group_id is
mandatory.

Tests: 24 webrtc, 176+ node+common.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: resource limits, signaling authz, node admin UI token</title>
<updated>2026-08-13T09:18:24Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T09:18:24Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=b86be704df752f2fd3086fcca43b7f4de78389d1'/>
<id>urn:sha1:b86be704df752f2fd3086fcca43b7f4de78389d1</id>
<content type='text'>
Phase 11.5 — findings H6, C4 (partial), and milestone 11.5.3.

H6 — resource exhaustion. Several paths let one peer degrade or stall a node:

  * the DataChannel frame limit was a flat 64 MB applied BEFORE authentication,
    so an unauthenticated peer could announce a huge frame and dribble bytes
    into it. Unauthenticated peers now get 64 KB; the large budget is granted
    only after the GEK proof, where it is needed for uploads.
  * _do_stream_segment ran subprocess.run(..., timeout=30) directly in the event
    loop, stalling the entire daemon — every peer, every group — for up to
    thirty seconds per request. Now async, with a timeout and process kill.
  * ffmpeg was spawned per stream request with no cap. Both streaming paths now
    share a transport-wide semaphore.
  * POST /v1/nodes/{id}/webrtc/offer was reachable by any authenticated user for
    any node, with no membership check and no rate limit, making the target node
    allocate an aiortc PeerConnection and gather ICE on demand — remote resource
    exhaustion against a third party's machine. Now rate limited, capped per
    user, SDP size bounded, and the caller must share an active group with the
    node. That also closes the H4 gap where signaling ignored group status.
  * POST /v1/nodes/{id}/incoming took peer_ip verbatim, so any user could make an
    arbitrary node emit UDP packets to an address of their choosing. The probe
    target must now match the caller's own source address.

C4 (partial) — the pre-proof bundle window. GEK and keypair bundle fetches are
served before the GEK proof by necessity: the client needs its wrapped bundle in
order to compute the proof. That window is a disclosure surface a hub can reach
by forging a JWT. Bounded to 4 fetches per session and audited as
"pre_proof_fetch". The real fix is removing remote keypair bundles entirely,
which belongs to the native client (Phase 13.3).

11.5.3 — the node admin UI was unauthenticated because it binds loopback. But
any local process can reach it, and so can a page in the operator's browser via
DNS rebinding — and this API re-initialises group keys and reads the audit log.
H2 showed script execution there equals full control. Now gated by a per-run
token, printed at startup, accepted as ?t= or X-MeshBay-Token.

One test needed rewriting rather than adding: the first version asserted
"subprocess.run(" was absent from the source, which also matched the comment
documenting the old behaviour. It now parses the AST and checks the property.

Tests: 121 node, 142 hub+common. Regression suite 47 node + 10 hub.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: swarm privacy, revocation persistence, keystore KDF, audit integrity</title>
<updated>2026-08-13T09:00:39Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T09:00:39Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=9df71bd1e5244743fae8c1b2bda41143f0748d9d'/>
<id>urn:sha1:9df71bd1e5244743fae8c1b2bda41143f0748d9d</id>
<content type='text'>
Phase 11.5 hardening batch — H7, H4, M2, M6, M7, L1, L3, L6.

H7 — private content hashes leaked to the hub. The daemon registered blake3
hashes for every group it hosted, private ones included, giving the hub a
content fingerprint of every private file and letting anyone confirm whether a
known file exists in the network. The leak was dormant only because the routes
were declared on the groups router with a full path and mounted at
/v1/groups/v1/swarm/* — the node's calls 404'd into a swallowed exception.
Fixing the path alone would have activated the leak, so both land together:
registration is gated on group visibility, the routes moved to a real
/v1/swarm router, and the lookup now requires authentication.

H4 — revocation was advisory. Group revocations were signed and broadcast by
the hub and then dropped by the node, whose handler understood only "user" and
"jti", so "suspend a group" enforced nothing. The denylist was also in-memory
only, so a restart silently un-revoked everyone. Now persisted to
data_dir/denylist.json, group targets honoured on both transports, and live
sessions for a revoked group are closed.

M2 — the node keystore, which protects the node's Ed25519 and X25519 private
keys, was still deriving at 64 MB long after the hub's password verifier moved
to 256 MB; the docs recorded the bump as done, true for the hub only. Raising
the constant alone would have made every existing keystore permanently
undecryptable, so envelopes now record the parameters they were written with
and pre-M2 files continue to open under the legacy profile.

M6 — registration inserted its audit row with a NULL user_id and then ran
UPDATE ip_logs SET user_id=&lt;new&gt; WHERE user_id IS NULL, claiming every
unattributed row in the table: failed logins for other usernames, concurrent
registrations. In logs retained a year for legal requests, that attributed
other people's connections to the wrong account.

M7 — X-Forwarded-For was trusted unconditionally at four call sites, so anyone
could forge the IP written to the compliance log and evade per-IP rate limits.
New netutil.client_ip honours the header only from a trusted proxy and takes
the rightmost hop (the one our proxy appended); no direct header reads remain.

L1 dead GEK_REQUEST/GEK_RESPONSE constants removed; L3 peer errors no longer
echo exception text (paths, internal state); L6 email sanity-checked instead of
accepting any string — deliberately not RFC 5322, to avoid a new dependency.

test_daemon_index_change_pushes_to_peers asserted that a PRIVATE group's hashes
are registered with the hub. Split: private asserts not-called (index push to
members still asserted), and a new test proves public groups still register.
That is the fourth pre-existing test found asserting a vulnerability as
intended behaviour, after gek auto-activation, the transport-wide chat_store
and the blind admin challenge.

Tests: 116 node, 132 hub+common. Regression suite now 43.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
