<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/db/migrations, 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-13T23:53:04Z</updated>
<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): the migration chain runs, and a test says so</title>
<updated>2026-09-13T19:41:28Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-13T19:41:28Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=5d9b2c03fb6bdd28e4c233a35005c709cdd3760c'/>
<id>urn:sha1:5d9b2c03fb6bdd28e4c233a35005c709cdd3760c</id>
<content type='text'>
`add_email_verification` wrote PostgreSQL's `(now() at time zone 'utc')` as a
literal server default, where every other migration in the chain uses
`sa.func.now()` and lets the dialect render it. On SQLite that is
`sqlite3.OperationalError: near "at": syntax error` — so `meshbay-hub migrate`
could not reach head on the database the suite and the documented local-hub
workflow both use.

Which is how it survived: the only test that ran alembic at all stopped at
`c3d4e5f6a7b8`, the revision immediately before it. The two newest migrations —
email verification, and the mail quota committed two days ago — had been run by
exactly one thing, a production deploy, and the newest by nothing at all.

`test_migrations_reach_head.py` upgrades to head and compares what that built
against `Base.metadata`, both directions: a column in the models and in no
migration never reaches production, and one in the migrations and in no model is
a rename abandoned halfway. Both tests fail on the unfixed migration with the
error above; the drift half was checked by adding a model column on purpose and
watching it be named. The schemas agree today.

It still does not check PostgreSQL-only behaviour — a default, an index type or
a constraint one dialect accepts and the other refuses. Running the chain
somewhere beats running it nowhere, and is not the same as running it where it
ships.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
</content>
</entry>
<entry>
<title>fix(packaging): the hub unit can start, and nothing carries the migration path</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T14:15:16Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=c5fff4ce8366b08669c0c8b6d30b99b94b9fefca'/>
<id>urn:sha1:c5fff4ce8366b08669c0c8b6d30b99b94b9fefca</id>
<content type='text'>
`ExecStartPre` ran `alembic -c /opt/meshbay-hub/migrations/alembic.ini upgrade
head`. The build does stage that file, so the path existed and the contents
were wrong: `alembic.ini` resolves `script_location` with `%(here)s`, so the
copy pointed at `/opt/meshbay-hub/migrations/src/meshbay_hub/db/migrations` —
which nothing installs, because the migrations ship inside `meshbay_hub`, in
the shared venv.

`ExecStartPre` failing stops the unit. A hub installed from the RPM or the DEB
could not start at all, and nothing noticed because the one live deployment
was assembled by hand — the same shape as the node unit that carried `User=`
into the user unit directory.

The same `%(here)s` trap was already found once on the server, where a stray
`alembic.ini` resolved to a month-old snapshot of the tree. Twice is a trap
rather than an accident, so the fix is that the path is no longer written down
anywhere: `meshbay-hub migrate` asks the installed package where its own
migrations are, which is correct for the RPM, the DEB, a venv and a checkout.
The build stages no `alembic.ini`; the repo keeps its own for `alembic
revision` and for deploy scripts that already work.

`env.py` now prefers a URL the caller resolved over re-reading the environment
itself, so `migrate --config` connects with exactly the string the server
will — one resolution, not two that agree until they do not.

Six tests, three of which fail against the unit as it was. They read the
directives rather than the file, because searching the whole thing finds the
comment explaining a directive and calls that the directive.

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>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>feat(hub): group names unique per owner, shown as name@owner</title>
<updated>2026-08-28T08:09:38Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-28T08:09:38Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=58ca879b017da62e77f40752d8d94fef3f315a1e'/>
<id>urn:sha1:58ca879b017da62e77f40752d8d94fef3f315a1e</id>
<content type='text'>
A group's identity stays its UUID. What changes is that "the name is
unique" — until now an unenforced expectation — becomes real, scoped to the
owner account, and the owner's username is surfaced so two groups called
"photos" on different nodes can be told apart.

Hub:
- `groups` gains a functional unique index `uq_groups_owner_name` on
  `(admin_id, lower(name))` (model + migration c3d4e5f6a7b8). The migration
  pre-flights: if the data already clashes it aborts and lists the
  offending (admin_id, name) pairs rather than renaming anyone's group.
  meshbay.org checked clean.
- `create_group` trims the name, rejects blank (422) and an owner-scoped
  case-insensitive clash (409), with an IntegrityError backstop for the
  race, and returns `owner_username`.
- `owner_username` added to `/v1/groups/mine`, `GET /v1/groups` (local
  rows), `POST /v1/groups/{id}/join`, `GET /v1/admin/groups`.

SPA:
- new `static/group-name.js` — `&lt;GroupName name owner [inline]&gt;` renders the
  name with the `@owner` handle on a smaller grey line under it.
- used in the sidebar, the group-page header, Explore cards, the Admin
  groups table, and cross-group Search (via a widened `cacheGroupIndex`
  carrying the owner). Delete/leave confirmations show `name@owner` inline.
- federated Explore rows show `@&lt;source_hub&gt;` instead of an account.

Design record and the locked decisions: ~/next/groupnames.md (out of repo).
MNP unchanged. Tests: test_group_name_unique.py, test_group_name_migration.py.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
</content>
</entry>
<entry>
<title>feat(hub): let a hub admin disable public groups instance-wide</title>
<updated>2026-08-28T00:51:00Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-28T00:51:00Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=b5b4f188a39fc96c4d32e67151e067b1add6dcfc'/>
<id>urn:sha1:b5b4f188a39fc96c4d32e67151e067b1add6dcfc</id>
<content type='text'>
A new General tab in Administration carries one switch, allow_public_groups,
stored in a hub_settings key/value table (runtime-editable, unlike hub.toml).
Default is on; an absent row means on, so an upgrade changes nothing.

