<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/api, branch 0.11</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.11</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.11'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-02T15:31:50Z</updated>
<entry>
<title>fix(hub): stop the maintenance loop racing the tests, and pin _ASSETS</title>
<updated>2026-09-02T15:31:50Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-02T15:31:50Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=7b25f1c09ba1b8692988d9616f3c33c97af9f3ca'/>
<id>urn:sha1:7b25f1c09ba1b8692988d9616f3c33c97af9f3ca</id>
<content type='text'>
Two defects found while closing out the Search merge, neither of them in
that feature.

The maintenance loop. create_app's lifespan starts cleanup_loop as an
asyncio task, so every test — each entering that lifespan — ran a purge
pass concurrently with its own requests. On SQLite :memory: that is not
merely noisy: the engine uses a StaticPool, one connection for the whole
process, so the request's session and the cleanup task's session
interleave transactions on the same connection. A registration could
commit and then be invisible to the login three lines later, surfacing
as 401 Invalid credentials for an account created moments before, in
roughly one run of test_node_ws_auth.py in four.

The purge itself is not at fault and this is not a production condition.
A passive SQL listener caught the DELETE removing 0 rows, and the INSERT
carrying status='active' — so neither the pending-account mechanism nor
the purge filter is involved, and PostgreSQL gives every session its own
connection. What the fixture removes is the second user of the shared
one. 60 runs of the previously flaky file, 0 failures; reproductions
before the fix landed on attempts 4, 6, 13 and 29 of separate loops, so
a clean run of 60 has about a 1% chance of being luck.

_ASSETS. source-merge.js shipped missing from webapp._ASSETS, the
cache-busting hash's input list — exactly the silent failure docs/apps.md
§4 step 5 warns about: the file changes, the asset URL does not, and a
browser holding the old page keeps the old copy. Harmless this time only
because search-page.js changed in the same commit and is listed, which is
the worst way for it to go unnoticed. Found by re-reading that checklist
for the doc pass, not by any test — so there is a test now, holding
_ASSETS to every .js in static/ (sw.js excepted, unversioned on purpose).
It was the only one missing.

Phase 9 of docs/refactoring-search.md also lands here: mediacenter.md
§10.6, musicbay.md §9b, photos.md §10b, apps.md §2b and step 5.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
</content>
</entry>
<entry>
<title>fix(hub): dismissing a notification deletes it</title>
<updated>2026-09-02T10:55:05Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-02T10:55:05Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=16b5e5c463ec998c6434bbf2ef39113ed01da801'/>
<id>urn:sha1:16b5e5c463ec998c6434bbf2ef39113ed01da801</id>
<content type='text'>
The previous commit filtered the list to unread, which corrected what the
reader saw and left every dismissed row in the table, invisible for ever. That
is a place to hide the disagreement rather than a resolution, and the operator
said so: "elles s'accumulent pour rien."

So dismissing drops the row. It is the reasoning `purge_notifications` has
carried all along — "these are signals, not a record: the group is still
there, the message is still in the chat, the invitation is still an
invitation" — applied one at a time instead of only in bulk.

- `DELETE /v1/notifications/{id}` is the honest name and what the SPA calls.
- `POST /{id}/read` reaches the same handler and now deletes too. It has to
  keep working: the interface ships inside the desktop package, so a hub is
  always answering some client older than itself, and giving the old path the
  new behaviour means those clients stop hoarding as well rather than only the
  updated ones.
- `read-all` deletes rather than marking, which makes it `DELETE ""` under an
  older name. Marking would have made it the one route still filling the
  table. Nothing in this repo calls it, but a reachable endpoint is one that
  can be called.

`Notification.read` is now vestigial — nothing stored can be read, because
reading it deletes it. It stays because dropping a column is a migration for
no gain, and `unread_only` stays because a SPA newer than its hub still needs
it to be right. Both are said in the module docstring rather than left to be
worked out.

