Follow-ups to the audit:
- Abuse protection: the signer service now bounds its event queue
(DROP_LATEST) and rate-limits per author BEFORE decrypting — decryption can
be an external-signer (NIP-55) IPC round-trip, so a looping or hostile client
can no longer force one per event or grow the queue without limit. Fixed
window (default 40 requests / 10s per author, oldest authors evicted). The
limiter is touched only by the single consumer coroutine, so it needs no
locking. Covered by a headless test.
- logout now clears the client's persisted metadata/relays too (not just the
ledger grant), so a disconnected app stops being listened for after restart.
Not changed: get_public_key/ping stay ungated — gating them behind a prior
connect risks breaking clients that discover the pubkey at connect time, and
the pubkey is already public, so the enumeration leak is negligible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Findings from an audit of the signer, all verified against the code:
- Data race: NostrConnectSignerService deduped request ids inside onEvent,
which the relay pool invokes CONCURRENTLY from each relay's socket thread
(PoolRequests dispatches listeners outside its lock). Two relays delivering
the same subscription could mutate the LinkedHashSet at once → race / CME.
Move dedup into the single consumer coroutine; onEvent now only does the
thread-safe channel send.
- Swallowed cancellation: broad `catch (Exception)` around suspend calls in the
processor, the service's decrypt + publish, and connectViaNostrConnect caught
CancellationException too, breaking structured cancellation when the service
restarts. Rethrow it first (matching the AccountCacheState convention).
- Write amplification: the ledger wrote last-used to that client's DataStore
file on EVERY authorized request (unthrottled, unlike the relay-auth store).
Coalesce to at most one write per client per 60s in the authorizer.
- Redundant resubscribe: the enable/relays collector lacked distinctUntilChanged,
so a duplicate inbox-relay emission tore the subscription down and re-opened
it on every relay for nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
- NostrConnectSignerService now bounds its request-id dedup set (LinkedHashSet
with an evicting cap) so a long-lived bunker can't leak memory on the ids it
has seen.
- Add NIP-46 `logout`: the processor recognises the method, acks it, and calls
a new Nip46RequestAuthorizer.onLogout hook (default no-op) so a host can
revoke the app's grant when it disconnects.
- New NostrConnectSignerServiceTest drives full request→reply round trips
(connect/sign/logout + drop-if-not-addressed) through the service over a fake
relay client with passthrough signers — headless proof of the subscribe →
decrypt → dispatch → publish wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Adds the bunker/signer half of NIP-46 as reusable, signer-agnostic quartz
components so Amethyst can act as a remote signer for other apps:
- BunkerRequestProcessor: turns a decrypted BunkerRequest into the
BunkerResponse the client expects, performing the work through whatever
NostrSigner the account uses (local keypair or NIP-55 external app).
Signing/encryption/decryption are gated through a Nip46RequestAuthorizer;
public reads (get_public_key/ping/get_relays) are not.
- Nip46RequestAuthorizer: the permission boundary the host app plugs its
own trust model into (connect validation + per-op authorization).
- NostrConnectSignerService: subscribes to kind-24133 requests on a relay
set, decrypts, dispatches to the processor, and publishes the reply.
- NostrConnectURI: KMP-safe parse/build for bunker:// and nostrconnect://
pairing URIs (percent-encoded), shared by CLI/desktop/Android.
Unit tests cover the dispatch/authorization matrix with a fake signer (no
crypto) and the URI round-trips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
A live relayop.xyz kind-33301 invite bundle (vsk=6) decrypted correctly but
failed to open: its JSON diverged from quartz's CommunityInvite model on two
CORD-05 wire details, so decodeOrNull returned null and the invite reported
Unreadable ("This invite link can't be opened...") instead of joining.
- InviteChannel.key was required; a public channel (e.g. an unencrypted
`general`) carries no delivered grant key and some reference clients omit
the field. Default it to "" so a keyless channel no longer rejects the
whole bundle.
- icon was modeled strictly as an ImagePointer object; relayop emits a bare
public URL string for an unencrypted icon. Add LenientImagePointerSerializer
(a JsonTransformingSerializer) that lifts a string into ImagePointer(url=...)
on read while still serializing the canonical object form on write.
Adds a regression test driving the real live bundle + its fragment token,
asserting it now classifies as Live. This is a distinct interop case from the
existing vsk=8 mis-posted-registry test: here the sub-kind, token, and crypto
are all correct — only the JSON schema was too strict.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iLfo68yVtG2ALkKB8tpAa
Replaces the two forked cached parsers (amethyst CachedRichTextParser on
android.util.LruCache + desktop DesktopCachedRichTextParser on ConcurrentLruCache
with a naive isMarkdown) with one shared object in commons/jvmAndroid/richtext,
built on quartz ConcurrentLruCache and keeping amethyst's CommonMark-aware
computeIsMarkdown and content-addressed key (content+tags+callbackUri+authorPubKey).
- Add ConcurrentLruCache.trimToSize(maxItems) (+ tests) for the onTrimMemory path.
- Repoint all amethyst callers (incl. the markdown unit test) and both desktop
callers; delete both forks.
Verified: :commons JVM, :desktopApp, :amethyst play debug + unit tests compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Addresses the four findings from the branch audit:
- Make the kotlinx codec (LimitsKSerializer, the iOS/native incoming path)
as lenient as the Jackson path: mistyped fields degrade to null, non-int
array elements are skipped, and a payload-less ["LIMITS"] frame yields an
empty message instead of throwing. Guard the payload access in
MessageKSerializer and MessageDeserializer likewise.
- Make the Jackson reads (LimitsDeserializer) type-checked so an explicit
JSON null or wrong-typed value stays null ("unspecified — keep previous")
instead of coercing to false/0. Both codecs now behave identically.
- LimitsMessage -> data class, so StateFlow.distinctUntilChanged in
RelayLimitsTracker suppresses no-op emissions when a relay re-advertises
identical limits, and tests get value equality.
- Drop the stale "NIP-22" labels (LIMITS is nostr-protocol/nips#1434, not
NIP-22) from LimitsKSerializer, MessageKSerializer and MessageSerializer.
Tests: added mistyped/null-field and payload-less coverage on both the
Jackson and kotlinx paths, plus a value-equality check. quartz:jvmTest
(RelayWireErgonomicsTest 11, KotlinSerializationMapperTest 57,
RelayLimitsTrackerTest 5) and amethyst play compile green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Captures what's done (parse, RelayLimitsTracker, cleanup), the audit fixes
to fold in (kotlinx parse leniency to match Jackson on iOS, drop stale
NIP-22 labels, make LimitsMessage a data class), and the remaining roadmap
(client apply helpers, server-side emit, NIP-11 bridge, amy tooling).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Cleanup after adding LimitsMessage + the client-side cache:
- Remove the unused experimental LIMITS prototype
(experimental/limits/Limits.kt + LimitProcessor.kt). Its @Serializable
model duplicated LimitsMessage (minus auth_for_read/auth_for_write and
the Message wiring); the processor's clamp/reject logic is superseded by
the server-side LimitsPolicy and will be reincarnated as pure helpers on
LimitsMessage. Both were prototypes with no references (preserved in git
history).
- Rename the client accessory RelayLimits -> RelayLimitsTracker so it no
longer collides on simple name with the relay-server-side
nip01Core.relay.server.policies.RelayLimits (the operator-configured
limits a relay enforces and advertises). Updates AppModules and the test.
No behavior change. quartz:jvmTest (RelayLimitsTrackerTest 5/5) and
amethyst play-flavor compile are green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Adds RelayLimits, a passive connection-listener accessory (modeled on
RelayAuthenticator) that caches the latest LIMITS each relay advertises
and publishes it as a Compose-stable StateFlow, so consumers can read a
relay's current rights/limits instead of only observing the raw message.
- RelayLimits: caches LimitsMessage per NormalizedRelayUrl, exposes
limitsFlow (StateFlow), get(url) and snapshot(); connection-scoped
(entry dropped on disconnect so stale limits don't leak).
- LimitsMessage marked @Immutable for Compose stability in the flow map.
- Wired into AppModules next to relayStats (Amethyst.instance.relayLimits).
- Tests: per-relay caching, later-replaces-earlier, independent relays,
drop-on-disconnect, and ignore non-LIMITS messages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Adds support for the relay-to-client LIMITS frame, which advertises the
connection's current rights and limits (can_read/can_write, auth
requirements, max_message_length, max_subscriptions, max_filters,
max_limit, POW, rate limits, required tags, etc.). Relays such as
wss://pipe.imwald.eu/ send it on connect and whenever rights change,
and Amethyst previously logged it as an unsupported message.
- New LimitsMessage model in quartz commonMain with every optional field.
- Jackson decode/encode (LimitsDeserializer + MessageSerializer) and the
kotlinx-serialization path (LimitsKSerializer + MessageKSerializer).
- Logs a summary line in RelayLogger.
- Tests: production payload parse, array/tag fields, empty object, and
round-trip parity between the Jackson and kotlinx codecs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Bugs:
- Metadata edit could re-root a subgroup or drop its children on a load race:
the edit ViewModel snapshotted parent/children at prefill and overrode the
Account-level live-read defaults. Children are no longer snapshotted (Account
reads the live child list at save time), and the parent is only overridden
when the user actually re-parents (parentTouched) — a plain rename can't
re-root or orphan children anymore, even if metadata hadn't loaded yet.
- Parent selector card cached a null channel via remember() and never
refreshed, so the parent's name/picture never loaded and the warm-up never
mounted. Now get-or-create + warm + observe the metadata flow.
- previousEventRefs could let a note with an unresolved author slip past the
self-exclusion and reference the sender's own event. Now requires a resolved
author.
- Assigning a relay-defined role replaced the member's whole role set while the
menu implied additive; now keeps existing roles (entry.roles + role.name).
- GroupNAddrInvite now also accepts a bare `invite=<code>` remainder if the `?`
is stripped upstream (+ test).
Performance:
- Subgroups bar mounted a full warm-up (metadata + content) subscription per
child chip — up to ~21 relay subscriptions per open group. Replaced with one
relay-directory subscription; chips read from cache.
- Parent picker recomputed the whole candidate scan on every recomposition
(each search keystroke) via a produceState initial-value argument; the scan
now lives only in the producer with a cheap empty initial.
spotless clean; quartz tests green; amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
Follows up the subgroup protocol work with four NIP-29 compliance/UX gaps.
previous timeline references (spec §Timeline references):
- RelayGroupChannel.previousEventRefs(): first-8-char id prefixes of the most
recent events seen from the host relay, excluding the sender's own, capped at
the spec's 50-event window. Only draws from events actually received in the
channel so the host relay is known to have them.
- Populate the `previous` tag on all outgoing group events: kind-9 chat and
replies, kind-1111 minichat comments, kind-11 threads, and group replies.
Custom roles (kind 39003):
- Route SupportedRolesEvent onto the channel (LocalCache.consume + a
RelayGroupChannel.supportedRoles field) instead of only storing it.
- Members screen: when the relay advertises a role set, offer those roles when
assigning (admins), and show each member's real relay-assigned role label
instead of collapsing everything to admin/moderator. Falls back to the
built-in admin/moderator shortcuts when no 39003 is published.
Subgroup navigation (spec §Subgroups):
- RelayGroupSubgroupsBar: a self-hiding bar under the pinned bar showing a
breadcrumb up to the parent group and chips for the child subgroups (in the
relay's `child` order), each opening that group on the same host relay.
naddr invite codes (spec §Group identifier):
- GroupNAddrInvite parses the `naddr1…?invite=<code>` suffix; both the tap
handler (ClickableRoute) and the deep-link handler (MainActivity) now feed it
into the kind-9021 join request so a shared invite naddr auto-joins.
Tests for the naddr invite parser. spotless clean; quartz tests green;
amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
GalleryListEvent (kind 10011, @Deprecated "Replaced by NIP-68") was
unreachable: EventFactory routes kind 10011 to ExternalIdentitiesEvent,
and no other code referenced the class. It also shadowed NIP-51's
"Favorite follow sets" kind. Removing the dead class; the live profile
gallery entry (ProfileGalleryEntryEvent) and its builders are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
NIP-51's kind:10000 mute list defines four entry types — `p` (pubkeys),
`word`, `e` (threads) and `t` (hashtags). Quartz parsed only the first
three, so `t` hashtag mutes written by other clients were silently
dropped: uncounted, invisible, and never applied to filtering.
Quartz:
- Add HashtagTag (`"t"`) implementing the MuteTag sealed interface, and
register it in MuteTag.parse/isTagged so it round-trips like the other
entry types.
- Add mutedHashtags()/mutedHashtagIds() TagArray helpers.
Filtering (commons):
- Add hiddenHashtags to LiveHiddenUsers plus isHashtagHidden(), and hide
notes carrying a muted hashtag in Note.isHiddenFor() (exact, case-
insensitive `t`-tag match — distinct from the existing substring word
scan).
Amethyst:
- Aggregate HashtagTag entries from the mute/block lists in
HiddenUsersState.
- MuteListState.hideHashtag/showHashtag + Account and AccountViewModel
wrappers, and observeUserIsMutingHashtag.
- Surface a Mute/Unmute hashtag action in the hashtag screen's options
overflow menu.
Tests: HashtagTagTest (parse/round-trip/MuteTag dispatch) and
NoteIsHiddenForTest cases for muted-hashtag hiding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
Implements the NIP-29 Subgroups feature merged upstream: groups can now be
organized into a parent/child tree, scoped per host relay.
Quartz:
- Add `parent`/`child` tag classes and TagArray (builder) helpers.
- GroupMetadataEvent (39000): parent()/children()/isRoot() accessors and
build params.
- EditMetadataEvent (9002): parent()/children() accessors and build params
(a 9002 re-carries the full child list, per spec, or the relay rejects it).
- SubgroupTree: assembles a relay's flat 39000 set into the hierarchy —
structure follows each group's parent tag, sibling order follows the
parent's child-tag order, orphans surface as roots, and malformed cycles
are broken rather than looping.
- NIP-11: advertise/detect subgroup support via `nip29: { subgroups: true }`,
with a `subgroups()` builder DSL helper.
- Tests for tag round-trips, tree assembly, ordering, orphans and cycles,
plus NIP-11 serialization.
Amethyst:
- RelayGroupChannel: parentGroupId()/childGroupIds()/isSubgroup() reading the
latest metadata.
- Account.editRelayGroupMetadata: preserve the group's current parent and full
children list on a plain metadata edit so an admin renaming a subgroup no
longer detaches it (or gets rejected for dropping children).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
Follow-up making the invite redeemer match the CORD-05 §2 spec for the
addressable invite coordinate (33301, link_signer, d=""):
- vsk=6 → live bundle (open with the link token)
- vsk=9 → revocation tombstone: the newest event wins, so a tombstone buries
even a stale, still-openable copy on another relay ("a fetcher finds the
grave instead of keys"). Amethyst previously never checked for this, so a
revoked link failed generically.
- anything else present (e.g. a mis-posted registry vsk=8, the shape of the
relayop.xyz link that hung) → unreadable
- nothing on any relay → absent
New pure `ConcordInviteBundle.classify(wraps, token): InviteBundleStatus` in
quartz (next to parse/validate), wrapped by `ConcordActions.classifyInvite`,
and mapped by `Account.joinConcordViaInvite` to the `ConcordInviteResult`
cases — including a new `Revoked` outcome with its own message and no futile
retry. Crypto is unchanged and already matches the spec
(hkdf(token,'concord/invite-key') → NIP-44 → snake_case CommunityInvite).
Adds ConcordInviteClassifyTest covering live / revoked (order-independent) /
unreadable / absent, plus the real relayop.xyz vsk=8 event → Unreadable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD
Makes location channels reachable and manageable natively:
- quartz GeohashChannelLevel: the named Bitchat precision levels
(region2/province4/city5/neighborhood6/block7/building8) with cellFor
truncation, so one location fix yields the whole ladder. Tested.
- LocationState.preciseGeohashStateFlow: an 8-char (building) location flow
alongside the existing 5-char one (untouched, so the "around me" feed is
unchanged); channels truncate it per level.
- NewGeohashChatScreen (Route.NewGeohashChat): join a cell from your current
location (region -> building, with place names via LoadCityName) or by typing
a geohash. Joining adds it to the kind-10081 geohash list (followGeohash) and
opens the chat. Includes a "Your channels" section that lists joined cells with
open + Leave (unfollow) -- the management surface the list previously lacked
(only the per-cell Follow toggle on the notes screen existed).
- Adds a "Location channel" option to the Messages "+" new-conversation chooser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Implements NIP-29 message pinning (nostr-protocol/nips#2379):
Protocol (quartz):
- GroupPinnedEvent (kind 39005): relay-signed pinned-message list, `d`
group id + ordered `e` ids.
- UpdatePinListEvent (kind 9010): moderator `update-pin-list` write,
carries the full list so pin/unpin/reorder/clear are one submission.
- Register both in EventFactory; add a pinnedEventIds tag helper.
Model + cache:
- RelayGroupChannel now folds the pin list (pinnedEventIds / isPinned)
with the same createdAt-supersede guard as the roster.
- LocalCache consumes 39005 into the channel and stores the 9010 write;
39005 added to the group's metadata REQ filter so pins load.
Publish path:
- Account.pin/unpin/updateRelayGroupPins + AccountViewModel wrappers.
UI (non-intrusive, self-hiding):
- Collapsed pinned-message bar under the top bar: shows the current pin,
N-count cycling, tap to jump to the message in-feed (hoisted jump
request threaded through the shared chat feed view). Renders nothing
when the group has no pins.
- Moderator-only Pin/Unpin action under "Show more" in the chat message
bottom drawer, gated on membership.canModerate().
- Small pin glyph on pinned bubbles' footer.
Tests: quartz build/parse round-trip for both kinds; channel pin-fold
supersede/replace/clear semantics.
Geohash chat stays anonymous by default (posting under the real npub would
broadcast the user's location tied to their public identity), but instead of a
random per-device seed the throwaway identity is now derived from the account:
- GeohashKeyDerivation.accountSeed(privKey) = HMAC(privKey, versioned label);
per-geohash keys are HMAC(seed, geohash). The result is stable across the
user's devices and recoverable from the account, yet publicly unlinkable to
their npub and across cells (all one-way).
- GeohashChatIdentity picks the account-derived seed for local-key accounts and
falls back to the random encrypted device seed for NIP-46/NIP-55 signers whose
key we can't reach.
Tests cover determinism, account-specificity, and cross-cell unlinkability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Adds the wire-level protocol for interoperating with Bitchat's Nostr location
channels:
- GeohashChatEvent (kind 20000): plain-text public geohash message with a single
exact ["g", geohash] tag plus optional ["n", nickname] and ["t","teleport"].
- GeohashPresenceEvent (kind 20001): presence heartbeat carrying only the g tag.
- GeohashKeyDerivation: deterministic, unlinkable per-geohash ephemeral identity
(HMAC-SHA256(deviceSeed, geohash||counter) with retry + SHA-256 fallback).
- Registers both kinds in EventFactory; NIP-13 PoW reuses the existing PoWTag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Concord/NIP-C7 image messages from interop clients (e.g. Ditto/Soapbox
Armada) attach the image purely as a NIP-92 `imeta` tag and leave the
message content empty. Amethyst's shared renderer only shows media whose
URL appears in the text, so those images never rendered — even though the
encrypted blob's key/nonce were already registered for transparent
decryption at ingest.
RenderChat now appends any imeta URL missing from the content before
handing it to the media renderer (symmetric to ChannelChat.imageMessage,
which appends the URL on send), so imeta-only attachments display.
Also forward the thumbhash on the encrypted-image imeta: FileHeader
computes both blurhash and thumbhash, but only blurhash was being sent.
encryptedImageImeta now emits an additive `thumbhash` field and
toConcordImeta forwards the computed value, so receivers can paint a
placeholder while the blob decrypts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TGjBiJL2fvTR37eePBKrG
`kotlin.jvm.*` is a default import on the JVM target but not on Kotlin/Native,
so `@JvmInline` on the ConcordPermissions value class resolved on JVM/Android
yet failed the iOS (compileKotlinIosSimulatorArm64) build with "Unresolved
reference 'JvmInline'". Add the explicit import; JVM is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Address the deep-audit findings across the new Concord code:
- H1: stop persisting kind-21059 ephemeral typing wraps as durable notes.
EphemeralGiftWrapEvent extends GiftWrapEvent, so every heartbeat was
stored forever; drop it once the session has ingested it.
- H2: follow()/unfollow() now read the offline backup (entriesWithBackup),
so a join racing the async backup load can no longer wipe the joined list.
- M1 (banlist): fold to the head (honors a chained unban) then union in
authorized editions that aren't ancestors of the head — concurrent bans
are healed without resurrecting an on-chain unban (CORD-06 down-only).
- M2: reproject only the newly-arrived channel wrap incrementally instead
of re-decrypting the whole buffer per message (was O(n^2)); refold only
projects newly-folded channels.
- M3: cancel a session's old state-watcher before replacing it on a
Refounding rebuild (was a coroutine + session leak per rekey).
- M4: publish typing/state/members/observed-authors under the lock and
make revision/observedAuthors updates atomic; clamp future-dated typing.
- M5: notification Concord bypass now requires the community to be one this
account has currently joined (mirrors the Marmot guard).
- M6: Concord chat honors the "Messages in notifications" toggle.
- L1: carry NIP-30 emoji tags on minichat replies, image captions and
custom-emoji reactions.
- C1: make the composer VM init() idempotent so recomposition can't wipe a
picked image or an open suggestion list.
- C2/C3: ConcordHome channel rows and unread badges react to the channel's
own notes flow instead of the global revision (no stale rows / flicker).
- C4: try/finally around mint-invite / create / save so a thrown call can't
strand the button disabled.
- C5: gate the typing ticker on active heartbeats so an idle channel stops
waking a 2s loop.
Adds regression tests for concurrent-ban union-heal and unauthorized bans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Verified against the mdk-core revision whitenoise-rs pins (marmot-protocol/mdk
@e8cd584): its NostrGroupDataExtension parser consumes name/description/admins/
relays/image_hash/image_key/image_nonce/image_upload_key and rejects ANY trailing
bytes at a known version, and its extension/group_image.rs fully implements avatar
encryption. The previous "canonical raw-key + media_type" approach both (a) added a
trailing media_type field that mdk rejects — breaking the whole group for whitenoise
members — and (b) used a key scheme mdk can't decrypt.
Re-implement to mdk's exact MIP-01 v2 scheme so avatars interoperate byte-for-byte:
- image_key / image_upload_key are HKDF seeds (reusing Mip01ImageCrypto's
mip01-image-encryption-v2 / mip01-blossom-upload-v2 labels; HKDF-SHA256 with empty
salt == mdk's Hkdf::new(None, seed)). AEAD key derived from the seed.
- ChaCha20-Poly1305, 12-byte nonce, EMPTY AAD, image_hash = SHA-256(ciphertext).
- Decrypt tries v2 (HKDF) then falls back to v1 (raw key), exactly like mdk.
- Remove media_type from the wire entirely (and from the model/cipher/uploader), so a
v2 image extension ends at image_upload_key with zero trailing bytes. The plaintext
MIME isn't stored; the display path lets Coil sniff the format.
- Derive the Blossom upload keypair from image_upload_key instead of storing a raw key.
Adds a regression test that reproduces mdk's v1/v2 field consumption and asserts a
v2 image extension has no trailing bytes, plus a test pinning the HKDF-seed + empty-AAD
scheme so future drift from mdk is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
Wire the shared NIP-30 custom-emoji picker into the Concord composer like the
@-mention flow: typing `:shortcode:` opens ShowEmojiSuggestionList (backed by
EmojiSuggestionState(account.emoji)); WatchAndLoadMyEmojiList loads the user's
packs. On send, account.emoji.findEmojiTags(text) attaches the NIP-30 emoji tags
to the kind-9 rumor (plain message + inline reply), so recipients render the
custom image inline via the shared chat renderer. Image uploads in messages,
icon and banner were already wired.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Add first-class support for Marmot (MLS-over-Nostr) group avatars.
Protocol (quartz):
- Implement the canonical `marmot-group-image-v1` scheme: raw ChaCha20-Poly1305
key + 12-byte nonce, AAD = "marmot-group-image-v1" || 0x00 || media_type,
image_hash = SHA-256(ciphertext). MarmotGroupImageEncryption emits canonical
and decrypts both canonical and the deprecated MIP-01 HKDF-seed scheme.
- Add the `image_media_type` field to MarmotGroupData as a trailing TLS field
(older readers ignore it; disappearing_message_secs stays positionally
unambiguous). Add withImage/withoutImage helpers.
- MarmotGroupImageCipher (NostrCipher) drives both encrypted upload and
transparent decrypt-on-download.
Model/manager (commons):
- MarmotGroupChatroom exposes an `image` StateFlow; MarmotManager.syncMetadataTo
populates it from the group metadata.
Android:
- Show the decrypted group icon in the Messages feed; when a group has no image,
fall back to the NIP-11 icon of one of its relays (fetched on cache miss).
- Group metadata editing gains an icon picker (add/change/remove) in both the
create and edit screens; create also gains a description field. Icons are
encrypted and uploaded to Blossom via UploadOrchestrator, signed with a fresh
per-image keypair stored as image_upload_key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
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 "like" on a group message was built as a plain NIP-25 reaction — `e`
(message) + `p` (author) + `k` — with no `h` tag. The recipient's only
notification query that reaches the group's host relay,
filterGroupNotificationsToPubkey, is scoped `#p`=them AND `#h`=their
groups (kind 7 is already in GroupNotificationKinds), so a like with no
`h` tag is never matched there. It would only surface if NIP-65 routing
happened to drop it on one of the recipient's inbox relays — never for a
host-relay-only group — so likes on group messages effectively never
notified.
Copy the target's `h` tag onto public reactions to group-scoped events,
mirroring how kind-9 replies carry it. ReactionEvent.build gains an
`initializer` (the API GroupScope's KDoc already documented); ReactionAction
applies the group `h` tag for both the tracked and fire-and-forget paths.
The like now lands on the host relay in-group and the existing kind-7
`#p`+`#h` query picks it up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
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>
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>
Every `amy graperank` / `graperank score` run now reconciles its result into
the local event store as NIP-85 kind:30382 cards signed by the per-observer
service key (cutoff --min-rank, default 2): changed ranks are re-signed,
unchanged ones skipped (no event-id churn), dropped targets retracted with a
kind:5 the store applies on insert. The store is the source of truth, so the
score of a run is durable and reusable instead of ephemeral stdout.
New verbs complete the crawl -> score -> publish pipeline:
- `graperank publish [OBSERVER] [--relay URL[,URL...]]` — transport only:
one NIP-77 up-only reconcile per operator relay over the provider key's
kind:30382 + kind:5, converging the relay to the local set (deletions
propagate, lost cards restored, full-set publish fallback for relays
without negentropy); also refreshes the observer's kind:10040 pointer.
- `graperank rank USER [--provider P] [--refresh]` — the consumer side:
newest card per provider from the local store, with a relay drain on miss.
GrapeRankPublisher (quartz) is reworked accordingly: reconcileAndPublish is
replaced by reconcileLocal (sign+insert+retract against the store) and
syncToRelays (NegentropyStoreSync up-only + blastPublish fallback), with a
commonTest covering upsert/skip/retract and re-carding a retracted target.
BREAKING (--json / flags): `--publish`, `--publish-limit`, `--publish-relay`
and `--bench-sign` are removed from the score command. Its JSON drops
published/publish_rejected/deleted/delete_rejected/skipped_unchanged/
publish_truncated/published_kind/published_to/publish_error/observer_10040/
bench_signed/bench_sign_ms and gains provider_pubkey/min_rank/cards_total/
cards_signed/cards_unchanged/cards_retracted/cards_ms. Publishing moved to
the new `graperank publish` verb.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
Concord channel messages can now carry images, wire-identical to Soapbox
Armada's `encryptAttachments`: a normal channel-bound kind-9 whose ciphertext
URL is appended to the content and annotated by a NIP-92 `imeta` tag with
`encryption-algorithm aes-gcm`, hex `decryption-key`/`decryption-nonce`, and the
plaintext `ox` hash (no `x`). The blob is AES-256-GCM ciphertext on Blossom, so
the media host and relays only ever see encrypted bytes — the community's E2E
guarantee holds.
Reuses the NIP-17 encrypted-media stack end to end: quartz's imeta tag vocab
and IMetaTagBuilder to build/parse the tag (ChannelChat.imageMessage /
encryptedImageImeta / encryptedImagesOf), the shared UploadOrchestrator
encrypted upload + ChatFileUploadDialog picker on the send side, and the OkHttp
EncryptedBlobInterceptor keyCache on the receive side — registering each
attachment's cipher (keyed by URL) lets the normal feed renderer display the
decrypted image with no shared-render changes. Encryption is mandatory
(no toggle, and a missing cipher fails closed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Clients in the wild publish kind-4550 approvals whose content is plain
text (e.g. braille ASCII art) or a custom non-event JSON wrapper instead
of the NIP-72 stringified approved event. containedPost() fed any
non-blank content straight into Event.fromJson, producing a
JsonParseException and a noisy logcat warning for every such approval.
Only attempt the parse when the trimmed content starts with '{', so
plain-text contents are skipped silently while genuinely malformed
event-looking JSON still logs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXiy3Ajmr3vdrV83jZsUx6