<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/api/revocation.py, branch 0.20</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.20</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.20'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-10-15T12:24:35Z</updated>
<entry>
<title>fix(hub): a phone's poll secret ends with the account's sessions</title>
<updated>2026-10-15T12:24:35Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-10-15T12:24:35Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2dbff20818d171242a15e3567e14292c0d63aea2'/>
<id>urn:sha1:2dbff20818d171242a15e3567e14292c0d63aea2</id>
<content type='text'>
The secret a phone polls /v1/push/poll with is a credential of its own,
outside any session, and it outlived the ones that should have ended it:
"sign out everywhere" — what someone who lost a phone presses — a
passphrase change and an admin revocation left every push row in place, so
the lost phone went on reading "X posted in &lt;group&gt;" lines. Each now deletes
the account's rows, as a reset and an erasure already did, and the poll
refuses a revoked or deleted account whatever deleted the row or not. A
suspended one still reads: the line telling it so is one of them.

A phone still in hand is not affected for long: a refused poll makes it
forget the row, and the page registers it again at its next start.

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(hub): one account, several linked nodes</title>
<updated>2026-10-15T07:33:28Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-10-15T07:33:28Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=1e8eae1f3de765fcd8163716de6e5fe5aab5a034'/>
<id>urn:sha1:1e8eae1f3de765fcd8163716de6e5fe5aab5a034</id>
<content type='text'>
An account links up to MAX_NODES_PER_ACCOUNT node keys (node_keys table)
instead of the one users.pk_node_ed25519; the migration carries each
account's key over and the downgrade keeps the last one linked.
/v1/nodes/auth accepts any linked key, and its token names the key that
won it (nk). PUT /me/node_key adds a key, idempotently, 409 past the
limit; DELETE /me/node_key names the key: a browser session any of its
own, a node only its own. Unlinking a key closes that node's socket and
refuses its tokens at once, on REST and on the socket; the account's other
nodes are untouched. /pubkeys no longer gives a node key to other accounts,
and gives the caller its last linked one, which installed desktop apps read.

The node reports waiting_for_node_key while its key is unlinked and signs
in again as soon as the hub says so, so a key linked again is back within
seconds; unlinking itself signs in first to hold a token naming its key.

Profile -&gt; Linked nodes lists each node (connected, last seen, IP, linked
since, This machine in the desktop app) with Copy and Unlink, and adds a
key by hand. The Node page banner, Create group and the desktop app add
this machine's key instead of replacing another's. Messages that pointed
at Settings -&gt; Link Node point at Profile -&gt; Linked nodes.

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>docs: generate an HTTP API listing for the hub and the node control API</title>
<updated>2026-10-05T08:36:18Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-10-05T08:36:18Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=b8671635cd891068afee81fde05bed880124ec85'/>
<id>urn:sha1:b8671635cd891068afee81fde05bed880124ec85</id>
<content type='text'>
docs/MESHBAY_HTTP_API.md lists every route of the hub (by domain, with the
authentication each requires) and of the node's loopback control API. It is
written by docs/generate_http_api.py from the routes and their docstrings;
test_http_api_doc.py fails when the file drifts from the code or when a
route has no docstring, so a new route must say what it does.

79 routes had no docstring and get a one-line description; a few whose first
line did not describe the route get a summary line.

The login page's developer docs gain an API link next to Design and
Protocol, in every language. README, MESHBAY_DESIGN.md (§0.1, §6.7, §7) and
CLAUDE.md point to the listing; README also points to examples/.

The examples scripts with a shebang become executable.

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(hub): what an offer or a node message costs the hub is bounded</title>
<updated>2026-10-01T11:37:38Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-10-01T11:37:38Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=aeec8ee7c9e39704433d93c82fafc0f5721d6fbf'/>
<id>urn:sha1:aeec8ee7c9e39704433d93c82fafc0f5721d6fbf</id>
<content type='text'>
An offer's IP-log row (kept a year) was written before any check, for any
string named as a node; it is written once the offer goes to a node. The ICE
list is capped (64 candidates, 32 KiB). A node's update_groups, a database read
each, is budgeted like chat_notify and claims at most 1000 groups (F-22).

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: only the owner decides who hosts a group, and nobody is made a member unasked</title>
<updated>2026-09-30T09:49:56Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-30T09:49:56Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4'/>
<id>urn:sha1:d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4</id>
<content type='text'>
- hub: a node may host a group only if its account owns it or the owner
  approved that node (new `group_hosts`). Membership was the ceiling, and
  every member holds the group key, so any member's node could register as a
  host and be the one clients kept. A node claiming a group it may not host
  is recorded as a request; the owner is notified once and approves or
  refuses it (GET/POST/DELETE /v1/groups/{id}/hosts[/{node_id}]), which
  takes effect on a connected node at once.
