<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/api, branch 0.5</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.5</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.5'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-16T18:57:52Z</updated>
<entry>
<title>fix(hub): serve the SPA under a fingerprint of what it is</title>
<updated>2026-08-16T18:57:52Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T18:57:52Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=5dea19d9950518887be7eb14696f600ee023dbbd'/>
<id>urn:sha1:5dea19d9950518887be7eb14696f600ee023dbbd</id>
<content type='text'>
`Cache-Control: no-cache` requires a browser to revalidate, but it only binds
one that asks. A browser that cached app.js before that header existed applies
heuristic freshness instead — a fraction of the file's age, which for a file
dated weeks ago is days — and never asks. It then runs an old player against a
new node.

That cost most of a session. A phone kept a player without the read-ahead bound
and filled the browser's buffer ceiling at 106 MB, the exact symptom the bound
had been written to remove, for an hour after the bounded player went live. A
fix that is written, tested, deployed and served, and still not what runs, is
indistinguishable from a fix that does not work.

The whole module graph now lives under `/a/&lt;content-hash&gt;/`. A path prefix
rather than a query string, because relative imports inherit it: `app.js`
reaching for `./i18n.js` gets the build it was written against, and never a
mixture of two — which does not render a stale page, it fails to link. The URL
changes with the content, so those may be cached hard.

`sw.js` stays at the root. Its scope is its own path, and under the prefix it
would no longer control the pages whose downloads it exists to intercept.
</content>
</entry>
<entry>
<title>feat(hub): leaving a group, a cap on public ones, and hosting as a precondition</title>
<updated>2026-08-16T13:28:28Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T13:28:28Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0bd3f805ffbd04b40b5150474336a5e5d200e72b'/>
<id>urn:sha1:0bd3f805ffbd04b40b5150474336a5e5d200e72b</id>
<content type='text'>
Leaving is its own endpoint rather than a relaxation of the owner's removal
check — an authorization rule with an exception in it is the one that gets read
wrong later. The owner cannot leave: the group would be left with nobody able
to admit, edit or delete it, which is the answer removal and account deletion
already give.

Public groups are capped at ten live ones per owner. They are the ones that
cost other people something — listed in Discover, joinable by anyone — so a
script that opens hundreds fills the directory for everybody. Private groups
are invisible to non-members and are not capped. Hub staff are exempt; the cap
is anti-spam, not a rule about running an instance. Creation is the only place
it can be checked, and deliberately so, because PATCH refuses to change
visibility at all.

A group is now listed only once a node has announced that it hosts it. Before
that it has no files, no key and nothing to connect to, so showing it to a
member produces a name they cannot open and cannot be told why; its owner still
sees it while they set the node up. `meshbay-hub prune-groups` collects the
ones that never got a node, meant for cron, with --dry-run. The migration
backfills hosted_at from created_at: without that the first run would have
deleted every live group.

Presence rides on the group list itself, read from the signaling registry the
hub already keeps — no poll, no timer. It says a node is connected *to the hub*,
which is not a promise that this browser can reach it and not something a
dishonest hub could not fake; the client downgrades it on a connection it tried
and failed, which is the evidence that concerns the reader.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: stop a stream on close, count only real users, record where a node is</title>
<updated>2026-08-15T17:01:08Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T17:01:08Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=05f4feab641740c944d636f29a03f8c0dd1328c7'/>
<id>urn:sha1:05f4feab641740c944d636f29a03f8c0dd1328c7</id>
<content type='text'>
**Closing the viewer left the node working.** Nothing told it to stop:
the player dropped its handlers, which only made the browser deaf. ffmpeg
kept running and held one of the node's two transcode slots until the
credit timeout expired two minutes later — which is why the next video
answered "server busy". `stream_stop` ends it at once, and the viewer
also drops its queue, ends the MediaSource and revokes the object URL on
the way out, any of which could be holding megabytes of decrypted video.

