<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-node/src, branch 0.3</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.3</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.3'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-15T17:19:40Z</updated>
<entry>
<title>chore: release 0.3.0</title>
<updated>2026-08-15T17:19:40Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T17:19:40Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=75065cc1ed5d9cd733871d478dc4f915663d795c'/>
<id>urn:sha1:75065cc1ed5d9cd733871d478dc4f915663d795c</id>
<content type='text'>
Twenty-one commits since 0.2, and enough of them change what the thing
does that moving the old tag would have been the wrong description.

Node: video streaming paced by the client rather than pushed at it, and a
stream that ends when the viewer closes instead of holding a transcode
slot for two minutes. An operator can remove an empty directory and
revoke a member over MNP. `meshbay-node group add` attaches another hub
group without hand-editing node.toml. The node.toml operator key is gone;
the roster is the only source of authority.

Hub and web client: transfers outlive the page that started them, with a
widget that shows the rate and can cancel them; downloads stream to disk
in every browser, through the File System Access API where it exists and
a service worker where it does not; a folder can be taken as a zip built
in the browser. A group owner can remove a member and edit the
description. Nodes are recorded at the address their signed announcement
arrived from, not the one STUN told them about. Deleted accounts stop
being counted while the connection log keeps their name.

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(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>fix(cli): --group takes a name, and operator pair says it takes none</title>
<updated>2026-08-15T07:50:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T07:50:53Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4c4fe55bc17ea2223a52b31d2be5b762af8d75bf'/>
<id>urn:sha1:4c4fe55bc17ea2223a52b31d2be5b762af8d75bf</id>
<content type='text'>
Two ways the CLI misled someone attaching a second group to a node.

`meshbay-node operator pair --group grenet` accepted the flag and ignored
it: pairing is node-wide and always was. That invites exactly the wrong
reading — that a code belongs to a group, and that pairing had failed
because the group did not change. It now refuses the flag and says one
paired browser covers every group the node hosts.

`--group` also only ever accepted a UUID. A name went through untouched
and the daemon answered as though the group did not exist, which is not
what happened. It now resolves a name against node.toml, and when there
is no match it prints the groups there are, with their ids — the missing
half of the answer.

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(files): one uploads/ directory, for files and chat alike</title>
<updated>2026-08-14T20:01:51Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T20:01:51Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=38f91818f876c51dcd7eb7911b65fc7bf5154c83'/>
<id>urn:sha1:38f91818f876c51dcd7eb7911b65fc7bf5154c83</id>
<content type='text'>
Correction to the previous commit. Uploads went wherever the member happened to
be looking, which spreads chat attachments through the tree and makes the
destination a client-supplied path — surface that had to be defended. Everything
a member sends now lands in `uploads/` at the root of the shared directory:
visible, one place, easy for the operator to look into or empty.

Chat attachments go there too, so the separate out-of-tree thumbs directory is
not needed and is not built. They were already ordinary uploads; now they are
ordinary uploads that land somewhere sensible.

The destination is chosen by the node, so a client naming somewhere else changes
nothing — the traversal surface simply is not there on this path. safe_subdir()
remains for dir_create, where the path genuinely does come from the client, and
keeps its tests.

One shared directory means name collisions are ordinary rather than adversarial:
every camera produces IMG_1234.jpg. The node finds a free name — "IMG_1234 (2).jpg"
— and reports it in the ack, because a chat message has to point at the file that
was actually written and not at someone else's. Nothing is ever replaced, which
is the property the per-user quarantine existed for (C5a) and the one the tests
assert; they fail if the free-name search is removed.

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