Two existing tests encoded the old semantics and now assert the opposite;
test_notification_dismissal.py gains one for the old `/read` path, because
version skew is the normal case here and not the exception. 617 hub tests
pass. docs/USERGUIDE.md's endpoint table updated in both places it lists them.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
</content>
</entry>
<entry>
<title>fix(hub): a desktop solve reports no hostname at all, not "meshbay"</title>
<updated>2026-09-02T09:54:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-02T09:54:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2d8c6bc449e343a80569e45d4ceae0423616cedd'/>
<id>urn:sha1:2d8c6bc449e343a80569e45d4ceae0423616cedd</id>
<content type='text'>
Registering from the native client failed with `captcha_failed` while the
checkbox was green — a worse symptom than the one being fixed, because the
widget now looked fine and only the hub's own log said otherwise:

    captcha solved on an unexpected host ''; allowed: ['localhost', 'meshbay', 'meshbay.org']

The previous commit assumed Google would report the host component of the
origin, so `app://meshbay` would come back as `meshbay` and could sit in
`allowed_hosts`. It does not. A solve Google cannot attribute to a domain
reports an **empty** hostname, and no allowlist entry can match that. An empty
entry is not the answer either: a blank in a TOML list is a typo far more
often than an intention, and `load_config` drops blanks for that reason —
`captcha.allow_unattributed_host` is a named flag instead, so the trade is
stated where it is made.

What it admits, plainly: every non-web client, not only ours. A file:// page
or somebody else's Electron application look identical from here. That is the
same bar the client's own origin would have been — main.js already records
that `app://meshbay` is not a credential — and it is a bar: the captcha still
has to be solved, per token, in something that can render it. What is given up
is the origin restriction for non-web clients, not the captcha. Off by
default, and a hub without the desktop client should leave it off.

The refusal now names which of the two it is, since they need different
answers: an unexpected host names the host, an unattributed one says to set
the flag.

docs/captcha.md §6 said `meshbay` was the value and told operators to add it;
it now records what was measured and why the guess was wrong. The packaged
example config carries the flag with the same warning.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
</content>
</entry>
<entry>
<title>fix(hub): check the captcha's origin here, so the desktop client can pass one</title>
<updated>2026-09-02T08:35:17Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-02T08:35:17Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=9c1b622611bb0b21852d3c9c11e1d8674aa35654'/>
<id>urn:sha1:9c1b622611bb0b21852d3c9c11e1d8674aa35654</id>
<content type='text'>
Reported from the native client: the reCAPTCHA box renders
"ERROR for site owner: Invalid domain for site key". The web browser is fine.

It is not a client restriction, and the CSP was never what refused — the
script loads, which is why the widget appears at all to say so. reCAPTCHA
validates the hostname of the page the widget is rendered in against the
domain list on the site key, and the desktop client's interface ships inside
the package and is served from `app://meshbay` (main.js: `win.loadURL`). Not a
preference: file:// breaks ES modules and IndexedDB, and the hub must never
become the document origin. So the hostname Google sees is `meshbay`, it is
not on the key's list, and it never can be — the check runs on Google's
servers and nothing client-side reaches it.

The fix turns that check off on the key and does it on the hub instead:

    [captcha]
    allowed_hosts = ["meshbay.org", "localhost", "meshbay"]

`verify_captcha` refuses a solve whose hostname is not in the list. The
hostname comes from `siteverify` — what Google observed, not what the caller
asserts — so it is a real check against what turning the console setting off
opens, which is a bot rendering the public site key on a page of its own.
Empty (the default) skips it, so an existing hub upgrades unchanged with
reCAPTCHA still doing the origin check. The two settings go together, and
docs/captcha.md §6 says so.

The `meshbay` entry is the weak one and the doc says that too: any Electron
application can claim the same scheme and host, as main.js already records.
What it still costs is a captcha solve per token inside a real Chromium
instead of a token farmed from any web page.