While there: `file_chunk` replies were matched to their requests by
arrival order, which was true by luck rather than by construction. The
reply now names the file it belongs to and is matched on that and the
chunk index; a chunk nobody is waiting for is dropped instead of being
handed to whatever request happens to be oldest.

**The administration panel counted its own history.** A deleted account
is tombstoned so the connection log stays readable, and every count and
list treated that row as a user — including a group's member count, and
the member list of the group itself. They do not any more.

**Where a node is.** `endpoint_hint` is what a node believes its address
to be, learned from a STUN server and sent to us: useful for reaching it,
and a claim. The announcement that carries it is signed with the node key
over a fresh timestamp, so the address that request *arrives from* is the
address of whoever holds that key — that is now recorded on the node row
and shown in a Nodes tab, next to the hint, with the difference spelled
out. Clients get the same treatment: `webrtc_offer` is logged with the
address the hub saw when a browser starts a peer connection.

Verified against the live deployment: the node's row reads 90.112.206.172
after a restart, and in e2e a stopped stream goes quiet in one message
and the next one starts immediately instead of being refused.

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(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(logs): keep the username on records the account no longer answers for</title>
<updated>2026-08-14T22:50:05Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T22:50:05Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f4d04741379e77d2ef86cccc93856e590bfe1082'/>
<id>urn:sha1:f4d04741379e77d2ef86cccc93856e590bfe1082</id>
<content type='text'>
The connection log took the name from a join on `users`, and deletion
tombstones that row — so every record belonging to a deleted account
reported `deleted-3f9a1c`, which is the one answer that helps nobody. The
log is kept for a legal retention period precisely so it can say who did
what; losing the name at deletion kept the data and lost the point of it.

`ip_logs.username` is written as the account is erased, and stays NULL
while the account is alive, where the join is better because it cannot go
stale. The admin view prefers the stored name when there is one: the join
still answers after deletion, just with the tombstone.

Releasing the username for re-registration and keeping it in the log are
separate things, and the guide now says so.

On the node side, the pre-proof audit line records the username the
session already knew, instead of leaving the column empty.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>Notifications: one per conversation, none for your own messages</title>
<updated>2026-08-14T21:52:13Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T21:52:13Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0355167e02a710c0e40484592ac794810cde3922'/>
<id>urn:sha1:0355167e02a710c0e40484592ac794810cde3922</id>
<content type='text'>
Four things were wrong, and they compounded: a busy chat produced one row
per message, muting a group did nothing at all, there was no way to clear
the list, and the one person guaranteed to know about a message — its
author — was told about it.

The author bug was a name mismatch across two processes. The node sent
chat_notify without saying who wrote the message, so the hub used the
node's own token subject, which is the operator's account. The skip
therefore matched the operator and no one else: everybody was notified of
their own messages, and the operator was notified of nobody's. The node
now names the author and the hub reads that field.

Muting lived in the browser's localStorage and nothing ever read it, so
the checkbox was decoration. It is a column on group_members now, checked
where the notification is created — a notification nobody wants is not
written at all.

Chat keeps a single row per (user, kind, group) whose date moves and whose
read flag clears, so a conversation is one line saying when it last spoke.
Clicking it opens the group and dismisses it; joining a group dismisses
its invitation; and DELETE /v1/notifications clears the lot.

The hub deploy now runs alembic. create_all() only creates missing tables,
so group_members.muted never arrived on the running hub and /v1/groups/mine
answered 500 — worth catching in the script rather than in a browser.

Verified end to end against the deployed hub and node: the author receives
nothing, the other member receives exactly one, carrying its group_id.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(account): a user can delete their own account, an admin can delete one</title>
<updated>2026-08-14T20:41:02Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T20:41:02Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8d8f85b4bf976249266a89f692408027216711b7'/>
<id>urn:sha1:8d8f85b4bf976249266a89f692408027216711b7</id>
<content type='text'>
Both go through the same erasure, so there is one description of what happens
rather than two that drift.

Gone: credentials, email, node key, group memberships, notifications, refresh
tokens, node registrations. The username is released.

Kept, on purpose and stated in the UI: the row itself, emptied, and the IP log
that points at it. Those logs exist for a year to answer legal requests, and a
log that can no longer say whose connection it recorded keeps the data while
losing the only thing it is for. So the account becomes a tombstone rather than a
hole in the table.

Out of reach, also stated: files uploaded to nodes, and the identity keys nodes
pinned. Those are on machines the hub does not command, and only their operators
can remove them — `member unpin` and a delete on their own disk. Saying so in the
confirmation matters more than the button.

Owning groups blocks deletion, with the list. Cascading would delete other
people's groups out from under them; the account holder can hand them over or
delete them first, deliberately.

Self-deletion re-checks the passphrase. A live token may be a borrowed laptop or
a tab left open, and it is not consent to something irreversible. Admin deletion
requires admin rather than moderator: suspension is the reversible moderation
tool and stays one click away.

A deleted account's access token stops working at once — the status check already
refuses anything but "active", which the tests now pin down, because refresh
tokens being gone would otherwise leave up to an hour of usable session.

Tests: 8 covering what survives and what does not, plus a db_session fixture for
assertions that cannot honestly be made through the API.

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>feat(client): Argon2id for the keypair bundle, and remove the backup toggle</title>
<updated>2026-08-14T10:42:51Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T10:42:51Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=86563d5db0ae19fc58336b71a5c29a8712973590'/>
<id>urn:sha1:86563d5db0ae19fc58336b71a5c29a8712973590</id>
<content type='text'>
Two corrections to yesterday's judgement, in the order they matter.

**The toggle is gone.** Asked to make the remote key backup optional, I shipped a
setting whose "off" position meant: no second browser, ever, and clearing your
storage destroys the account. I wrote the warning that says so without drawing
the conclusion. A control whose only effect is to break the ordinary case is not
a control, and removing an exposure by removing the feature is not a fix. Every
browser backs its keys up again, unconditionally.

**The exposure is fixed where it actually lives: the KDF.** The keypair bundle
rests on every node whose group its owner joins, protected by the passphrase
alone (finding C4). It used PBKDF2-SHA512 at 600k — compute-only, which is
exactly what a GPU eats. Measured on this machine: PBKDF2 600k costs 241 ms and
Argon2id 64 MB/t=3 costs 322 ms, near enough the same honest work, except only
one of them forces an attacker to find 64 MB per guess.

So the bundle key is now Argon2id 64 MB / t=3 / p=1, via a vendored WebAssembly
build (no external host — the CSP forbids one, and 12.2 will tighten it further).
Parameters chosen by measurement through that build: 19 MB is OWASP's floor at
118 ms, 256 MB is 1.3 s and too slow for a phone, 64 MB sits where a login should.

What this buys, stated honestly: cracking a bundle yields the owner's identity
keys, and with them content on OTHER nodes and the ability to sign as them — not
the content on the operator's own node, which they host in the clear by design.
Argon2id raises that price steeply; it does not remove it, and a weak passphrase
still loses. Hence the floor raised to 12 characters and ~60 bits in the same
breath, which can only be enforced client-side: with the password split (T1) the
hub never sees a passphrase.

Migration is automatic and invisible. Bundles carry an "MBK2" marker; the old
form is still readable, and is re-encrypted the first time a browser backs it up.
Both keys are derived at sign-in, because which one a bundle needs is only known
once it is read and the passphrase is deliberately not kept around.

Two implementations of the KDF now exist — the browser's WASM and argon2-cffi in
QE — so a parity test holds them byte-identical. A disagreement would not look
like an error; it would look like an account nobody can open.

keypair_bundle_delete stays, without a UI. It is the mechanism behind withdrawing
your data from a node, exercised end to end, and it will belong to a deliberate
"forget me on this node" action rather than a setting that quietly disables
multi-device.

Verified against the live deployment: the full workflow passes, including
recovering keys on a second client from the passphrase alone.

Tests: 341.

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