| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The pass before this applied ruff's own fixes. These are the ones needing a
decision, and the point of doing them is that `ruff check .` now passes: a
linter reporting 98 known-acceptable findings reports nothing, because the next
real one arrives invisible.
**Lines over 100 (70).** Mostly wrapped where they stood. Two exceptions: the
aligned trailing comments in `protocol.py`'s message table were shortened rather
than wrapped, because wrapping one row of a table breaks the table; and in
`models.py` the column comments moved above their columns for the same reason.
**Imports below the first statement (14).** `csam.py` kept its FastAPI imports
under a section header halfway down the file; two node tests had a constant and
a `pytestmark` wedged between two import blocks. Moved, not suppressed.
**Bindings nothing reads (4).** Three in tests, where the call stays and only the
name goes — `_user(client, "listener")` is there to create the user, not to
return one. The fourth was in `revocation.py` and was not a lint finding at all:
`_connect_and_listen` opened an httpx stream to the WebSocket URL, did `pass`,
and then opened the real connection through the `websockets` library. One
pointless request per connect, left over from before that library was used
directly. Removed, and `httpx` with it.
**`l` as a name (4)**, **semicolons (6)** in the POC spikes, and the rest.
2893 passed, the same count as the two commits before it.
`meshbay_node/revocation.py` is worth a decision separately: 154 lines that
nothing imports, superseded by `hub_client.maintain_ws`'s `on_revocation`. This
commit only stopped it failing the linter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The per-feature design notes were merged into docs/MESHBAY_DESIGN.md and
deleted from the tree on 2026-09-11, but ~230 comments across the three
packages still named them — usually written `docs/musicbay.md §3.2`, as
though the file were still in docs/. A reader had to know §16 existed to
resolve any of them. They now name the section directly.
Every mapping comes from §16, the concordance, which already records where
each old section landed: musicbay -> §9.8, mediacenter -> §9.7 for the
Videos app and §6.5 where the subject is derived data, photos -> §9.9,
auth-confirm -> §3.6, refactoring-search -> §9.11, invite-pairing-v1 ->
§3.4, per-node-identity-v1 -> §3.2, captcha -> §7.7, chat-sender-keys ->
§4.5, apps/refactor-groups -> §9.1–§9.4, desktop-client-v1 per section.
Bare citations of the same documents (`draft-v6 §2.11`, `§4.8`, `§3.4`)
are retargeted too: those collide with real section numbers in the design
document, so leaving them would have been worse than the named form.
Four cases the concordance does not cover, each decided rather than guessed:
Sub-item references into documents that no longer exist — mediacenter's
`§3.3 row 4`, `§3.4b/c`, `apps.md §3b` — name rows and sub-items §9.7 and
§9.2 do not reproduce. The module-level citation stays; the sub-item
pointer is dropped.
The V-findings keep their labels but lose the dead `§10.1/` prefix.
§13.8 lists V1–V13 as per-application open items, which is not what the
labels mean in these comments, so pointing them at §13.8 would have been
a false citation.
`apps.md §5`'s virtualization requirement has no counterpart anywhere in
the design document. The requirement is stated in the comment itself, so
the citation is dropped rather than aimed at a section that does not say
it.
Comments that attributed a *sentence* to an old note — musicbay's "several
thousand files" example, its "what got measured" note, its measured
~11%/~26% cover-art figures, the "original no root, whole shared tree"
call — state the fact without attribution now. §9.8 does not contain those
sentences and citing it for them would have been wrong.
CLAUDE.md's "a reference to a document that no longer exists" row now says
the concordance is for git history and out-of-tree material; the code cites
sections directly.
Verified: 2851 passed, 4 skipped. The 12 errors in the run are the Firefox
leg of test_sticky_header.py's browser harness, which is broken at the
browser level on this machine — headless Firefox (snap) dies with
`[GFX1-]: RenderCompositorSWGL failed mapping default framebuffer`, renders
nothing, and the probe exits `{"error": "no measurement"}` after its full
90s wait. Chrome runs the same 12 assertions in 3.2s and passes. Nothing
here can affect it: every changed line in style.css is inside a comment.
Also checked: ast.parse on every changed .py, `node --check` on every
changed .js, the /* */ balance in style.css, and that no changed line
exceeds the width its file already used.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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` — `<GroupName name owner [inline]>` 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 `@<source_hub>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Stage C. Identity keys are per node, so a browser and a desktop client are two
keys on one account there — and the node refused the second where it accepted
the first. Without this, an account created natively could never be opened in a
browser without an operator code per node, and "a native client must not prevent
web use" would have been dead on arrival.
Device linking (node)
---------------------
`identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The
old shape did `INSERT OR REPLACE`, so a second device overwrote the first
silently; SQLite cannot change a primary key in place, so the table is rebuilt.
Existing pins are carried over — verified against a live roster with 10 of them,
nobody re-pairs.
A new device files a request bound by `sha256(code ‖ its own keys)`, and a key
the node **already pinned** countersigns it. The hub cannot: it has stored no
user keys since 2026-08-14, which is what makes this safe to do without an
operator in the loop.
**The code never reaches the node.** It lists this account's pending requests
with their stored hashes; the approver recomputes and keeps the match. A node
offering fabricated keys would have to produce a hash over a code it has never
seen. Nothing rests on a human comparing digits — that ritual was dropped in
12.1 as "correct, unusable as the default" and must not return by the back door.
The design document had the approver look a request up *by* its hash, which is
circular: computing it needs the keys being asked about. Corrected in both.
Revocation marks rather than deletes, because a deleted row is a key the node
would happily pin again — which is the laptop somebody just reported lost. Your
last device cannot be revoked: coming back would need an operator's code.
Hub — the only change in the whole plan
---------------------------------------
`POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as
`/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New
`user_devices` table with an Alembic migration, because `create_all()` is not
one.
This is **not** the key directory that was H3, and the tests say so: nothing
reads it but the hub, no group key is ever wrapped for one, and it is a
different key from the per-node identities. What it does cost is metadata — the
hub now knows how many devices an account has and when each last signed in.
Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an
installed client meets a newer hub the day the interface ships in a package, and
that is cheap now and awkward to retrofit.
Browser
-------
The `key_changed` refusal becomes `unknown_device` and offers a linking code
instead of telling someone to find their operator. The Members panel lists this
account's devices here, approves one by code, and retires one.
773 tests pass. `e2e.py` gained a step that links a device end to end against
the live deployment — file, list, recompute, countersign, then open the group
with the new keys and no code — and it also gained `recv_type`, because a step
that assumes the next message is its own answer reads an ack left by the step
before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Leaving is its own endpoint rather than a relaxation of the owner's removal
check — an authorization rule with an exception in it is the one that gets read
wrong later. The owner cannot leave: the group would be left with nobody able
to admit, edit or delete it, which is the answer removal and account deletion
already give.
Public groups are capped at ten live ones per owner. They are the ones that
cost other people something — listed in Discover, joinable by anyone — so a
script that opens hundreds fills the directory for everybody. Private groups
are invisible to non-members and are not capped. Hub staff are exempt; the cap
is anti-spam, not a rule about running an instance. Creation is the only place
it can be checked, and deliberately so, because PATCH refuses to change
visibility at all.
A group is now listed only once a node has announced that it hosts it. Before
that it has no files, no key and nothing to connect to, so showing it to a
member produces a name they cannot open and cannot be told why; its owner still
sees it while they set the node up. `meshbay-hub prune-groups` collects the
ones that never got a node, meant for cron, with --dry-run. The migration
backfills hosted_at from created_at: without that the first run would have
deleted every live group.
Presence rides on the group list itself, read from the signaling registry the
hub already keeps — no poll, no timer. It says a node is connected *to the hub*,
which is not a promise that this browser can reach it and not something a
dishonest hub could not fake; the client downgrades it on a connection it tried
and failed, which is the evidence that concerns the reader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
**Closing the viewer left the node working.** Nothing told it to stop:
the player dropped its handlers, which only made the browser deaf. ffmpeg
kept running and held one of the node's two transcode slots until the
credit timeout expired two minutes later — which is why the next video
answered "server busy". `stream_stop` ends it at once, and the viewer
also drops its queue, ends the MediaSource and revokes the object URL on
the way out, any of which could be holding megabytes of decrypted video.
While there: `file_chunk` replies were matched to their requests by
arrival order, which was true by luck rather than by construction. The
reply now names the file it belongs to and is matched on that and the
chunk index; a chunk nobody is waiting for is dropped instead of being
handed to whatever request happens to be oldest.
**The administration panel counted its own history.** A deleted account
is tombstoned so the connection log stays readable, and every count and
list treated that row as a user — including a group's member count, and
the member list of the group itself. They do not any more.
**Where a node is.** `endpoint_hint` is what a node believes its address
to be, learned from a STUN server and sent to us: useful for reaching it,
and a claim. The announcement that carries it is signed with the node key
over a fresh timestamp, so the address that request *arrives from* is the
address of whoever holds that key — that is now recorded on the node row
and shown in a Nodes tab, next to the hint, with the difference spelled
out. Clients get the same treatment: `webrtc_offer` is logged with the
address the hub saw when a browser starts a peer connection.
Verified against the live deployment: the node's row reads 90.112.206.172
after a restart, and in e2e a stopped stream goes quiet in one message
and the next one starts immediately instead of being refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The connection log took the name from a join on `users`, and deletion
tombstones that row — so every record belonging to a deleted account
reported `deleted-3f9a1c`, which is the one answer that helps nobody. The
log is kept for a legal retention period precisely so it can say who did
what; losing the name at deletion kept the data and lost the point of it.
`ip_logs.username` is written as the account is erased, and stays NULL
while the account is alive, where the join is better because it cannot go
stale. The admin view prefers the stored name when there is one: the join
still answers after deletion, just with the tombstone.
Releasing the username for re-registration and keeping it in the log are
separate things, and the guide now says so.
On the node side, the pre-proof audit line records the username the
session already knew, instead of leaving the column empty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Four things were wrong, and they compounded: a busy chat produced one row
per message, muting a group did nothing at all, there was no way to clear
the list, and the one person guaranteed to know about a message — its
author — was told about it.
The author bug was a name mismatch across two processes. The node sent
chat_notify without saying who wrote the message, so the hub used the
node's own token subject, which is the operator's account. The skip
therefore matched the operator and no one else: everybody was notified of
their own messages, and the operator was notified of nobody's. The node
now names the author and the hub reads that field.
Muting lived in the browser's localStorage and nothing ever read it, so
the checkbox was decoration. It is a column on group_members now, checked
where the notification is created — a notification nobody wants is not
written at all.
Chat keeps a single row per (user, kind, group) whose date moves and whose
read flag clears, so a conversation is one line saying when it last spoke.
Clicking it opens the group and dismisses it; joining a group dismisses
its invitation; and DELETE /v1/notifications clears the lot.
The hub deploy now runs alembic. create_all() only creates missing tables,
so group_members.muted never arrived on the running hub and /v1/groups/mine
answered 500 — worth catching in the script rather than in a browser.
Verified end to end against the deployed hub and node: the author receives
nothing, the other member receives exactly one, carrying its group_id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
One keypair was copied to every node its owner joined, so cracking the bundle on
any single node yielded the identity used on all of them: their content on other
operators' machines, and the ability to sign as them anywhere. That lateral reach
was the part of C4 worth attacking.
Each node now gets its own keypair, generated the first time its owner joins it
and left with that node alone. An operator who cracks what sits on their own disk
holds a key that is a stranger to every other node — and on their own node, one
that unlocks nothing they did not already hold: they serve the content, the index
and every byte of it by design.
Nothing changes for the user. A first contact with a node already needed that
operator's code, and the key is created in the same step; a second browser still
recovers it from the node with the passphrase alone. Two operators can also no
longer tell they host the same person by comparing keys.
BREAKING, and deliberately without a compatibility path — the deployment is wiped
for the next demo:
- users.pk_ed25519 / pk_x25519 dropped (migration a7c31f9e40b2)
- registration no longer sends or stores a key
- PUT /v1/users/me/keys and regenerateKeys() gone; rotation is now
`member unpin` plus a fresh code, decided on the machine that pinned it
- /pubkeys returns an account id and a node's linking key. It was the directory
H3 read, and nothing wraps for it any more
- the pk_user JWT claim is gone
That last one closed a live defect the inventory turned up: the node recorded
pk_user as the uploader's identity and authorized deletion against it, so a hub
issuing a token naming its own key could delete anyone's uploads on any node.
Attribution now uses the key the node itself pinned.
A simplification falls out. Registration generates nothing, so a scripted signup
is a real account: `demo.py bootstrap` takes a wiped hub and node to a working
demo with no browser, which was impossible while keys were born in one.
Also fixes, found by running it on a wiped deployment: the key handed back on a
join now belongs to the group the connection is for, not the group named in the
invitation — an operator pairs node-wide but redeems the code while opening a
group, and expects to read it.
Tests: 343, including the two that state the property — a key pinned by one node
is refused at another, and someone else's code does not admit it. Verified end to
end against a wiped hub and node: bootstrap, pair, invite, join, download,
stream, second browser, revoke.
Design: docs/per-node-identity-v1.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Baseline commit capturing in-progress Phase 12 work that was already present
in the working tree (uncommitted) before the Phase 11.5 security remediation
begins. Committed as-is, without review or modification, so that remediation
changes arrive as a separable diff.
Contents: BundleStore (P2P GEK + keypair bundles), password split
(auth_key / bundle_key), node Ed25519 auth (POST /v1/nodes/auth, node-scoped
JWT), GEK-HMAC handshake proof with DTLS channel binding, Ed25519 admin
challenge-response, node local admin UI rewrite, browser key persistence.
Not authored in this session — captured to establish a baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
- 10.5: Notification model + CRUD API (list, mark read, mark all read)
Triggered on: group invite, role change, suspend/unsuspend
- 10.6: SettingsPage shows role, per-group notification mute (localStorage)
- 10.7: GET /v1/groups?q= search filter (ilike on name)
- 10.8: NotificationFeed on home page + bell with unread badge in navbar
- 10.10: GET /v1/hub/version endpoint for client update checks
- 8 new tests (test_notifications.py), 155 total
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
- Site overlay: landing page, /about, /downloads (dark/light, responsive)
- User role column (user/moderator/admin) with config-based admin sync
- require_moderator dependency + admin API (8 endpoints: stats, users,
groups, audit logs)
- Admin SPA panel at #/admin with 5 tabs (stats, users, groups, logs,
blocklist) — visible only to moderators/admins
- SPA also served at /app/ for Caddy site overlay integration
- GET /v1/users/me returns current user role
- 15 new tests, 147 total passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
8.1 Config-based admin authz (require_admin on all admin endpoints)
8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key)
8.3 Refresh token rotation with family-based reuse detection
8.4 Federation persistence (HubPeer model replaces in-memory dict)
8.5 Federation token verification now async (DB-backed)
8.6 CSAM hash check wired into swarm registration flow
8.7 Rate limiting on auth endpoints (5/10/20 per minute)
8.8 Healthcheck endpoint (GET /v1/health, no auth)
8.9 IP log cleanup background task (365-day retention)
8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login)
Deployed to meshbay.org — schema migrated, existing emails encrypted.
117 tests pass (29 hub, 88 common+node).
Resolves security review items S1, S2, S5.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
keyderive.py: derive Ed25519+X25519 from username+password via Argon2id.
Same credentials → same keys on any device. Encrypt/decrypt keypair
bundle (AES-256-GCM) for hub storage (web clients).
7/7 tests. Full suite: 81/81.
keyderive.js: browser counterpart using PBKDF2-SHA512 + random keypairs
encrypted for hub storage. Avoids algorithm mismatch with Python.
hub/models.py + users.py: keypair_bundle field added to User, stored on
registration, returned in login response for web client key recovery.
QUICKSTART.md: fully rewritten. 3 operational scripts in QE/demo-v1/:
setup_demo.py — create accounts, group, distribute GEK
run_node.py — start HTTP node (watches shared/ directory)
download.py — bob login → GEK fetch → decrypt → save
All tested locally end-to-end. No invented URLs.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
6.1 Double Ratchet (meshbay_common/ratchet.py):
Forward secrecy, break-in recovery, out-of-order delivery.
Signal-spec KDF_RK/KDF_CK via HKDF-SHA256. 11/11 tests.
6.2 Multi-group node (config.py):
[[groups]] TOML array, per-group ports, back-compat [group].
6.3 MHP federation persistence (db/models.py FederatedGroup + SwarmSource):
receive_directory() now persists to federated_groups table.
list_public_groups() includes federated results with source attribution.
6.4 Content replication (node/replication.py + hub SwarmSource):
ContentReplicator: fetch-index, download, hash-verify, register-swarm.
Hub: POST /v1/swarm/register, GET /v1/swarm/{hash} for multi-source.
6.5 Browser private group (webcrypto.py + static/crypto.js):
AES-256-GCM variant of GEK for WebCrypto-compatible groups.
crypto.js: SubtleCrypto importGEK + deriveChunkKey + decryptChunk.
Keys distinct from ChaCha20 via :aes HKDF info suffix. 4/4 tests.
74/74 tests total.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
| |
DB: ContentReport + ContentBlocklist tables.
POST /v1/reports: public endpoint, auto-blocks after 2 reports.
GET /v1/blocklist/check: node sync check before serving public content.
GET /v1/blocklist: full list for node startup sync.
GET|POST|DELETE /v1/admin/blocklist: admin management.
6/6 tests. Full suite: 59/59.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
|
|
Models: User, Node, Group, GroupMember, GEKBundle, RefreshToken, IPLog.
Engine configurable via MESHBAY_DATABASE_URL (asyncpg/aiosqlite).
Alembic async env.py + initial_schema migration autogenerated.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|