docs/captcha.md §6 replaced. It documented a design that was superseded twice
— an `auth_key`-keyed carve-out that turned out to disable the gate for
everyone, and "works in the Electron client too, both run Chromium", which is
the assumption this bug is made of: reCAPTCHA validates the domain, not the
rendering engine.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
</content>
</entry>
<entry>
<title>revert(hub): M6 — add_group_member must keep accepting node tokens</title>
<updated>2026-09-01T18:50:45Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T18:50:45Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=1e6db7d23c70b7bd7e1422f09911b3645f0fb2e2'/>
<id>urn:sha1:1e6db7d23c70b7bd7e1422f09911b3645f0fb2e2</id>
<content type='text'>
M6 in the third review was a misread. `add_group_member` accepting a
node-scoped token is deliberate (commit 0443cf8): the node calls
POST /v1/groups/{id}/members/{username} after a CLI `member invite` so
the group shows up in the invitee's SPA, authenticating with a
node-scoped token. `group.admin_id == caller` is the real guard.

An older test (`test_node_scope_blocks_add_member`) asserted the
opposite and had been left red on main; the M6 "fix" (commit 6b38704)
satisfied that test by switching the dependency to `require_user_scope`
— which made `ops.create_invite`'s hub-membership call 403. That
exception is swallowed with a log.warning, so an invited user silently
never lands in group_members and the group is invisible to them.
Reported from live testing (CLI `member invite grenet`, grenet saw
nothing).

Dependency back to `get_current_user`. The stale test now asserts the
intended behaviour: a node token may add a member to its own
operator's group (201) but not to a group it does not own (403).
Third-review M6 marked WITHDRAWN.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
</content>
</entry>
<entry>
<title>fix(hub): send a CSP and protective headers on every response</title>
<updated>2026-09-01T18:03:03Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T18:03:03Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f2915bc7b1550b80cadfa36f5910223106cb3bfe'/>
<id>urn:sha1:f2915bc7b1550b80cadfa36f5910223106cb3bfe</id>
<content type='text'>
The SPA shell and its assets went out with no Content-Security-Policy
and no X-Content-Type-Options / Referrer-Policy / X-Frame-Options — so
an injection that reached the SPA (rendered third-party OpenGraph
data, a federated group name, chat content) had nothing stopping it
from loading more code or exfiltrating to any host, and the page
could be framed by any site.

A middleware in `create_app` now adds all four to every response.
`webapp.CSP` is deliberately the *same* policy the desktop client's
protocol handler already enforces on these exact UI files, plus the
two reCAPTCHA hosts the sign-up widget needs: `default-src 'none'`,
`script-src 'self' 'wasm-unsafe-eval' &lt;recaptcha&gt;` (the hub's own
origin is not a script source — T3), `style-src 'self'
'unsafe-inline'` (htm/preact inline `style=` only, nothing executes),
`connect-src 'self' https: wss:`, `frame-ancestors 'none'`,
`base-uri 'none'`, `form-action 'none'`.

The shell's dead `&lt;script&gt;window.__MB_ASSET_V = ...&lt;/script&gt;` is
removed (nothing has ever read it) so `script-src` needs no inline
allowance.

Needs verification against the running SPA — a mis-tuned CSP shows as
a blank page — but it matches a policy already proven with these
files under Electron.

Second-review L5 / third-review M5.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
</content>
</entry>
<entry>
<title>fix(hub): constrain what a federated peer hub can do (MHP)</title>
<updated>2026-09-01T17:50:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T17:50:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=24d29e910c8d1649a1cd51969f27b921b94d97e3'/>
<id>urn:sha1:24d29e910c8d1649a1cd51969f27b921b94d97e3</id>
<content type='text'>
A registered peer was trusted with more than "advertise your own
public groups":

- `receive_directory` set `source_hub` from `body.hub_id`, so a peer
  could relay or spoof a third hub's groups into our directory. It is
  now bound to the token's verified `iss`. The push is also capped
  (500 groups/request, 2000/peer), rows are type- and length-checked,
  and a federated id that collides with a local group is refused so it
  cannot shadow one.

