<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/app.py, branch 0.15</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.15</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.15'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-23T15:46:48Z</updated>
<entry>
<title>feat(hub): invitation-link tickets bound to a verified address</title>
<updated>2026-09-23T15:46:48Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-23T15:46:48Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=998f9c69308ee88fac36cfb77dfb6d07c6fa926a'/>
<id>urn:sha1:998f9c69308ee88fac36cfb77dfb6d07c6fa926a</id>
<content type='text'>
group_invite_links holds sha256(ticket) and the invitee's address blind
index; redeeming grants membership to that account only. Owner-only
create/list/cancel (a node token may create, never mail), 20 outstanding
per group, optional mail written by the hub itself and capped at 10 per
sender per day (mail.invite_link_daily_cap). MESHBAY_DESIGN.md §3.4 now
carries the whole link design.

Co-Authored-By: Claude Opus 5.5 &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>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): let this origin frame its own download URL</title>
<updated>2026-09-08T20:53:35Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-08T20:53:35Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=a15c3912008b7dc444c7fc6a2b4ccf782c647215'/>
<id>urn:sha1:a15c3912008b7dc444c7fc6a2b4ccf782c647215</id>
<content type='text'>
Three headers govern whether a page may be framed, and all three had to be
wrong for the streamed download to work — so fixing them one at a time cost an
afternoon of redeploys and retests. They were visible together in a single
`curl -I` against the deployed hub, which is where this should have started.

The streamed-download path navigates a hidden iframe to `/_mbdl/&lt;id&gt;` so the
service worker is asked for the response it is holding. On Firefox and Safari
that is the only way to write a large file to disk: neither has the File System
Access API, and OPFS is capped at 10% of the volume's size (measured on Firefox
154: 389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte),
which a film exceeds.

  - `frame-src` was reCAPTCHA's two origins with no `'self'`, so the frame
    could not be loaded at all. Added when the captcha needed a frame; nobody
    connected the two.
  - `frame-ancestors 'none'` forbids all framing, this origin included.
  - `X-Frame-Options: DENY` says the same in an older dialect. The spec says a
    browser must ignore it when frame-ancestors is present — relying on that
    while shipping a header that contradicts our own policy is asking to be
    surprised, and we were: the CSP was fixed and the download stayed broken.

`'self'` and `SAMEORIGIN` refuse every foreign origin exactly as `'none'` and
`DENY` do. The clickjacking property is untouched; what they additionally allow
is this origin framing itself, which is the only thing the download needed.

Pinned three ways: `frame-src` must carry `'self'`, `frame-ancestors` must be
`'none'` or `'self'` and never name an origin, and the two framing headers must
agree — the defect was the disagreement, and either one read as correct alone.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
</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>feat(hub): reCAPTCHA v2 on Register and Password Reset pages</title>
<updated>2026-09-01T09:06:47Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T09:06:47Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=b6c15f35d570d4f54901b811654991847502ca82'/>
<id>urn:sha1:b6c15f35d570d4f54901b811654991847502ca82</id>
<content type='text'>
Server-side verification module, CaptchaConfig in hub.toml,
captcha_site_key exposed via /v1/hub/info, useCaptcha() hook
in the SPA with stable DOM rendering (strength bar always present
to avoid Preact re-ordering the captcha widget). Native clients
(auth_key path) skip captcha. All 10 locales updated.

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat: email verification for registration, email change, and invitations</title>
<updated>2026-08-31T15:19:17Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-31T15:19:17Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=c6fd7ea89b6e0a96eb1d81989de891b4768b1044'/>
<id>urn:sha1:c6fd7ea89b6e0a96eb1d81989de891b4768b1044</id>
<content type='text'>
Registration now creates a pending account and sends a 6-digit code via
email; the account activates only after verification. Email changes on
the profile page follow the same flow. Group invitations send a
notification email to the invitee (without revealing their address to
the inviter) containing the invite code and hub link.

Backend: blind HMAC-SHA256 email index for uniqueness without decryption,
mail.py for localhost Postfix delivery, verification endpoints, cleanup
of expired codes and stale pending accounts, startup backfill of
email_hash for existing users.

Frontend: 3-phase register page, inline email change verification on
profile, invite-notify call with status display. All 10 locales updated.

Co-Authored-By: Claude Opus 4.6 &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>fix(hub): revalidate static assets so a split SPA version cannot load</title>
<updated>2026-08-15T22:21:16Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T22:21:16Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=aa63cae5f004fc4d01471561404d59360784b784'/>
<id>urn:sha1:aa63cae5f004fc4d01471561404d59360784b784</id>
<content type='text'>
The SPA became a module graph that has to agree with itself: app.js
imports i18n.js, which imports a catalogue from locales/. Serving one of
them from cache while fetching another is not a stale page, it is a broken
one, and both directions were reproduced:

- a browser holding the previous i18n.js fails to *link* the new app.js
  ("does not provide an export named 'initLocale'"). That happens before
  any code runs, so the fallback in the boot cannot catch it — blank page.
- the reverse pairing links and renders, but nothing calls initLocale(),
  so every string comes out as its own key: nav.logout, group.tab_files.

Starlette sends only etag and last-modified, and with no explicit
freshness a browser is entitled to guess one — roughly 10% of the age
since last-modified, which is comfortably long enough to catch a deploy.
Caddy is a plain reverse proxy and adds nothing.

`no-cache` does not disable caching; it requires a conditional request,
which the existing ETag answers with a 304 and no body. Measured: 304,
0 bytes, on every static path.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
