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 chip that names each Concord channel's parent community (and opens it on
tap) renders only when channel.communityName is set. That value is populated
by refreshConcordChannelIndex -> ConcordChannel.updateFrom on each Control
Plane fold, but nothing invalidated the channel's metadata flow afterward, so
the row (which observes metadata.stateFlow via observeChannel) never
recomposed to show the chip — it appeared only if the row happened to
recompose for another reason.
updateFrom now returns whether a displayed field actually changed, and the
index refresh calls updateChannelInfo() only on a real change, so the community
name, icon and chip recompose the moment the fold resolves them, without
churning every row on every fold tick.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Regression: after wiring CORD-06, joined communities showed but their
channels were empty. Folding the Guestbook + next-epoch base-rekey planes
into the SAME kind-1059 REQ and NIP-42 stream-key AUTH set as the control
and channel planes starved the whole subscription on relays that gate a
REQ on stream-key AUTH: the control plane stopped folding, so no channels
appeared (the community list is a separate kind-13302 fetch, so it still
showed).
Restore the control + channel subscription and AUTH set to exactly their
pre-CORD-06 form: drop auxiliaryPlaneSubs from the shared REQ, and drop the
Guestbook/next-rekey keys from streamKeys() (moved to auxStreamKeys() for a
future isolated subscription). The receive-side pieces (guestbook fold,
rekey buffering/drain) stay in place but are dormant until re-introduced in
their own subscription that cannot affect the core chat path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Ban is only a soft removal — the member still holds the room key and can
decrypt everything; clients just decline to show their posts. This wires
CORD-06 Refounding: the hard removal that rotates the community_root so a
removed member's key stops working for anything sent afterwards.
Quartz:
- ConcordKeyDerivation: baseRekeyAddress / channelRekeyAddress (the rekey
stream addresses) and epochKeyCommitment (prevcommit, CORD-02 A.5).
- ConcordRekey: signer-based blobForSigner / findNewKeyWithSigner so a
bunker account opens its blob with a single nip44Decrypt.
- ConcordRefounding: compactControlPlane (re-wrap each head edition's
original plaintext seal under the new root, preserving signatures),
buildBaseRekeyWraps, build, findNewRoot. OpenedStreamEvent now carries
the inner seal for compaction. ConcordRefoundingTest.
Commons:
- ConcordActions: guestbookPlane / nextBaseRekeyPlane, buildGuestbookJoin /
guestbookMembers, buildRefounding, openBaseRekey.
- ConcordCommunitySession folds the Guestbook plane into members (the
recipient set), buffers inbound base-rekey wraps, exposes controlPlaneWraps,
and AUTHs to + subscribes the Guestbook and next-epoch base-rekey planes.
- ConcordSessionRegistry.sync rebuilds a session when its entry's root/epoch
changed; ConcordSubscriptionPlanner.auxiliaryPlaneSubs REQs the new planes.
Amethyst:
- Account announces a Guestbook JOIN on create/join; refoundConcordCommunity
(owner / BAN-holder) bans + rolls + publishes + persists; drainConcordRekeys
adopts an inbound rotation from an authorized rotator; adoptConcordRoot
persists the new root (prior kept as a HeldRoot) and re-seeds the new epoch's
Guestbook, guarded against double-adopt.
- AccountViewModel.removeConcordMember; ConcordMembersScreen "Remove from
community" action + confirmation dialog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The composer's Notify row previously showed only the parent's p tags and
its author, each with an x that removed the user for good. It now lists
every thread member (authors along the reply chain plus the parent's
mentions), and each chip carries a bell instead: tapping it mutes the
notification for that user but keeps the chip — faded and with a
bell-off icon so the state is obvious — making it one tap to add them
back. Muted members are dropped from the outgoing event's p tags (and
from a private note's receivers), and drafts round-trip the muted state
by re-deriving thread members whose p tag the draft dropped.
The NIP-22 comment composer gets the same chip semantics; there the
mute is honored for the optional zap-sender tag, while the structural
root/reply scope tags stay as NIP-22 requires.
Adds the notifications_off glyph to the Material Symbols subset font.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eK8mGPcuKYXNyhKVJ8Wg7
Feed videos run under REPEAT_MODE_ONE and looped forever. AutoReplayLimiter
counts each MEDIA_ITEM_TRANSITION_REASON_REPEAT as one completed play and
pauses the player when the 5th play finishes, leaving the video on its first
frame with the play button showing. Pressing play (or the feed mutex resuming
a video scrolled back into view) grants a fresh 5-play allowance, and pooled
players reset the count when they switch to a different media item.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9RXiM42yqznwTVjw8tLZv
Every Concord/minichat screen now stands on its own regardless of how it's reached:
- MinichatScreen mounts its own datasource (the Concord plane subscription when the
root is a Concord message; a relay reply REQ for public chats) and drives its list
from a reactive MinichatFeedViewModel, so new replies arrive live and auto-scroll
into view even when opened via deep link. Adds a LazyListState that scrolls to the
newest reply as they land.
- ConcordMembersScreen and ConcordEditScreen now mount the plane subscription too
(previously they showed nothing when opened directly), and all three community
screens (channel list, members, edit) re-resolve sessionFor(communityId) on each
session revision instead of caching it once — so a deep link that lands before the
community has folded picks it up as soon as it does.
Minichats are deliberately kept OFF NIP-17 DMs: the "N replies" chip is gated to the
chat types where minichats are wired (Concord, NIP-28, NIP-29), since most clients
don't render kind-1111 replies in a DM view. The DM composer already has no thread
toggle, so DMs neither create nor surface minichats.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
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>
Follows the code-review of the dual-reply + Concord work:
1. Public-chat/relay-group minichat replies no longer drop @-mentions, hashtags,
URL references, quotes, custom emojis, attachments, content-warning or
expiration: ChannelNewMessageViewModel.createTemplate's MINICHAT branch now runs
after NewMessageTagger and builds the kind-1111 from tagger.message with the same
enrichment the inline path uses (parent author is already tagged by replyBuilder).
2. refreshConcordChannelIndex no longer runs a full re-index + ban rescan on every
ingested message: the revision collector is sample(500)-throttled, coalescing
bursts into at most one pass per window.
3. Concord stream-key AUTH is no longer blocked behind the user-auth prompt: on a
relay that hosts our planes, the ASK path is DISMISSed so the derived stream-key
AUTHs return immediately instead of waiting on (or being dropped by) a dialog.
4. Account.sendMinichatReply evaluates chat.relays() once instead of twice.
5. AuthCoordinator caches the per-stream-key NostrSignerSync by secret, so the
secp256k1 keypair isn't re-derived for every plane on every relay challenge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Generalizes the minichat feature beyond Concord to the public chats:
- ChannelNewMessageViewModel gains a replyMode state + toggle; createTemplate now
builds a kind-1111 CommentEvent for a MINICHAT reply (with the NIP-29 `h` tag on
relay groups) instead of the native reply, so users can start threads in NIP-28
public chats and NIP-29 relay groups.
- Account.sendMinichatReply resolves PublicChatChannel and RelayGroupChannel from
the note's gatherer and publishes a public kind-1111 (h-tagged + host-relay for
groups), so replying inside a public-chat minichat works too.
- observeNoteMinichatReplyCount re-registers each visible message with the
EventFinder subscription, which batches their ids into shared REQs for kind-1111
replies — so the "N replies" chip appears on public-chat messages as their
threads load. (Concord's replies still arrive over the plane; that REQ no-ops.)
- The reply-mode toggle is extracted to a shared ReplyModeToggle composable used by
both the Concord and public-chat composers.
The chat-styled minichat screen already generalizes: it reads the root's kind-1111
replies and works for any chat type. DMs (NIP-17) remain the deferred Phase 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds the "chat within a chat" screen the N-replies chip now opens: the root
message pinned at top, its kind-1111 thread replies below as chat bubbles (reusing
ChatroomMessageCompose so they look identical to the main chat), and a composer
that posts a kind-1111 rooted at that message — flat, so replying inside a minichat
doesn't spawn sub-threads.
- Route.ChatMinichat(rootId) + AppNavigation registration + MinichatScreen.
- The shared "N replies" chip now navigates to the minichat instead of the generic
thread view.
- Account.sendMinichatReply resolves the chat context from the note's gatherer and
drives the Concord channel path (kind-1111 on the plane). Observing the root's
replies also loads kind-1111s from relays, so public-chat minichats already
display; their send path (NIP-28/NIP-29) is the remaining follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds the two-way reply model: an INLINE reply stays in the timeline (native chat
message referencing its parent), a MINICHAT reply is a kind-1111 NIP-22 comment
pulled into a thread opened from the parent — matching Armada.
Foundation (quartz/commons):
- ReplyMode enum (INLINE default, MINICHAT).
- ChannelChat.inlineReply / ConcordActions.buildChannelInlineReply restore the
kind-9 q-tag quote path alongside the existing kind-1111 ChannelChat.reply.
- ChannelFeedFilter excludes kind-1111 CommentEvents from the chat timeline (they
belong in the minichat), so inline replies stay and thread replies move aside.
- observeNoteMinichatReplyCount: local count of a message's kind-1111 replies.
Concord composer + send (amethyst):
- ConcordNewMessageViewModel gains a replyMode state + toggle; the composer shows a
"In chat" / "In thread" toggle beside the reply preview.
- Account.sendConcordChannelMessage routes MINICHAT to kind-1111, INLINE to kind-9
quote, fresh post to kind-9 message.
Shared row chip (all chat types):
- ChatMessageCompose's action row shows an "N replies" chip when a message has
kind-1111 thread replies; tapping opens the thread. Wired to the thread view for
now; a chat-styled minichat screen and NIP-28/NIP-29 loading follow.
Plan: amethyst/plans/2026-07-12-dual-reply-minichat.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Concord replies were kind-9 chat messages carrying a `q` tag. In the Concord
model (matching Soapbox Armada) a `q` on a kind-9 is an inline *quote*, which
clients deliberately keep OUT of threads — a real thread reply is a kind-1111
NIP-22 comment. So our replies rendered (and were sent to Armada) as inline
quotes, never grouping into a message's thread.
ChannelChat.reply now builds a CommentEvent via CommentEvent.replyBuilder: the
uppercase K/E/P tags pin the immutable thread root and the lowercase k/e/p tags
point at the immediate parent (root inherited when the parent is itself a
comment, so the root is stable at any depth), plus the same channel/epoch binding
every Chat Plane rumor carries. This is byte-compatible with Armada's
buildV2CommentTags, so replies thread correctly in both directions. The read path
already accepts these (they carry the binding, and consumeConcordRumor handles
CommentEvent), so incoming Armada thread replies now land bound to their channel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Reverts the Kover coverage aggregation (introduced in dca345212b) from every
module, the root aggregation block, the Sonar coverage import, the version
catalog, and BUILDING.md. With the plugin present the 16 GB CI runner OOM-killed
during the Android test+build job even with Kover opt-in disabled; removing it
entirely clears the OOM. No other CI changes — the diff versus main is exactly
the inverse of dca345212b.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each Concord channel row in the Messages tab has a chip naming its parent
community (tap → opens the community), mirroring the NIP-29 relay chip — but it
only renders when ConcordChannel.communityName is set, and that was populated
solely by refreshConcordChannelIndex() inside the Concord hub screen's
subscription composable. On the Messages tab that screen isn't mounted, so the
channel objects there never got their community name/icon and the chip was
absent.
Moves the channel-index refresh to an account-scoped collector on the
ConcordSessionManager revision, so community metadata (name/icon, channel flags,
membership) and per-community ban pruning apply across the whole app the moment a
Control Plane folds — not only while the hub screen is open. The hub subscription
now just re-derives its filters; the shared refresh lives in Account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The NIP-34 status buttons (Mark merged / Close / Reopen) and the PR
View-changes button used default Material3 sizing, which reads oversized
inside a note card. They now use the same 32dp compact height, tighter
content padding and labelMedium text as the app-recommendation buttons.
Issue and PR cards also showed the title twice when the authoring client
duplicated the subject tag as a leading markdown heading in the content
(gitworkshop/ngit do this). The body renderer now strips that first
heading when it matches the subject already rendered as the card title.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USiovhenaijgVEFFzASgfy
Concord channel messages arrived as 6 rows stuck on the 'Event is loading or
can't be found in your relay list' placeholder. A Concord inner rumor is
unsigned (empty sig) and never exists on a relay, but consumeConcordRumor fed it
to justConsume with wasVerified=false, so justVerify ran a signature check,
failed, and the event never loaded onto its Note — the chat row then fell back to
loading-from-relay and showed the placeholder forever.
The rumor's authenticity is already established when the envelope is opened
(ConcordStreamEnvelope.open verifies the seal signature, binds rumor.pubKey ==
seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59 gift-wrapped DM
rumor. Consume it as pre-verified (wasVerified=true) so the event loads and the
message text renders in the Messages tab alongside other groups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
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 — a Concord wrap
is authored by the stream key and p-tagged to a throwaway ephemeral key, so the
member is neither author nor recipient. Amethyst authenticated only as the user,
so every plane REQ was refused ('auth-required: all authors must be
authenticated') and no channel or message ever loaded, even though the community
list (kind 13302) folded correctly.
Amethyst's relay-auth plumbing already supports multiple identities per
connection: RelayAuthenticator sends one AuthCmd per event in the list its
provider returns, deduped on (pubkey, challenge). This wires the derived stream
keys into that provider:
- ConcordCommunitySession.streamKeys() exposes control + folded-channel GroupKeys.
- ConcordSessionManager.streamAuthSecretsFor(relay) returns the stream secret keys
a NIP-42 challenge from that relay must be answered with (relay-scoped to each
community's own relays — the same scope the plane subscription uses).
- AuthCoordinator signs one kind-22242 per stream key locally (raw KeyPair via
NostrSignerSync — never the account signer, never exposing user identity), and
attaches them to every challenge independent of the user-auth policy. On auth
success the existing syncFilters re-fires the previously-refused plane REQ.
- RelayAuthStatus LruCaches widened 10 -> 200 since one connection now
authenticates as the user plus many plane keys (control + channels).
Mirrors Armada's streamAuth.ts (sign one AUTH per scoped stream key, accumulating
on the connection). Verified live on-device by the debug pass: authing as the
derived stream key returns the control-plane wraps that were previously refused.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The community-list import only queried the 4 Concord stock relays, so a list the
user copied onto their own outbox/read relays (or that a client published there)
was never fetched — the normal account subscription never asks for kind 13302.
Widens the query to stock ∪ mineRelays ∪ outboxRelays with a 30s window (stock
relays like relay.ditto.pub can take 10–20s to first response), and logs how many
13302 events were fetched and how many entries decoded so an empty hub is
diagnosable. Renames importConcordCommunitiesFromStockRelays → importConcordCommunities.
Verified live: the account's newest kind-13302 is served without AUTH by the
dreamith stock relay (and, slowly, relay.ditto.pub); the codec, LocalCache
dispatch, and fold path all match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Removes the last catch-all ConcordKinds constant object. Each remaining kind
now lives on the class that owns it:
- wrap/seal kinds were already ConcordStreamEnvelope.KIND_WRAP/SEAL_* — callers
(ConcordActions, ConcordSubscriptionPlanner) reference those directly.
- guestbook join/leave (3306) and kick (3309) become Guestbook.KIND_JOIN_LEAVE /
KIND_KICK.
- direct-invite (3313), voice-presence (23313), and rekey (3303) inline their
literals on ConcordDirectInvite/VoicePresence/ConcordRekey.
The unused edit/webxdc/typing/snapshot/invite-list/ephemeral-wrap constants are
dropped; they will own their literals when those events get dedicated classes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig