<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-node/tests/test_roster_pairing.py, branch v0.9.0</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=v0.9.0</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=v0.9.0'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-28T09:32:40Z</updated>
<entry>
<title>fix(node): a pinned device can be invited to a further invite-only group</title>
<updated>2026-08-28T09:32:40Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-28T09:32:40Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=6b838593d975792ac4fcbd49176089aecc7512e9'/>
<id>urn:sha1:6b838593d975792ac4fcbd49176089aecc7512e9</id>
<content type='text'>
Regression from device linking (Stage C, 2026-08-18). Once a device is
pinned on a node — as a member of one group, or an operator pairing — the
`known` fast-path in `_do_join_request` dropped straight into `_join_ok`.
For any *other* invite-only group it had no roster row for, that answered
`not_authorized_for_group` and stopped there: the client never got
`code_required`, so the pairing-code form never appeared and a legitimately
invited member could not join.

The `known` branch now, when there is no membership for the group being
opened:
  - with a valid code → consumes the invite and admits (as the unknown-
    device path already does);
  - with no code but an invite waiting for this user here → `code_required`,
    so the client prompts;
  - with no code and nothing inviting them → `not_authorized_for_group`,
    unchanged, so the H3 guarantee (a hub-invented pin gets no key) holds.

Also fixed: the group's own roster row is now consulted first, so an
existing member opening their group is never mistaken for a stranger.

Tests in test_roster_pairing.py cover all three branches plus the H3 guard.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
</content>
</entry>
<entry>
<title>feat: unified group management, public groups, and activity-based sidebar</title>
<updated>2026-08-20T20:21:19Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-20T20:21:19Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2e9490ca27047ae03e495d397abbe1aec1b2273a'/>
<id>urn:sha1:2e9490ca27047ae03e495d397abbe1aec1b2273a</id>
<content type='text'>
Create Group wizard (Electron-only) consolidates 6 steps across 4 interfaces
into a single multi-step page: group creation on hub, node attachment, root
selection via folder picker, GEK initialization, and auto-pairing — all in one
flow. Browser SPA keeps its current behavior unchanged.

Public group support (Option A — GEK for all groups):
- All groups have GEK regardless of visibility; open-join groups auto-admit
  via TOFU when join_policy is "open"
- Key rotation blocked for public groups (API guard + UI hidden)
- Hub signaling allows WebRTC offers for nodes hosting open-join groups even
  when the caller isn't a member yet
- attach_group writes join_policy to node.toml
- Daemon loads GEK for all groups, not just private ones
- Known-device path in join_request now auto-admits to open-join groups

Node loopback API bridge (Electron IPC):
- node:detect, node:call, node:pairing-code IPC handlers in main process
- Renderer never sees tokens, paths, or keys (session token = physical access)
- platform.js node namespace for UI consumption
- Loopback endpoints: roots CRUD, member-upload toggle, reload

Bug fixes:
- Root change detection: removed premature ctx["roots"] updates from add_root
  and remove_root that prevented indexer retarget on reload
- Duplicate offline message: global fallback now gated on !group
- Signaling membership check: fallback to open-join groups for non-members

Sidebar groups sorted by last_activity_at (most recent first):
- New Group.last_activity_at column with Alembic migration
- POST /v1/groups/{id}/activity endpoint, called on connect and chat send
- Client-side sort + throttled hub updates (1/min)

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat: device linking, and signing in to the hub with a device key</title>
<updated>2026-08-18T01:24:55Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T01:24:55Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=768e07046368819b8a8f15c8b21e5a8bbfcdf282'/>
<id>urn:sha1:768e07046368819b8a8f15c8b21e5a8bbfcdf282</id>
<content type='text'>
Stage C. Identity keys are per node, so a browser and a desktop client are two
keys on one account there — and the node refused the second where it accepted
the first. Without this, an account created natively could never be opened in a
browser without an operator code per node, and "a native client must not prevent
web use" would have been dead on arrival.

