mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
422ae2d2d6f7bfd2da8a5d559b471a39afdb26e6
18392
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
422ae2d2d6 | chore: sync Crowdin translations and seed translator npub placeholders | ||
|
|
b6eb610749 |
Merge pull request #3892 from vitorpamplona/feat/negentropy-want-id-predicate
negentropy: let a caller decline an id before the download REQ |
||
|
|
7041980996 |
negentropy: let a caller decline an id before the download REQ
negentropySync names every id the relay has that the caller lacks, then fetches all of them. There is no point between those two steps where a caller can say "not that one" — onEvent is the first hook, and by then the body has already crossed the wire. That is a real cost for a mirror. A store keeping only the newest version of a replaceable event refuses every relay's older copy, and negentropy re-offers those copies on every sync because they are genuinely absent from the local id set. Measured on a downstream mirror against relay.damus.io, nos.lol and relay.primal.net: three passes over kinds 0/3/10002 produced 5, 18 and 29 REPLACED rejections, the same events re-downloaded each time. Adds an optional `wantId: ((HexKey) -> Boolean)?` to negentropySync, negentropySyncOrFetch and negentropySyncFanOut, consulted for each id before the REQ, plus a `skipped` count on the three result types. Default null keeps every existing call byte-identical. Three decisions worth stating: - The gate runs on a whole reconcile round's ids, BEFORE they are chunked into fetch batches. Gating after the chunking keeps the batch count and shrinks every batch instead — at the density this exists for, a fetchBatch of 500 becomes a hundred REQs of five ids each, turning a bandwidth saving into a latency regression. - `skipped` is reported apart from both `downloaded` and `needCount`. needCount stays the honest protocol diff whether or not the caller fetched it, and without a separate number an operator cannot tell a predicate that does nothing from one that eats everything — both are silent. - keep() returns an empty list, never null. A nullable return invites `gate?.keep(ids) ?: ids`, which reads as "no gate, keep everything" and means "everything was declined, so send everything". That elvis turned the fully-declining case into a full download during development; the non-null contract removes the trap rather than documenting it. The gate does not cover a window handed to onUnreconcilableWindow: that is drained over REQ, which names no ids before streaming bodies. Documented on both the base function and the combinator. |
||
|
|
b259382652 |
Merge pull request #3888 from vitorpamplona/fix/concord-followups
feat(concord): close the CORD-05/06 invite lifecycle — Invite List, re-mint on Refounding, revocation |
||
|
|
70c53a8fcd |
fix(concord): make the invite-list writes actually durable
A high-effort audit of the branch found that the durability guarantees the
previous commits claimed were not the guarantees the code provided. Three of
these are in the code written to close the last review, and they defeat
exactly what those commits set out to fix.
`INostrClient.publish` returns Unit — it queues an event and never reports
acceptance; `publishAndConfirm` is the confirming variant. So every
`runCatching { publish(...); true }` was true whenever local signing worked.
That made minting's "record the link before handing out the URL" gate
decorative, and made revoke worse than decorative: it reported success for a
tombstone no relay stored, then recorded the kind-13303 tombstone, whose
merge drops the entry — destroying the only `signer_sk` that could ever
retire the link while the link stayed live. Both paths, and the Refounding
re-mint, now confirm.
`fetchAll` returns an empty list on cannot-connect / CLOSED / idle-timeout,
so "a relay served us and had nothing" and "nobody answered" were the same
observation. Reading the second as "no list yet" reintroduced, one layer
below, the wipe the null-vs-empty work existed to prevent. `fetchAllWithHooks`
gains a `doneOut` of per-relay terminal reasons plus `anyRelayServed()`, and
both clients now only treat an empty read as an empty list when a relay
actually reached EOSE.
The rest:
- `drainConcordRekeys` discarded the entry `adoptConcordRoot` now returns, so
only the account that *launched* a rotation re-minted its links. An admin
who was merely re-keyed left every link they had handed out on the dead
root, and anyone stranded behind one could never recover — which is the
branch's headline goal, holding only for the rotator.
- The join-time ban gate fetched the Control Plane from `bundle.relays`
alone (stale metadata refuses a community we can plainly reach) with a
single un-paged REQ (truncated at the relay's filter cap, so a missing
older ban edition fails the gate OPEN, re-admitting the account it exists
to refuse). Now unions in the relays that just served the bundle, and pages.
- `decodeOrNull` failed the whole document for one structurally incompatible
entry. Since null now means "refuse to write", that converted the old
silent data loss into a permanent write lock on a coordinate that never
ages out. Unreadable entries are carried verbatim instead, so they neither
block the account nor get dropped on re-encode.
- The list read took the newest event of any kind and then cast, so one stray
event at the coordinate read as "unreadable" forever. Filters by kind first.
- The Refounding refresh did one full round trip per link, serially, inside a
user-visible rotation. One pooled REQ over every link signer, then
concurrent confirmed re-mints, classified per coordinate so one link's
tombstone cannot decide another's status.
Verified on a tablet: mint, list, revoke and the cross-client refusal still
work end to end — and with the community relay killed, revoke now reports
"The link couldn't be revoked" and leaves the entry intact, where before it
would have claimed success and destroyed the key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d27930fe76 |
feat(concord): manage and revoke your invite links from Android
Revoking existed only in `amy` after the last commit, so the app could hand out a link it could never take back. This adds the Android half. `Invite links…` in a community's overflow menu opens a screen listing every link this account minted for it, read from the creator's own kind-13303 Invite List, each row offering Copy and Revoke. It shows only *our* links, because a link's `signer_sk` is what authors its coordinate and only the minting account ever held it — another admin's links are invisible here and un-revokable from here. That is the protocol, not a gap in the screen. Two deliberate choices: The entry point is NOT gated on CREATE_INVITE, unlike minting. Revoking acts on a key we hold rather than on the community, and gating it on the bit would mean a demoted admin could no longer retire the links they had already handed out — exactly when that matters most. An unreadable list is its own state, never an empty one. Telling a creator who came to kill a leaked link that they have no links would be a lie in the one direction that costs them something. `revokeConcordInvite` publishes the wire tombstone first and records the kind-13303 tombstone second, for the same reason the CLI does: the entry holds the only copy of the `signer_sk` the publish needs, and a merge drops a tombstoned token's entry terminally. A failed list write is reported as success because the link is already dead on the wire. Verified on a tablet against a local relay, cross-client with amy: the screen lists the two links the device minted (and not the one alice minted), the confirm dialog revokes exactly one coordinate — flipping it to vsk=9 with empty content while its siblings stay vsk=6 — the row disappears on reload, and a link revoked from the UI is then refused by `amy concord join` with `revoked` while the surviving link still joins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fc3f181184 |
feat(cli): add amy concord revoke — retire an invite link (CORD-05)
The reading half of revocation already existed: `classify` has always resolved a `vsk=9` tombstone to `Revoked`, and Amethyst's join honours it. Nothing anywhere could *produce* one, so a leaked link could only be outrun by a Refounding — rotating the whole community to retire one URL. `ConcordInviteBundle.buildRevocation` emits the grave the spec describes and Armada's `buildRevocationEvent` already publishes: kind 33301 at the link's own `["d",""]` coordinate, empty content, `["vsk","9"]`, signed by the `link_signer` secret. Empty content is the interop contract, not an omission — there is nothing to encrypt when the point is that no bundle key opens anything. `amy concord revoke COMMUNITY TOKEN|URL` takes either the shareable URL a creator actually has to hand or the bare token. It publishes the wire tombstone FIRST and records the kind-13303 tombstone second, which is the inverse of minting and deliberate: the list entry holds the only copy of the `signer_sk` the publish needs, and a merge drops a tombstoned token's entry terminally. Recording first and then failing to publish would leave the link live with its signer gone and no way left to retire it. A failed list write is recoverable by comparison and is reported rather than swallowed. Also fixes a revocation bypass in amy's own `join`, found while testing this: it opened the first wrap that decrypted instead of classifying the coordinate, so a relay still serving a stale copy alongside the grave would have handed out a revoked link. It now resolves per CORD-05 §2 like Amethyst does, and can say which of revoked/expired/unreadable/absent it hit instead of reporting everything as `not_found`. Verified end to end against a local relay: revoking flips the coordinate to vsk=9 with empty content, the link is refused from that moment on, a second revoke reports `already_revoked`, and a Refounding afterwards moves the surviving link while leaving the grave alone — the first real proof of the tombstone-skip in the refresh path, which until now had only unit coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a1f980babd |
fix(concord): make the invite failure messages visible in the dark theme
Found while proving the new ban gate on a tablet: the refusal reached the view hierarchy but rendered as black pixels on the black background, so the screen looked blank and the user was told nothing. `ConcordInviteScreen`'s Column sits on the bare window background with no Surface above it, so `LocalContentColor` is still Material 3's default black. This predates the invite work and silently affects every state the screen can end in — invalid, incompatible, revoked, expired, unreachable — plus the "Redeeming invite…" progress label, which is why only the spinner was ever visible while a join was in flight. Verified on device: the message now renders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eb8c812690 |
fix(concord): close ten review findings in the CORD-05 invite path
A high-effort review of the invite work found ten correctness bugs, nine confirmed and one plausible. All are fixed here; the on-device pass on a tablet proved the three that are observable through the UI. The load-bearing one: the kind-13303 Invite List is replaceable, and both clients merged a patch onto a base that silently degraded to EMPTY whenever the read failed — an unanswered relay or a bunker signer declining one decrypt was enough. Republishing that destroys every `signer_sk` it could not read, and those secrets cannot be regenerated, so every outstanding link is orphaned at a dead epoch. `decode` is now `decodeOrNull` and `decrypt` returns null, so "I could not read it" is distinguishable from "it is empty", and the write aborts rather than overwriting. The rest: - `join` is now ban-gated on both clients. A Refounding re-mints every outstanding link onto the new root, and an ex-member keeps the URL and its token forever, so the rotation meant to expel them handed them the new keys instead. Fails closed on an unreadable plane. - Android's Refounding re-read the entry from `liveCommunities` straight after adopting the new root, but that flow decrypts asynchronously, so every link was re-minted onto the epoch just left. `adoptConcordRoot` now returns the entry it wrote. - amy's refound folded the fresh bans locally and then never used them, re-draining from relays instead; a relay slow to echo them back would produce a new epoch whose roster never banned anyone. - Link refresh rebuilt the bundle from scratch, stripping expiry, channel grants, icon and label; it now moves the link's own current bundle and changes only the epoch's key material. - Refresh also re-posted over revocation tombstones, silently un-revoking a retired link. - The 13303 coordinate is (13303, me, "") — one list per account — but was read and written on per-community relays, forking it into divergent versions that newest-wins then collapsed. Now account-outbox only. - Minting returned the URL even when recording the link failed, handing out a link that could never be refreshed. It now fails closed. - amy's refound had no equivalent of Amethyst's recipient cap, leaving the attacker-writable half of the union unbounded. - amy's store dropped the banked epoch's `controlRoot` on the round-trip, losing the staff write key that rebuilds the anti-rollback floor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
66d2777262 |
feat(concord): wire the Invite List into Amethyst; fix the QR quiet zone
Android half of the CORD-05 Invite List (kind 13303). Minting records the
link's `token` + `signer_sk` in the shared, self-encrypted list, and a
Refounding re-posts every live link it finds there at that link's own
coordinate, carrying the new epoch. Read-merge-write, never overwrite: two of
the user's devices minting concurrently would otherwise delete each other's
`signer_sk`, which is unrecoverable. Expired links are skipped — re-posting
one would only resurrect a dead URL at a live epoch.
Verified on a Galaxy Tab A7 Lite against a loopback geode, driving the real
UI:
- tapping Invite publishes a kind-13303 authored by the device
- Remove member → the Refounding publishes 5 control wraps + 1 rekey blob;
`amy` follows it (epoch 0 → 1), and the removed member gets
`no_blob_for_us` and stays behind
- with a device-minted link in the list, the next Refounding logs
"refreshed 1 invite link(s) to epoch 2" and the bundle at that link's
coordinate is REPLACED in place rather than orphaned
Also fixes the invite QR rendering as a postage stamp. QrCodeDrawer's quiet
zone was a fixed 100px per side, which does not scale: at the dialog's 220dp
box that ate ~45% of the canvas, so a long payload drew tiny inside a large
white card. Expressed as the QR spec's 4-module zone it stays proportional,
and the code now fills whatever box it is given at every call site.
Note: OpenCV cannot decode this drawer's stylized modules either before or
after the change, so scannability was not machine-verified — the change only
shrinks excess quiet zone to the spec minimum and enlarges the modules, but a
camera check before release is worthwhile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a5507f9a4d |
Merge pull request #3889 from vitorpamplona/claude/paged-walk-termination-guards
quartz: make a paged walk terminate — floor the cursor at 0, stop when a relay ignores it |
||
|
|
293ffd0bc5 |
feat(concord): implement the CORD-05 Invite List (kind 13303), wire-compatible with Armada
The previous commit kept link secrets in amy's local store, which made link
refresh work but only for that one client. The spec already defines where
they belong, and Armada implements it, so this replaces the local field with
the real cross-client document.
Kind 13303, replaceable, NIP-44-encrypted to self — the creator's private
bookkeeping:
{ "entries": [ { "token", "signer_sk", "community_id", "url",
"label?", "created_at", "expires_at?" } ],
"tombstones": [ { "token", "community_id" } ] }
`token` is both the link's unlock secret and the merge key; `signer_sk` is
what lets any of the creator's clients re-sign at that link's addressable
coordinate. Armada types both the entry and the tombstone as
`[k: string]: unknown`, so unknown keys are contract: the codec preserves
entry-, tombstone- and document-level residue, and re-encoding never deletes
another client's data.
Merge is by token, read-merge-write rather than overwrite — the list is
replaceable and per-creator, so two devices minting concurrently would
otherwise destroy each other's `signer_sk`, which is unrecoverable. A token
tombstoned on either side stays dropped, so a stale device cannot resurrect
a retired link.
Registers 13303 in EventFactory (without it the kind deserializes as a plain
Event and every typed read fails), and points amy's mint and Refounding
refresh at the list instead of its own store.
Semantics were taken from the spec and confirmed against Armada's observable
behaviour — read for semantics only, never copied: Armada is AGPLv3 and
Amethyst is MIT.
Verified against a loopback geode: `concord invite` publishes an encrypted,
untagged 13303; a Refounding reads it back, refreshes the live links, and a
member with no role who never posted — unfindable by any rotation — recovers
epoch 0 → 1 through the link he already held.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7ca4fbab33 |
Cover the dense-second step-past the new guard sits in front of
The step-past path had no test, and it is the one thing the ignored-cursor guard could plausibly break: both cases reach the same `delivered == 0` branch. They are told apart by WHERE the events landed — a dense boundary second returns them AT the boundary, so `aboveBoundary` stays 0 while `received` is 1 and the guard holds its fire; only a relay answering ABOVE the boundary is not paging at all. Scripted end to end: a second the relay's page cap can only ever return the head of, the step strictly past it, and the empty EOSEd page below. Also proves the documented cost is still paid rather than silently changed — the unreachable tail of that second is lost, and `downloaded` says so. `event()` grows a nonce so two events can share one `created_at`; the id was derived from the timestamp alone, which collapsed them into one event and made a dense second impossible to script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HPSzniNdvJxkhRsCe1QcyT |
||
|
|
3b7ffcd06c |
Make a paged walk terminate: floor the cursor at 0, stop when a relay ignores it
`fetchAllPages` could not end against a relay that does not honour `until`.
Found in production on purplepag.es, which holds twelve `kind 10002` events
stamped `created_at = 0` and treats `until <= 0` as *no* `until` — so the page
below them comes back with its five hundred NEWEST events. None of those
matches the filter's own `until`, so the page delivers nothing, which read as
"the boundary second is too dense to page", stepped one second lower, and
asked the identical unanswerable question again.
Measured against the live relay: ~5.5 pages a second, 500 events fetched and
discarded on each, an EOSE on every single page, `until` marching one second
further negative every time, for as long as the process ran. A cold walk pulled
1,490,010 real events in ~10.8 minutes and then never returned, so the caller's
coverage was never recorded and the next boot re-walked all of it. Not a rate
limit: ~1,000 consecutive pages drew no NOTICE, no CLOSED and no throttling.
Two guards, both at the points where the cursor moves:
- The cursor floors at zero. `created_at` is unsigned, so nothing can exist
below epoch 0: a cursor that would step under it has reached the bottom of
the time axis and the walk is DRAINED. `until = 0` is still asked — it is a
legal query and the boundary re-fetch for epoch-stamped events — only going
BELOW it ends the walk. This also keeps a negative `until` off the wire,
which relays disagree violently about: measured across five, one CLOSEs the
subscription with a parse error, three answer a NOTICE and then never EOSE,
and one drops the bound and serves its newest events. The floor is applied
on the advance path too, not just the step: `pageMinTs` is an event's own
`created_at`, so one relay serving a negative timestamp is enough to drive
the cursor under zero, and clamping rather than stopping would not help —
such an event never equals the boundary, so it dodges the dedup and returns
on every page.
- A relay that ignores the cursor is UNPAGEABLE. When a page delivers nothing
and every event it received was NEWER than the `until` it asked for, the
relay is not paging at all and stepping one second lower just repeats the
question. `aboveBoundary == received` is what tells this apart from a
genuinely dense boundary second, whose events are AT the boundary rather
than above it. UNPAGEABLE is deliberate and conservative: it proves nothing
about what the relay holds, so no coverage claim can be built on a page the
relay never really answered.
This second guard is the structural fix. Giving the filter a `since` does not
substitute for it: the step path decrements `until` without regard to `since`,
so a cursor-ignoring relay still walks from the window floor down to 0 — up to
~1.5 billion pages. A `since` only helps when the relay honours it, and then
only because the empty page arrives as a drain.
Three scripted tests cover both guards, and CursorTerminationProbe dials the
five relays that found this (opt-in, `-PprodRelayBench=1`, asserts nothing).
Against the live relays after the change: purplepag.es ends UNPAGEABLE in one
page and 2.4s with the cursor never going under 0, where it previously ran 244
pages to `until = -243` without ending; the other four still DRAIN unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPSzniNdvJxkhRsCe1QcyT
|
||
|
|
409339b375 |
feat(concord): re-mint invite links on Refounding so stranded recovery fires
Closes the liveness half of A2. Recovery's whole premise is that the
community keeps re-minting its bundle at the SAME addressable coordinate, so
the link a stranded member already holds starts pointing at the new epoch.
Nothing did: `ConcordInviteBundle.mintLink` generates a fresh KeyPair and
token per call, and no client persisted `linkSignerPrivKey`. Every mint was a
new coordinate, so `recover` could only ever return `already_current` — the
mechanism was dead code, and an owner evicted by a rogue admin had no way
back.
The kind-33301 bundle is addressable and authored by the link signer, so
re-signing at that coordinate with the same token replaces what is there and
every holder of that link keeps working. Exposes that as
`ConcordActions.remintBundleAt`, persists the link signer + token in amy's
store at mint time, and has `concord refound` refresh every link it minted
for the new epoch.
Re-minting every live link is safe precisely because the security half is
already in: `refound` bans the removed members on the way out, and `recover`
reads the banlist of the epoch being LEFT, so a removed member's own recovery
is refused even though their link now resolves. That gate stops being
belt-and-braces here and becomes load-bearing — which is what the audit
predicted for any client that re-mints (Armada does).
Verified end to end against a loopback geode, both directions:
bob joins by link, holds no role, never posts (unfindable by a rotation)
alice refound --remove <stranger> → recipients=1, invites_refreshed=1
bob rekey → no_blob_for_us (genuinely stranded)
bob recover → recovered, epoch 0 → 1 ← first time this has ever fired
bob reads the community at the new epoch
alice refound --remove bob → epoch 1 → 2, invites_refreshed=1
bob recover → refused, reason "banned", still at epoch 1
Still open for the shipping client: Amethyst persists no link signer, so
A2 liveness remains open on Android. Doing it there means deciding where the
secret lives in the kind-13302 list, which Armada also reads — a wire-schema
call, not a code one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
0aa3adfc07 |
feat(cli): add amy concord refound + rekey — the rotation half of CORD-06
`refound` is the hard removal a ban cannot give: a ban only strips standing,
while the removed member keeps every key they ever held. Rotating the
`community_root` — and, since CORD-02 §2, a fresh `control_root` beside it so
a demoted staffer's retained secret dies with the epoch — is what actually
closes the room. The compacted Control Plane is re-sealed at the new epoch
and each retained member gets a rekey blob.
Authority mirrors Amethyst exactly: `hasPermission`, never
`effectivePermissions`, so a banned BAN-holder cannot launch one; the owner
is never a valid target; and removal takes the same rank rule as a ban
(CORD-04 §3) — an admin cannot Refound a peer admin out.
The recipient set reaches past the roster to the Guestbook AND the authors of
every channel message we can decrypt, because a member who only ever posted
holds no role and files no Guestbook motion — building the set without them
silently expels them. It is still a floor, not a census.
`rekey` is the receive half, and without it `refound` was actively harmful
from the CLI: a retained member's blob sat on the relay unopened, so a
Refounding launched from amy stranded every other amy member. It authorizes
the rotator against the roster of the epoch being LEFT and fails closed.
Verified end to end against a loopback geode — the full cycle, which was not
previously expressible from the CLI at all:
alice refound --remove <stranger> → epoch 0 → 1, recipients=2
(bob is kept because he POSTED, holding no role — the author harvest)
bob rekey → epoch 0 → 1, same root + control_pk
bob sends, alice reads it at the new epoch
alice roles → the removed member is banned
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4022a6a5da |
feat(cli): add amy concord recover for stranded-recovery (CORD-05/06)
Completes the CLI's Concord receive path. A Refounding carries only `(newRoot, newEpoch, rotator)` and no recipient list, so a member simply left out of the rekey receives nothing and sits on the dead epoch forever while everyone else moves on. There is no message to miss, which is why the rekey drain cannot help: the only way back is the invite link the membership was joined through, since the community keeps re-minting its bundle at the same addressable coordinate. amy never stored that anchor, so recovery was impossible in principle. Adds `inviteRef` to the stored record, populated on `join` (bare, domain-agnostic) and carried through `import` — backstopped by what we already held, because a list entry without one must not clear ours or the NEXT exclusion becomes unrecoverable. Recovery is an explicit verb rather than Amethyst's timer sweep, so it stays deterministic and scriptable. Each community reports why it did or didn't move: `no_invite_ref`, `bad_invite_ref`, `no_live_bundle`, `banned`, `already_current`, `control_plane_not_folded`, or the epoch it advanced to. The ban gate is the part that matters (A2 in docs/concord-soft-ban-audit.md): a removed member keeps the link's unlock token forever, so without it this walks them straight back into the epoch they were rotated out of. It reads the banlist of the epoch being LEFT — the last plane we can still fold — and fails closed: a plane that will not fold yields no verdict and is skipped, never recovered. Verified against a loopback geode: a current member gets `already_current`, a community with no anchor gets `no_invite_ref`, and a member banned at the current epoch is refused with `banned`. The merge-forward itself is quartz's `ConcordStrandedRecovery` (already unit-tested); it is not exercised live here because amy cannot perform a Refounding to strand anyone with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3153942bac |
feat(cli): let amy adopt the Control Plane write key a Grant delivers
A promotion to staff delivers the `control_root` inside the Grant edition itself (CORD-04 §3). Amethyst drains that on its Concord revision tick, but that logic lived only in `AccountConcordActions`, so `amy` could hold a rank it could never write under: the fold seated it as staff and every moderation verb still refused with `forbidden`. That is also why #3873's delivery path shipped without a CLI test — the harness could not accept a promotion. Extracts the decision into `commons` as `ConcordReceive`, pure and shared: - `deliveredControlRoot` — the whole fail-closed check (are we staff by our OWN fold, does a Grant carry a wrap, does it open under the pairwise key, name this epoch, and derive to the `control_pk` we already hold). - `withAdoptedRoot` — the entry rewrite a base rotation produces, banking the leaving epoch's address for the anti-rollback floor. - `isAuthorizedRotator` — the ban-aware rotator check. Amethyst now calls the shared versions (no behaviour change; its persist + publish and Guestbook re-announce stay put). amy adopts during the Control Plane drain every moderation command already performs, since it has no tick of its own, and returns the refreshed record so a freshly promoted staffer can pass the secret on in its own Grant. Adoption is local to amy's store on purpose: Amethyst republishes the kind-13302 list so a user's other devices follow, and doing that here would mean rebuilding and signing the whole list from the CLI. Verified end to end against a loopback geode: bob is refused before the promotion, alice promotes him, bob's stored `control_root` is blank, his next command adopts and persists it, and his BAN lands and is honored by alice's independent fold. A role edition he lacks MANAGE_ROLES for is still dropped on fold — possession remains a spam gate, never authority. Still Android-only: `recoverStrandedConcordCommunities`, which needs invite re-resolution over the network. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
05a331068f |
test(concord): pin that a banned member's typing heartbeat is dropped
The soft-ban audit's A4 shipped with a send-side guard and a receive-side filter, and the receive-side filter — the only one that binds a modified client — had no test. Bans first, so the assertion exercises the filter rather than an entry that was seated before the ban, and checks the filter is targeted rather than a blanket mute. Mutation-tested: removing the isBanned check in ConcordCommunitySession.ingestTyping fails it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e7d71d28fb |
Merge pull request #3858 from dskvr/codex/chase-nip5d-naps
Align napplet host with current NIP-5D and NAPs |
||
|
|
a0528c5745 |
Merge branch 'main' into codex/chase-nip5d-naps
Brings the NIP-5D napplet-host alignment up to current main, which had moved
98 commits ahead of the branch point (
|
||
|
|
384d5219ea |
Merge pull request #3886 from vitorpamplona/claude/push-notification-grouping-lbd13e
fix: stop reposts from being bundled with the always-on service notification |
||
|
|
2ecbb66cdb |
Merge pull request #3885 from vitorpamplona/claude/concord-soft-ban-vulnerability-rbjjet
fix(concord): close the soft-ban authority holes (audit A1–A4, B1, B2, B4) |
||
|
|
59994a7847 |
fix: mark a notification dismissed on swipe, mark-read and inline reply
The 25s enrichment window re-posts a notification every time metadata for it lands, and postStandard/postConversation only skip that when NotificationUtils.wasDismissed says the user is done with the event. Only one path ever recorded that: reading the note in-app. Swiping the notification away, hitting "mark as read", or replying from the tray all just cancelled the notification id, so the enricher happily put it back seconds later — and kept a relay subscription and a wakelock open for it until the window elapsed. Every notification already carries a delete intent and its actions target the same receiver, so thread the event id through them and mark it dismissed there. Replies mark it only once the send succeeds, leaving a failed send free to enrich and retry. Also, in the same area: - Pin the group summary's timestamp to the child's event time. It defaulted to "now", and the summary is re-posted on every enrichment re-render, so the group kept re-sorting in the shade while the user was reading it. - Make the childless-summary scan a single pass. Now that every child ships with a summary the active list is about twice as long and the pairwise scan grew four-fold. Deciding what is a child by the summary flag instead of by comparing ids also fixes the case where a child's id equals the summary's. |
||
|
|
f1241e891b |
fix(quartz): compaction must carry the authority-gated head, not the chain head
Findings from an independent review of this branch (a different model, per CONTRIBUTING-WITH-AI.md). Two were real, and the first is a regression this branch introduced. compactControlPlane picked its per-entity head with a bare structural chain walk, which is worse than the raw max-version it replaced. With no floor, foldEntity anchors at the lowest-version edition carrying no `prev` — and after a PRIOR compaction the genuine head's `prev` dangles into a trimmed epoch by design. So a forged `version = 1, prev = null` decoy outranks a real v50→v52 chain, and because nothing in this path checks a signature it became the entity's entire carried-forward state. A forged empty banlist would have erased every ban at the next Refounding. Reproduced, then fixed by selecting the owner-rooted authority-gated head — the same edition ConcordCommunityState.fold would seat, so the new epoch starts where the old one left off, and an unprivileged author cannot influence the choice at all. recoverStrandedConcordCommunities derived its new ban gate with `?.isBanned(..) == true`, which reads "not banned" when the session does not exist yet or its first fold has not landed. The sweep runs on the revision tick, so a banned member's own client would have hit that window on cold start and recovered itself — the exact bypass the gate exists to stop. It now fails closed and retries on the next sweep. Also from the review: resolve() now warns when the ban fixpoint exhausts its pass cap without settling, instead of silently returning a roster folded under a mask that no longer matches its banlist; and banGate stops lowercasing the same author three times. Two review findings are accepted rather than fixed, and recorded on the PR: the anchor tie-break picks the lowest rumor id before testing whether that candidate connects (pre-existing, and changing it is consensus-affecting), and non-owner moderators now need a resolved roster before a verb succeeds, which is the intended fail-closed trade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
d15ee295d7 |
fix: stop reposts from being bundled with the always-on service notification
Android 16 force-groups notifications the app leaves loose. A group child whose summary is missing counts as ungrouped (GroupHelper. isGroupChildWithoutSummary), and with config_autoGroupAtCount at 2 it takes one such child plus one other ungrouped notification in the same shade section to form an aggregate bundle. We produced both halves. sendGroupSummary only posted the summary once two children of a group were live, so a lone repost or reaction sat there as a summary-less child; and the always-on relay service posts an ongoing, IMPORTANCE_LOW notification, which shares the Silent section with those two IMPORTANCE_LOW kinds. The system's aggregate summary inherits FLAG_ONGOING_EVENT from any child that has it, so the resulting bundle could not be swiped away without dismissing the service notification. Post our own group summary from the first child on, which keeps the group app-owned and off the system's list. It changes nothing visually: the shade hides any group with fewer than two children and renders the child on its own. That promotion is also why the summary now needs cleaning up: a promoted child is no longer "the only child in its group", so dismissing it leaves the summary behind, and a childless summary is both shown standalone by the shade and force-grouped by the system. Children now carry a delete intent that prunes it, the mark-read and inline-reply paths prune through cancelAndPrune, and the pruning ignores an id cancelled moments ago (cancel and notify are asynchronous, so activeNotifications can still list it) and leaves the platform's own aggregate summaries alone. Summaries also gain GROUP_ALERT_CHILDREN so they stay silent now that they go up alongside the first child. |
||
|
|
9cc19c60ca |
fix(concord): three defects found auditing this branch's own changes
Self-review of the diff before merge. One of these is a real correctness bug in the B2 fix as shipped. The resolver stopped after two passes, which left the mask a pass resolved UNDER disagreeing with the banlist that pass produced — and the disagreement is not cosmetic. A moderator whose only ban came from an admin the owner banned concurrently is released by pass 2, correctly; but pass 2 had already dropped her editions, because she was on pass 1's list. The fold then reported her as a moderator in good standing whose promotions had silently vanished, and did so deterministically, so she never got them back. resolve() now iterates until the mask and the resulting banlist agree. The mask cannot simply be assumed to shrink, which is why this is bounded rather than proven monotone: masking an author can strip a THIRD member's role, which drops their rank to roleless, which lets a junior BAN holder who previously could not reach them ban them after all. The loop keeps its last pass if it does not settle within the cap — still better than the two-pass answer, and it always terminates. Real communities settle on the first or second pass, and the skip-if-no-banned-author guard means most never enter the loop at all. boundRecipients could exceed its own budget while reporting that it had capped at it, because the roster was added with filterTo before the budget loop ran. The roster now goes in whole deliberately — it is owner-rooted and cannot be padded from outside, and dropping an admin to make room for a stranger inverts the point — and the log reports what was actually kept and dropped. mintConcordInvite started requiring a session, which the owner's own invite button would not have on a cold start, since sessions are built asynchronously off the joined list. The owner is proven by the community id, so they are read off the entry; everyone else still needs the folded roster. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
54d3bfc84a |
perf(quartz): skip the resolver's second pass when it cannot change anything
The two-pass ban-aware fold doubles resolve(), which runs once per held epoch in controlFloorsLocked plus once in fold, on a client that re-folds the whole buffer from scratch on every Control Plane change. So the second pass is now skipped unless a banned member actually authored a Control edition — not merely when the banlist is empty. Bans overwhelmingly land on plain members who hold no role and write nothing, and for those pass B is provably identical to pass A. Armada's fold checks the same condition. Measured over ConcordCommunityState.fold (throwaway benchmark, not committed; 226 and 2059 editions, 200 reps after warmup). Pass A is byte-for-byte the old algorithm, so the single-pass rows are the before-numbers: 226 eds, no bans 1457 us 226 eds, 20 bans, none authored 994 us 226 eds, 20 bans, one authored -> pass B 1881 us 2059 eds, no bans 2194 us 2059 eds, 50 bans, none authored 2001 us 2059 eds, 50 bans, one authored -> pass B 5697 us 2059 eds, with floors (B1's arm) 2015 us So the common case is free, and B1's chain-first compaction arm is not measurable — the floored fold matches the unfloored one. A banned staffer costs ~2-3x, which is the price of the fix and is paid only under the attack. The audit records this, plus the standing opportunity it surfaced: we have no fold memoization where Armada does, which predates this work and would absorb the pass-B cost too. Not done here — that is a change to make on its own merits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
6147f72c81 |
docs(concord): check the audit against Armada, and correct two conclusions
Read gitlab.com/soapbox-pub/armada src/concord-v2/ against every finding. Two conclusions change. B2 is NOT consensus-affecting, and the warning in the last commit was wrong. Armada's foldControlState already runs the same bounded two-pass — fold once, take the banlist, re-fold with banned authors' editions excluded — arrived at independently, same shape, same CORD-04 §4 justification in the comment. This change brings us into line rather than out of it. One narrower divergence remains: they keep pass 1's banlist as final, we recompute it in pass 2, so a banned admin's mass-ban still stands for them and is dropped by us. Both defensible; ours closes an attack theirs leaves open, and the self-erasure they guard against is unreachable under the rank rule. A2's fork is resolved, in favour of the fix having been necessary. useLinkRefreshWatch2 re-posts every invite bundle on each epoch change, so the "if anything re-mints at a stable coordinate" branch is what actually happens — in any cross-client community a removed member's Amethyst client would have pulled the new root within fifteen minutes. Their catch-up is push instead: a privileged member sends a direct invite carrying the fresher root, so a human authorizes each re-admission, and useBanSelfRemove2 has a banned member's own client silently drop the community. The liveness half stands and now has two concrete options rather than an open question. Also recorded: B1 is present in Armada unfixed, in exactly the same place (bootstrapHead is unbounded, headCandidates uses it, pickHead raises the floor) — the second bug both clients share by reading one section the same way, so it goes to them in writing like the rank rule did. A1 was ours alone; they gate invite creation on CREATE_INVITE in both the hook and the page. C1 is unchanged on their side. A4 is a shared gap. And a divergence in the other direction: their banlist takes only the head's content, with no §4 re-heal union, so we honor concurrent bans they drop. B4 is marked unchecked rather than guessed at — I could not locate their recipient-set construction with confidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
4e99aafb59 |
fix(quartz): honor the banlist against the Control Plane itself
B2 in docs/concord-soft-ban-audit.md, plus B4's bound and the audit's status pass. hasPermission was ban-aware; the resolver's own ROLE/GRANT/BANLIST gates were not, and could not be as written — the roles/grants fixpoint settled before `banned` was computed at all. So half the Control Plane honored a ban and half was blind to it, and a banned staffer still holding control_root kept the whole roster: banning everyone beneath them, revoking the surviving moderators, retiring the roles under them, and minting a fresh un-banned npub that passed every ban-aware gate and finished the job. resolve() is now a bounded two-pass where authority only ever shrinks. Pass A resolves as before and yields a candidate banlist; pass B re-resolves with every author on it treated as holding no authority. Two passes always, so it terminates by construction, and mutual bans cannot oscillate because the rank rule makes them unreachable — only someone who strictly outranks you may ban you, and you cannot outrank them back. A chain-local rule would not have worked: forking the banlist at genesis means no parent ever mentions the ban and §4's re-heal union carries it in regardless, so the rule is a whole-pass mask rather than a per-edition check. This cascades, deliberately: every edition a banned member ever authored is dropped, grants included, so banning an admin also demotes everyone that admin promoted. That is the literal reading of CORD-04 §4 and it is what kills the sockpuppet, but a legitimate promotion by a later-banned admin vanishes with it and has to be re-issued. Both the cascade and its blast radius are pinned, and the trade-off is written up in the Armada report as the answer to its own open row 3 — which also widens the divergence recorded there: we now drop editions they honor wherever a privileged member was banned. B4: the Refounding recipient set is capped. allMembers() is the Guestbook ∪ observedAuthors ∪ the roster, and the first two are unbounded and attacker-writable, so each throwaway npub someone posts from became one more mandatory blob in the next Refounding — the attack inflating the cost of its own remedy. The owner-rooted roster is kept first and anything dropped is logged, never silently truncated, because a dropped member is stranded. The nine escalation reproductions now assert the fixed behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
54c412da7a |
fix(quartz): stop a stray edition from pinning a Control entity forever
B1 in docs/concord-soft-ban-audit.md, the worst item on the list: one edition at version = Long.MAX_VALUE permanently pinned its entity to the author's content, for every client holding a floor for it, with no way back. The floor rose to MAX_VALUE, no honest edition could exceed it, and a Refounding that dropped the poison fell back to EntityFloor.known — the poison. Authored in the tests by a current, legitimately granted moderator: no ban, no sockpuppet, one ordinary permission bit. The chain walk was never the weakness; it advances only to head.version + 1 citing the head's hash, so a fresh joiner was untouched. The compaction arm was: it trades contiguity for cross-epoch tolerance, which left VERSION as the only contest an edition had to win. Two changes. The arm now tries the floor-anchored chain first and falls back to the raw-version bootstrap only when nothing connects, so a stray never wins a fold where the honest chain is present. And the bootstrap will not follow a jump of more than MAX_COMPACTION_VERSION_JUMP above the floor — a compacted head is legitimately ahead by a chain's worth, not by 2^63 — so the version space cannot be exhausted in a step. A new test pins the tolerance the arm exists for, so the bound cannot later be tightened into breaking CORD-06 §3. compactControlPlane picked its per-entity head by raw highest version too, which made an honest rotator the delivery mechanism: a disconnected stray never joins the chain but won that comparison, and was re-wrapped into the new epoch as the entity's whole history, where fresh joiners anchor on it. It now picks the chain head, keeping foldEntity's fresh-joiner fallback for the dangling `prev` a prior compaction leaves behind. The three reproductions now assert the fixed behaviour. The banlist's escape hatch (a floor-less chain walk plus the re-heal union) is kept and still pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
3a53292993 |
fix(concord): close the soft-ban holes reachable from the shipping app
Part A of docs/concord-soft-ban-audit.md — the ones a banned user reaches by tapping a button, no custom tooling involved. A1. mintConcordInvite checked that the account was writeable and that we held the community, and nothing else, while its button was the one control on the screen with no gate at all. A member banned a minute ago could hand out a working link to the community they were removed from, and every account they invited arrived as a fresh un-banned npub. Now gated on CREATE_INVITE, both in the verb and on the button. Worth noting the bit was not enforced anywhere else: the fold gates the INVITE_* Control entities on it, but a link's bundle is a standalone kind-33301 published outside the Control Plane, so this check is the only one that exists. A3. Every moderation verb checked isWriteable() plus the Control write key — which is a spam gate, never authority (CORD-02 §5) — and left the real decision to whichever composable drew the button. Those gates then tested effectivePermissions, which ignores the banlist, so a banned staffer kept seeing the controls; the editions were dropped by everyone's fold, making them silently no-op, which this codebase elsewhere calls out as worse than absent. Ban and Remove survived only because a second, unrelated condition happened to route through the ban-aware canActOn. Authority now lives in the action layer behind isAuthorizedFor(), so a caller from desktop, amy or a future screen inherits it, and every authorization test uses hasPermission. refoundConcordCommunity's own guard was ban-blind outright and now rank-checks each removed member too. A2. The recovery sweep merges us onto any higher-epoch bundle found at our stored invite_ref, and an ex-member keeps that link's unlock token forever — so our own background timer walked a removed member back into the epoch a Refounding had rotated them out of. isStranded/mergeForward now take bannedAtCurrentEpoch as a required argument rather than leaving it to callers, because a caller that forgets it inverts the mechanism. The liveness half of that finding (nothing re-mints at a stable coordinate, so legitimate recovery never fires either) needs a spec answer and is untouched here. A4. Typing heartbeats are filtered on both ends, so a banned member stops announcing that they are typing messages nobody will see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
875531afb0 |
Merge pull request #3881 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations |
||
|
|
395e821da2 | chore: sync Crowdin translations and seed translator npub placeholders | ||
|
|
cee2a9ea4b |
Merge pull request #3884 from vitorpamplona/claude/paged-drain-signal
Let a drained paged walk close the sync leg below it |
||
|
|
94b679fe7c | Address NIP-5D review comments | ||
|
|
5136a97b16 |
Return the walk's outcome instead of signalling a drain by callback
`onDrained` was the wrong shape. It reported the one ending a coverage caller happens to need and threw the rest away, so a CLOSED and an idle timeout still arrived indistinguishable from a clean finish — the very conflation this branch set out to remove, just moved one step along. `fetchAllPages` now returns `PagedFetchResult(downloaded, end)`, where `end` names every way the loop can stop: DRAINED, LIMIT_REACHED, IDLE, CLOSED, CANNOT_CONNECT, UNPAGEABLE. `drained` stays as a shorthand on the result so the meaning lives in one place. A caller can no longer ignore the reason by accident, and the two failure endings are now reportable rather than silently swallowed. I argued for the callback on the grounds that ~25 call sites use the `Int`. That was overstated: most call it as a statement and never touch the return. Six needed a `.downloaded`, all mechanical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TNy5BsU9NErXYa3UNGTeJ |
||
|
|
37d715830b |
Let a drained paged walk close the leg below it
A paged band records the events it SAW, never the range it asked for, so `legs()` can only ever say "walked this far" and keeps re-asking the leg below the floor. Against a relay whose corpus for one kind simply starts later than the others' that leg is unclosable: it comes back empty every cycle, an empty fetch earns no band, so the floor never moves. Measured on a live mirror of five NIP-65 indexers, three were in that state — kind 10002 re-walked from the beginning of time to Feb 2023 forever, because relay lists did not exist before then. The missing fact is why a page ended. `fetchAllPages` treated all three terminal signals as one bare `Unit`, so an empty page could not be told apart from silence or a CLOSED. It now carries a PageEnd, and reports `onDrained` only for the one ending that proves absence: an EOSE on a page that returned nothing, with no filter capped by its `limit` and no `search` filter in play (both stop the walk short of the corpus). An idle timeout is silence, not an answer, and recording it would durably claim coverage the relay never served. A callback rather than a richer return type: ~25 call sites across quartz, geode and downstream use the `Int`, and none should have to change to learn a fact they do not want. It follows `onNewPage`'s shape. `SyncCoverage.record` takes `drained` and marks the kinds that produced evidence complete — which required completeness to move from Band onto Span. It could not stay on the band: once kinds diverge, `legs()` hands each group its own ask, so a walk that drained `kinds: [10002]` proves nothing about kind 0, and a band-level flag set from that leg would claim both. That is the same over-claim per-kind spans exist to prevent, one level up. `Band.complete` stays as a DERIVED all-kinds-complete, so both state files keep writing the flag a pre-per-kind reader expects, and read it back as every span's default. A kind the walk never saw at all still earns nothing: there is no interval to anchor a claim to, and inventing one would be the over-claim again. Tests: five in NostrClientFetchAllPagesDrainTest pinning EOSE-empty vs silence vs CLOSED vs cannot-connect vs a fulfilled limit, and five in SyncCoverageTest for per-kind completeness, widening, and the deeper-floor escape hatch that a drain must not defeat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TNy5BsU9NErXYa3UNGTeJ |
||
|
|
f52a8b0432 |
docs(concord): split the audit by what the attacker needs
Re-reviewed every finding against the shipping app rather than against the protocol, and split the list in two: what a banned user can do with stock Amethyst (our bugs) versus what needs a hand-written client (fix in the fold, or defend against). Several items moved, and the review turned up a new one that belongs at the top. A1 is new and is the realistic attack. mintConcordInvite checks only that the account is writeable and that we hold the community — no CREATE_INVITE, no banlist — and unlike the Edit and channel buttons next to it, the invite IconButton carries no guard at all. A banned user stays in the app, taps person-add, and shares a working link to the community. The mint publishes a fresh link signer, so revoking the links they were given does not touch the ones they make; and because the bundle is a standalone kind-33301 outside the Control Plane, the CREATE_INVITE bit the fold enforces on INVITE_* entities never applies to the actual invite mechanism. A3 is the general form: every moderation verb checks isWriteable() and the Control write key and nothing else, so authority lives in the composable that draws the button — and those gates use effectivePermissions, which is ban-blind. Ban and Remove survive only because a second, unrelated condition routes through the ban-aware canActOn. refoundConcordCommunity guards itself with effectivePermissions outright, so a banned BAN-holder can launch a Refounding from the shipping app; honest receivers refuse it, but that is a race against banlist propagation, not a check. A2 moves to Part A because our own client is what performs it: the recovery sweep runs every 15 minutes with no banlist check. C2 (voice) is downgraded from High — ConcordBrokerToken and VoicePresence are referenced nowhere outside quartz, so there is no shipping path to attack. It is a note for whoever wires one up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
d37e183a57 |
docs(concord): audit the surfaces the first pass never opened
The first pass was bounded by the Control Plane, the fold and the relay. Three more findings from the surfaces it skipped, plus an explicit list of what is still unexamined so the next reader knows where the edges are. V10 is the serious one, and it forks. ConcordStrandedRecovery.isStranded takes only (entry, bundle): no banlist check, no check that we were legitimately re-keyed. The whole test is "the bundle at my stored invite_ref sits at a higher epoch than I do", and the unlock token lives in the link fragment an ex-member keeps forever. So whether a removed member walks back in depends only on whether anything re-mints at that coordinate. Amethyst mints a fresh link signer per invite and the Refounding neither re-mints nor revokes, so today nothing does — which means stranded recovery never fires for anyone, and the cure that drainConcordRekeys' KDoc points to for "a BAN-holder can evict anyone, the owner included, by omission" does not actually exist. If any client does re-mint at a stable coordinate, as CORD-05's design describes, then every removed member auto-recovers the new root on the 15-minute sweep and re-announces a Guestbook join. Either the safety net is missing or the only hard removal is undone; which one it is needs a spec answer, not a patch. V11: voice rooms authenticate with the channel's derived voice signer key against a stateless SFU that holds no community secret and cannot know a banlist exists, so a banned member keeps talking until a Refounding. V12: ingestTyping filters on binding and self only, so they keep showing as "typing". Checked and sound, recorded so they are not re-audited: the envelope pins rumor.pubKey == seal.pubKey (no author impersonation), and Note.latestConcordEdit is author-gated, so a member cannot rewrite someone else's message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
1e6cda712d |
docs(concord): audit the soft-ban and Control Plane attack surface
Collects the findings from this branch into docs/concord-soft-ban-audit.md, each marked Verified (a test reproduces it, named) or Read (follows from the code, untested), with a suggested order of attack. Adds the reproduction for the one finding that was still unverified, and it did not hold up the way it was first described. Version inflation does not poison the anti-rollback floor through the chain walk — that walk advances only to head.version + 1 citing the head's hash, so a fresh joiner is untouched. It goes through the COMPACTION ARM: once a client holds a floor and the entity is in the epoch snapshot, the head comes from bootstrapHead, which is highest-version at or above the floor with no prev, no hash and no contiguity. Version is then the whole contest and Long.MAX_VALUE wins it permanently — the floor rises to MAX_VALUE, no honest edition can exceed it, and a Refounding that drops the poison falls back to EntityFloor.known, which is the poison. That makes it the worst item on the list: unrecoverable, and authored in the tests by a current, legitimately granted moderator — no ban, no sockpuppet, one ordinary permission bit. compactControlPlane picks per entity by raw max version too, so honest rotators carry it into every future epoch. The banlist escapes only because AuthorityResolver folds it on a floor-less chain walk and re-heals the union, so an honest ban still lands. That accident is all that separates this from a permanently unmoderatable community, so it is pinned by its own test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
41a035034b |
test(quartz): pin the hand-crafted routes out of a Concord ban
The ban/unban verb is not the threat model — a malicious client writes editions directly, so what matters is which routes the FOLD refuses. Three more, all of them ones the UI would never author. Two are refused, and it is worth pinning why, because neither is refused by the rule you would expect. Removing yourself from the banlist is caught by the delta rule's strict outranking (nobody outranks themselves), so the sharper attempt does not remove anything: it forks the banlist at genesis, or builds a private chain, that simply never mentions him, at a version high enough to win the head fold. There is then nothing to remove and the rank rule never fires. What catches it is CORD-04 §4's re-heal — the owner's edition is not on the forged head's back-chain, so it is unioned back in as a concurrent ban. The union is load-bearing security here, not just convergence. The third works. A §3 compaction re-wraps one edition per entity and the ROTATOR picks it, so a rotator can decline to carry the banlist forward; every edition it serves is genuine and no signature check can see the omission. A banned member cannot rotate — drainConcordRekeys gates the rotator on the ban-aware hasPermission — but the puppet from the previous commit is not banned and can. EntityFloor is the entire defense, so the community splits: clients that already folded the ban refuse the rollback, fresh joiners have no floor and see no ban. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
fb7c710a88 |
test(geode): pin that a Concord plane key cannot delete the channel
A soft ban leaves the community_root in the ex-member's hands, so they keep
deriving the channel's stream key. CORD-01 signs every wrap with that shared
key rather than with the author, so on the wire a Concord channel looks like a
single author publishing everything — and NIP-09/NIP-62 authorize on the outer
pubkey. Read naively that hands any ex-member a one-event wipe of the whole
community's history, and geode's own Nip09DeletionTest guarantee ("a kind-5
from pubkey X cannot delete pubkey Y's events") would be vacuous inside a plane.
It is refused, but only because of a rule written for something else:
Event.owner() gives a kind-1059 to its p-tag RECIPIENT rather than its signer,
and ConcordStreamEnvelope stamps a freshly random p-tag on every wrap. Each
wrap is therefore owned by a one-time key nobody holds, attacker included.
Neither half was written with this attack in mind and either one silently
re-opens it, so both are pinned: two tests fail if ownership ever moves back to
the signer, and a counterfactual (a wrap addressed to a real key IS deletable
by its holder) fails the moment that p-tag becomes anything a member holds.
Scope: this is our relay's rule, not the protocol's. A third-party relay that
authorizes deletion by matching pubkey still hands every ex-member a wipe
button, and a Refounding only protects the future.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
|
||
|
|
f2160f6264 |
test(quartz): pin what a soft-banned Concord staffer can still do
CORD-04 §4 row 3 of docs/concord-banlist-rank-conformance.md was left open as "a genuine fixpoint-ordering question". This reproduces what that gap costs. ConcordCommunityState.fold gates METADATA/CHANNEL/INVITE through authority.hasPermission (`!isBanned && ..`), but ROLE, GRANT and BANLIST are gated inside AuthorityResolver.resolve by holdsManageRoles / bitsOf / effectivePermissionsOf, none of which consult the banlist — and none of which can, as written, since the roles/grants fixpoint settles before `banned` is computed. So half the Control Plane honors a ban and half is blind to it. A banned member who still holds control_root therefore keeps the roster: they revoke the surviving moderators, retire the roles beneath them, ban everyone they outrank, and — since a role edition they author is honored — mint a fresh, unbanned npub at the next position down. That npub passes every ban-aware gate, so it tombstones the channels (terminal ids), rewrites the metadata, and, being a non-banned BAN holder, is accepted as a rotator by drainConcordRekeys. The tests assert the CURRENT, VULNERABLE behaviour so it cannot regress silently; each ESCALATION assertion is to be inverted, not deleted, when the ordering rule lands. Two companions pin what the fix must preserve: self-unban and puppet-unban both stay refused, closed already by the delta rank rule. Also records why a chain-local fix is insufficient — forking the banlist at genesis dodges any "was the author banned by this edition's parent" rule, and §4's re-heal union carries the rogue bans in anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj |
||
|
|
4f41f16db5 |
Merge pull request #3883 from vitorpamplona/fix/quartz-nip66-prober-merge
fix(quartz): merge probe verdicts into the record they replace |
||
|
|
4b49032e52 |
fix(quartz): merge probe verdicts into the record they replace
Follow-up to #3882, which made RelayReachabilityStore edit a relay's kind:30166 rather than rebuild it. toDiscoveryEventTemplate was the remaining co-writer: it builds from the verdict alone, so a consumer following its own KDoc — sign with the monitor key, insert — wipes whatever else is on that address, undoing the merge for exactly the writer #3882 set out to protect. It now takes the current record and carries across every tag the verdict did not measure, on the same rules: - Ownership is per writer, and this one measures more than the store does. A write probe determines `pow` from the OK message, so `R pow` is its own finding and must not be re-dated from an older record. Without a ReadWriteVerdict it never exercised the write path, so the same tag is somebody else's and is carried across untouched — hence the hasReadWrite flag rather than a fixed set. - Both polarities of each requirement are owned, so an update cannot leave the record asserting `pow` and `!pow` at once. - created_at is max(requested, current + 1): a store enforcing replaceable semantics rejects anything not strictly newer, and the probe would be lost with nothing to show for the round trip. The parameter defaults to null, so every existing caller keeps today's behaviour and the change is additive. Test plan: ./gradlew :quartz:jvmTest — 4,081 tests, all passing. Three new cases in RelayProberFlowTest: a foreign tag and an unmeasured `R pow` surviving a probe without a write verdict, a stale `R pow` being replaced when the write path DID run, and the stamp landing past the record it replaces. |
||
|
|
0f9f61f3d1 |
Merge pull request #3882 from vitorpamplona/fix/quartz-nip66-record-merge
fix(quartz): update NIP-66 relay records instead of rebuilding them |
||
|
|
56eec420e4 |
fix(quartz): correct the merge's ownership, guard and timestamp rules
Second review pass on the merge itself. Three of these reverse choices
made in the previous commit; the reasoning there was wrong.
The created_at cap is gone. Capping the bump to a window past `now`
looked prudent and was worse: a record already further ahead than the cap
can then never be replaced, because every stamp we are willing to write
is older than what is stored, so the relay's live/dead verdict freezes
until the wall clock catches up — 24h in the test that shipped asserting
that behaviour as correct. It did not even buy the freshness it claimed:
snapshot() selects on `since` alone, so a future-stamped record sits
inside the window either way. A record ahead of the clock is a defect in
whatever produced it; this class's job is to keep updating it.
Ownership is now the full liveness set on every write — `n`, all three
rtt types, and both polarities of `R auth` — rather than the narrower
per-path sets. A 30166 carries ONE created_at, so a tag carried across is
re-dated as a current measurement: keeping a rtt-read from an earlier
observation beside a fresh rtt-open republishes a stale latency as
today's, which aggregators rank on, and RelayObserver documents exactly
how wrong a queued rtt can be. Carrying `R auth` forward was worse still
— only an observation can clear it and that needs the connection the flag
discourages, so it became permanent, a regression against the rebuild
this PR replaced. Owning only the positive auth form also let `R !auth`
survive while `requirement("auth")` appended the opposite, publishing a
record asserting both.
The per-relay guard no longer swallows. It caught Exception, which
includes CancellationException, so a shutdown flush wrapped in
withTimeout — the pattern RelayMonitor.close() prescribes — could not
abort and would grind through every remaining relay. And a caught failure
went nowhere: collectUnreported() has already cleared the observation
flags by then, so the measurement is lost for good while the run reports
success. Cancellation now propagates, every relay is still attempted, and
the first real failure is rethrown once the loop finishes.
Also corrected a comment: the 16,507-relay figure is measured in
RelayObserver, not RelayProber, and the SQLite ceiling is verified here
rather than quoted — 32,765 `d` values pass, 32,766 fails.
Test plan: ./gradlew :quartz:jvmTest — 4,078 tests, all passing. Four new
cases: a future-stamped record still updatable, a stale rtt-read not
re-dated, an auth wall not outliving its observation, and a run whose
writes all fail reporting failure instead of success.
|
||
|
|
a81c43e1d4 |
fix(quartz): address code-review findings on the record merge
Five issues from a review pass on the previous commit, all in the new merge path. currentRecords() bound one SQL host parameter per relay with no chunking. Callers pass the whole relay universe — RelayProber's own measurement puts that at 16,507 — and a bundled SQLite refuses past 32,766 variables. The throw lands BEFORE anything is written, so an entire probe run's records are lost rather than one relay's. Chunked at 500, in the same range as the author chunking elsewhere. The created_at bump had no ceiling, so a stamp that once landed in the future was sticky: every later edit derived from the bad value and never re-anchored to now. Such a record never ages out of snapshot()'s TTL window (an isKnownDead verdict that can never expire) and relays enforcing future-timestamp limits reject every publish for it. Capped at 60s past now — a pathological record now costs the updates made while the clock catches up, and heals itself. writeOne owned all three rtt names but only ever measures rtt-open, so the reachable path deleted rtt-read/rtt-write taken by an observation — the exact silent loss this change exists to stop. It now owns rtt-open alone; only the dead path clears them all, which liveness semantics require. writeObserved owned the whole R tag name but can only prove `auth`, so it erased `R pow` and friends written by RelayProber. Ownership is now per VALUE, which is why edit() takes a predicate rather than a set of names. The read-modify-write spans a store round trip and IEventStore exposes no read inside a transaction, so a concurrent writer to the same address can still win the race and get our stale insert rejected. That cannot be closed at this layer; it is now isolated per relay so one loser does not end the loop and silently drop every relay after it. Test plan: ./gradlew :quartz:jvmTest — 4,075 tests, all passing. Four new cases, one per fixable finding: a flush wider than one chunk writing every relay, a far-future record not being pushed further ahead, a reachable update keeping latencies it never measured, and an observation clearing only `auth`. |
||
|
|
9ce0fb9559 |
fix(quartz): update NIP-66 relay records instead of rebuilding them
A kind:30166 is addressable, so RelayReachabilityStore keeps exactly one record per (monitor, relay) — but it is not necessarily the only thing writing per-relay knowledge under that identity. Both write paths built the record from their own tags and inserted it, so every update deleted whatever else was in that slot. Observed while adding a "this url is an alias of that one" tag alongside the monitor: `[d, n, rtt-open]` became `[d, redirect]` on our write, and the monitor's next observation turned it back into `[d, n, rtt-open]`. Nothing looks wrong at any point — the event still signs, still parses, still reads as a valid NIP-66 record. It just says less than it did, and the reader downstream cannot tell. Writing is now an edit: read this monitor's current record, carry across every tag the writer does not own — including tags this version of quartz has never heard of — and replace only what it measured. `n` and the three `rtt-*` types are owned by both paths, so a dead update still clears a stale rtt and liveness keeps meaning what it meant. `R` is owned only by the observation path, which is the one that learns whether a relay challenged us; writeOne leaves it alone rather than deleting what it cannot re-measure. Only OUR records are merged. Folding another monitor's tags into a document signed with this key would republish their claims as ours. The timestamp is now `max(now, current + 1)` rather than `now`. A store enforcing replaceable semantics REJECTS a record that is not strictly newer than the one it replaces, and two writers inside the same second — or a peer whose clock runs ahead — are ordinary. That is not theoretical: it silently swallowed a repair pass in the caller that found this bug, which reported success having written nothing. The reads are batched per call rather than per relay, so a flush over N relays costs one extra query, not N. Test plan: ./gradlew :quartz:jvmTest — 4,071 tests, all passing, including four new cases in RelayReachabilityStoreTest covering a foreign tag surviving an update, an update against a record stamped an hour ahead, a dead update clearing its rtt, and another monitor's record not being merged. ./gradlew :quartz:spotlessApply clean. |