The unread count, the channel-list/hub last-message previews, and the
Messages-row summary all read a broader note set than the channel feed
renders: the feed's ChannelFeedFilter hides kind-1111 CommentEvent thread
replies (and unacceptable authors), but these row surfaces counted/showed
them. A trailing minichat reply therefore stuck the unread badge at a count
opening the channel could never clear (markAsRead is monotonic and only
advances for rendered timeline messages), and showed up as a "last message"
that isn't on the timeline.
Centralizes the feed's predicate as isConcordTimelineMessage (loaded,
acceptable, not a CommentEvent) plus a newestTimelineNote helper, and routes
the unread count, both list-row previews, and the Messages hub filter through
it — so every channel-row summary agrees with what the open channel renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
detectCleartextNetwork() is enforced by netd per-UID at the packet level: it
flags any socket whose first bytes aren't a TLS handshake, with no per-host
exemption. Every relay WebSocket tunneled over the embedded Arti Tor SOCKS
proxy on 127.0.0.1:17392 opens with a cleartext SOCKS5 greeting, so the
detector fired constantly on legitimate loopback traffic.
The Network-Security-Config localhost allowlist does NOT silence it — that flag
only governs the voluntary NetworkSecurityPolicy.isCleartextTrafficPermitted()
check that HTTP stacks consult, not netd's packet inspection. The old XML
comment claiming otherwise was wrong.
Drop the detector (the app already permits cleartext globally for ws:// relays,
so it produced little signal) and correct the misleading comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The channel-list screen titled itself `state.metadata.name ?: app_name`, so
before the metadata edition folded it showed the app's own name — "Amy Debug"
in a debug build. Prefer the folded name, then the stored community name from
the list entry (always present from the join/create, and what shows everywhere
else); the app-name fallback is now effectively unreachable.
The channel-create FAB used Material 3's default rounded-square shape; every
other FAB in the app is a circle. Set shape = CircleShape to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Picking a community icon or banner always failed with "Failed to upload media".
The upload was launched from the form's rememberCoroutineScope() (the Compose
Main dispatcher), so the very first pipeline step — MediaCompressor.compress —
hit Amethyst's checkNotInMainThread() guard and threw OnMainThreadException
before any bytes left the device.
Run ConcordImageUploader.uploadEncrypted inside withContext(Dispatchers.IO) so
the whole compress → strip → AES-GCM-encrypt → Blossom pipeline is off-main; the
Compose state write stays on the launching (Main) scope.
Also stop the form's catch from swallowing the real cause: surface the actual
exception message in the toast (falling back to the generic string only when it
has none), log it, and rethrow CancellationException instead of eating it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NotificationFeedFilter derived `isConcord` from the note's own gatherers
(is it in a joined ConcordChannel), but LocalCache.consumeConcordRumor only
attaches kind-9 messages and kind-1111 replies to the channel — never a kind-7
reaction. So a reaction's `isConcord` was always false, it didn't bypass the
follow filter, and since a fellow member usually isn't a follow it was dropped
in Curated/Selected mode.
Recognize a reaction/repost as Concord through its TARGET instead: if
`replyTo.lastOrNull()` is a message in a community I've joined, it bypasses the
follow filter exactly like a reply. Relevance is still the existing p-tag gate,
so only reactions that actually tag me notify (a well-formed NIP-25 kind-7
p-tags the reacted author, which is what our own ChannelChat.reaction writes).
The "Messages in notifications" toggle now gates only Concord messages, not
reactions — a like isn't a message, so it follows the same rule as any other
reaction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Concord (and MLS/WhiteNoise) chat reply is a kind-9 ChatEvent that carries
its reply target as a NIP-18 `q` (or NIP-10 `e`) tag, not a NIP-10 thread — so
it isn't a BaseThreadedEvent and RenderTextEvent's reply-to preview never fires
for it. On the chat feed the preview is drawn by chat-only code, but everywhere
else NoteCompose routes kind-9 through RenderChat, which rendered only the
content and never `note.replyTo`. Result: on the Notifications tab a Concord
reply showed no quoted parent (no border) — most visibly when replying to an
image, whose target is likewise a kind-9.
RenderChat now takes unPackReply and, when FULL and not makeItShort, renders
ReplyNoteComposition(note.replyTo.lastOrNull()) like the threaded path does,
skipping it when the parent is already cited inline (`nostr:...`) so an
MLS-style quote isn't drawn twice. NoteCompose forwards unPackReply; the thread
view passes NONE since its structure already shows the parent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The members roster was a fraction of the real membership (e.g. ~13 vs ~44 on
Armada). Concord membership includes every "observed author" (CORD-02 §5 — anyone
seen publishing), but the live channel subs only carry the recent tail the relay
serves, so most members — who posted outside that tail and never sent a Guestbook
Join — never appeared.
Add ConcordMemberHarvest: a headless, run-once background sweep mounted by the
members screen that pages every folded channel's history back to a bounded window
(90 days — tunable; bounds the data pulled onto the device, per the "how far back"
limit) in one pooled `fetchAllPagesFromPool`. The wraps ride the app's normal ingest
(global CacheClientConnector → concordSessions.ingest), which folds each author into
`observedAuthors`, so the roster fills in with no extra plumbing. AUTH is free — the
channel stream keys are already registered for these relays. `beginMemberHarvest()`
gates it to once per community.
Prerequisite fix: `ConcordCommunitySession.ingest` re-decrypted a channel's WHOLE
wrap buffer on every incoming message (reprojectChannel), which is O(n²) in the
message count — fine for a ~50-wrap live tail but fatal for a history sweep. Split
it: a message now projects only its own wrap (O(1)); the re-decrypt-all path stays
for a re-fold (where channel keys can change). This also speeds the live path.
`ConcordCommunitySessionTest` now asserts the one-wrap-per-message projection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two roster gaps against the reference client (Armada):
1. Moderators showed as "Admin" and role definitions were often empty. The
displayed roles came from ConcordCommunityState.roles, which was built from the
RAW structural fold heads — so a rogue higher-version edition on a role's
coordinate (e.g. marking the Admin role deleted) corrupted or emptied the roster,
and even when present the UI collapsed every role-holder to a single "Admin"
badge. Now state.roles comes from the authority-gated resolver
(AuthorityResolver.roles(), exposed alongside rolesFor()), and ConcordMembersScreen
renders each member's actual most-privileged role name (Admin / Moderator / custom).
2. Member count was a fraction of the real one (e.g. 10 vs ~44). CORD-02 §5: "an
author seen publishing is observably present, auto-included even if their Join
never arrived." The roster only counted Guestbook joiners + the privileged roster,
omitting the bulk of members who never post a Join. ConcordCommunitySession now
tracks observedAuthors from every decrypted channel message and folds them into
allMembers() and the roster.
Also: amy's `concord roles/grant/ban/...` now register the control-plane stream key
before draining (like `channels`/`read`/`send` already do), so the mod verbs aren't
served an empty fold on NIP-42-gated relays — used to ground-truth the resolved roles.
Verified via amy against live Soapbox: `concord roles` now returns Admin (pos 1) and
Moderator (pos 2) instead of []. quartz + commons concord suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Concord community icon is CORD-02 §6 encrypted media: rememberConcordImageModel
fetches the ciphertext, AES-256-GCM-decrypts it, and caches the plaintext at a
local file:// path, which it hands to the avatar as the model. But
RobohashFallbackAsyncImage always wrapped the model in ProfilePictureUrl, and the
thumbnail-cache fetcher behind it (ProfilePictureFetcher) delegates a cache-miss
to Coil's http-only NetworkFetcher — so the file:// load failed and the row fell
back to the robohash, even though the icon had decrypted and cached correctly.
Only route remote http(s) pictures through the thumbnail cache; hand local/content
URIs straight to Coil's native fetchers, which load them directly. Verified
on-device: the Soapbox community icon now renders on the Messages rows and the
community header instead of a robohash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fold selected each entity's head by STRUCTURAL fold only and never dropped
editions from unauthorized signers, so a spoofed edition that structurally
supersedes a legit one won — on the live Soapbox community this surfaced a decoy
metadata edition ("invalid ... clients should be ignoring this", no icon) over
the real owner-authorized one, so no community icon/banner ever rendered.
Two stacked wire/authority bugs, both fixed:
- RoleEntity.scope was typed String, but the reference client (Armada) writes it
as an object ({"kind":"server"} / {"kind":"channel","channel_id":...}) per
CORD-04 §2. The type mismatch failed the whole RoleEntity decode, dropping the
role — and with it every grant depending on it — so no admin ever resolved.
Added RoleScope and retyped the field (no writer set it, so no migration).
- AuthorityResolver.resolve now folds each role/grant/banlist CHAIN through
authorized editions only, via an owner-rooted fixpoint over the full edition
set (not the post-hoc structural heads). A rogue higher-version grant from an
unprivileged key is dropped instead of superseding the owner's grant, so the
legit authority stands. ConcordCommunityState.fold then gates metadata by
MANAGE_METADATA, channels by MANAGE_CHANNELS, the banlist by BAN, and the
dissolution tombstone to the owner alone.
Verified live end-to-end: `amy concord channels Soapbox` now folds the real
metadata (name "Soapbox Community", the blossom icon + banner pointers) and all
9 channels; fetching + AES-256-GCM-decrypting the icon pointer yields a
hash-verified PNG. Regression tests cover the object-scoped role and the rogue
superseding grant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them
only to a connection authenticated AS the plane's derived stream key — the
member is neither the wrap's author (the stream key) nor its recipient (a
throwaway ephemeral key), so an account AUTH is refused and every plane REQ
came back empty (no channels, no messages), even though the community folded
its name.
Mirror the app's per-plane AUTH (60475c10c0) in the CLI:
- Context.registerConcordStreamKeys(relays, secrets) records the derived
control/channel stream secrets, scoped to the community's relays.
- The RelayAuthenticator provider now signs one kind-22242 per registered
stream key alongside the account AUTH — locally, from the raw derived key
(NostrSignerSync), never the account, so no user identity is exposed.
- The concord channels/read/send verbs register their control + channel
stream keys before draining/publishing.
- drain() gains pendingOnAuthRequired: an auth-required CLOSED keeps the relay
pending instead of terminal, so the post-auth subscription re-fire delivers
the events rather than the one-shot drain returning empty. The concord verbs
opt in; all other drains are unchanged.
Verified end-to-end against the live Soapbox community (relay.ditto.pub /
relay.dreamith.to): `amy concord channels` now folds the name + 9 channels
consistently and `amy concord read` returns messages. `channels` also emits
the folded icon/banner/description pointers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CORD-05 Appendix A.1 says the HKDF `id` is always present — 32 bytes,
all-zero for a label with no meaningful id — and A.6 lists
`concord/invite-key` with `id = 0…0`. inviteBundleKey built its info with
no id at all, so it derived a different bundle key than the reference
client (Armada) and NIP-44 decryption of an Armada-minted invite bundle
failed with "Invalid Mac" — the join aborted with "no valid bundle for
this link". amy round-tripped with itself (same wrong key both sides), so
its own tests passed and the divergence went unnoticed.
Verified by decrypting a real Armada invite bundle: the no-id key fails the
MAC; the zero-id key yields a valid CommunityInvite JSON. Pass ByteArray(32)
like the sibling banlist/dissolved derivations already do.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After a Refounding (CORD-06 §3), compaction re-wraps each entity's head
verbatim, so a head edited past genesis still carries an `ep`/prev citing
an edition in the prior epoch that a fresh joiner never fetches.
EditionFold.foldEntity required a genesis edition (prevHash == null) and
returned null otherwise, so a fresh login folded the whole Control Plane to
nothing: no community icon, no name, no edited channels (while Armada,
which implements the CORD-04 §1 fresh-joiner rule, showed them all).
Anchor at the lowest-version edition when no genesis is present and walk up
from there. Safe: Amethyst always re-folds the whole buffer from scratch
(no persistent floor), so it is structurally always a fresh joiner;
authority is validated on top by AuthorityResolver, so an unrooted forgery
is still dropped; and a genuine mid-chain gap still stops at the intact
prefix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping a minichat reply (a kind-1111 CommentEvent) in notifications routed to the whole channel:
routeFor(note) matched the reply's Concord / relay-group / public-chat gatherer and opened the
channel, swallowing the thread it was replying in. routeFor now recognizes a chat-context CommentEvent
as a minichat reply and routes to Route.ChatMinichat(rootId) — rootId being the reply's NIP-22 root
(the parent message), for all three chat contexts.
For Concord, the parent message may not be cached (a cold reply notification), and MinichatScreen used
to resolve the plane only from the root note's gatherer — so an unloaded parent could never pick a
relay. The reply itself arrived over the channel plane, so its ConcordChannel gatherer carries the
community/channel: Route.ChatMinichat now also threads concordCommunityId/concordChannelId from the
reply, and MinichatScreen uses them to mount the plane subscription + this channel's backward-history
pager, paging until the parent message loads. Its whole kind-1111 thread then projects normally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ConcordImageUploader now drives UploadOrchestrator.uploadEncrypted — the same
path DM/chat encrypted media uses — instead of a hand-rolled BlossomUploader
call. A community icon now gets image compression, EXIF/metadata stripping, and
the account's configured Blossom server, keeping the simple photo picker. It
hands the orchestrator a fresh AESGCM cipher and maps the result — ciphertext
url + plaintext hashBeforeEncryption — into the CORD-02 §6 ImagePointer, which
the read path (rememberConcordImageModel) round-trips.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live plane subscription only carries each channel's relay-capped recent
tail, shared across one merged REQ per relay for every channel, so a channel
with plenty of history opened showing just its last few messages until the user
scrolled. The old bootstrap only paged when the feed was completely empty.
ConcordBackfillHistoryToWindow now pages older history on open until the feed
holds at least CONCORD_HISTORY_TARGET (50) messages or the relays are exhausted
— mirroring Armada's multi-page backfillStore — page by page via the existing
BackwardRelayPager, then latches off and lets scrolling drive further paging.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every inbound plane wrap that a session claimed — including a plain chat
message — bumped ConcordSessionManager.revision, and the always-on preload,
the channel subscription, and the open-channel history subscription each call
invalidateFilters() on every bump. So every message re-derived and re-REQ'd
every community's control + channel planes. On a cold load of hundreds of
buffered messages that is hundreds of re-subscriptions, which the relays answer
with "there is a bug in the client, no one should be making so many requests"
and close the plane subs mid-load (each needing a fresh NIP-42 AUTH). The
result: channels load only their last few messages, or none.
ingest() now reports a ConcordIngestOutcome (NOT_MINE / NON_STRUCTURAL /
STRUCTURAL). Only a STRUCTURAL wrap — a Control-Plane fold, a guestbook
membership change, or a buffered base-rekey — bumps the revision. Chat messages
are NON_STRUCTURAL: they still reach the feed via the rumor sink → LocalCache,
but no longer churn the subscriptions. The manager keeps its Boolean contract
(claimed) for DecryptAndIndexProcessor. Verified on-device: the "so many
requests" rate-limit is gone and the plane subscription stays open and drains
steadily instead of being closed and reopened per message.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the CORD-02 §6 image write path: the community-metadata form's
icon hero is now a photo picker that AES-256-GCM-encrypts the chosen image
under a fresh key/nonce, uploads the *ciphertext* as an opaque blob to the
account's Blossom server, and seals the resulting ImagePointer
{url,key,nonce,hash} into the metadata — the inverse of the read path, and
what Armada does in concord-v2 (`encryptImageBlob` + Blossom upload).
- ConcordImageUploader reuses the existing NIP-17 DM encrypted-media
primitives (AESGCM + BlossomUploader.upload(inputStream, …) + the account's
Blossom server list + createBlossomUploadAuth). The blob is content-
addressed by the ciphertext SHA-256; the pointer's hash is the plaintext
SHA-256 for read-side integrity.
- ConcordMetadataFields now holds an ImagePointer? and its hero opens the
photo picker (spinner while uploading), replacing the plain-URL field — a
URL-string icon was never CORD-02-valid (Armada renders robohash for it).
- Create/edit pass the encrypted pointer straight through to
createConcordCommunity / editConcordMetadata.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concord community icons never showed (robohash instead), and the community
name silently fell back to the invite name. Root cause: the icon/banner are
CORD-02 §6 **encrypted media** — the metadata entity carries an
`ImagePointer` object `{url,key,nonce,hash}` (AES-256-GCM ciphertext at
`url`, decrypted with `key`/`nonce`, `hash` = SHA-256 of the plaintext) —
but `MetadataEntity.icon` was typed `String?`. An object where a String is
expected fails the whole entity's decode, so metadata came back null: no
icon, and the name dropped to the entry fallback. Matches the Concord v2
reference client (Armada `concord-v2/lib/{types,image}.ts`).
- Promote `ImagePointer` to a shared CORD-02 type (was invite-only) and give
it `decryptOrNull` (AES-256-GCM via the existing `AESGCM`, verifying the
plaintext SHA-256 — a swapped blob fails closed).
- `MetadataEntity.icon`/`banner` are now `ImagePointer?`, so the entity (and
the community name) decodes. `ConcordChannel` carries the pointers.
- `rememberConcordImageModel` resolves a pointer for the avatar: a plain-URL
pointer (Amethyst's own form) passes through; an encrypted one is fetched,
decrypted, verified, cached to disk, and rendered — else the robohash. Wired
into the Concord hub avatars and the Messages-tab community chip.
- Amethyst's create/edit still take a URL and wrap it as a url-only pointer;
authoring encrypted images (encrypt + upload) is a follow-up.
Adds ImagePointerTest: Armada-shape object decode, decrypt round-trip, and
fail-closed on a tampered hash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Communities only folded — revealing their channels, metadata (name/icon)
and membership — while a Concord screen was actually open, because the
Control-Plane subscription (ConcordChannelSubscription) was mounted only on
the six Concord screens. Sit on the Home feed and nothing streams in; a
community you haven't opened stays unfolded (no channels, no image).
Concord control-plane wraps are addressed to derived stream keys, not
`#p=self`, so the always-on account/DM gift-wrap tail can't pick them up.
Add ConcordChannelPreload — an account-level, always-on mount using the
non-lifecycle KeyDataSourceSubscription (the same primitive
AccountFilterAssemblerSubscription uses for DMs) with the existing
revision-driven filter re-derivation — and mount it in LoggedInPage next to
the DM/account preload. Now every joined community's planes are requested
from login regardless of screen, so folds happen in the background. The
already-always-on ingest/refreshConcordChannelIndex path was only missing
this continuous network request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay-auth fix added an `interactive` flag to
RelayAuthenticator.signWithAllLoggedInUsers; update the desktop and cli
implementers (which don't prompt) to the 3-arg lambda.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concord channels only showed the recent tail the relay served for the
channel plane and never loaded older messages on scroll. Add backward
`until`+`limit` paging, per relay, on demand — the same model as the
NIP-04 per-conversation history.
Reuses the shared paging stack as-is (BackwardRelayPager,
RelayLoadingCursors, the RelayReach* markers/sentinels and
DmHistoryLoadingCard); only the Concord-specific data layer is new:
- ConcordChannel holds a per-channel RelayLoadingCursors (`history`), so
cursors share the channel's cache lifetime.
- ConcordChannelHistory{FilterAssembler,SubAssembler} binds a pager to the
open channel, builds `{kinds:[1059], authors:[planePk], until, limit}`
per armed relay, and forwards relay callbacks; registered in
RelaySubscriptionsCoordinator and mounted by the channel screen.
- ConcordCommunitySession.channelPlaneAddress() resolves a channel's REQ
author from the fold.
- ConcordChannelScreen wires the olderBoundary/markersInGap/sentinels feed
hooks and bootstraps an empty channel.
The history floor is `now` (not the DM 7-day tail): the Concord live sub
isn't a strict recent-tail (it asks the plane author unbounded and the
relay caps the result), so paging must walk the whole history from the top
to reach recent-but-capped messages. Overlap with the live tail is
harmless — wraps dedup by id on ingest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Concord chat row rendered a permanent "Event is loading or can't be
found in your relay list" placeholder when a message's kind-5 delete was
processed before the message itself — easy to hit because a reproject
re-emits the whole wrap buffer and wrap ordering isn't guaranteed.
`consumeConcordRumor` attaches the row before `justConsume`, but
`justConsume` bails without loading the event once the rumor has been
deleted, leaving an event-null note pinned in the channel forever.
Skip attaching a row for a rumor already known deleted (also avoids
add/remove churn on every reproject), and after consuming, drop the row if
its event never loaded. The reverse order (delete after the message) is
still handled by the normal deletion cascade unlinking the note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concord channels loaded empty because the channel-plane REQ was refused
until the relay happened to re-issue an AUTH challenge (~27s later, or
never on relays that don't re-challenge).
A channel's derived stream key only exists after the community's Control
Plane folds, but by then the connection's initial NIP-42 AUTH already ran
with just the control key. `RelayAuthenticator` only re-authenticated on a
fresh `AUTH` message and ignored `auth-required` CLOSED frames — so the
newly-revealed channel keys were never sent.
NIP-42 says the client must store the connection's challenge and reuse it
"in response to the auth-required CLOSED message". Do that: remember the
last challenge per relay, and on an `auth-required:` CLOSED re-run the
sign/send pass with it. `saveAuthSubmission` dedups by (pubkey, challenge)
so only not-yet-authed identities (the folded-in keys) are sent — a no-op
once they all are, so no loop. A burst guard skips re-signing while an AUTH
is already in flight (syncFilters re-drives the REQ when it settles), and
the re-auth is non-interactive: it re-sends only already-approved
identities (ledger-ALLOW accounts + stream keys) and never raises a prompt,
so it can't drag a bystander account onto a paid relay.
Adds an `interactive` flag to the signing callback and a
RelayAuthenticatorReauthOnClosedTest covering reuse, loop-safety,
burst-coalescing, and the non-auth-required CLOSED no-op.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay NOTIFY (payment prompt) went into a process-wide NotifyRequestsCache
and DisplayNotifyMessages showed it under any account whose relay list contained
the relay — so a prompt billed to Amethyst surfaced under Vitor, and stale
entries re-appeared on every account switch.
A NOTIFY doesn't reliably name a pubkey, so instead of parsing the message we
correlate it with the AUTH that triggered it: a paid relay answers an
unauthorized AUTH with `OK <authEventId> false …` right before the NOTIFY, and we
signed that auth event. NotifyCoordinator remembers each auth event's signer,
maps the failing OK back to it, and files the NOTIFY into THAT account's own
Account.relayNotifications. Unattributable NOTIFYs are dropped. DisplayNotifyMessages
now reads only the current account's cache.
Also removes the now-unused app-wide RelayAuthPermissionStore singleton and wires
NotifyCoordinator with the pubkey -> Account lookup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One shared NostrClient serves every logged-in account, so a relay's NIP-42 AUTH
challenge is not tied to any one of them. AuthCoordinator used to fold every
account's verdict into one decision and then sign with EVERY account (or a random
throwaway key), so a paid relay like inbox.nostr.wine billed and de-anonymized
accounts that never used it — including via a merged read filter that merely named
them, and via account switches / the background notification consumer.
Decide and sign PER ACCOUNT now: an account signs only if the relay is in its own
relay list, or it is publishing its own event there (RelayAuthFirstParty), AND its
own ledger verdict (Account.relayAuthLedger) allows it. A subscription merely
naming the account is NOT first-party — that is the merged-filter false positive.
The random-ephemeral-key fallback is removed. Own-inbox reads still qualify: the
relay serving them is by definition in the account's own list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-relay ALLOW/DENY AUTH overrides (and the policy ledger that reads them)
lived in one process-wide DataStore keyed only by relay URL, so a DENY set for
one account silently applied to every logged-in account. Move them to a
per-account file under accounts/<pubkey>/ and warm-cache the overrides in memory
on the Account (RelayAuthPermissionCache) so an AUTH challenge is answered
without a disk read.
DataStoreRelayAuthPermissionStore now shares one DataStore per file path —
DataStore v1 forbids two live instances on one file, and loadAccount can build
the store more than once per account.
Introduces the per-account state holders wired up by the following commits:
Account.relayAuthPermissions, relayAuthLedger and relayNotifications.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>