<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/api, branch 0.6</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.6</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.6'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-23T13:15:35Z</updated>
<entry>
<title>feat(hub): split the group UI into a pluggable "applications" architecture</title>
<updated>2026-08-23T13:15:35Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-23T13:15:35Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83'/>
<id>urn:sha1:9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83</id>
<content type='text'>
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).

Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.

Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:

- group-page.js called onRefreshAuth on a stale-token handshake rejection,
  but app.js never imported refreshAccessToken from hub-client.js — so a
  brand new member (including a group's own creator) hit "Not a member of
  this group" and the retry silently failed, throwing before it could
  refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
  it from i18n.js. Opening Chat on a group with real messages threw mid-
  render; uncaught, that appears to wedge Preact's render scheduler, so
  every button on the page stopped responding until reload.

Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
</content>
</entry>
<entry>
<title>fix: delete notifications and detach content_reports before deleting a group</title>
<updated>2026-08-21T16:10:19Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-21T16:10:19Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=83881b389b96931bd504965479ff05aeabcd7ff2'/>
<id>urn:sha1:83881b389b96931bd504965479ff05aeabcd7ff2</id>
<content type='text'>
The DELETE /v1/groups/{id} endpoint only deleted group_members before
removing the group row, causing a FK violation (500) when notifications
or content_reports referenced the group. Delete notifications outright
and nullify group_id on content_reports to preserve moderation history.

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</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(ui): 11-point UI overhaul — tabs, transfers, settings, uploads</title>
<updated>2026-08-19T14:36:29Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-19T14:36:29Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=90c3c6a01f46c9b29c5d92702d7383f32e8951d7'/>
<id>urn:sha1:90c3c6a01f46c9b29c5d92702d7383f32e8951d7</id>
<content type='text'>
Hub/UI:
- Icon-only group tabs (chat, files, settings) with per-group default tab
- Transfer widget: filename becomes a clickable link to open completed downloads
- Pulse animation on transfer icon (pale→dark green) while active
- Download button feedback in FilePreview (spinner, auto-reset)
- Group mute toggle persists across navigation
- Login page autofocus, chat refocus after send
- Theme toggle closes menu, status badge and duplicate connecting removed
- Create-folder restricted to operators, download-path note removed
- User preferences API (CRUD) with Alembic migration
- Profile: email display/edit via PATCH /v1/users/me
- Settings: "Defaults" section for default tab selector
- All 10 locale files updated

Node:
- upload_dir in node.toml: separate filesystem path for uploads
- Root.direct flag: uploads land at root path, no subdirectory
- CLI --upload-dir flag on `group add`
- Admin UI accepts upload_dir

Client (Electron):
- shell.openPath bridge for opening completed downloads
- platform.js passes open callback from native save

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(ui): the M of the wordmark is the logo</title>
<updated>2026-08-19T00:54:05Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-19T00:54:05Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3a54cce7df3bf86e0e900865b880855845c5f766'/>
<id>urn:sha1:3a54cce7df3bf86e0e900865b880855845c5f766</id>
<content type='text'>
The stylised M replaces the letter M in "MeshBay" at the top left; `eshBay`
beside it stays text.

**The source artwork is now in git.** It was in `QE/`, which is gitignored, so
it would not have survived a clone — and the tree is about to be moved to
another machine. `assets/brand/` holds the original and the `convert` command
that derives the 10 KB nav asset from it (trimmed, 72px tall for a picture shown
at 30, so it survives a 2× screen). Verified: the documented command reproduces
the committed file byte for byte.

The derived file is in `_ASSETS`, which is what the `/a/&lt;hash&gt;/` fingerprint is
computed from. A file missing from that list is a file whose change never moves
the URL, so a browser holding the old one never asks again — the comment on that
list says so, and a logo is exactly the kind of asset one would forget.

Its URL is resolved from `import.meta.url`, so the hub's fingerprinted path and
the application's `app://` scheme both come out right without either being named
in the source.

**The two sizes are independent, and that took four rounds to get right.** The
picture's height was written in `em` — a fraction of the lettering — so every
adjustment to the text resized the picture by the same stroke and the ratio
between them could never change. The operator spotted the coupling before I did
("si on change la taille du M, tu vas encore tout décaler"). The height is in
pixels now: `font-size: 1.375em` (22px) and `height: 30px`, two knobs that move
one thing each. The vertical nudge went with it — it was correcting a
misalignment I was causing myself.

Also: the wordmark blue goes one step down from sky-400 towards sky-500. It sat
brighter than the picture next to it, which made the two read as different
materials rather than one object.

A note on how this went wrong first: I checked the result on a mock HTML page I
wrote, not in the application. The picture was loading the whole time, but at
25px against a 29px line box it was *smaller* than the letters, so the wordmark
read as unchanged and the report was "none of what I asked for was done". Every
size since has been measured in the running window over CDP.

883 tests pass.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: the chat tab no longer scrolls, and a group is listed or invite-only</title>
<updated>2026-08-18T15:46:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T15:46:54Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=cd2e89f5f5cccdb116db4fcb82d00b6325972782'/>
<id>urn:sha1:cd2e89f5f5cccdb116db4fcb82d00b6325972782</id>
<content type='text'>
**The chat tab was 8px too tall, at every window size.** The panel is sized from
JS to `viewport - top - 16`, which puts its bottom 16px above the fold — but it
sits inside `.main`, which adds 24px of padding below it. Eight pixels of
document past the window, whatever the window. Measured at 700, 900 and 1200:
`scrollHeight` 708, 908, 1208.

This is the second one of these — the sign-in card was `.page-center` and
`.layout` each reserving `100vh - 52px` — so it is now measured in the suite
rather than reasoned about. `tests/harness/scroll_probe.py` renders the real
markup against the real stylesheet and **runs the real `fit()` lifted out of
`app.js`**: a copy of the formula in a test would go on passing after the
original changed, which is exactly the bug being guarded.

The fix does not encode 24 anywhere. The first pass runs as before, then the
leftover is measured and taken off, so anything added below the panel later is
absorbed the same way. Now `scrollHeight == innerHeight` at all three heights,
nothing below the fold, and the panel still fills the room it has — that last
one has its own test, because shrinking the chat to 240px would satisfy every
other assertion here and be useless.

The Settings tab was measured too and is **not** a bug: it fits at 1200px and
overflows only when its content is genuinely taller than the window.

**Group creation asked one question twice.** Visibility and admission were
separate selectors that could only ever be set together — picking Public reached
over and set the policy — and two of the four combinations are meaningless. The
API already refused public+invite with a 422, so the form could build a request
that could not succeed. Private+open was accepted and should not have been: a
group anyone may join that nobody can find is a listing with the listing
removed, since joining goes through the node and there is no link to pass
around.

So: one selector, "who can join", and the request derives the rest. The API now
refuses the other impossible pair as well, with a message that says which way to
resolve it. Six locale strings the visibility box owned are deleted rather than
left unread in ten files, and the two surviving descriptions now say what each
choice means for who can *find* the group — with the word "public" gone from the
page, nothing else would have said it, and someone would publish a group without
meaning to.

865 tests pass.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(client): the platform seam, and an Electron shell that has never been run</title>
<updated>2026-08-18T07:42:34Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T07:42:34Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=30e855f55f1d920b25da0bdd8e538c249d3c0c26'/>
<id>urn:sha1:30e855f55f1d920b25da0bdd8e538c249d3c0c26</id>
<content type='text'>
Stage D, and the honest half of it.

D1 — the seam (done, and verified)
----------------------------------
`static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is
built with the same base, so one address has one source. In a browser it returns
'' and every path stays relative to the origin that served the page — the
acceptance criterion for this split was "the browser SPA behaves identically",
and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the
content hash and a cached browser would never ask for it.

D2 — the shell (written, never launched)
-----------------------------------------
**There is no npm on this machine. Electron was never installed and
`packages/meshbay-client/` has not been run once.** That is stated here rather
than discovered later.

What is there: a main process serving the packaged interface over a privileged
`app://` scheme (`secure` and `standard` are not cosmetic — without them the
service worker refuses to register and streamed downloads break silently), a
preload exposing an enumerated bridge that never passes a filesystem path, a
window with `sandbox`, `contextIsolation` and no node integration, navigation
away from the package refused, and a CSP where the hub is reachable over
connect-src and is not a script source. The hub address arrives as a process
argument because `platform.hubBase()` runs before anything can await.