Enforcement is server-side on every hub-mediated path, not just the SPA:

  - create_group        refuses visibility=public (403), staff included
  - list_public_groups  the directory returns nothing (local + federated)
  - join_group          open-joining a public group is refused
  - group_online_nodes  a non-member of a public group is handed no node
  - signaling.webrtc_offer  drops the "node hosts an open group" fallback
  - federation.export_directory  advertises nothing to peer hubs

The switch is read live, so flipping it back restores every path. Existing
members of a group that predates the switch keep their membership row and
their access — this is plan A, not a purge. GET /v1/hub/info exposes the
flag (unauthenticated) so the create-group form and the sidebar's "Public
groups" link render correctly.

Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the
reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets
status=revoked and broadcasts a signed revocation every node enforces
(denylist + dropped live sessions). It is confirm-guarded and names the group.

And a message fix the revoke work surfaced: group_online_nodes, join_group and
webrtc_offer answered "Group is suspended" for any non-active status. They now
report the real state, so a member of a revoked group is told "Group is
revoked" rather than something reversible-sounding.

Tests: test_public_groups_toggle.py (10) covers the switch end to end and the
five enforcement paths; test_revocation.py gains the status-message assertion.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
</content>
</entry>
<entry>
<title>feat: unified group management, public groups, and activity-based sidebar</title>
<updated>2026-08-20T20:21:19Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-20T20:21:19Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2e9490ca27047ae03e495d397abbe1aec1b2273a'/>
<id>urn:sha1:2e9490ca27047ae03e495d397abbe1aec1b2273a</id>
<content type='text'>
Create Group wizard (Electron-only) consolidates 6 steps across 4 interfaces
into a single multi-step page: group creation on hub, node attachment, root
selection via folder picker, GEK initialization, and auto-pairing — all in one
flow. Browser SPA keeps its current behavior unchanged.

Public group support (Option A — GEK for all groups):
- All groups have GEK regardless of visibility; open-join groups auto-admit
  via TOFU when join_policy is "open"
- Key rotation blocked for public groups (API guard + UI hidden)
- Hub signaling allows WebRTC offers for nodes hosting open-join groups even
  when the caller isn't a member yet
- attach_group writes join_policy to node.toml
- Daemon loads GEK for all groups, not just private ones
- Known-device path in join_request now auto-admits to open-join groups

Node loopback API bridge (Electron IPC):
- node:detect, node:call, node:pairing-code IPC handlers in main process
- Renderer never sees tokens, paths, or keys (session token = physical access)
- platform.js node namespace for UI consumption
- Loopback endpoints: roots CRUD, member-upload toggle, reload

Bug fixes:
- Root change detection: removed premature ctx["roots"] updates from add_root
  and remove_root that prevented indexer retarget on reload
- Duplicate offline message: global fallback now gated on !group
- Signaling membership check: fallback to open-join groups for non-members

Sidebar groups sorted by last_activity_at (most recent first):
- New Group.last_activity_at column with Alembic migration
- POST /v1/groups/{id}/activity endpoint, called on connect and chat send
- Client-side sort + throttled hub updates (1/min)

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(ui): 11-point UI overhaul — tabs, transfers, settings, uploads</title>
<updated>2026-08-19T14:36:29Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-19T14:36:29Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=90c3c6a01f46c9b29c5d92702d7383f32e8951d7'/>
<id>urn:sha1:90c3c6a01f46c9b29c5d92702d7383f32e8951d7</id>
<content type='text'>
Hub/UI:
- Icon-only group tabs (chat, files, settings) with per-group default tab
- Transfer widget: filename becomes a clickable link to open completed downloads
- Pulse animation on transfer icon (pale→dark green) while active
- Download button feedback in FilePreview (spinner, auto-reset)
- Group mute toggle persists across navigation
- Login page autofocus, chat refocus after send
- Theme toggle closes menu, status badge and duplicate connecting removed
- Create-folder restricted to operators, download-path note removed
- User preferences API (CRUD) with Alembic migration
- Profile: email display/edit via PATCH /v1/users/me
- Settings: "Defaults" section for default tab selector
- All 10 locale files updated

Node:
- upload_dir in node.toml: separate filesystem path for uploads
- Root.direct flag: uploads land at root path, no subdirectory
- CLI --upload-dir flag on `group add`
- Admin UI accepts upload_dir

Client (Electron):
- shell.openPath bridge for opening completed downloads
- platform.js passes open callback from native save

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(hub): rewrite initial migration to match the ORM models</title>
<updated>2026-08-19T10:19:02Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-19T10:19:02Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=9498ce0a34cc363e8cf9a42575c8fcf7dfe0fd1a'/>
<id>urn:sha1:9498ce0a34cc363e8cf9a42575c8fcf7dfe0fd1a</id>
<content type='text'>
Six columns (users.pw_version, users.pk_node_ed25519, users.role,
groups.description, refresh_tokens.family_id) and two tables
(notifications, hub_peers) were never created by migrations — they
relied on create_all(), which creates missing tables but never a
missing column. This passed every test (fresh SQLite each run) and
broke every deploy to a wiped PostgreSQL database.

One migration now creates the complete schema. The seven incremental
files are removed; their changes are folded into the initial schema.
Safe on a fresh database only, which is the only case that exists
after the meshbay.org wipe.

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