<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/tests/test_availability_between_members.py, branch 0.16</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.16</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.16'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-19T12:24:13Z</updated>
<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: the two ceilings §13.5b was still missing, as AV27 and AV28</title>
<updated>2026-09-19T08:47:28Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-19T08:47:28Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=1db49c37ea01db8498694613b3d0e2d54bd0d96d'/>
<id>urn:sha1:1db49c37ea01db8498694613b3d0e2d54bd0d96d</id>
<content type='text'>
**A free-text TMDB search spends the operator's credential.** TMDB rates it, and
the automatic matching every member sees runs on the same one, so a member
holding down the search box — or a script doing it — degrades the library for
everyone and empties a quota the operator pays for. The handler had no ceiling of
any kind, where link previews beside it carry two. §6.5's standing rule is a bound
and a named adversary in the same commit; this arrived with neither.

Per member and not per connection, unlike link previews: three tabs is one
person, and a ceiling a tab can multiply is not a ceiling. Kept in the group
context, so a reconnect does not reset it — a client that drops its channel
between searches would otherwise have no ceiling at all. The node-wide window
stays too, because the two answer different questions: one keeps a member from
spending everyone's quota, the other keeps a roomful of them from doing it
together. Ten a minute each, thirty for the node — a search every six seconds,
sustained, is past what anyone types.

The refusal is an error rather than an empty list. An empty list is what "no such
film" looks like, and telling somebody their film is unknown when the node simply
declined to ask is a worse answer than the truth; `video-app.js` already puts
`detail` on screen.

**How many node keys one account may announce.** Each is a row in `nodes` plus a
row in the IP log, and the IP log is kept for a year, so an account in a loop
writes a year of storage on the operator's disk having paid only for signatures.
M8 settled whose key it is and said nothing about how many. Ten: a node is a
machine left running, and an account wanting an eleventh *identity* rather than
an eleventh machine is the case this refuses.

Counted only where a row is added. Applied to every announce it would freeze the
address of every node an account already runs the moment it reached the limit,
and a node that cannot re-announce is unreachable after its ISP renumbers it —
an outage caused by the protection. There is a test for exactly that.

Both tests are two accounts, per §13.5b: a ceiling one person can exhaust for
another is not a ceiling but a queue, and a ceiling shared between accounts would
let one member stop every other from bringing a machine online. Checked by
removing each ceiling: seven tests fail.

`test_season_and_search_requests.py` built its session without a `_user_id`,
which production guarantees — `_dispatch_message` refuses every message until the
handshake settles it. The fixture was narrower than the node, so it could not
exercise a per-member bound at all; it has one now.

Twelve `test_sticky_header.py[firefox]` setup errors in a full run here: Firefox
is open on this machine, the trap CLAUDE.md describes, and its twelve `[chrome]`
tests covering the same geometry pass.

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(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(hub): no mail from the event loop, and a ceiling on every path that sends it</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T08:08:36Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=02f061ee2c1824734bf63c91d39b47848926f59c'/>
<id>urn:sha1:02f061ee2c1824734bf63c91d39b47848926f59c</id>
<content type='text'>
`users.py` and `admin.py` under the availability lens. `admin.py` needed
nothing — its moderator/admin line is drawn explicitly, self-modification is
refused, and every list it serves is bounded. `users.py` had four findings and
one of them is the worst of this whole pass.

  AV9   `mail._send` is `smtplib` with a ten-second timeout, called straight
        from four async handlers. That wait is not one request's, it is the
        instance's: nothing else served, no node socket read, no WebRTC offer
        relayed, until the MTA answers. Reachable by any signed-in user at
        request rate through the endpoint below. It has no symptom a test
        catches — everything simply works slowly, for everyone, whenever the
        mail server is having a bad day.

  AV10  `PATCH /v1/users/me` is the third path that makes the hub send mail
        and the only one with neither a rate limit nor a captcha, while
        `register` and `password/reset-request` have both. The address is any
        string the caller types and the duplicate check only rejects one
        already held by an account here, so every address *not* registered on
        this hub was a valid target: a relay for verification codes with the
        hub's own reputation attached. A rate limit counting by IP bounds a
        caller and not an inbox, so the floor under it is a cooldown per
        account — the same for a reset request, whose cost also lands in a
        mailbox that is not the asker's.

  AV11  `default_tab:` accepted any suffix on a `{key:path}` route with an
        unbounded Text value and no cap on rows: one account could write
        without limit into a table shared with everyone. The suffix is a group
        id, which is what the SPA writes, so it is checked as one. A key over
        64 characters was also a 500 rather than a 400 — the column is
        String(64), which PostgreSQL enforces and SQLite does not, so it would
        have appeared in production and in no test.

  AV12  `/v1/notifications` and `/v1/groups` had no upper bound on `limit` and
        no floor under `offset`, while every list in `admin.py` carries
        `le=200`. The group directory takes no authentication at all.

Two shapes recur and are now named in §13.5b: a limit written on one of
several equivalent paths, and a bound that counts the wrong thing.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
<entry>
<title>fix: bound what one member can cost the others</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T07:47:34Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=bf7ff9ec311660318c8562abe03ef4db62475c98'/>
<id>urn:sha1:bf7ff9ec311660318c8562abe03ef4db62475c98</id>
<content type='text'>
An availability review, prompted by the group claim above: a participant
supplies input — who else bears the cost? Six answers where the cost fell on
someone other than the sender, and none of them needs an attacker.

  AV3  `chat_notify` carried a `group_id` the hub believed, so any connected
       node could write a notification to every member of any group on the
       hub, carrying a display string of its choosing, with its account
       having no relation to that group. This is the group claim again, two
       hundred lines further down the same socket. Gated on what the node is
       registered for, and metered: the fan-out is one write per member. The
       budget expires by time rather than on disconnect, or reconnecting
       would refill it and a node token is good for an hour.

  AV4  A swarm source named its own `endpoint` as free text documented as
       "ip:port", so an account could publish a third party's address — H6's
       `peer_ip` defect, never applied here. Nothing dials a swarm source
       today, which is the only reason it was not already a reflection
       primitive. It is a transport and a port now, never a host, and the
       number of hashes one account may claim is bounded: rows were keyed
       (hash, account) with no cap at all.

  AV5  `handle_webrtc_answer` resolved any pending `peer_id` from any node's
       socket. The answer is the SDP a browser then connects to. That this
       had not happened rested on a uuid4 being unguessable.

  AV6  `relay_register` had no authentication of any kind: it compared
       `pk_relay` against the approved value, which is a *public* key, so
       anyone who could read it could rewrite where the hub tells nodes to
       send relayed traffic. The module docstring promised signed JWTs and
       `jwt` was imported and never used.

  AV7  The node held unlimited peer connections and kept one that never
       completed a handshake for the life of the daemon. H6 bounded what one
       unauthenticated peer costs; the hub's cap is three offers in flight
       per *account*, a limit on each caller and not on the machine, so an
       operator's exposure grew with the size of their groups.

  AV8  `invite-notify` put a request-supplied `group_name` into the subject
       of an email the hub sends under its own domain, to any account, with
       no rate limit. The name comes from the group row now.

The tests are two accounts each, in one file that says why: a one-member test
proves a one-member property, and every finding here needed a second person
to exist at all. Each was checked against the unfixed code. Two did not
survive that check and were rewritten — one re-enacted the disconnect path
instead of running it (hence `forget_node`), the other called the reaper
itself and would have passed with the call removed from `handle_offer`.

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