mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
b2cfb5e7eedeca7bd17c6be3efb45e92c2fca499
2770
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a0ab3ec66d |
Merge PR: feat(compose): resolve NIP-05 (incl. Namecoin .bit) in the @-mention popover
Merges nostr proposal b07eb505 into main:
- feat(nip05): add Nip05Id.parseLenient for mention/text rendering
- feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…
Also closes duplicate proposal 4b90b41f, which pointed at the same commits.
Beyond the feature, this replaces the unvalidated `Nip05Id("_", prefix)` raw
constructor in UserSuggestionState with `Nip05Id.parseLenient(prefix)`, closing
a hole where a typed mention such as `evil.com#x.bit` produced a GET to an
arbitrary host via `toUserUrl()`'s bare interpolation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
8913af7a79 |
fix(concord)!: enforce CORD-04 rank gating on the Banlist fold
Closes the privilege escalation: any BAN holder could ban the authorities above
them — including the owner — because the Banlist gate checked only the BAN bit.
Once banned, a member loses all authority (`hasPermission` is `!isBanned && ..`)
and honest clients drop their events, so a single edition from the most junior
moderator permanently silenced every admin above them.
CORD-04 §3 requires the rank half: "One hard rule binds every action: the actor
must hold the required bit and strictly outrank its target — equal cannot act on
equal (an admin cannot ban a peer admin)", restated as §5 step 3. Only §4, which
defines the Banlist, states the bit half alone — which is why both this client
and Armada shipped the same rank-blind gate.
§3 is stated per TARGET while the Banlist is one whole-list document, so it is
enforced as a DELTA rule: an edition may only add or remove npubs its signer
strictly outranks, judged against the roster settled behind it; the owner is
never a valid target (position 0 is "supreme and unremovable"); and entries the
signer may not act on are IGNORED rather than rejecting the edition, so one bad
entry cannot discard the bulk-ban §4 recommends as the collision remedy, and a
rogue cannot grief the list by forcing rejections.
ConcordModeration.currentBanned now reads the honored banlist through the
resolver instead of decoding the raw head. Besides picking up the fork healing
it was missing, this closes a laundering path: our own next ban/unban would
otherwise re-publish an entry our fold refuses, under our signature.
BREAKING (consensus): Armada has not shipped this rule, so banlists can differ
between clients until it does — we now ignore a ban Armada honors whenever the
signer did not outrank the target. Shipping the spec-conformant behaviour was
judged better than continuing to honor an escalation. Write-up to send upstream
is docs/concord-banlist-rank-conformance.md.
The three tests added in
|
||
|
|
0ae6bc6698 |
fix(concord): rank-gate the Ban and Remove affordances
Ban/Remove were offered to any BAN holder against any non-owner, ignoring rank — unlike the role picker, which routes through `canActOn`. Both the Members roster and the message-level path (`Account.concordBanTarget`, the chokepoint for the quick-action menu, the note dropdown, the note action sections and the chat action sheet) now require `canActOn(me, target, BAN)`. This is NOT the no-op it first looked like. The premise that the fold would drop such a ban is wrong, and a test proves it: BANLIST is a single whole-list entity, so `authorizedHeads`/`banGate` gate on the author's BAN bit alone and never rank-check the list's *contents*. A rank-5 moderator's ban of a rank-1 admin is therefore ACCEPTED by every client, and the admin then loses every permission, since `hasPermission` is `!isBanned && ..`. It is privilege escalation, not a silent no-op. The fold is deliberately left alone. Armada has the identical gap — its `banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while its role path uses the rank-aware `canActOnPosition` — so rank-gating our fold would make us ignore bans every other client honors, splitting the banlist across clients. Closing it needs a spec change, like CORD-05. Refusing to AUTHOR such a ban restricts only what we write, never what we accept, so it cannot diverge consensus. Three `@Ignore`-d tests in AuthorityResolverTest state the fold-level invariant and currently fail by design; two companions assert the gate does not over-correct (a moderator still bans a plain member; the owner still bans anyone). Un-ignore the first three when the spec closes the gap. The owner short-circuits the check rather than going through `canActOn`, which begins at `hasPermission` and is false while banned — since a rogue BAN holder *can* currently banlist the owner, routing them through it would let them be locked out of moderating their own community. Device-verified on Amethyst QA Concord as Dr. Edo (QA Lead, rank 2): Bob (Admin, rank 1) now offers only the disabled "Roles… / You don't outrank this member" where Ban and Remove used to be enabled, while the Helper (rank 5) still offers Roles…, Ban and Remove. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
62440748a7 |
fix(concord): stop a rejected edition orphaning the honest ones after it
An unauthorized control edition in the middle of an entity's chain
permanently froze that entity. Observed on device for a member's GRANT:
v0 owner (grant mods)
v1 owner (grant admins) <- fold stopped here, forever
v2 MIDTIER (escalation, correctly rejected)
v3,v4,v5 owner orphaned, unreachable
`AuthorityResolver` filtered unauthorized editions out BEFORE calling
`EditionFold.foldEntity`, and the walk only advances when the next
version cites the current head's hash. Removing v2 severed the chain, so
every honest edition above it was lost. Any member could permanently
freeze any member's role assignment — including the owner's ability to
change it — with a single event, recoverable only by a Refounding. It
predates the recent rank gates (verified with a zero-role identity); the
gates only widen which editions can poison.
Armada does not have this bug, and its approach settles the design.
Reading its control-plane fold (read for semantics only — Armada is
AGPLv3, Amethyst is MIT, no code taken): the chain walk runs over the
UNFILTERED set, producing an ordered candidate list — chain-verified head
first, then every remaining edition version-descending — and authority is
applied AFTERWARDS, per candidate, picking the first admissible one. A
rejected edition is skipped during the ascending admissibility walk
without truncating it. For the chain above, Armada picks v5.
So the fix is not to filter later but to gate later: `EditionFold` gains
candidate-based gated folding, and the resolver and community state now
gate per candidate instead of pre-filtering the pool. Authority checks
themselves are unchanged — only WHEN they run moved. Applied to ROLE,
GRANT, BANLIST, CHANNEL, METADATA and the authorized-head map.
The writer had to be fixed too, for a sharper reason than expected. With
an ungated `headOf`, a rogue banlist edition at the tip is read as
current state, so the owner's next ban REPUBLISHES THE ROGUE'S CONTENT
UNDER THE OWNER'S SIGNATURE — an unauthorized empty banlist laundered
into an owner-signed one the moment the owner bans anyone else. Tolerant
reading cannot heal that, because the resulting edition is genuinely
authorized. `ConcordModeration.headOf` now folds the authority-gated
heads, and `owner` is a REQUIRED parameter rather than defaulted, since a
silently-wrong default here is a consensus footgun.
Banlist healing is preserved with one necessary change: the ancestry walk
now runs over the full pool rather than the authorized subset. Ancestry is
structural — walking only authorized editions stops at the rejected one
and misreads genuine ancestors as concurrent forks, resurrecting bans an
unban had cleared.
Six regression tests, each verified to fail without the fix. Two process
notes worth recording: the first "without the fix" run reported BUILD
SUCCESSFUL because Gradle served a stale up-to-date `jvmTest` — trusting
it would have meant concluding the tests were worthless. And the
forged-edition test initially passed both ways because the forgery's
content coincided with the honest outcome; it was rewritten so the
mid-chain arm genuinely discriminates.
The rank-gate, rogue-higher-version, floor and rollback tests all pass
unchanged.
Known gap: `headOf` gates through the per-kind permission map, which is
coarser than the resolver's rank gates, so the writer can still pick a
head the reader rejects when an in-permission but out-of-rank edition
sits at the tip. Tolerant reading makes that benign, but it is not an
exact reader/writer match; tightening it needs the resolver to expose
per-entity heads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
73d59a29bf |
fix(nip46): gate identity reads on pairing; make decrypt consent informed
Two problems in the remote signer, both about a client getting something
without the user meaningfully agreeing to it.
**`get_public_key` and `get_relays` answered anyone.** Every other method
runs through `ifAuthorized`; these did not, and nothing required a prior
successful `connect`. The service decrypts and dispatches any well-formed
kind-24133 envelope, so anyone holding the `bunker://` URI — pasted into a
malicious app, posted for support, leaked in a screenshot — could ask it
which account it belongs to, without the secret and without connecting.
`get_relays` additionally handed over the inbox relay set. That defeated
the transport/identity split, which otherwise works: the relay-visible
traffic really is anonymous, since the p-tag and author are a transport
key and the payload is NIP-44.
Both now require the client to be paired. The authorizer interface gains
`isPaired` with NO default, so a future authorizer has to state its own
rule rather than silently inheriting "everyone is paired".
`ping` is deliberately left open. It reveals nothing the caller does not
already have — a signer is alive at a pubkey they hold — and first-party
behaviour could be confirmed but third-party clients that ping before
connecting could not be ruled out. Breaking a legitimate handshake to
close a minor oracle is a bad trade. The choice is pinned by a test that
also asserts the pairing check is never consulted, so it stays deliberate
rather than drifting back by accident.
**Decrypt consent showed nothing at all.** The bridge populated the
content preview and raw data only for signing requests, so a decrypt
request produced an empty preview block — no ciphertext, no counterparty,
not even the "Show event" toggle — leaving "AppName wants to read your
private messages" with *Allow always* as the primary button. Meanwhile
the coordinator documented the opposite: "Amethyst decrypts first, then
asks permission to expose." That was never implemented.
Now:
- The counterparty is resolved and shown, so the prompt reads "…read your
private messages **with Alice**". It never degrades to nothing —
cached name, else a shortened npub. Knowing *whose* messages is a
categorically different decision.
- The message is decrypted BEFORE prompting and the plaintext is the
preview, as documented. It is a local operation and nothing is exposed
until approval. Failure, blank and hang all collapse to an explanatory
string under a timeout, so the dialog is never empty and cannot stall.
- A narrower grant is offered ALONGSIDE the broad one, not instead of it:
`DecryptFrom(counterparty)` keyed `decrypt:<hex>` next to `Decrypt`.
The dialog's primary button becomes "Always allow for Alice" with the
broad option demoted. Because the ledger stores an opaque op key, no
persisted decision migrates and the storage format is untouched.
Scoping decrypt per counterparty *instead* would have been worse than
the bug: a DM client would prompt once per conversation, training users
to approve everything. A narrow option beside the broad one gives
granularity without the prompt explosion.
Also fixes a latent bug found on the way: `AllowForSession` recorded the
*requested* op rather than the *granted* one, which would have widened a
narrow session grant back to broad.
Verified by three sabotage passes; the tests that stayed green under them
are the intended negative guards. One existing test asserted the buggy
behaviour outright ("public reads are never gated") and was rewritten.
Not done: the batched consent sheet still records the broad op for
"remember" — offering the narrow choice per row there is a UX design
question, not a mechanical change.
Needs a device check before release: the decrypt preview runs the account
signer before consent. That is free for a local key, but an account backed
by an external NIP-55 signer (Amber) may show Amber's own prompt ahead of
Amethyst's.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
7792c42f1d |
fix(blossom): bound what a paid server can extract on a 402
Four defects in the pay-to-upload path, all exploitable by a hostile or
merely broken media server.
**No amount cap, and the shown amount was not the paid amount.**
`amountSats()` was display-only — `pay()` never read it, so a server
asking 10,000,000 sats was handled exactly like one asking 10. `pay()`
now takes the amount the dialog showed, re-derives it from the invoice,
and refuses on over-cap, on mismatch with the dialog, and on an
amountless invoice (a payee-chosen amount must never be paid
unattended). The cap is 10,000 sats: real BUD-07 per-blob fees are
single-digit to low-hundreds, so this is one to two orders of magnitude
above legitimate use and caps one prompt's damage without blocking
anyone.
**Unbounded re-prompting.** The post-payment retry caught generic
`Exception` — including a second `BlossomPaymentException` — marked the
target missing, then re-entered the mirror path, so a server that
pocketed the preimage and replied 402 again got an indefinite pay-prompt
cycle. A new prompt ledger allows one prompt per (blob, target) per
user-initiated action; the user's own tap resets it, the automatic
continuation does not.
**Double-spend window.** `withTimeoutOrNull(90_000)` abandoned the wait
without cancelling the in-flight NWC request, so a payment settling at
second 91 was reported as failure and could be paid again. NIP-47
`sendZapPaymentRequestFor` is fire-and-forget with no cancellation and
does not return a request id, so cancelling is not reachable. Instead the
invoice is claimed before sending and released only on a definitive
wallet answer — the timeout path deliberately does not release it, so it
can never be sent twice. Known limit: the claim is process-lifetime, so a
timed-out invoice becomes payable again after a restart; persisting it
needs a real store.
**Unsanitised server text.** The server-controlled `X-Reason` header was
rendered verbatim directly above the pay button, letting a hostile server
assert its own amount ("Pay 1 sat") in what looked like Amethyst's voice.
It is now stripped of control characters and Unicode bidi overrides,
whitespace-collapsed, clamped to 200 chars, and rendered in a distinct
style as `<host> says: "…"` so it cannot be mistaken for our wording.
The two policies are small standalone classes so they are testable
without an Account graph. Verified by disabling all four checks: 12 of 15
tests fail, covering every defect; the 3 that stay green are the intended
negative controls.
Not covered: the background mirror sweep does not go through this handler
and cannot prompt, so it was never exposed to the cap or re-prompt
defects, and it inherits the invoice claim and sanitisation for free.
`pay()` itself is still not end-to-end tested — that needs Account
mocking well beyond this fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
6045e28830 |
fix(cashu): validate a token's mint URL before contacting it
`MintHttpClient` only trimmed trailing slashes — no scheme check, no host check — and `token.mint` comes verbatim from any pasted or posted Cashu token. Tapping Redeem on a token in someone's note therefore made the device issue HTTP requests to an arbitrary URL: `http://127.0.0.1:<port>`, LAN addresses, `169.254.169.254` (cloud metadata), any scheme at all — plus it disclosed the user's IP to whoever controlled the URL. Validation now runs in the constructor, so no caller can issue a request before it. The rule: `https://` to a public host, or `http://` to a `.onion` host, and nothing else. Onion mints matter — a blanket "https only" rule would have silently broken every Tor mint. Rejected hosts cover the private/loopback/link-local/unique-local ranges plus CGNAT, multicast, reserved and `0/8`: none is a public unicast host, so allowing them buys nothing and leaks reachability. The bypasses are what make this worth care, and each has a test: IPv4-mapped and IPv4-compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`), the full `inet_aton` spellings (`2130706433`, `0177.0.0.1`, `0x7f000001`, `127.1`), trailing-dot hosts, and userinfo disguise (`https://mint.example.com@127.0.0.1/`) — handled by splitting on the LAST `@`. The host parse is hand-rolled rather than delegated to `java.net.URI`/`HttpUrl` precisely because those normalise these forms inconsistently. A mint the user added to their own wallet is exempt from the host and https rules — a self-hosted mint on a LAN is a legitimate setup, and the threat here is a *pasted, untrusted* token pointing inward, not a mint the user chose. The exemption never relaxes the scheme check. It is threaded properly rather than TODO'd: the melt path passes the wallet's known mints and marks the mint user-configured only on a match; the wallet ops and CLI pass it directly, since those URLs are the user's own. Refusal gets its own message rather than reusing the mint-error string, whose wording would have misattributed our own refusal to the mint. DNS rebinding is out of scope and noted in a comment — the check runs pre-resolution and cannot defend against a host that resolves differently on the second lookup. Verified by disabling the scheme and host checks: 11 of 20 tests fail, every rejection case among them, and every allow case still passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
5c23f8490d |
fix(concord): stop destroying other clients' data in the community list
The private community list (kind 13302) is documented as wire-compatible with Armada's `communityList.ts`, whose entry type ends in `[k: string]: unknown` — unknown keys are part of the contract, and `ConcordJson`'s own KDoc says shapes are "deliberately client-extensible (CORD-03/04)". But `ignoreUnknownKeys = true` plus closed `@Serializable` DTOs meant decode dropped every unmodelled key and encode never restored it, so **every Amethyst write of a user's list silently stripped fields another client had written**, across every community in it. Already proven, not hypothetical: `JoinMaterialWire` declared a `refounder` field that nothing in the repo reads, so it was parsed and destroyed on the first write. We only avoided destroying Armada's `invite_ref`/`excluded_at_epoch` because those were modelled hours ago, for stranded recovery — the anchor recovery depends on would otherwise have been deleted on every write. Each wire DTO's compiler-generated serializer is now wrapped in a shared `JsonTransformingSerializer` that lifts unknown keys into a bag on decode and merges them back on encode, with declared fields winning on conflict. The known-key set is read from the descriptor rather than hand-listed, so it cannot drift from the DTO. Preserved at the document root, each entry, the `current` join material, each channel, each held_root, each tombstone, and everything nested inside `seed`. `refounder`'s typed field is removed so it round-trips generically. Two further data-loss bugs surfaced while doing it, both fixed here: - **`seed` was overwritten with `current` on every write**, destroying the immutable join anchor. It is now kept and re-emitted verbatim as a raw JsonObject — we never hydrate from it while `current` exists, so we have no business rewriting it, and keeping it raw preserves everything nested inside for free. - **`tombstones` were re-encoded as an empty list**, which did not just lose their unknown keys: it RESURRECTED communities another client had deliberately removed. They are now carried verbatim. Verified by four separate sabotage passes (no-op the transform, re-mint `seed`, restore the empty-tombstone write, flip the merge order); each new test fails under at least one, and every mechanism is covered. Control-plane re-serialization was audited too and is NOT fixed here: `compactControlPlane` is safe (it re-wraps the original seal verbatim), but the user-facing *edit* paths — `editConcordMetadata`, `grant`, and the channel edits — construct fresh typed entities and re-encode, so they drop extensions the same way. Fixing those means merging into the head edition's raw JsonObject on each edit path, which is a larger change than this should carry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f8d8a2b135 |
feat(concord): recover a membership stranded by a Refounding
A rotation carries only (newRoot, newEpoch, rotator) — no recipient list — so a member simply left out of the recipient set receives nothing and is stranded on the dead epoch forever while everyone else moves on. It applies to any member, the owner included, and cannot be prevented on the receive side: there is nothing to check. Armada does not prevent it either; it recovers, and this follows the same approach. The invite link a membership was joined through is stored as the anchor, and when that link later resolves to a HIGHER epoch, the membership merges forward. Uses Armada's exact wire names so the kind-13302 list stays compatible in both directions: `invite_ref` (the link in bare `<naddr>#<fragment>` form, host-stripped so a link minted by a different front end reduces to the same anchor) and `excluded_at_epoch`, both at the entry level. Details that decide whether this works at all: - `merge()` lets a higher-epoch winner inherit the loser's `invite_ref` when it has none. Without it a two-device merge silently discards the only anchor recovery has, disarming it permanently. - `adoptConcordRoot` carries `invite_ref`/`excluded_at_epoch` through a rotation; it rebuilt the entry field-by-field and would have dropped them at exactly the moment they matter. - Merging forward preserves `heldRoots`, so prior-epoch history the member legitimately holds is not lost by recovering. - Recovery requires a strictly higher epoch and a matching community id, so it is monotonic and cannot be steered by an unrelated bundle. Hooked onto the existing Concord revision tick immediately after `drainConcordRekeys`, because the two are halves of one problem: a rotation you were included in arrives as a rekey to drain, one you were excluded from produces no message at all and can only be found by polling the link. Rate-limited to 15 minutes per community; an idle tick costs a map lookup. Only a Live bundle recovers — an expired or revoked link is not a missed rotation. Verified by mutating the production code eight ways (dropping the anchor, dropping heldRoots, dropping the epoch comparison, renaming the wire keys, removing the merge inheritance, breaking bare-form parsing) and confirming each produced exactly the expected failures. Two things this surfaced, both left for their own change: - Our parser does NOT round-trip unknown JSON keys — `ignoreUnknownKeys` plus closed DTOs — while Armada's format ends in `[k: string]: unknown`. So every Amethyst write of the community list silently strips fields Armada added that we do not model; it already discards the `refounder` field we parse but never re-emit. That is live interop data loss, caused by us, independent of this work. - `mergeForward` keeps the entry's existing private-channel grants rather than adopting the bundle's, matching what `joinConcordViaInvite` already does. If a recovered member should pick up new-epoch grants, both paths need it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fac1bf5b5d |
feat(concord): refuse Control-Plane rollbacks with a version floor
`ConcordRefounding.compactControlPlane` re-wraps one edition per entity when a community rotates epoch, and the ROTATOR chooses which one survives. The receiving side had no memory: `refold()` folds only the wraps at the current epoch's Control-Plane address and discards the prior epoch's buffer, and `EditionFold` accepts whatever it is handed (its no-genesis fallback anchors at the lowest version present). So a rotator could publish only version 1 of a chain and omit version 2 — restoring a revoked role, clearing a banlist, reverting metadata. Every signature is genuine; this is rollback by omission, not forgery. Adds a per-entity floor: the version AND hash last successfully folded. - **No floor (fresh joiner)** — unchanged: genesis anchor, else the lowest-version edition as the legitimate compaction bootstrap. - **With a floor** — the walk is anchored AT the floor: the offered set must contain the exact edition already folded (version and hash; a same-version sibling is a fork, not our chain), then walks up. A head below the floor is structurally unreachable. - **Gap** (the floor edition is absent) — refuse, and keep the known head. Refusing by *retaining* matters here: this fold is recomputed from scratch each time, so letting an entity vanish would itself be a rollback — a dropped banlist is an unban. The floor needs no new persistence. It is derived from `heldRoots`, the rotated-out access roots already persisted in the NIP-44 self-encrypted kind-13302 list: the session derives each prior epoch's Control-Plane address from them, folds oldest-first, and takes the resulting heads as the floor. That survives both a process restart and the session rebuild `ConcordSessionRegistry.sync` performs at exactly the moment of a Refounding — which would have destroyed any in-session floor. If the old planes are not served, there is no floor and behaviour is as before. Floors are built from AUTHORITY-GATED heads, not raw ones. Without that, any ex-member still holding a rotated-out root could mint a high-version edition on the old plane and freeze the entity for every honest client — a denial of service this change would otherwise have introduced. Covered by a test. Verified by disabling both enforcement points: 7 of 12 quartz tests and the end-to-end commons test fail, and the ones that still pass are exactly the non-regression cases (fresh joiner, honest compaction, pass-through without floors). Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the edition pool and does not carry floors itself; gating happens at the pool level before the resolver sees anything. Sound, but connectivity checked on the full set is a weaker precondition than on each subset — passing floors into the resolver's three folds is worth a follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
30f4638954 |
fix(concord): re-key observed members on a Refounding
The Refounding recipient set was `Guestbook joins ∪ roster ∪ owner ∪ self` — it never consulted `observedAuthors`, the members seen publishing to a channel. `ConcordCommunitySession.allMembers()` already unions them in (and already subtracts the banned); the rotation path just didn't use it. Amethyst announces a Guestbook Join, but nothing in the protocol requires one, so in practice most members of a cross-client community have never sent one. Every member who had only ever *posted* — no role, no Join — therefore received no rekey blob and was silently expelled by the next rotation. Removing a single spammer would quietly strand most of the community, and it fell hardest on Armada members, who are the bulk of the roster in the communities we interop with. Now uses `allMembers()`. Still a floor rather than a census, as its KDoc says: a member who joined silently, holds no role, and has never posted leaves no trace to re-key, and nothing here can find them. Stranded recovery is what brings those back. Also corrects an overclaim on `ConcordInviteBundle.validate`. Its KDoc said self-certification stops a bundle smuggling "a false owner or a fake key for a real community". Only the first half is true: `community_id` commits to (owner, salt) — both public in any invite — and NOT to `community_root`, so an attacker can mint a bundle carrying a real community's id, owner and salt beside a root of their own. A joiner adopts that root, believes they are in the real community, and posts into planes the attacker can read. That is a CORD-05 limitation, not an implementation gap: Armada's `validateBundle` checks exactly the same thing and also leaves the root unbound, so a stricter unilateral rule would break interop while protecting nobody. Verifying the adopted root's Control Plane does not close it either — sealed editions carry the owner's own signature, so an attacker can re-wrap genuine owner editions into their plane, which is what a legitimate compaction does. Closing it needs a spec change (commit the root into the self-certifying id, or require the bundle to be signed by a roster-authorized member); the KDoc now says so instead of claiming a guarantee the code does not provide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0f53eb09a2 |
fix(concord): gate revokes on target rank; drop the owner rotation refusal
Reviewed the previous commit's authority changes against Armada
(gitlab.com/soapbox-pub/armada), the reference Concord client we interop
with. Read for its rules only — Armada is AGPLv3 and Amethyst is MIT, so
no code was taken from it.
It confirmed the role rank gate (Armada checks both directions too: the
author must outrank the position being minted AND the standing position
being replaced) and the banlist check on rotation. It also showed one of
our rules was wrong and one gate was missing.
**Removes the owner's refusal of foreign rotations.** The previous commit
made the owner ignore any rotation it did not author, reasoning that a
BAN-holder could otherwise carry the owner onto a root of their choosing.
Armada does the opposite on purpose — "authority is the roster, never key
possession" — and it is right: an admin legitimately rotating to remove a
spammer would leave the owner alone on the dead epoch, self-inflicting
the strand the rule was meant to prevent, and diverging from the
reference implementation forks communities across clients. The threat is
better answered by the rank gate: with role editions gated, nobody can
escalate themselves to BAN, so BAN-holders are people the owner
deliberately trusted.
**Adds the missing rank gate on grants.** A grant was authorized if the
granter outranked every role it handed out — but a REVOKE carries no role
ids, and `all {}` over an empty list is vacuously true. So any
MANAGE_ROLES holder could strip anyone's roles, the owner's admins
included: promotion was gated, demotion was free. Armada treats a grant
as an action ON the member and requires outranking the target's standing
rank; this now does the same.
Both new tests were verified to fail with the corresponding check
disabled, and each has a companion asserting the legitimate case still
works (an admin can still revoke a moderator beneath it).
Also records what Armada does about exclusion, since we cannot prevent it
receiver-side: it does not try to. A rotation carries no recipient list
there either, so a stranded member instead re-resolves the invite link
they joined through and merges forward to the higher epoch ("stranded
recovery"). Amethyst has no equivalent, so a stranded member stays
stranded — noted at the call site as follow-up work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
c9c91a8c98 |
fix(concord): rank-gate role editions and harden rotation authority
Two authority holes, plus the limit that remains. **Role editions had no rank gate.** Grant editions are correctly gated — a granter must hold MANAGE_ROLES *and* strictly outrank every role it hands out. Role editions checked only that the author held MANAGE_ROLES. Nothing stopped an authorized signer editing a role at or above its own rank, including the role it holds itself. So a moderator at position 5 with MANAGE_ROLES could publish one edition on their own role's chain claiming position 1 and every permission bit, then a second demoting the real admins beneath them — reaching full authority over everyone but the owner in two editions. The rogue-higher-version defence added earlier does not cover this: it drops editions from *unauthorized* signers, and this signer is authorized. Role editions are now gated in both directions: an author may not claim a position at or above its own rank, may not touch a role that already sits at or above it, and may not hand a role permission bits it does not hold itself. Deleting keeps only the second rule, so retiring a role beneath you still works. The owner is unaffected. **A banned moderator could still rotate the community.** The rekey receive path authorized the rotator with `effectivePermissions(...)`, which ignores the banlist, rather than `hasPermission(...)`, which excludes banned members. Now uses the latter. **The owner no longer adopts a root someone else minted.** A rotation replaces the community root, so a rotator who *includes* the owner as a recipient hands themselves the keys to the owner's own community — the owner would follow them onto an attacker-chosen epoch. The owner changes epoch only by rotating themselves. **Known limit, documented at the call site.** A rotation carries only (newRoot, newEpoch, rotator) — no recipient list — so a receiver cannot tell who was omitted. A BAN-holder can therefore still evict the owner by leaving them out: everyone else adopts, the owner is stranded on the old epoch. That is not fixable in the receive path; it needs a protocol change (a recipient commitment the receiver can check, or owner co-signing). Tracked for CORD-06. The escalation test was verified to fail with the gate disabled, and a companion test asserts an admin can still edit a role beneath it, so the gate is not merely blocking everything. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c8be65a02e |
fix(concord): require consent for invite links, enforce expiry, fold the head
Three fixes to the Concord invite and moderation paths. **Invite deep links redeemed with zero consent.** `ConcordInviteScreen` called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a link on any web page — or a QR code, or a push — silently caused a connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL fragment (disclosing the user's IP to a third party), a Guestbook JOIN signed by the user's identity published to those relays, and a write to their private community list. No tap, no preview. The screen now opens in an awaiting-consent state and only joins from an explicit Join button. The preview is built entirely from the link itself — base64url and NIP-19 decoding, both pure in-memory — and touches the network for nothing: no relay connection, no signing, no publishing. It shows the relays it would contact so the user can see whom they'd be talking to. The community name lives inside a bundle only those relays can serve, so it is honestly reported as unknown until joining rather than fetched. **Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no production callers at all — the only ones were in a test — so an expired invite redeemed forever. Expiry is now enforced at `classify`, the choke point every redeem path funnels through, with its own result and message so the user knows to ask for a fresh link. **Moderation read the wrong edition.** `ConcordModeration` used `firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order, not the folded head. Once an entity had two or more editions the next one chained off a stale predecessor, forking the chain at an already-used version, and `EditionFold` then resolved the fork by `minByOrNull` on the rumor id — a coin flip. Bans were masked by a down-only healing union; UNBANS and role revocations were not, so they could silently fail to apply. Both call sites now fold to the true head. Regression tests assert the fold-head behaviour under two arrival orders — a single order accidentally puts the head first and passes against the buggy code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
5fd8be64fd |
fix(podcast): clamp V4V fee splits so a feed cannot multiply payments
`computeShares` paid each fee recipient `totalMilliSats * split / 100` with no upper bound on `split`, and `split` comes verbatim from a kind-30054 episode event that anyone can publish (`ValueTag.parse` is a bare `fromJson` with no validation). A single `fee:true, split:1000` recipient was therefore paid TEN TIMES the amount the user chose. The `remainder` clamp looked like a safety net but only zeroed the honest recipients; it never touched the fee recipients themselves. Proven by test before fixing: `split:1000` pays 10x, and two fee recipients at 60% each pay 1,200,000 millisats for a 1,000,000 zap. This was reachable in the worst possible place. Streaming V4V pays every minute, automatically, so a modest multiplier stays under a typical NWC budget and simply runs — and the on-screen running total tracks the INTENDED amount, so it reads "100 sats" while 1,000 left the wallet, while the streaming error handler suppresses the toast. The ordinary zap button reroutes through the same path for any note carrying a value block, so it was not limited to the streaming toggle. Fees are a percentage off the top, so each is now clamped to 100% and the cumulative total to the remaining budget: the payout can never exceed what the user chose. The existing test asserted the right invariant (`sum <= total`) but only ever ran it on well-formed input, which is why this survived. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
035c50421c |
Merge pull request #3642 from vitorpamplona/claude/cli-module-deep-review-le4dsj
amy CLI overhaul: exit-code/JSON contract v1, per-command help, contract tests, docs, and thin-layer extractions |
||
|
|
4efb2cca98 |
fix(quartz,cli): audit fixes — bounded drains, no event loss, honest verdicts, false-reject traps
Adversarial audit of the PR's own changes (8 finder angles, verified before fixing). Quartz core: - fetchAll-family drains get a wall-clock ceiling (maxTotalMs, default 10x the idle window, delay()-watchdog: cancellable and virtual-time testable). The pure idle window was unbounded when a relay trickled events forever — sandboxed napplet queries, set -e fetches, and marmot await stuck inside one drain. Streaming relays still finish. - The suspending onEvent hook no longer runs inside a cancellable timeout scope (an expiring window could cancel verifyAndStore mid-write and silently drop a received event); the timeout is armed only when the channels are dry (no per-message timeout-job churn). - fetchAll is a projection over fetchAllWithHooks: fixes its unsynchronized events/seenIds mutation from concurrent socket threads and deletes the duplicate loop + per-event activity channel. - publishAndConfirmDetailed regains its only-responders contract (synthetic no-response entries no longer render as 'relay rejected your message' in app callers); results built by pure associateWith; shared failure-reason constants + PublishResult.isTransportFailure. - NIP-65 mutations: split read+write r-tags for the same URL now merge to BOTH instead of last-wins dropping a facet (+ test). - TcpProber's 128-thread pool drains after 60s idle. CLI: - publishGuard: all-transport failure exits 124 as timeout; rejected/1 is reserved for an actual OK-false answer. - --help anywhere in argv is hoisted centrally; 'amy notes post "x" --help' prints usage instead of publishing. - rejectUnknown false-reject traps fixed: geochat --no-fetch behind an early return, and 13 elvis-alias short-circuit sites read eagerly. - Aliases load once per Context and only match name-shaped inputs (no shadowing a real npub/NIP-05/hex); stderr color requires a positively-known terminal (TERM sniff polluted captured logs). - Relay-CSV strictness unified on RawEventSupport.relayFlag (post, graperank publish/followers/register no longer silently drop malformed URLs); Args.timeoutMs(+OrNull) replaces 27 hand-rolled conversions, all strict; offer/debit --timeout > 3600 rejected with a 'looks like milliseconds' hint; NPub.create idiom; stale jq .id in the marmot reactions harness; printUsage drift (offer pay --with, profile --clink-offer, search --kind). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj |
||
|
|
962d1706dc |
fix(quartz): fetchAll-family timeouts are idle windows, not absolute deadlines
The fetchAll/fetchAllWithHooks accessories wrapped their whole collection loop in one withTimeoutOrNull, so a relay actively streaming a large backlog was cropped mid-delivery the moment the absolute deadline hit — even though the loop already has proper terminal conditions (per-relay EOSE / CLOSED / cannot-connect) and the timeout's only real job is stall detection. timeoutMs now measures the delta since the LAST message: every event or terminal signal resets the window (fetchAll gains a conflated activity ping so event progress is visible to its wait loop), and only a full window of silence ends the fetch early. fetchFirst/count keep absolute waits (single-response — idle and absolute coincide), and subscribe's duration timeout stays absolute by design (a live stream has no terminal state). Since the pages/pool helpers delegate to fetchAll, pagination inherits the semantics. This also changes app-side callers of these accessories — in their favor: the timeout only ever fired on slow relays, exactly when cropping loses data. New commonTest suite pins the behavior: a relay emitting every 200ms under a 300ms window streams to completion (10/10 events); a stall ends one window after the last message, not after the start; EOSE still returns immediately. CLI docs reworded (--timeout = idle window). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj |
||
|
|
620fc465df |
feat(cli): publish results carry each relay's rejection reason; converge output shapes
With no external consumers yet, converge the --json surface to its
ideal shape in one pass:
- quartz gains publishAndCollectResults: the NIP-01 OK message, connect
errors, and silent timeouts now survive as PublishResult(accepted,
message) per relay instead of dying in a debug log. The existing
boolean APIs delegate unchanged. Silent relays are reported as
'no response within timeout' rather than omitted.
- Context.publish returns the rich map; the new
RawEventSupport.ackFields(ack) is the one canonical projection every
publisher emits: published_to (urls) + rejected_by as
[{relay, reason}] — 'why didn't it post' now answers itself, in
partial failures and in the rejected error alike.
- author/pubkey rule enforced module-wide: 'author' is the key that
signed an event (feed/search/dm/message list items), 'pubkey' an
identity being described; profile show and outbox add the bech32
npub beside the hex when the user is the primary subject.
- Event-list items converge on event_id/author/created_at/content
(dm, feed, search, marmot message, geochat, concord).
- Byte counts standardize on *_bytes keys (blossom/nsite size ->
size_bytes); the text renderer drops the fragile bare-'size'
heuristic and colors stderr progress independently of a piped
stdout.
- Error details are sentences everywhere (not bare gids); dead Result
class removed from the quartz publish accessory.
Docs updated (DEVELOPMENT output conventions, README rejected example).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
|
||
|
|
43704772a7 |
Merge pull request #3641 from vitorpamplona/claude/nip29-group-load-perf-wz4yca
NIP-29 group chat: split state (always-on) from content (paginated) |
||
|
|
8d3a47b8f8 |
refactor(cli): split GrapeRankCommand into graperank/ sub-files; TCP prober to quartz
The 1500-line GrapeRankCommand (15 sub-verbs in one object, 7.5x the
module's 200-line smell threshold) becomes a 165-line dispatch that
delegates to graperank/{Crawl,Score,Publish,Operator,Support}. The TCP
reachability pre-probe with its dedicated 128-thread dispatcher is
transport infrastructure, not command code — it moves to quartz
nip66RelayMonitor/reachability (TcpProber, jvmAndroid). Pure move, no
behavior change; cli tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
|
||
|
|
6e664b2db6 |
refactor(cli): extract the drain loop to quartz accessories; split Context per domain
Context.drain/drainAllPages/requestResponse re-implemented the subscription state machine quartz already ships in relay/client/accessories — the CLI-specific needs (per-event verify-and-store hook, dead-relay collection, pending-on-auth) now live in an option-rich fetchAll variant there, and Context keeps thin adapters. The per-domain sections bolted onto Context (Cashu seed warming/snapshot/restore counters; the Concord stream-key AUTH registry) move to CashuContext/ConcordAuth, with the NUT-09 restore counter rule shared via commons CashuWalletOps so the CLI and Android can't drift. Context.kt: 1246 -> 950 lines; behavior unchanged (cli tests green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj |
||
|
|
e66550091d |
fix(relay): send an unresolvable host straight to the long backoff
A relay whose domain no longer exists (lapsed registration, decommissioned host) was treated like a busy relay: the backoff doubled from 1s and spent about ten dials climbing to the ceiling it was always going to reach. An HTTP upgrade rejection already jumps straight there; a name that does not resolve deserves the same. Matching is on the exception type rather than the message because the message is localized and platform-specific — Android says `Unable to resolve host "x"`, JVM on macOS says `nodename nor servname provided, or not known` — while the class name is stable. That is why onCannotConnect appends it in the first place. Neither message ends with "Host unreachable", so the existing check never caught DNS failures. Being this eager is only safe because the verdict is cheap to revisit: a DNS answer is a property of the network, not of the relay (a captive portal or a filtering resolver forges NXDOMAIN), and both a network-identity change and a transport change now clear the backoff outright. The test pins that round trip, not just the classification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b1b6190d2f |
fix(relay): forgive reconnect backoff when the network or transport changes
A relay's reconnect backoff was process-global and network-blind: the delay earned on one network was still being served out on the next. The only reset signal was OkHttpClient reference identity, which is rebuilt off the metered bit, so it fired for wifi<->cellular and nothing else. Wifi A -> wifi B, a VPN coming up, a captive portal clearing, and metered-wifi -> cellular all left every relay parked on a penalty earned against a network the device had left — up to five minutes of silence on a network that might reach the relay instantly. The transient Off that would have reset things is swallowed by the 200ms debounce in ConnectivityFlow, so it never rescued those cases. Key the decision on ConnectivityStatus.Active.networkId instead, which is the same signal SurgeDns already uses to stale its cache, and treat a genuine network change as a full pool rebuild: every socket is bound to an interface that no longer carries traffic, and needsToReconnect() cannot see that because it only compares the proxy and the timeouts. Also treat a Tor policy flip as a transport change. Flipping a Tor toggle while Tor is already up leaves both OkHttpClient references identical, so a relay whose transport just changed kept waiting out a backoff earned on the other transport. Only TorRelaySettings is compared, not the relay sets that TorRelayEvaluation also carries — those churn while an account's relay lists load (observed firing three times in one cold start), and forgiving the whole pool every time any list updates is far more damage than it repairs. Adds IRelayClient.resetBackoff() (default no-op, so the existing fakes and BleNostrClient are unaffected) rather than reusing ignoreRetryDelays, which only skips the gate for a single attempt and still doubles the stored delay — a relay that failed that one dial came back worse off than before. INostrClient.resetBackoff() is deliberately separate from reconnect(): the latter debounces, so folding this into a coalescing command would let a later request silently drop the reset. The decision table moves into RelayProxyClientConnector.apply() so it can be exercised directly, without a debounce and a shared StateFlow in the way. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7ea6920679 |
refactor(nip65): move read/write-marker merge semantics from the CLI into quartz
The kind:10002 facet-merge rules (adding a write marker to a read-only relay promotes it to BOTH; removing one facet of BOTH demotes to the other; removing the last facet drops the relay) lived as private helpers in the CLI's RelayCommands. Any frontend that edits a NIP-65 list needs them, so they now live in quartz nip65RelayList as AdvertisedRelayListMutations (applyFacet/addFacet/removeFacet/setFacet) with commonTest coverage. Behavior unchanged; the CLI rewires to the shared functions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj |
||
|
|
b852a163c7 |
feat: paginate the NIP-29 group Threads tab with a backward history pager
The Threads tab loaded kind-11/1111 with a single #h since-filter and no
limit, so a group with more threads than the relay's default result cap
silently lost the older ones. Mirror the chat history stack for threads:
- RelayGroupChannel.threadsHistory: separate RelayLoadingCursors so paging
the forum doesn't move the chat's cursor.
- buildRelayGroupThreadsHistoryFilters: per-armed-relay #h + kind-11/1111
until+limit page, the forum analog of buildRelayGroupHistoryFilters.
- RelayGroupOpenThreadsHistoryFilterAssembler: the on-demand BackwardRelayPager
("relayGroup.threads.history"), bound to the open group's threadsHistory
cursors, landing on the normal ingest path (kind-11 -> addThread).
- Threads screen: mount the history subscription, eagerly backfill to a
window on open, page older content as the list nears its end, and show a
quiet loading/caught-up footer.
Tests: RelayGroupFilterBuildersTest gains the threads-history filter shape;
RelayGroupFilterServingRelayTest gains a geode backward #h + thread-kinds
walk proving every thread is covered exactly once and the walk terminates.
The screen wiring is device-untested (flagged with the other Tier-D items).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
|
||
|
|
3214095b99 |
fix: route stray NIP-29 group content to the host, not a phantom channel
A group-scoped content event (kind-9 chat, poll, kind-11 thread) is keyed to its RelayGroupChannel by the relay that served it, because a NIP-29 event doesn't carry its host relay. That's correct for the group's own host-pinned subscriptions, but a message resolved from a NON-host relay -- e.g. a quoted kind-9 fetched by id during missing-event resolution -- was filed under GroupId(groupId, strangerRelay), a channel the group's screens never read, so the message silently vanished (the serving-relay hazard). LocalCache.attachToRelayGroupIfScoped / attachThreadToRelayGroupIfScoped now, when no channel is keyed to the serving relay, redirect the stray to the group's single confirmed host channel via redirectStrayRelayGroupContent, keyed off RelayGroupChannel.hasRelaySignedState(). A phantom channel never has relay-signed state, so the redirect can only ever land on a real host, never on another phantom -- the fix is strictly safe and the common host-pinned arrival stays an untouched O(1) fast path (the scan runs only on the rare no-channel-for-serving-relay miss). Also add the cache-prune gap-fill can't-miss test: drive the production RelayLoadingCursors down a real relay, rewindTo below the window, and confirm the pruned band re-loads with no gap. Tests: RelayGroupContentRoutingTest (pure router + the channel signal); RelayGroupHistoryPagingRelayTest gains the rewind reload case. The LocalCache wiring is unit-covered at the router level but still device- untested end-to-end (flagged in the test plan). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU |
||
|
|
db5170a82e |
test: extend NIP-29 group-chat coverage to every assembler + relay integration
Round out the branch's test plan across the headless-runnable tiers. Tier B (filter shapes) — now every assembler: - reconnect stability: a since-only bump on the state/tail filters is not a resend (no full replay on reconnect), while a history until step is; via FiltersChanged.needsToResendRequest. - directory: extract buildRelayGroupDirectoryFilter into RelayGroupFilterBuilders (kinds 39000-39003, no d/h scope, limit 500) and point the RelayGroupsOnRelay assembler at it, with a shape test. - ChannelPublic relay-group branch (filterRelayGroupState): state + pinned-id backfill and crucially NO message window. - group notifications (filterGroupNotificationsToPubkey): #p+#h scope, kind set, empty-guards. - discovery #p roster augmentation (filterRelayGroupsByAuthors): the author, #p-roster and #d-backfill filter shapes. Tier C (serves-the-shape, against the in-process geode relay): state #d, batched preview tail, threads, a pinned message reachable by id below the tail window, notification #p+#h scoping, and the relay directory. Tier C3/E1 (can't-miss + resilience): drive the production RelayLoadingCursors backward over the wire to the bottom, and pin that a short page is not exhaustion (only an empty page + EOSE ends the walk). The remaining hostile-relay faults (echo-newest, no-EOSE, auth/stall) are already covered generically by RelayLoadingCursorsTest, WindowLoadTrackerIdleTest and BackwardRelayPagerTest; Tier D (device) and E2 (real third-party relay) are not headless-runnable and stay flagged for a human in the test plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU |
||
|
|
399bef6bf7 |
test: cover NIP-29 group-chat filter shapes and can't-miss history paging
Extract the pure REQ-filter construction out of the NIP-29 group-chat assemblers into RelayGroupFilterBuilders so the exact filter each screen puts on the wire can be unit-tested without an Account or relay client, and point every assembler at the shared builders (dropping the duplicated per-file kind lists). Add two test suites from the branch's test plan: - RelayGroupFilterBuildersTest (Tier B): pins the kinds, #d/#h scope, per-host-relay batching, since/until/limit and all-authors shape of the state, joined chat-tail, open chat-tail, history-pager, threads and card-warmup joined-skip filters. - RelayGroupHistoryPagingRelayTest (Tier C3/E1): the can't-miss-messages property against the in-process geode relay -- a backward #h + kind-9 walk delivers every group message exactly once and stops on an empty page, and an #h-scoped walk isolates one group from another on the same relay even when their createdAt ranges fully overlap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU |
||
|
|
9bc436b0c2 |
feat(blossom): BUD-07 confirm-then-pay for paid-server mirroring
Adds a confirm-then-pay flow so a 402 from a paid Blossom server can be settled from the app instead of only being reported. - quartz: BlossomPaymentProof (settled Cashu token / lightning preimage) with the X-Cashu / X-Lightning retry headers; BlossomClient.mirror accepts a proof. - BlossomPaymentHandler (Android): pays the challenge's BOLT-11 invoice via the account's existing NIP-47 (NWC) wallet and returns the preimage — it never handles keys or funds itself, only drives the connected wallet. Decodes the invoice amount for display. - Blob manager: a mirror that hits 402 now raises a payment prompt; a dialog shows the amount and, on confirm, pays and retries the mirror, then continues with the remaining servers. Cancel leaves the blob unmirrored. Cashu-only servers and the composer upload path still surface a clear message; auto-settlement there can reuse this handler next. Not yet validated against a live paid server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP |
||
|
|
bb03cd2a3c |
feat(blossom): full-client protocol support across quartz, commons, CLI and Android
Extends Blossom support toward a full client on both the CLI and the mobile app. Quartz (protocol): - BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11 `server` domain scoping on every factory (stops replayable upload/delete tokens) - BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and BUD-07 payment header constants, and a lowercase bare-domain helper - BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08) - BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning) - BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders Commons (shared JVM client, now in jvmAndroid so Android shares it too): - BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05), preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling - BlossomAuth: media/list/delete passthroughs with server scoping CLI (first-class): - amy blossom now routes all HTTP through the shared client and adds `media` and `report` verbs; auth tokens are scoped to --server Android (first-class): - uploads mirror to the user's other Blossom servers (BUD-04) best-effort - new "Manage stored files" screen: per-server presence matrix (BUD-02 list + BUD-01 HEAD), delete, mirror-to-missing, and report actions Tests: quartz URL/auth/descriptor/payment parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP |
||
|
|
cd5060e5dc |
Merge pull request #3626 from vitorpamplona/claude/graperank-algorithm-improvements-oco5ue
Add follower crawl and trusted-follower metrics to GrapeRank |
||
|
|
699c2dc7e5 |
fix(graperank): import kotlinx.coroutines.IO for Kotlin/Native compile
FollowerCrawler is in commonMain but used `Dispatchers.IO` without importing the multiplatform `kotlinx.coroutines.IO` extension. On JVM `Dispatchers.IO` resolves to the JVM member (compiled fine locally), but on Kotlin/Native that member is internal, so `:quartz:compileKotlinIosSimulatorArm64` failed with "Cannot access 'val IO': it is internal". Add the explicit import — the same idiom the sibling GrapeRankCrawler already uses across all targets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4 |
||
|
|
60d492e2bc |
Merge pull request #3619 from vitorpamplona/claude/nip-84-tag-rendering-0o9owh
NIP-84: Support W3C TextQuoteSelector for highlight context |
||
|
|
db586e30d3 |
feat: render NIP-84 highlights from web highlighter clients
Web-based highlighter clients publish kind:9802 highlights with W3C Web Annotation selectors (textquoteselector / textpositionselector / rangeselector) instead of a NIP-84 `context` tag. These were previously ignored, so the highlight rendered without its surrounding paragraph and the "jump to page" link couldn't disambiguate repeated quotes. - Parse the W3C textquoteselector into TextQuoteSelectorTag (exact/prefix/ suffix; a "-" or empty exact is treated as a placeholder since the quote lives in .content). - HighlightEvent.contextOrReconstructed() prefers an explicit `context` tag and otherwise rebuilds the paragraph from prefix + content + suffix, so the in-context bolding still works. - Build a disambiguated Text Fragment URL (`#:~:text=prefix-,exact,-suffix`) from the selector's prefix/suffix so the source link scrolls to the correct occurrence. The position/range selectors are left unparsed; they only matter for an in-app live-page re-highlighter, which we don't have. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Di4UurD9SQGrpScX7uy2Kh |
||
|
|
dc157e32d2 |
fix(graperank): page through ALL followers, not just the first limit
FollowerCrawler set the reverse-lookup filter's `limit` to the page size, but fetchAllPages treats a filter `limit` as the TOTAL cap across all pages and stops paging once it's reached — so the crawl silently capped at ~500 followers per relay (verified live: relay.damus.io returned exactly 500 for a many-thousand-follower observer). Leave the filter limit null by default so pagination walks the whole result set (the same observer now returns 12,823 followers from damus alone); Config gains `maxPerRelay` and the CLI a `--max N` flag to opt back into a bounded spot check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4 |
||
|
|
3254b90b19 |
Merge pull request #3618 from vitorpamplona/claude/amy-nip46-bunker-concord-epoch-diag
fix(nip46): remote-signer pubKey is the user identity + amy Concord epoch tooling |
||
|
|
f0c21f3513 |
fix(nip46): remote-signer pubKey is the user identity, not the transport key
NostrSignerRemote extended NostrSigner(signer.pubKey), where `signer` is the
ephemeral NIP-46 transport keypair — so `pubKey` returned the transport key,
not the user's identity. Every self-encryption / self-authorship site keys off
`signer.pubKey`, so for bunker accounts this silently broke:
- private NIP-51 lists (private bookmarks / mute / follows / hashtags) and
NIP-37 drafts — an `if (signer.pubKey != event.pubKey)` guard short-circuits
(desktop: private bookmarks always empty);
- NIP-44 self-encrypted data (Concord list, Cashu) sealed to / read against
the wrong peer key.
Android is unaffected (no bunker path); desktop and CLI were affected.
Make `NostrSigner.pubKey` open and have `NostrSignerRemote` return the
bunker-resolved user key: `getPublicKey()` now caches it, and `bindUserPubkey()`
sets it eagerly for a reloaded account / stored identity. Internal transport
(the response-subscription `p` filter, request addressing) keeps using the
transport keypair explicitly, so it is unchanged. No-op for local/external
signers, where signer.pubKey already equals the account key.
Wired: desktop AccountManager.loadBunkerAccount binds the resolved pubkey; CLI
Context binds identity.pubKeyHex. amy's Concord-list decrypt workaround is
dropped — `newest.decrypt(ctx.signer)` now works for a bunker. Verified live:
`amy concord import` over a bunker account decrypts the kind-13302 list and
recovers Soapbox heldRoots [0,1].
Plan: quartz/plans/2026-07-17-nip46-remote-signer-self-pubkey.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
a96dd12a49 |
docs(nip46): plan to fix remote-signer self-pubkey bug
NostrSignerRemote.pubKey returns the ephemeral NIP-46 transport key, not the verified user identity, so every self-encryption / self-authorship site that uses signer.pubKey as "myself" breaks for bunker accounts. Verified impact: Android unaffected (no bunker path); desktop private NIP-51 lists (private bookmarks/mute/follows) and NIP-37 drafts silently empty, Cashu self-encryption sealed to the wrong peer; CLI the same incl. `concord list`. Records the two failure modes, the affected call sites, and the fix direction (resolve pubKey to the user key via get_public_key while pinning transport uses to the transport keypair). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5f65405b08 |
feat(graperank): add reverse follower crawl (amy graperank followers)
The outbox model can't find an observer's followers — you don't know a follower exists until you've seen their kind:3, so you can't route to their outbox first. FollowerCrawler casts a wide net instead: it asks as many relays as possible for kind:3 lists that #p-tag the observer, paging each relay past its per-REQ cap via fetchAllPagesFromPool, verifies with ParallelEventVerifier, keeps only lists that genuinely tag the observer, dedups by id, and group-commits to the store. Each follower's list is a full contact list, so persisting it also enriches the graph a later `graperank score` builds — every follower becomes a FOLLOW edge into the observer. CLI: `amy graperank followers [OBSERVER]` assembles "all possible relays" from the reachability-cache live set + every kind:10002/30166 relay in the store + the index/aggregator relays, skipping proven-dead relays. Runs anonymously (no signing) when given an explicit observer. Tunable via --relay/--page-limit/--timeout/--relay-concurrency/--insert-batch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4 |
||
|
|
c8ff1bb18f |
feat(graperank): persist follower count and hop distance on trust cards
Each kind:30382 GrapeRank card now carries two more public tags alongside `rank`: - `followers` — the number of the target's followers whose own score clears a threshold (`--followers-threshold`, default 0.02), mirroring Brainstorm's trusted-follower cutoff. - `hops` — the shortest follow-graph distance from the observer (1 = a direct follow), matching the `hops` field on Brainstorm's ScoreCard. New `HopsTag` (the `followers`/`FollowerCountTag` already existed) is wired through the ContactCardEvent tag accessors/builders. TrustGraph gains `hopsFrom` (a follow-only BFS over the compact int-CSR) and `trustedFollowerCounts`; the out-CSR now packs the relation code so a forward walk can filter FOLLOW edges. The publisher's `reconcileLocal` takes a richer `ScoredCard` and diffs the full (rank, followers, hops) triple, so a card re-signs when any of them moves and older cards migrate onto the new tags once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4 |
||
|
|
d1f267a2a3 |
Merge pull request #3613 from vitorpamplona/feat/relay-req-refusal-suppression
feat(relay): stop re-sending REQs relays structurally refuse |
||
|
|
10d88afea3 |
feat(relay): stop re-sending REQs relays structurally refuse
Relays that can't serve a request (a NIP-50 search-only relay pulled into
the feed, a write-only relay, a relay whose filter shape is rejected) were
being hammered with the same doomed REQs. Observed on a 40s cold start:
search.nos.today CLOSED 13-14x ("error: search filter is required") across
6 subscriptions, plus repeated "restricted: does not accept REQs" and
"unsupported: too many filters". The reconnect path replayed refused REQs on
every reconnect, and many different subscriptions kept hitting the same
capability wall.
Two complementary, purely-quartz mechanisms (so the app and Amy both benefit
with zero wiring):
- Per-subscription refusal memory (RequestSubscriptionState + PoolRequests):
a filter a relay CLOSES is not replayed to that relay across reconnects
until it meaningfully changes or a REQ succeeds (EOSE/event). Never applies
to auth-required (the auth subsystem re-signs and replays) or rate-limited
(the adaptive limiter spaces it out).
- Per-relay capability block (RelayReqRefusals): after 2 refusals, classify a
relay SEARCH_ONLY (suppress only non-search filters; genuine search REQs
still flow) or NO_READS (suppress all), from narrow substring markers that
deliberately avoid auth-conditional messages. A fully-blocked relay is
dropped from PoolRequests.desiredRelays so the pool disconnects it, closing
the idle socket rather than keeping it open with every REQ suppressed. A
SEARCH_ONLY relay stays connected while any subscription carries a search
filter for it, so the separate search path is unaffected.
Adds UNSUPPORTED to MachineReadablePrefix (relays send "unsupported:"; parse()
returned null on it before).
Device-verified: search.nos.today now Connecting -> OnOpen -> 2x Closed ->
Disconnected; sendit.nosflare.com (NO_READS) -> 3x Closed -> Disconnected;
feed event volume unchanged (kind-1 from 17 relays) - no coverage loss.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
fd5556609f |
fix(nip46): audit fixes on the new signer code
Self-review of this session's changes surfaced four issues: - Perms re-seeded on every re-pair. connectViaNostrConnect pre-granted the offer's declared ops outside the first-contact guard, so re-pairing an app overwrote per-op decisions the user had since changed (e.g. an op set to DENY came back as ALLOW). Now only on first contact. - Perms could silently grant sensitive ops. The nostrconnect flow shows no dialog (scan = consent), so the declared perms are never surfaced — yet seeding pre-granted everything except decrypt/deletion, which would silently allow config-overwrite (kinds 0/3) and other sensitive kinds. Tightened to only the ops REASONABLE already auto-allows, so pairing never exceeds the default policy; sensitive kinds still prompt on first use. - Batched-consent deny-all race. SignerConsentActivity.onDestroy denied every pending request when finishing; a request arriving as the sheet closed is owned by a freshly-launched instance, so it was wrongly denied. Removed — each dialog's onDismissRequest already fails closed, and the 120s bridge timeout backs it up. - Redundant work: batched-selection state keyed on list size (a new request in a same-size swap was unselectable) → key on the token set; connected-apps loader re-read loadPolicy per app when allPolicies() already carried it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF |
||
|
|
1d8b7d7c8f |
feat(nip46): batched consent via concurrent request dispatch
Third refinement from the Primal comparison — and the one that needed an architecture change, not just UI. Quartz: NostrConnectSignerService now fans each request into a child coroutine under a Semaphore(maxConcurrentHandles=16) instead of handling them inline, so a request awaiting a consent prompt no longer blocks other clients' auto-allowed traffic and several prompts can be pending at once. Intake (dedup, staleness, rate-limit, seen-id persistence) stays on the single consumer. Two guards keep it safe: BunkerRequestProcessor serializes the actual crypto with a Mutex (authorization — the prompt — runs unlocked, only sign/encrypt/decrypt holds the lock) so an external NIP-55 signer never sees concurrent IPC ops; and Nip46PermissionAuthorizer serializes first-connect consent so two connects can't stack dialogs. Covered by BunkerRequestProcessorConcurrencyTest (crypto never overlaps; a blocked prompt doesn't stall another client's signing). Amethyst: SignerConsentCoordinator is now a shared pending StateFlow; one SignerConsentActivity observes it and shows the rich single-request dialog (1 pending) or a batched checkbox list with select-all + a Remember toggle + Allow/Deny selected (>1). Dismissing the sheet denies every still-open request (fail closed). Needs on-device validation (burst batching, no concurrent external-signer IPC, fail-closed on dismiss) — see the device checklist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF |
||
|
|
00ec826e8b |
feat(nip46): refuse requests when the account can no longer sign
Reject sign/encrypt/decrypt when the identity signer is not writeable — the account was logged out, is read-only, or lost its external (NIP-55) signer — returning an `account unavailable` error instead of prompting the user or hanging on a key that can't be used. Checked before authorization, so no dialog is raised for a key we can't sign with. Public reads (get_public_key, ping, get_relays) stay ungated. Test: a non-writeable signer refuses a sign request without invoking the signer or the authorizer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF |
||
|
|
5fa8a0a2aa |
feat(nip46): persist serviced request ids so a restart never re-signs replays
The 30s window shrank the restart re-sign problem but couldn't close it: a relay replays stored ephemeral requests on re-subscribe, and the in-memory dedup set is wiped on restart, so anything within the window came back. Persist the recently-serviced kind-24133 event ids (bounded to 128) and seed the service's dedup set from them on start, so a replay after an app restart is dropped by EXACT event id. Chosen over a created_at high-water mark on purpose: a global timestamp floor would wrongly drop a second connected app whose clock lags behind another's, whereas id-matching is immune to client clock skew. The `since` filter still bounds how far back relays replay. - AccountSettings.nip46SeenRequestIds (persisted via putStringSet) + host-side bounded LinkedHashSet, fed to NostrConnectSignerService.initialSeen and advanced through onHandledId. - Tests: a fresh request whose id was serviced last session is not repeated; the serviced id is reported for persistence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF |
||
|
|
9a0af15f36 |
fix(nip46): tighten stale-request window to 30s
An app restart re-subscribes with `since = now - window`, so relays replay (and the signer re-signs) anything created within the window. 120s was wide enough that a request made a minute before restart still came back; 30s keeps that replay window small while still tolerating normal NTP clock skew. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF |
||
|
|
cdf43db0eb |
fix(nip46): ignore stale/replayed sign requests by age
Users on multiple relays were being asked to sign the same request repeatedly, some minutes old. Root cause: kind-24133 is ephemeral, but many relays store and REPLAY it every time the signer re-subscribes (a relay-set change, reconnect, toggle, or rotation), and the in-memory dedup set is scoped to one run() call so it's wiped on restart — the old requests then get signed again. Gate requests by created_at (default 120s window): - a `since` on the subscription filter so compliant relays never replay old stored events, and - a receive-side staleness drop for relays that ignore `since`. The window must exceed realistic client/relay clock skew so a genuinely fresh request is never dropped. Also corrected the seenCap KDoc, which called the event-id dedup set a "request-id" set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF |
||
|
|
f9d9691017 |
feat(nip46): activity feed + account clarity on the signer screen (Tier 2)
Give the user visibility into what the signer is doing: - Nip46ActivityLog: a bounded, newest-first, in-memory feed of serviced requests (method + kind + client + ok/denied), fed from the service's onServiced hook (enriched to pass the full BunkerRequest so the event kind is available). Survives service restarts; not persisted (it's a live feed). - The signer screen shows a "Recent activity" card (last 8, friendly labels like "Signed an event (kind 1)", green/red status dot, relative time) and a "Signing as npub1…" line so it's clear which account is the bunker. onServiced now hands callers the BunkerRequest instead of just the method string (CLI updated to match). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF |