<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/static/style.css, 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-15T13:48:36Z</updated>
<entry>
<title>fix(downloads): automatic really is automatic, and a selection downloads all of it</title>
<updated>2026-08-15T13:48:36Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T13:48:36Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4066c754a613deb965472853fe69727d68be593e'/>
<id>urn:sha1:4066c754a613deb965472853fe69727d68be593e</id>
<content type='text'>
Two bugs in what shipped last, and both were mine.

Automatic mode still opened Save As, because with no folder granted the
code fell through to the file picker — while the documentation said it
would use the browser's own download folder. It does that now. Over
512 MB it still asks, since getting there means holding the file in
memory and a tab will not survive a 40 GB blob; Settings is where to stop
it asking again.

Selecting two files downloaded one. They were started without awaiting,
so each asked the browser for a save dialog at once, and a browser allows
exactly one — the rest were rejected and the errors went nowhere. They
are awaited one at a time now, which serializes the dialogs and not the
transfers: each call returns as soon as its transfer is registered.

Then the adjustments. The transfers widget offers Open on a finished
download that went into a granted folder — the bytes go to a new tab, and
that is the whole of what a page can do: no browser lets one start a
desktop application or show a file manager, so the folder half of that
request cannot be built and the guide says so.

The Files toolbar was four controls of three different heights in a row.
It is three groups now — what you can add, where you are, what you can do
with what is here — on one baseline, with icons from the set and a gap
between the dots and the word Actions. Chat comes first among the tabs
and is the one you land on. The three Discover entries in the sidebar
have icons. And a link in a chat message becomes a link: built as an
element and never as markup, http and https only, so `javascript:` is not
one message away from running here.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(settings): choose between Save As and saving into a folder</title>
<updated>2026-08-15T12:34:08Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T12:34:08Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8528bce49bb637f4e9fb653fff148b7ea45cfdac'/>
<id>urn:sha1:8528bce49bb637f4e9fb653fff148b7ea45cfdac</id>
<content type='text'>
Downloading a selection of twenty files meant twenty Save As dialogs,
which is the wrong answer for the feature that had just been built.
Settings → Downloads now offers saving automatically, and that is the
default; asking every time stays available for people who want it.

The correction worth recording: a web page cannot be given a filesystem
path and cannot read one either. There is no ~/Downloads to configure and
nothing to type, on any operating system — which is also why none of this
will need changing on Windows. What a browser grants is a handle to a
folder the user picked in a dialog, so that is what the setting keeps:
picked once, stored in IndexedDB, re-confirmed once a session because the
grant comes back as a claim rather than a permission. Where no folder has
been granted, and in Firefox and Safari where none can be, files go to
the browser's own download folder — which on most machines is the folder
that was meant all along.

Automatic saving has one risk a dialog does not: it can silently replace
a file. It does not — a taken name gets a suffix before the extension,
`clip (2).mp4`, so a download folder does not fill up with files the
system no longer recognises. That, and the default, are what
test_downloads.py pins.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(files): transfers that outlive the page, and selection instead of per-row menus</title>
<updated>2026-08-15T11:13:08Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T11:13:08Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=41e2b79cb1bc9d188853aeff5a55cd2237268587'/>
<id>urn:sha1:41e2b79cb1bc9d188853aeff5a55cd2237268587</id>
<content type='text'>
Downloads and uploads were state inside GroupPage. Leaving a group
unmounted the component, its cleanup closed the DataChannel, and a
half-written file was all you had — which is also why only one thing
could be in flight at a time.

They live in a module-level store now. A group page hands its transport
over on the way out rather than closing it, and the last transfer using
it closes it; signing out is the one thing that cancels everything,
because those transfers are moving data on a token about to stop being
ours. The store is plain JavaScript with no browser globals, so
test_transfers.py runs it under Node and pins the parts that are timing
and lifetime rather than markup: that a cancel stops the work instead of
greying out a row, that a stalled transfer reads as stalled rather than
reporting its own historical average, and that a released transport is
closed by the last transfer and not before.

The widget by the bell shows each transfer with its rate and a cancel
button, so the Files panel no longer carries progress bars — you can
watch a 40 GB archive from the chat, or from another group.

