<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/api, branch 0.14</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.14</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.14'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-15T00:21:01Z</updated>
<entry>
<title>feat(hub): session lifetime is an admin setting, and a browser signs out when idle</title>
<updated>2026-09-15T00:21:01Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-15T00:16:39Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=73ad8e4eb566fe682107fa7e50ef624591199e99'/>
<id>urn:sha1:73ad8e4eb566fe682107fa7e50ef624591199e99</id>
<content type='text'>
Browser idle sign-out (media playback counts as activity; not the desktop app),
refresh idle window and maximum session length, in hours. Sign-out now revokes
on the hub, and the profile has "sign out everywhere".

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
</content>
</entry>
<entry>
<title>feat(hub): usernames are at least 8 characters at registration</title>
<updated>2026-09-15T00:21:01Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-15T00:16:39Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=bdefcd025604f2c3009fe5e0cc01213c2ba62a6a'/>
<id>urn:sha1:bdefcd025604f2c3009fe5e0cc01213c2ba62a6a</id>
<content type='text'>
Existing shorter accounts keep signing in. Test usernames padded to match.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
</content>
</entry>
<entry>
<title>fix(hub): Argon2 runs off the event loop, on exactly one worker</title>
<updated>2026-09-14T00:29:13Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-14T00:29:13Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=a1aaf31a27d1c1b65efc3c6a25fc6cc8771578ea'/>
<id>urn:sha1:a1aaf31a27d1c1b65efc3c6a25fc6cc8771578ea</id>
<content type='text'>
One derivation is 256 MB and a quarter to half a second of CPU (240 ms here,
485 ms on meshbay.org). All eleven call sites — sign-in, registration, the two
rehashes, passphrase change, reset and account deletion — ran it inline in an
async handler, so every one stopped the whole hub for that long: no request
served, no node socket read, no offer relayed. Measured on a local hub during
eight concurrent sign-ins, the worst `/v1/health` response went from 232 ms to
10 ms; the sign-ins themselves take the same time.

It could not simply go to a thread pool. Two concurrent `lanes=4` derivations
deadlock inside OpenSSL and never return, at no CPU — reproduced on
cryptography 50.0.x / OpenSSL 4.0.x both locally and on meshbay.org, while
`lanes=1` does not. `lanes` is part of every stored hash, so it is not ours to
change, and inline on the loop two derivations could never overlap, which is
the only reason production never hung.

So `auth.hash_password_off_loop` / `verify_password_off_loop` hand the work to
a dedicated executor with exactly one worker. Not a semaphore around
`to_thread`: a cancelled request would release its permit while its thread was
still deriving, and the next derivation would start beside it. One worker also
bounds Argon2's memory to one derivation whatever the number of callers.

`test_argon2_off_loop.py` reads every module for a direct call, pins the single
worker, runs four derivations and four sign-ins concurrently to completion, and
checks the loop keeps turning during a derivation; each fails with its guard
removed. CLAUDE.md and AV9 state the rule and the trap.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
</content>
</entry>
<entry>
<title>fix(hub): a per-account sign-in lockout, and a reviewed unauthenticated surface</title>
<updated>2026-09-13T23:53:04Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-13T23:53:04Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=392b5e4a53aace725794c7bbabf9e95fb4e1b9c5'/>
<id>urn:sha1:392b5e4a53aace725794c7bbabf9e95fb4e1b9c5</id>
<content type='text'>
Passphrase sign-in locks per username: after `login.max_failures` wrong
passphrases (default 4) the name is refused with `429 account_locked` and a
`Retry-After` for `login.lockout_minutes` (default 60), without the passphrase
being checked. Both numbers are instance policy an admin sets from the panel;
zero failures turns it off. The per-IP limit bounds one address, and IPv6
gives every subscriber a /64 of them — an online guess targets an account, so
the account is what is counted.

- Counted by the name as typed, existing or not, so `login` stays uniform (M1).
  The key is a hash: people type passphrases into the username field.
- The attempt is taken before the check in one `INSERT … ON CONFLICT DO UPDATE
  … WHERE … RETURNING`, so a concurrent burst gets no more than the limit.
- Sign-in, passphrase change and account deletion count on the same row; the
  last had no rate limit at all.
- A lockout refuses passphrase sign-in and nothing else: sessions, renewal and
  device sign-in continue, and a reset code clears it (AV26). A session learns
  its own lockout from `/v1/users/me`, and the passphrase change checks it
  before re-wrapping any node's bundle — the hub accepts the new passphrase
  only after the nodes have it.

The SPA now shows what the hub said. `loginAndRecover` threw "Login failed:
{json}", so `email_verification_required` never matched and was never shown;
the passphrase-change form rendered no error at all in its first phase.

The unauthenticated surface, reviewed route by route:

- No `/docs`, `/redoc` or `/openapi.json`, in the code. The Caddyfile hid them
  on meshbay.org only; a packaged hub behind any other proxy published all three.