- hub: an owner adding a username creates an invitation (new
  `group_invitations`), accepted or declined by the invitee
  (/v1/groups/invitations, /{id}/invitation/accept|decline). Until then the
  group is not listed, not dialled, not searched and not in any token.
  Invitation links, open joins and group creation still make members
  directly: they are the account's own act.
- hub: the MNP token names only the group it is minted for (group_id is now
  required), so a node operator no longer learns a member's other groups.
- SPA: invitations on the home page; invited people and host requests in
  the group's settings; the transport sends group_id. Ten catalogues.
- Browser probes for both screens, run in Chrome and Firefox.
- Design §5.2, §7.2, §7.3, AV32, AV33; protocol §6.3; user guide.

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat: nodes apply the content blocklist in their public groups</title>
<updated>2026-09-28T19:49:14Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-28T19:49:14Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=07480eb3f8ad0bb4369ac8c41df7c4140b108d0e'/>
<id>urn:sha1:07480eb3f8ad0bb4369ac8c41df7c4140b108d0e</id>
<content type='text'>
A node hosting a public group syncs the hub's blocklist on every
connection (paged, node token only) and applies pushed changes. A
blocked file leaves the index and is refused (content_blocked); private
groups are untouched. The unused per-hash check route is gone.

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(hub): the NAT-punch signal needs a shared active group, like the offer relay</title>
<updated>2026-09-26T00:44:52Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-26T00:44:52Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4500fe3854fd3a499d1139bc89ccc488d104cc29'/>
<id>urn:sha1:4500fe3854fd3a499d1139bc89ccc488d104cc29</id>
<content type='text'>
POST /v1/nodes/{id}/incoming checked only the caller's own address, then revealed
whether the node was connected (404 vs 504) and, with QUIC on, made it punch —
so any authenticated account could poll it for a node's liveness or make a
stranger's node emit a UDP probe. The membership gate webrtc_offer did inline is
now require_shared_active_group() in api/signaling.py, called by both routes; in
notify_incoming it runs before anything depends on the node's connection state,
so a non-member gets one uniform 403 whether the node is up or not.

test_incoming_membership.py holds it (a non-member is refused with a membership
403 whether the node is connected or not; a member passes the gate); red before,
green after. The offer relay is unchanged in behaviour (it now calls the shared
helper); signaling/availability suites pass. Design §7.2 updated.

Co-Authored-By: Claude Opus 4.8 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>style: ruff's own fixes, mechanically applied</title>
<updated>2026-09-19T12:24:13Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-19T12:24:13Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=86188385cbdae1ee90c1dca7a7b9db2edef1ecd4'/>
<id>urn:sha1:86188385cbdae1ee90c1dca7a7b9db2edef1ecd4</id>
<content type='text'>
`ruff check .` had gone unrun long enough to report 568 errors, which is the
same as having no linter: the next real finding would have been invisible in the
noise. This is the 521 it fixes by itself, in 173 files, and nothing else — the
98 it cannot fix are the next commit.

What actually changed: import sorting (225), imports nobody used (87, none of
them a re-export — no `__init__.py` is touched, which was the one way this could
have broken an import elsewhere), `datetime.timezone.utc` to `datetime.UTC` (69)
and `asyncio.TimeoutError` to `TimeoutError` (18), both plain aliases on the 3.12
this project requires, `Optional[X]` to `X | None` (24), and f-strings with
nothing to interpolate (19).

Checked rather than assumed: every module in the three packages still imports,
and the suite is 2893 passed — the same count, test for test, as the merge
before it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</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: 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>
</feed>