Selection replaces the per-row menu: a Select toggle puts checkboxes on
files and folders, and ⋮ Actions acts on what is ticked. Ticks survive
walking into another folder, so a selection can span directories.
Downloads start together and run together. Videos offer Play only — View
did the same thing, which is the sort of duplication that makes people
wonder what the difference is.

Uploads had to become parallel-safe for any of this to mean anything:
their acks were matched by arrival order, so two at once credited each
other's progress. The node names the file in every ack, so they are keyed
by name now — with the same file twice refused, since the node keys its
own upload state that way too.

Two mistakes worth recording. The selection column went into the body
rows and not the header, because that edit matched nothing and I had not
made it assert; the columns were misaligned until a screenshot showed it.
And the Actions menu opened leftwards from a button at the right edge of
the toolbar, half of it off-screen.

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>The Files panel stops guessing, and search says how old its answer is</title>
<updated>2026-08-14T23:31:49Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T23:31:49Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=5338894f7fec9e1a60affb0e2ff3b9797bcbc968'/>
<id>urn:sha1:5338894f7fec9e1a60affb0e2ff3b9797bcbc968</id>
<content type='text'>
Four small things, two of which were the same bug wearing different hats.

The index cache seeded the Files panel and then raced the live index:
IndexedDB is async, so a fast node could hand you the real list and have
it overwritten a moment later by the cached one. That is the "choses
bizarres". The panel now shows what the node says, or says it cannot
reach the node — no third state that looks like data but is memory.

The cache stays, written on every sync, and the search page is the only
thing that reads it. Search across groups has no other source: it cannot
connect to every node to answer a keystroke. So it now says how stale
each hit is — "synced 2 hours ago", per group — and a line under the
results explains that opening a group refreshes what search knows about
it. Deleting a file also rewrites the cache now; it used to refresh the
table and leave the cache holding a file that no longer existed, which is
why search kept offering it.

The invite form is shown only once this browser holds an operator key.
Invites are signed with it and the node checks the signature against its
roster, so an unpaired browser could fill the form in and fail on submit.
An owner who is not the node's operator is told to ask the one who is.

Group descriptions now show on the home cards, as they already did in
Explore.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>One stroked icon set, matching the Administration shield</title>
<updated>2026-08-14T22:10:12Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T22:10:12Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=ed2e0640b48148b69eca00ad7dd06789506e12ac'/>
<id>urn:sha1:ed2e0640b48148b69eca00ad7dd06789506e12ac</id>
<content type='text'>
The site drew its icons with colour emoji, which means a different
drawing on every operating system and never the same line weight twice —
except the Administration entry, whose shield is a text-default glyph and
so rendered as a thin outline. That one was the odd one out and also the
one that looked right.

Fifteen inline SVG paths now replace it: menu, bell, shield, globe, gear,
sun, moon, power, lock, envelope, door, clip, check, chevron, close. They
are stroked in currentColor and sized in em, so an icon takes the colour
and size of the text beside it and needs no rule of its own — the caret
in the dark nav bar and the gear in a light dropdown are the same file.

The explorer keeps its emoji. There the icon says what kind of file this
is and the colour is doing real work; a wall of identical grey outlines
would be a worse file list.

One bug fell out of the change: .nav-notif was painted with --text, the
page's text colour, on a nav bar that is nearly black. The emoji bell
carried its own colours and showed anyway; a stroked one was invisible
until it was given --nav-text.

Checked by rendering the real stylesheet in headless Chrome, light and
dark.

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(ui): upload progress, PDF preview, invite form above the member list</title>
<updated>2026-08-14T18:54:57Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T18:54:57Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=54b535d7102e9a68d9b37fb215623fbd4faff95e'/>
<id>urn:sha1:54b535d7102e9a68d9b37fb215623fbd4faff95e</id>
<content type='text'>
Three of the ten UI items, the ones that needed no decision.

Upload showed a disabled button and nothing else — on a large file that is
indistinguishable from a hang. It now has the same bar downloads have, filled
from chunks the node has acknowledged rather than bytes read locally, and says
"indexing" for the pause at the end: the node re-indexes on a filesystem event,
so there is a real wait there with nothing to poll.

PDFs open in the overlay through the browser's own viewer. The file is decrypted
in the page as any other preview is, and shown from a blob: URL — nothing leaves
the tab, and no external viewer is involved.

The invite form sits above the member list, where the action is rather than after
the thing it acts on.

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>
</feed>