Device linking (node)
---------------------
`identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The
old shape did `INSERT OR REPLACE`, so a second device overwrote the first
silently; SQLite cannot change a primary key in place, so the table is rebuilt.
Existing pins are carried over — verified against a live roster with 10 of them,
nobody re-pairs.

A new device files a request bound by `sha256(code ‖ its own keys)`, and a key
the node **already pinned** countersigns it. The hub cannot: it has stored no
user keys since 2026-08-14, which is what makes this safe to do without an
operator in the loop.

**The code never reaches the node.** It lists this account's pending requests
with their stored hashes; the approver recomputes and keeps the match. A node
offering fabricated keys would have to produce a hash over a code it has never
seen. Nothing rests on a human comparing digits — that ritual was dropped in
12.1 as "correct, unusable as the default" and must not return by the back door.

The design document had the approver look a request up *by* its hash, which is
circular: computing it needs the keys being asked about. Corrected in both.

Revocation marks rather than deletes, because a deleted row is a key the node
would happily pin again — which is the laptop somebody just reported lost. Your
last device cannot be revoked: coming back would need an operator's code.

Hub — the only change in the whole plan
---------------------------------------
`POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as
`/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New
`user_devices` table with an Alembic migration, because `create_all()` is not
one.

This is **not** the key directory that was H3, and the tests say so: nothing
reads it but the hub, no group key is ever wrapped for one, and it is a
different key from the per-node identities. What it does cost is metadata — the
hub now knows how many devices an account has and when each last signed in.

Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an
installed client meets a newer hub the day the interface ships in a package, and
that is cheap now and awkward to retrofit.

Browser
-------
The `key_changed` refusal becomes `unknown_device` and offers a linking code
instead of telling someone to find their operator. The Members panel lists this
account's devices here, approves one by code, and retires one.

773 tests pass. `e2e.py` gained a step that links a device end to end against
the live deployment — file, list, recompute, countersign, then open the group
with the new keys and no code — and it also gained `recv_type`, because a step
that assumes the next message is its own answer reads an ack left by the step
before.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node): several named roots per group, and one implementation per operation</title>
<updated>2026-08-18T00:15:02Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T00:15:02Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e9d5e979fdab9a1cc3c729d602e6f27207b9480c'/>
<id>urn:sha1:e9d5e979fdab9a1cc3c729d602e6f27207b9480c</id>
<content type='text'>
Stage A — a group's content is a set of named roots
---------------------------------------------------
`shared_dir` becomes a list of {name, path, kind}. The name is the directory's
basename, derived once at add time and *stored*: recomputing it would
re-identify a whole library the day someone renames a folder on disk. Duplicate
names are refused case-insensitively and no root may contain another — both
compared with NFC folding, because most of these directories live on exFAT or
NTFS where `Films` and `films` are one directory.

Every index path carries its root name, in a one-root group as much as in a
five-root one. One path shape has to be got right once; two have to be kept
right for ever.

**A root that goes away freezes; it never empties.** Unmounting a volume makes
watchdog report every file under it as deleted, or presents an empty directory
to the next scan. Acting on either propagates deletions for a whole library to
every member, as though the owner had erased it. So a deletion is acted on only
once its root is confirmed readable, and availability is tracked per root — one
unplugged drive leaves the others serving. 12 tests, verified to fail against an
indexer without the check.

Events are not trusted to be complete either: ReadDirectoryChangesW drops them
under load and inotify on a FUSE mount misses changes made outside it. A
periodic reconciliation sweep is the only thing that recovers a missed event.

MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content
hash, a node id and an endpoint — no paths, no filenames — and private groups
register nothing (H7).

Stage B — one implementation behind every front door
----------------------------------------------------
C1 and C6 were both "a second path into the node with its own weaker
handshake". Two implementations of `revoke` with two authorization checks is
that shape one size down. `meshbay_node/ops.py` holds each operation once,
takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback
API is one `_op(...)` line per endpoint; the MNP handlers call the same
functions. test_ops.py asserts the shape rather than trusting it.

Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload`
(SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a
browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP
operations: rotation is the half of revocation that revocation cannot do, since
the ex-member holds the current key, and the node generates the replacement
with its own CSPRNG — no key material crosses the wire, which is what the C5b
rule is actually about.

Two bugs found by running it rather than by testing it
------------------------------------------------------
GroupIndex is keyed by **content hash**, so the same bytes at two paths are one
entry — which is also why a scan reports ten files and indexes nine.
Reconciliation compared paths, so it decided the second path was a missed event
every 60 s, rewrote the entry and pushed an index update to every connected
peer. Seen in a live node's log.

`meshbay-node reload` crashed on first use with `subprocess` unimported: the
module compiles fine, which is the "syntax, not names" trap already recorded for
the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be
added to the parser without an entry there.

Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the
wire carried "0.2" — harmless only because nothing imported it. And
_do_dir_create/_do_dir_delete referenced an undefined `filename` on their error
path.

740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(groups): remove a member, and keep gigabytes out of the tab</title>
<updated>2026-08-15T15:23:10Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T15:23:10Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=dd3927a661273734493f65a593755b95aecf5f09'/>
<id>urn:sha1:dd3927a661273734493f65a593755b95aecf5f09</id>
<content type='text'>
**Removing a member.** The owner can do it from the Members tab, and it
is two halves in the order that fails safe: the node stops serving the
group key first (an operator-signed request, so a paired browser only),
then the hub drops the membership row. The other order would leave
someone able to reach a node that still serves them.

It is a membership, not an account. The user row is never written: their
other groups, their files and their pinned identity survive, because one
group's owner must not be able to erase someone from the hub. It is also
per group — a node hosting two loses them from one — and it does not take
back the key they already unwrapped, which is what rotating the GEK is
for. The confirmation and the panel both say so.

**Downloads and streaming through the disk, in both browsers.** The audit
this started as found two ways to put gigabytes in a tab.

Firefox and Safari have no File System Access API, so every download
there was collected in memory. A service worker fixes it: the page keeps
the writable half of a transferred stream, the worker answers a made-up
URL with the readable half and a Content-Disposition header, and the
browser writes it to disk as it arrives, with real backpressure. The
worker caches nothing and falls through on every request that is not one
of these downloads. A zip announces no Content-Length, since the archive
is larger than the files in it and a length we miss truncates the file.

Video was worse and affected both browsers. The node pushed ffmpeg's
whole output as fast as it was produced while the player consumed a
segment at a time, so the queue held the film — and appending all of it
hit the SourceBuffer's cap, where the handler logged the error and
dropped the segment, leaving a hole in the middle of the film with
nothing to show for it. Streaming is credit-based now, 24 segments of
256 KB in flight, verified against the live node: three credits, three
segments, then silence until more are granted. The player evicts what is
more than a minute behind the playhead and retries a refused segment
rather than dropping it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(files): download a folder as a zip, and remove an empty one</title>
<updated>2026-08-15T10:19:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T10:19:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8cd7e467ebec987f66c4fe93a8d87dfbc57304d2'/>
<id>urn:sha1:8cd7e467ebec987f66c4fe93a8d87dfbc57304d2</id>
<content type='text'>
Two things a Files panel needs and did not have.

**Removing a directory** is privileged, where creating one is not: it
acts on a name other members are using, on the operator's disk. It is
refused unless the directory is empty, and that rule is the safety
property — whatever the browser sends, this cannot destroy content. The
check runs twice, once before the challenge and once after the signature
comes back, because a file can land during the round trip. A file also
accepts its uploader's key; a directory has no uploader, so only the
operator's key will do.

**Downloading a folder** produces a zip built in the browser, written
straight to disk as the chunks arrive. An archive of a group folder is
routinely tens of gigabytes, so nothing is held: peak memory is one chunk
plus a small record per file. The node is not involved at all — it serves
the same encrypted chunks as any other download, holds no temporary
files, and cannot be asked to compress anything.

zipstream.js is store-only. Group content is video and images, already
compressed, so deflate would spend CPU on every byte to save nothing, in
the thread that is also decrypting. Sizes and CRCs go in a data
descriptor after each file because a stream cannot seek back to patch a
header, and zip64 kicks in per entry past 4 GiB and for the archive
itself. Because none of that can be checked from the Python side of the
house, test_zipstream.py runs the real module under Node and reads what
it produces with zipfile — CRCs, UTF-8 names, zip64 records and all. The
archives also pass `unzip -t`.

Firefox and Safari have no File System Access API, so there is nowhere to
stream to: the fallback builds the archive in memory and says so, with
the size, before starting rather than after failing.

One mistake worth recording: the first version of deleteDirectory passed
the node's own answer as the value to check the challenge against, which
turns the comparison into a tautology. It checks the path we asked for.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node): meshbay-node group add — host another of your groups</title>
<updated>2026-08-15T08:17:02Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T08:17:02Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0503682c0e2add135b88c2a1fadfe07455680a71'/>
<id>urn:sha1:0503682c0e2add135b88c2a1fadfe07455680a71</id>
<content type='text'>
Attaching a group to a node meant hand-editing node.toml with a UUID
copied from a browser URL, restarting, and knowing that gek-init exists.
Nothing in the CLI said so, and on a node reached over SSH there is no
paste buffer to carry a UUID across in the first place.

    meshbay-node group add grenet --dir ~/grenet-share