- The node socket's first message must arrive within ten seconds. It is
  accepted before anyone is known, and an unbounded read is a connection any
  stranger holds for free.
- `/v1/relays` answers 503 behind `relay.RELAYS_ENABLED`, as federation does:
  nothing in the tree calls it and two of its routes take no account.
- `test_unauthenticated_surface.py` walks every route and fails on one without
  an authentication dependency that is not listed with its reason.

Verified in Chrome against a local hub: the lockout and wrong-passphrase
messages, the admin section saving both lockout and mail limits, and the
passphrase change refused while locked. Not verified in Firefox (a running
instance blocks the headless one), nor the upsert's concurrency on PostgreSQL.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
</content>
</entry>
<entry>
<title>fix(hub): federation is closed until two hubs have run it</title>
<updated>2026-09-13T20:14:47Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-13T20:14:47Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=413837a0845240241ed7e9d9ac1f3b1dc45a2f40'/>
<id>urn:sha1:413837a0845240241ed7e9d9ac1f3b1dc45a2f40</id>
<content type='text'>
Every MHP route answers a stated 503. `federation.FEDERATION_ENABLED` is the
only thing that decides it, applied as a dependency on the router so the six
routes that exist and any added later are covered by construction — a gate you
have to remember to write in each handler is the shape C6 is the standing
lesson about.

The protocol is not what is wrong with it. What is wrong is that nothing has
ever run it: two hubs have never completed one authenticated request between
them. AV14 was two defects in the same path — an issuer signing with a key
bound before it was loaded, naming itself after the reference deployment
whatever the instance was called, and a verifier naming no audience for the
`aud` the issuer sets, which PyJWT refuses outright. Both stood for a month
behind a green suite, and both were found by reading rather than by running,
because a second implementation of a peer proves the protocol and nothing about
two machines.

Four of the six routes carry no authentication of their own — the MHP token is
the authentication — and two of those write, a directory push and a revocation.
That is the surface being closed until somebody stands up a second hub.

A constant and not a `hub_settings` row, deliberately: a switch in the admin
panel invites an operator to turn on a feature that has never worked between
two machines, where this takes an edit, a deploy, and reading the comment above
it. `/v1/hub/info` reports the state, because the `mhp_version` beside it would
otherwise be a claim the hub does not honour.

The protocol tests open the gate for their own duration and say why; the one
that runs with the flag as it ships asserts all six routes refuse. §7.6 states
the closure, §15.2 carries federation between two hubs as not built.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
</content>
</entry>
<entry>
<title>fix(hub): a node that hosts nothing is not a free target, and a private group's hosts are its members'</title>
<updated>2026-09-13T19:18:23Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-13T19:18:23Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=dd7d9c80baafe08287516fd4d486223b5199b102'/>
<id>urn:sha1:dd7d9c80baafe08287516fd4d486223b5199b102</id>
<content type='text'>
Two checks that were not performed, in the same shape: something true of the
public case, written so it did not apply to the rest.

**Signaling.** The membership check read `if node_group_ids:`, so an empty set
skipped it — membership, group status and the public-group gate together — and
the offer was relayed. Since AV1 made an empty claim mean "no groups" rather
than "all of my owner's", that is the *normal* registration of a node hosting
nothing: the unconfigured node left running, the machine in this register's
founding incident, and the one least able to absorb the work. Each offer makes
it allocate an RTCPeerConnection and gather ICE, 30 a minute, which is H6
restored in exactly the case AV1 made common. It is refused now. Nothing
legitimate is lost: such a node refuses the handshake anyway — `group_id` is
mandatory (M1) and a node with no group key refuses (NS8) — so this declines
work the node would decline one step later at its own expense.

**The node list.** `GET /v1/groups/{id}/nodes` checked membership only for a
public group with public groups switched off. A private one answered any
authenticated account that knew the id — which an ex-member knows for ever —
with the ids and public keys of the machines hosting it. §7.4 already stated the
property for the public case. Membership is required now unless the group is
public and public groups are on. Nothing needs the list before joining: an open
join writes the membership row first, and an invitation registers the invitee's
when the code is created, both checked before making the change.

`test_webrtc_signaling_roundtrip` had to be repaired, and is the finding
restated: it registered a node in `_connected_nodes` and never in
`_node_groups`, so it drove the relay through the hole rather than through the
door — the check it routed around was the defect. It now registers the group a
real node registers.

§7.2 and §7.3 gain the rules, the register AV24 and AV25, and
test_availability_between_members.py two cases that fail on the property
against the previous source.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
</content>
</entry>
<entry>
<title>fix: hold every background task, in both codebases</title>
<updated>2026-09-13T13:40:50Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-13T13:40:50Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f2d9a026db453899e5bb50f101b1f5f1a9f91ddf'/>
<id>urn:sha1:f2d9a026db453899e5bb50f101b1f5f1a9f91ddf</id>
<content type='text'>
asyncio keeps only a weak reference to a task, so a coroutine started with
`asyncio.ensure_future(...)` whose result is discarded can be collected while
it is still running: the loop logs "Task was destroyed but it is pending!" and
the work simply does not happen. No error reaches the caller, and what is lost
is whatever that coroutine was in the middle of.