`test_desktop_shell.py` pins each of those by reading the source — the treatment
`test_downloads.py` already gives the three browser save paths. It catches a
property being removed and proves nothing about the application running. Two
were checked by breaking them.

The interface is *copied* into the package by `build/sync-ui.js` from the hub's
static directory, and `ui/` is gitignored: a silent fork is the only real way to
end up maintaining the interface twice.

D3 — partial
------------
The bridge, and the part worth having now: safeStorage's backend is reported
rather than assumed. On Linux it falls back to a fixed key when no keyring is
running, silently — someone who believes the OS is holding their keys is told
when it is not. The native key lifecycle belongs with D4 and needs a running
application to mean anything.

D8 — partial, and a real defect found
--------------------------------------
`meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` —
into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry
`User=`; systemd refuses the file, so the packaged unit could never have
started. Nothing noticed because nobody had built and installed the RPM.

Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service`
that a person enables themselves without a password — which is what lets the
desktop client install a node without asking for one. It carries ExecReload, so
`meshbay-node reload` does not have to stop a service somebody is streaming from,
and documents the drop-in for a drive outside the home, RequiresMountsFor
included.

798 tests pass; e2e.py still passes end to end. Nothing here was built or
launched: no npm, no rpmbuild.

Co-Authored-By: Claude Opus 5 &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>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>
</feed>