The name is resolved against the operator's groups on the hub by the
daemon, which is the process holding the session. The [[groups]] block is
appended to node.toml as text rather than round-tripped through a TOML
writer: the file is hand-written and its comments explain decisions worth
keeping. The directory is created, and the command says what remains —
restart, then gek-init for that group.

It refuses a name it cannot find by printing the groups it can, with
their ids. That listing is the useful half of the answer and it was
missing everywhere: _daemon_api now renders an `available` list from any
endpoint that offers one.

The key is per group and pairing is not, which is the part that reads as
a gap until it is written down: one paired browser covers every group the
node hosts, while each group's key admits only its own members. §4 of the
user guide now says all three of those in one place.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(groups): editable description, and one source of operator authority</title>
<updated>2026-08-15T00:34:19Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T00:34:19Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=84b032c65e17267d41e04605e79eea82a6f5a59f'/>
<id>urn:sha1:84b032c65e17267d41e04605e79eea82a6f5a59f</id>
<content type='text'>
A description could only be set the moment a group was created, so every
group made before anyone thought of one stayed blank for good. The owner
can now edit it from the group's page, and PATCH /v1/groups/{id} takes it.

That endpoint takes the description and nothing else, deliberately. The
name, the visibility and the join policy are the terms members joined on;
a private group that can quietly become public is not the group they
agreed to be in. Changing those needs a decision about who gets told, not
a field on a form — there is a test saying so.

Separately, the legacy operator key is gone. `admin_pk_ed25519` in
node.toml named the operator before the roster existed and was kept so
that an existing deployment would keep working; nothing uses it, and a
second source of node authority is not something to carry around out of
politeness. Authority is the roster, read fresh on every check.

It is removed rather than ignored: a config that still names the key gets
a warning at startup pointing at the file. Dropping it in silence would
refuse invites and file deletion with a signature error that looks like a
bug somewhere else — which is exactly how finding M3 presented.

Two tests were verifying admin operations by naming a key in the context,
which was the legacy path. They now pair an operator into a roster, the
way an operator does. The authority test anchored on the deleted function
and passed vacuously once it disappeared; it states the invariant against
the verifier and the daemon instead.

Also defined .btn-secondary, used in four places and styled in none.

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