The node already had a guard for this, written after an abandoned stream task
lost a transcode slot for good — and it read one file, `webrtc_server.py`,
because that is where the defect was found. Outside that file there were
nineteen sites: the hub's `chat_notify` (a notification for every member of a
group), the indexer's debounce (every real-time index update), eleven in
`daemon.py` including the SIGHUP reload and each enrichment pass, two in
`ops.py`, and five in the loopback API.

`meshbay_common.background.spawn()` is the one door. It holds the task, drops
it when it finishes, and logs what it raised under the coroutine's own name —
an exception in a task nobody awaits was otherwise reported by asyncio at
collection time, out of context or not at all. A peer session's `_spawn` stays
as it is: that one can also *cancel* what it holds, which a module-level holder
cannot, because a session ends and a process does not.

`test_background_tasks.py` walks every package's source and refuses a discarded
handle. It parses rather than greps, so an assignment, a comprehension or an
await is not mistaken for one, and it was checked against a deliberate
reintroduction. A guard that stops at the edge of the file where the bug was
found is a guard against that bug, not against its class.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
</content>
</entry>
<entry>
<title>feat(hub): the mail bounds are settings, with a panel to change them</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T11:48:09Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=98e27c8f251022320020716a9ef7a5b892ad2b61'/>
<id>urn:sha1:98e27c8f251022320020716a9ef7a5b892ad2b61</id>
<content type='text'>
They were constants in two modules, so an operator could not touch them
without editing code and redeploying — and the hour a budget runs out is not
when anyone wants to do that.

`[mail]` in hub.toml carries the defaults; the live values live in
`hub_settings`, read at each use. A missing row falls back to what the
configuration file says, so an instance that never opens the panel behaves as
its file describes. The panel sends only what changed, the hub clamps each
value to a stated range and refuses a key it does not know, and the response
is what gets rendered — so a clamped value is never shown as stored.

`GET /v1/admin/mail` is the other half. There was no way to see any of this:
a refusal was a line in the journal, so an instance that had stopped sending
sign-up codes looked, from the panel, exactly like one with no sign-ups. It
reports the hour's use, what is left for sign-ups, and what is left for
recovery — the difference between those two being the reserved share made
visible.

Labels in all ten catalogues.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
<entry>
<title>fix(hub): the mail allowance is written down, and recovery keeps a share</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T11:47:49Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e671b931fd594a39fc840916c81b5d4b1f1e3227'/>
<id>urn:sha1:e671b931fd594a39fc840916c81b5d4b1f1e3227</id>
<content type='text'>
Two dicts in `mail.py` held the budget, so every deploy handed out a fresh
one — and this hub is deployed several times a day. A bound a restart forgets
is not a bound, for the reason the denylist is persisted rather than held in
memory (S3). It is a `mail_quota` table now, one row per counter, the
recipient hashed so the table does not become a list of plaintext addresses.

The counting moves with it, into an async `reserve` that has a session, and
`send_off_loop` is the one door it stands in. `_send` keeps the purpose
allow-list: that half needs no state, and it is what stops anything which
puts a message on the wire from naming a reason this hub does not send for.
The caller owns the commit, so a request that fails afterwards is not charged
for mail nobody received.

`hourly_reserved_for_recovery` is new. A flood of sign-ups used to be able to
spend the whole hour and lock out the person waiting on a passphrase reset;
registration and address changes may now spend only the unreserved share.

Values changed as agreed: 10 messages a day to one recipient, 300 s between
two reset codes. The address-change ceiling and its cooldown were two bounds
on one thing — 3 a day and 60 s apart — and collapse into one 48-hour delay.
Asking again for the address already pending is exempt: it reaches no new
recipient, that recipient is bounded anyway, and without the exemption a typo
locked the account out of correcting it for two days.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
<entry>
<title>fix(hub): bound the blocklist reads a stranger can make</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T10:14:40Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=c8462c6060ccd2cccb305edcd64ef12a675f2f58'/>
<id>urn:sha1:c8462c6060ccd2cccb305edcd64ef12a675f2f58</id>
<content type='text'>
`GET /v1/blocklist` takes no authentication — a node syncs it at startup —
and had no ceiling on `limit`, so anyone could ask for the table in one query,
repeatedly. `GET /v1/blocklist/check` took any string of any length straight
into a primary-key lookup, unmetered.

Not changed, and worth a decision rather than a quiet edit:
`AUTO_BLOCK_THRESHOLD` is 3. Three distinct accounts blocking a hash adds it
to the list every node enforces, network-wide, automatically, with manual
admin removal the only undo. Far better than the anonymous version it
replaced, and still a censorship primitive an attacker buys for three email
addresses. §13.5b records the option — count only accounts more than a day
old, which costs a patient attacker a day and an honest reporter nothing.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
</feed>