- `receive_revocation` forwarded the peer's token to local nodes,
  which reject a token signed by another hub's key — a silent no-op,
  and there is no local node hosting a federated group anyway. It now
  verifies the inner token against the sending peer's key and, for
  `target == "group"`, prunes our copy of the peer's directory entry
  when `source_hub` matches. A peer cannot revoke our users or a group
  it did not advertise.

- The state-changing endpoints (`POST /mhp/directory`, `/mhp/revoke`)
  now reject a replayed `jti` within the token's TTL. Audience binding
  is unavailable — the sending side that would set `aud` is unbuilt —
  and this covers the replay concern in its place; the idempotent
  `GET /mhp/directory` is not affected.

Third security review, finding M4.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
</content>
</entry>
<entry>
<title>fix(hub): require user scope to add group members</title>
<updated>2026-09-01T15:49:19Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T15:49:19Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=6b38704d459dec1271c7ca883de3eb14190218f7'/>
<id>urn:sha1:6b38704d459dec1271c7ca883de3eb14190218f7</id>
<content type='text'>
Every mutating group endpoint depends on require_user_scope except
POST /v1/groups/{group_id}/members/{username}, which depended on
get_current_user — so a node-scoped daemon token (or a stolen one)
whose subject owns the group could add any existing user to it,
contradicting NS7 ("operator manages groups from the browser only").

test_node_auth.py::test_node_scope_blocks_add_member already existed
and was red on main; it passes now.

Third security review, finding M6.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
</content>
</entry>
<entry>
<title>fix(hub): enforce registration captcha for every client</title>
<updated>2026-09-01T15:49:13Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T15:49:13Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=7d8c774e250b134222374f45726d5426714475de'/>
<id>urn:sha1:7d8c774e250b134222374f45726d5426714475de</id>
<content type='text'>
The server only checked the captcha when auth_key was absent — but
every real client (browser included, via the password split) sends
auth_key, so the check was off for everyone, and a bot skipped it by
including the field. The Register form still made humans solve a
widget whose token was never transmitted.

Gate is now unconditional on captcha.enabled. The web client
(registerUser in keyderive.js) forwards captcha.token; RegisterPage
resets the (single-use) token on a failed attempt. The desktop client
shares this UI source and is Chromium, so it renders the same widget
(see the paired meshbay-client commit for the CSP change that allows
it).

Tests: test_register_captcha.py.

Third security review, finding M1 (Option A).

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
</content>
</entry>
<entry>
<title>fix(hub): require auth and distinct reporters for content reports</title>
<updated>2026-09-01T15:49:05Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T15:49:05Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=99eb93a00269fbfafba7536cf1613b3d4c18c3ce'/>
<id>urn:sha1:99eb93a00269fbfafba7536cf1613b3d4c18c3ce</id>
<content type='text'>
POST /v1/reports had no authentication and no rate limit, and counted
every raw report row toward AUTO_BLOCK_THRESHOLD regardless of who
sent it or from where — two anonymous requests naming any blake3 hash
added it to the hub-wide content blocklist. A network-wide censorship
and DoS primitive for anyone who learns a public file's hash.

- require a signed-in account (get_current_user)
- rate-limited (10/hour)
- threshold now counts DISTINCT reporting accounts (reporter_id),
  one vote per account per hash; raised 2 -&gt; 3
- refused outright (403) when the hub has public groups switched off:
  a private-only hub brokers no public content and nothing syncs the
  blocklist, so the endpoint would be pure abuse surface
- admin blocklist management (/v1/admin/blocklist*) is untouched, so a
  manual block still works regardless of the public-groups setting

Noted while fixing: no node currently consumes ContentBlocklist
(swarm_register checks the separate CSAM list), so the network-wide
block effect was latent — the abuse surface (DB fill, poisoned
moderation signal) was live today.

Tests rewritten in test_moderation.py.

Third security review, finding H2.

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