From d73348d19bd8e0b620fd7bca20de0eb0e9cb202e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:11:18 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(concord):=20CORD-05=20=C2=A76=20Direct?= =?UTF-8?q?=20Invites=20=E2=80=94=20wire=20fixes,=20send,=20and=20a=20head?= =?UTF-8?q?less=20inbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quartz: - ConcordDirectInvite: NIP-59 timestamp tweak (seal and wrap backdated up to 2 days, rumor keeps the send time), NIP-40 expiration on the wrap matching expires_at, open()/openSeal() returning the seal-verified sender, rejecting a rumor whose pubkey differs from the seal's (anti-spoofing), an unverified seal, a non-3313 rumor, and bundles failing the §1 bounds or the owner proof. - ConcordInviteVend: the vend rule (a link gets no Private Channel keys, a member exactly what their channel-scoped Roles entitle) and the catch-up rule for an already-joined community (new keys only on the same root, epoch and control_pk; the base never moves). commons: - ConcordActions.directInviteFor/buildDirectInvite/openDirectInvite, directInviteDeliveryRelays (10050, else NIP-65 read, else stock), and a since parameter on directInvitesFilter. - ConcordDirectInviteInbox: dedupe by wrap id, skip expired wraps, park validated invites, remember declines; visible() hides joined communities but keeps catch-ups; acceptPlan() decides join / catch-up / refuse. - AccountConcordActions: sendConcordDirectInvite, refreshConcordDirectInvites, acceptConcordDirectInvite (shares the link join path, now factored into joinValidatedConcordInvite), declineConcordDirectInvite. - The NIP-17 seal handler routes a kind-3313 rumor to the inbox instead of the cache/chat feeds; declined wrap ids persist per account. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../com/vitorpamplona/amethyst/AppModules.kt | 3 + .../commons/actions/ConcordActions.kt | 77 ++++- .../commons/model/AccountConcordActions.kt | 219 +++++++++++++- .../model/ConcordDirectInviteSendResult.kt | 45 +++ .../commons/model/DecryptAndIndexProcessor.kt | 12 + .../model/concord/ConcordDirectInviteInbox.kt | 278 ++++++++++++++++++ .../ConcordDirectInviteDeclineStore.kt | 82 ++++++ .../actions/ConcordDirectInviteActionsTest.kt | 158 ++++++++++ .../concord/ConcordDirectInviteInboxTest.kt | 263 +++++++++++++++++ .../cord02Community/ConcordCommunityList.kt | 23 ++ .../cord05Invites/ConcordDirectInvite.kt | 154 ++++++++-- .../cord05Invites/ConcordInviteVend.kt | 141 +++++++++ .../cord05Invites/ConcordDirectInviteTest.kt | 188 +++++++++++- .../cord05Invites/ConcordInviteVendTest.kt | 117 ++++++++ 14 files changed, 1711 insertions(+), 49 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0e857e8564..c68b4cfb12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore +import com.vitorpamplona.amethyst.commons.model.preferences.ConcordDirectInviteDeclineStore import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore @@ -1070,6 +1071,8 @@ class AppModules( // Eager like the rest, so a held NIP-OA attestation is loaded before this account's // first Buzz-relay AUTH rather than after it. BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation) + // Concord Direct Invites the user declined (CORD-05 §6) stay declined across restarts. + ConcordDirectInviteDeclineStore(sharedSettingsStore, account.scope, account.pubKey, account.concord.directInviteInbox) }, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 40ff004467..86f7a9d918 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -33,14 +33,18 @@ import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend import com.vitorpamplona.quartz.concord.cord05Invites.ConcordStrandedRecovery import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.OpenedDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding @@ -50,11 +54,15 @@ import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -316,8 +324,15 @@ object ConcordActions { */ fun bundlesFilter(linkSignerPubKeyHexes: List): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = linkSignerPubKeyHexes) - /** Pending direct invites addressed to the given member (indexed by k=3313). */ - fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString()))) + /** + * Pending direct invites addressed to the given member (indexed by k=3313, CORD-05 §6). [since] + * should come from [ConcordDirectInvite.inboxSince]: wraps are backdated up to two days, so a + * cursor at the newest wrap seen would miss invites published after it. + */ + fun directInvitesFilter( + memberPubKeyHex: HexKey, + since: Long? = null, + ): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString())), since = since) // ---- community lifecycle -------------------------------------------------- @@ -583,6 +598,64 @@ object ConcordActions { label = label, ) + /** + * The §1 bundle a Direct Invite hands [recipient] for the community [entry] holds (CORD-05 §6): + * the current base, epoch and `control_pk`, the relays, a name/icon preview, the optional + * [expiresAtMs] (unix ms) and [creator] attribution — and exactly the Private Channel keys the + * recipient's Roles entitle them to in [authority] ([ConcordInviteVend.vendableChannels], Armada's + * `VendAudience` "member" rule). A key the recipient isn't entitled to is never whispered, even + * though nothing on the wire could stop it. + */ + fun directInviteFor( + entry: ConcordCommunityListEntry, + authority: AuthorityResolver, + recipient: HexKey, + creator: HexKey, + expiresAtMs: Long? = null, + name: String = entry.name, + icon: ImagePointer? = null, + ): CommunityInvite = + CommunityInvite( + communityId = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + communityRoot = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk, + channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)), + relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), + name = name.ifBlank { entry.name }, + icon = icon, + expiresAt = expiresAtMs, + creatorNpub = creator, + ) + + /** Giftwraps [invite] to [recipient] as a Direct Invite (see [ConcordDirectInvite.build]). */ + suspend fun buildDirectInvite( + senderSigner: NostrSigner, + recipient: HexKey, + invite: CommunityInvite, + createdAt: Long = TimeUtils.now(), + ): GiftWrapEvent = ConcordDirectInvite.build(senderSigner, recipient, invite, createdAt) + + /** Opens + validates a Direct Invite wrap addressed to [recipientSigner] (see [ConcordDirectInvite.open]). */ + suspend fun openDirectInvite( + wrap: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? = ConcordDirectInvite.open(wrap, recipientSigner) + + /** + * Where a Direct Invite reaches a member, and where that member scans for one (CORD-05 §6): + * their kind-10050 DM relays, else their NIP-65 read relays, else the stock Concord set every + * client ships (Armada `inviteDeliveryRelays`). Send and scan share this so both sides meet. The + * stock set is fallback-only: a curated private inbox is never also fanned out to public relays. + */ + fun directInviteDeliveryRelays(lists: RecipientRelayFetcher.Lists?): Set { + val inbox = lists?.dmInboxOrFallback().orEmpty() + if (inbox.isNotEmpty()) return inbox.toSet() + return InviteRelayDictionary.STOCK.mapNotNullTo(LinkedHashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } + } + /** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */ fun mintInviteLink( base: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 0ebe09d709..e844c8b791 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -24,12 +24,16 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView +import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode @@ -65,6 +69,7 @@ import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -90,8 +95,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn /** Name of the default Concord community Admin role minted by "Make admin". */ private const val CONCORD_ADMIN_ROLE = "Admin" @@ -559,6 +567,38 @@ class AccountConcordActions( InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable } + return joinValidatedConcordInvite( + bundle = bundle, + servedBy = relays, + // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a + // Refounding that leaves us out of the recipient set is recoverable later. See + // recoverStrandedConcordCommunities(). + inviteRef = ConcordActions.bareInviteRef(url), + // Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their + // Guestbook Join, which is what makes per-link usage counters possible. + inviteCreator = bundle.creatorNpub, + inviteLabel = bundle.label, + ) + } + + /** + * The join half shared by every redeem path (link [joinConcordViaInvite], Direct Invite + * [acceptConcordDirectInvite]): [bundle] is already opened, bounded and owner-proof validated, + * and not expired. An already-held community only moves forward through a stranded rejoin (a + * Refounding left us behind and the user re-accepted); otherwise it refuses a community whose + * roster bans us (fails closed on an unreadable Control Plane, fetched over [servedBy] ∪ the + * bundle's relays), then stores the secret-bearing entry and announces the Guestbook Join with + * [inviteCreator]/[inviteLabel] attribution. + */ + private suspend fun joinValidatedConcordInvite( + bundle: CommunityInvite, + servedBy: Set, + inviteRef: String?, + inviteCreator: HexKey?, + inviteLabel: String?, + ): ConcordInviteResult { + val relays = servedBy + // Already a member? Just take the user to the community. Re-following and re-announcing a // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an // old invite is reopened, so short-circuit to Joined — the screen forwards to the community @@ -620,15 +660,14 @@ class AccountConcordActions( return ConcordInviteResult.Banned } - // Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their - // Guestbook Join, which is what makes per-link usage counters possible. - val inviteCreator = bundle.creatorNpub?.lowercase()?.takeIf { HEX64.matches(it) } - val inviteLabel = bundle.label?.takeIf { inviteCreator != null && it.isNotBlank() } + // Invite attribution (CORD-05 §1), echoed in the Guestbook Join; a label only rides with a creator. + val creator = inviteCreator?.lowercase()?.takeIf { HEX64.matches(it) } + val label = inviteLabel?.takeIf { creator != null && it.isNotBlank() } if (rejoined != null) { if (!adoptedConcordRotations.add("${rejoined.id}:${rejoined.rootEpoch}")) return ConcordInviteResult.Joined(bundle.communityId) Log.i("Concord") { "Stranded rejoin by explicit invite: ${rejoined.id} -> epoch ${rejoined.rootEpoch}" } - joinConcordCommunity(rejoined, inviteCreator, inviteLabel) + joinConcordCommunity(rejoined, creator, label) _strandedConcordCommunities.value -= rejoined.id return ConcordInviteResult.Joined(bundle.communityId) } @@ -649,15 +688,175 @@ class AccountConcordActions( relays = bundle.relays, name = bundle.name, addedAt = TimeUtils.nowMillis(), - // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a - // Refounding that leaves us out of the recipient set is recoverable later. See - // recoverStrandedConcordCommunities(). - inviteRef = ConcordActions.bareInviteRef(url), + // Anchor for stranded recovery (null for a Direct Invite, which has no link). + inviteRef = inviteRef, ) - joinConcordCommunity(entry, inviteCreator, inviteLabel) + joinConcordCommunity(entry, creator, label) return ConcordInviteResult.Joined(bundle.communityId) } + // ---- CORD-05 §6 Direct Invites --------------------------------------------- + + /** + * The Direct Invite inbox: wraps from the dedicated sweep ([refreshConcordDirectInvites]) and + * from the NIP-17 giftwrap pipeline land here, parked until the user accepts or declines. + */ + val directInviteInbox = ConcordDirectInviteInbox(account.signer) + + /** + * The parked Direct Invites a UI should show, newest first: invites for communities we don't + * hold, plus catch-ups for ones we do ([ConcordDirectInviteInbox.visible]). + */ + val pendingConcordDirectInvites: StateFlow> = + combine(directInviteInbox.pending, account.concordChannelList.liveCommunities) { pending, joined -> + ConcordDirectInviteInbox.visible(pending.values, joined) + }.stateIn(account.scope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** + * Where this account scans for Direct Invites — where senders deliver them (CORD-05 §6): our DM + * inbox relays (kind 10050, plus the NIP-65 read and private/local relays the DM feed already + * reads), else the stock Concord set. + */ + private fun concordDirectInviteScanRelays(): Set = + account.dmRelays.flow.value.ifEmpty { + ConcordActions.directInviteDeliveryRelays(null) + } + + /** + * Sweeps our inbox relays for Direct Invite wraps + * (`{"kinds":[1059],"#p":[me],"#k":["3313"]}` since the inbox cursor, rewound by NIP-59's backdate + * window) and offers each to the inbox. Returns how many new invites were parked. Read-only: it + * decrypts, it never joins or contacts a community's relays. + */ + suspend fun refreshConcordDirectInvites(): Int { + val relays = concordDirectInviteScanRelays() + if (relays.isEmpty()) return 0 + val before = directInviteInbox.pending.value.keys + val filter = ConcordActions.directInvitesFilter(account.signer.pubKey, directInviteInbox.since()) + val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }) + wraps.distinctBy { it.id }.forEach { directInviteInbox.offer(it) } + return (directInviteInbox.pending.value.keys - before).size + } + + /** + * The recipient's giftwrap inbox (CORD-05 §6): their kind-10050 DM relays, else NIP-65 read + * relays — from the cache when we have their lists, fetched otherwise — else the stock set. + */ + private suspend fun concordDirectInviteDeliveryRelays(recipient: HexKey): Set { + val user = account.cache.getOrCreateUser(recipient) + val dmInbox = user.dmInboxRelayList()?.relays().orEmpty() + val cached = + if (dmInbox.isNotEmpty() || user.authorRelayList() != null) { + RecipientRelayFetcher.Lists(dmInbox = dmInbox, keyPackage = emptyList(), nip65 = user.authorRelayList()) + } else { + null + } + val lists = + cached ?: run { + val seed = DefaultDmIndexerRelays.RELAYS.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value + RecipientRelayFetcher.fetchRelayLists(account.client, recipient, seed) + } + return ConcordActions.directInviteDeliveryRelays(lists) + } + + /** + * Hands the keys of [communityId] straight to [recipientPubKey] as a Direct Invite (CORD-05 §6): + * the §1 bundle — carrying only the Private Channel keys the recipient's Roles entitle them to — + * sealed by our real key inside an ephemeral, `k`-tagged giftwrap, published to the recipient's + * inbox relays. It appears in no Registry and never flips the community Public; it cannot be + * revoked once it lands. [expiresAtMs] (unix ms) bounds its shelf life. + * + * No community permission gates it — none could (CORD-05 §6) — but a banned member is refused, + * like minting, and so is a banned recipient, whom the join would refuse anyway. + */ + suspend fun sendConcordDirectInvite( + communityId: String, + recipientPubKey: HexKey, + expiresAtMs: Long? = null, + ): ConcordDirectInviteSendResult { + if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE + val recipient = recipientPubKey.lowercase() + if (!HEX64.matches(recipient)) return ConcordDirectInviteSendResult.INVALID_RECIPIENT + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return ConcordDirectInviteSendResult.NOT_MEMBER + val state = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED + if (state.dissolved) return ConcordDirectInviteSendResult.NOT_MEMBER + if (state.authority.isBanned(account.signer.pubKey)) return ConcordDirectInviteSendResult.NOT_MEMBER + if (state.authority.isBanned(recipient)) return ConcordDirectInviteSendResult.RECIPIENT_BANNED + + val invite = + ConcordActions.directInviteFor( + entry = entry, + authority = state.authority, + recipient = recipient, + creator = account.signer.pubKey, + expiresAtMs = expiresAtMs, + name = state.metadata?.name ?: entry.name, + icon = state.metadata?.icon, + ) + val wrap = ConcordActions.buildDirectInvite(account.signer, recipient, invite) + val relays = concordDirectInviteDeliveryRelays(recipient) + if (relays.isEmpty()) return ConcordDirectInviteSendResult.NOT_DELIVERED + val delivered = + runCatching { account.client.publishAndConfirm(wrap, relays) } + .onFailure { Log.w("Concord", "direct invite publish failed for $communityId", it) } + .getOrDefault(false) + return if (delivered) ConcordDirectInviteSendResult.SENT else ConcordDirectInviteSendResult.NOT_DELIVERED + } + + /** + * Accepts the parked Direct Invite [wrapId] (CORD-05 §6) through the same join path as a link: + * refused once `expires_at` has passed, refused when the roster bans us, and — for a community + * we already hold — only a catch-up adopting newly granted Private Channel keys on the same base. + * The Guestbook Join is attributed to the seal-verified sender. **Only from an explicit user + * action**: this is the first moment anything contacts the community's relays. + */ + suspend fun acceptConcordDirectInvite(wrapId: HexKey): ConcordInviteResult { + if (!account.isWriteable()) return ConcordInviteResult.InvalidLink + val opened = directInviteInbox.get(wrapId) ?: return ConcordInviteResult.InvalidLink + val bundle = opened.invite + val held = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id.equals(bundle.communityId, ignoreCase = true) } + val heldState = + held?.let { + account.concordSessions + .sessionFor(it.id) + ?.state + ?.value + } + val result = + when (val plan = ConcordDirectInviteInbox.acceptPlan(opened, held, heldState, account.signer.pubKey)) { + DirectInviteAcceptPlan.Expired -> ConcordInviteResult.Expired + DirectInviteAcceptPlan.Banned -> ConcordInviteResult.Banned + // No folded roster yet: whether it bans us is unknown, so the invite waits. + DirectInviteAcceptPlan.RosterNotLoaded -> ConcordInviteResult.NotReachable + DirectInviteAcceptPlan.NothingNew -> ConcordInviteResult.Joined(bundle.communityId) + // Keys only, on the held base: no second Guestbook Join. + is DirectInviteAcceptPlan.CatchUp -> + if (persistConcordEntry(plan.entry)) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable + DirectInviteAcceptPlan.Join -> + joinValidatedConcordInvite( + bundle = bundle, + servedBy = emptySet(), + inviteRef = null, + // Attributed to the seal-verified sender (Armada), never the bundle's claim. + inviteCreator = opened.sender, + inviteLabel = bundle.label, + ) + } + if (result is ConcordInviteResult.Joined) directInviteInbox.resolve(opened.wrapId) + return result + } + + /** Declines the parked Direct Invite [wrapId]: its keys are discarded and it never resurfaces. */ + fun declineConcordDirectInvite(wrapId: HexKey): Boolean = directInviteInbox.decline(wrapId) + /** * Post [text] to a Concord channel: derive the channel plane key, build an * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt new file mode 100644 index 0000000000..d82eb09e5c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +/** The outcome of sending a Concord Direct Invite (CORD-05 §6), so the UI can say why it failed. */ +enum class ConcordDirectInviteSendResult { + /** At least one of the recipient's inbox relays accepted the wrap. */ + SENT, + + /** This account can't sign (read-only key). */ + NOT_WRITEABLE, + + /** The recipient isn't a valid 32-byte pubkey. */ + INVALID_RECIPIENT, + + /** We don't hold this community, it was dissolved, or its roster bans us. */ + NOT_MEMBER, + + /** The community's Control Plane hasn't folded yet, so which keys the recipient may receive is unknown. */ + ROSTER_NOT_LOADED, + + /** The community's roster bans the recipient; their join would be refused anyway. */ + RECIPIENT_BANNED, + + /** No inbox relay accepted the wrap. */ + NOT_DELIVERED, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt index ccea35dc44..5ca3c6f8f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.chatMessageMarksRoomAsRead import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor @@ -536,6 +537,17 @@ class SealEventHandler( ) { val innerRumor = event.unsealOrNull(account.signer) ?: return + // A Concord Direct Invite (CORD-05 §6) is a standard NIP-59 giftwrap, so the DM inbox sees + // it too — tagged `k=3313` or not. It is not a DM: its rumor carries a community's keys. Hand + // the seal to the Concord invite inbox, which re-opens it with the NIP-59 anti-spoofing check + // the generic unseal skips and parks it for the user, and keep the rumor out of the cache and + // every chat feed. Must run before the seal's content is stripped below. + if (innerRumor.kind == ConcordDirectInvite.KIND) { + account.concord.directInviteInbox.offerSeal(publicNote.event ?: event, event) + eventNote.event = event.copyNoContent() + return + } + eventNote.event = event.copyNoContent() cache.justConsume(innerRumor, null, true) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt new file mode 100644 index 0000000000..10e74ca258 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend +import com.vitorpamplona.quartz.concord.cord05Invites.OpenedDirectInvite +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.Volatile + +/** + * One parked Direct Invite as the UI renders it (CORD-05 §6): who sent it (seal-verified), what it + * opens (name/icon preview from the bundle), whether its `expires_at` has passed, and whether it + * is a [catchUp] — a Private Channel key for a community this account already holds on the same + * base, which accepting merges in without moving the base or announcing a new Join. + */ +@Immutable +class ConcordDirectInviteView( + val opened: OpenedDirectInvite, + val catchUp: Boolean, + val expired: Boolean, +) { + val wrapId: HexKey get() = opened.wrapId + val sender: HexKey get() = opened.sender + val invite: CommunityInvite get() = opened.invite + val communityId: HexKey get() = opened.invite.communityId + val name: String get() = opened.invite.name + val icon: ImagePointer? get() = opened.invite.icon + + /** Names of the Private Channels the bundle carries (what a catch-up would add). */ + val channelNames: List get() = + opened.invite.channels + .filter { it.key.isNotBlank() } + .map { it.name } +} + +/** What accepting a Direct Invite does; see [ConcordDirectInviteInbox.acceptPlan]. */ +sealed interface DirectInviteAcceptPlan { + /** `expires_at` has passed: the preview renders, joining refuses. */ + data object Expired : DirectInviteAcceptPlan + + /** A community we don't hold: run the shared join path. */ + data object Join : DirectInviteAcceptPlan + + /** A held community: store [entry] — the held one plus the newly granted Private Channel keys. */ + class CatchUp( + val entry: ConcordCommunityListEntry, + ) : DirectInviteAcceptPlan + + /** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */ + data object NothingNew : DirectInviteAcceptPlan + + /** The held community's roster bans us. */ + data object Banned : DirectInviteAcceptPlan + + /** The held community's roster isn't folded yet, so the ban verdict is unknown: wait. */ + data object RosterNotLoaded : DirectInviteAcceptPlan +} + +/** + * The Direct Invite inbox (CORD-05 §6) — headless, shared by the app and `amy`. + * + * Wraps arrive from anywhere — a `{"kinds":[1059],"#p":[me],"#k":["3313"]}` sweep + * ([com.vitorpamplona.amethyst.commons.actions.ConcordActions.directInvitesFilter]), or the general + * NIP-17 giftwrap pipeline, which honours an untagged invite all the same — and are [offer]ed here. + * The inbox opens each wrap once (two NIP-44 decrypts), dedupes by wrap id, drops a wrap whose NIP-40 + * `expiration` has passed, validates the bundle exactly like a fetched one, and parks it in + * [pending]. **Nothing** else happens: no relay connection, no icon fetch, no Join, until the user + * accepts (the caller's join path) or [decline]s. + * + * Declined wrap ids are remembered ([declined], restorable via [restoreDeclined]) so a re-delivered + * wrap never resurfaces. [newestWrapCreatedAt] is the sweep cursor; query from [since], which + * rewinds it by NIP-59's two-day backdate window. + */ +class ConcordDirectInviteInbox( + private val signer: NostrSigner, +) { + private val mutex = Mutex() + + /** Wrap ids already handled this session (opened, refused, or expired), oldest first. */ + private val seen = LinkedHashSet() + + private val _pending = MutableStateFlow>(emptyMap()) + + /** Parked invites by wrap id, as opened. See [visible] for what a UI should show. */ + val pending: StateFlow> = _pending.asStateFlow() + + private val _declined = MutableStateFlow>(emptySet()) + + /** Wrap ids the user declined; persisted by the front end so they stay declined across restarts. */ + val declined: StateFlow> = _declined.asStateFlow() + + /** The newest wrap `created_at` offered so far (the sweep cursor), or null on a cold inbox. */ + @Volatile + var newestWrapCreatedAt: Long? = null + private set + + /** The `since` for the next sweep: the cursor rewound by the backdate window (null = everything). */ + fun since(): Long? = ConcordDirectInvite.inboxSince(newestWrapCreatedAt) + + /** Replaces the declined set — used to restore it from disk at startup. Drops any pending one. */ + fun restoreDeclined(wrapIds: Set) { + _declined.value = wrapIds + _pending.update { current -> current.filterKeys { it !in wrapIds } } + } + + /** + * Considers one kind-1059 [wrap] addressed to us. Returns the parked invite (new or already + * pending), or null when it isn't one: not a direct invite for us, a forgery, an invalid + * bundle, an expired handoff, or a wrap the user already declined. Never throws. + */ + suspend fun offer( + wrap: Event, + nowSecs: Long = TimeUtils.now(), + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.open(wrap, signer) } + + /** + * [offer] for a pipeline that already peeled [wrap] down to its kind-13 [seal] (the NIP-17 + * giftwrap inbox). [wrap] only lends its id, `created_at` and tags, so a content-stripped copy + * is fine; the seal is re-opened with the anti-spoofing check the generic unseal skips. + */ + suspend fun offerSeal( + wrap: Event, + seal: Event, + nowSecs: Long = TimeUtils.now(), + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSeal(wrap.id, seal, signer) } + + private suspend fun admit( + wrap: Event, + nowSecs: Long, + open: suspend () -> OpenedDirectInvite?, + ): OpenedDirectInvite? { + if (wrap.kind != GiftWrapEvent.KIND) return null + mutex.withLock { + val newest = newestWrapCreatedAt + if (newest == null || wrap.createdAt > newest) newestWrapCreatedAt = wrap.createdAt + _pending.value[wrap.id]?.let { return it } + if (wrap.id in _declined.value || wrap.id in seen) return null + remember(wrap.id) + } + // An expired handoff is never decrypted or surfaced (NIP-40 on the wrap mirrors expires_at). + if (ConcordDirectInvite.isWrapExpired(wrap, nowSecs)) return null + val opened = open() ?: return null + mutex.withLock { + if (wrap.id in _declined.value) return null + _pending.update { it + (wrap.id to opened) } + } + return opened + } + + /** The parked invite behind [wrapId], if any. */ + fun get(wrapId: HexKey): OpenedDirectInvite? = _pending.value[wrapId.lowercase()] ?: _pending.value[wrapId] + + /** Discards [wrapId] for good (CORD-05 §6 "declining means discarding them"). False if not pending. */ + fun decline(wrapId: HexKey): Boolean { + val id = get(wrapId)?.wrapId ?: return false + _pending.update { it - id } + _declined.update { it + id } + return true + } + + /** Drops [wrapId] after it was accepted; this session will not re-park it. */ + fun resolve(wrapId: HexKey) { + _pending.update { it - wrapId } + } + + private fun remember(wrapId: HexKey) { + if (seen.size >= SEEN_CAP) { + val drop = seen.take(SEEN_CAP / 2) + seen.removeAll(drop.toSet()) + } + seen.add(wrapId) + } + + companion object { + /** Cap on remembered wrap ids; the oldest half is shed past it (a sweep re-dedupes deeper). */ + const val SEEN_CAP = 4096 + + /** + * What accepting [opened] should do (CORD-05 §6), given the community entry this account + * already [held] (if any) and its folded [heldState]: + * - past `expires_at` → [DirectInviteAcceptPlan.Expired] ("`expires_at` refuses a late join"); + * - not held → [DirectInviteAcceptPlan.Join] (the shared join path, which still ban-gates + * against the community's own Control Plane); + * - held on the SAME base with new Private Channel keys → [DirectInviteAcceptPlan.CatchUp], + * the held entry with only those keys merged in — never moving the base (Armada + * `catchUpChannelIds`) — unless the held roster bans [me]; refused while the roster isn't + * folded ([DirectInviteAcceptPlan.RosterNotLoaded]); + * - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew]. + */ + fun acceptPlan( + opened: OpenedDirectInvite, + held: ConcordCommunityListEntry?, + heldState: ConcordCommunityState?, + me: HexKey, + nowMs: Long = TimeUtils.nowMillis(), + ): DirectInviteAcceptPlan { + if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired + if (held == null) return DirectInviteAcceptPlan.Join + val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite) ?: return DirectInviteAcceptPlan.NothingNew + if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded + // Death wins every race (CORD-02 §9): a dissolved community takes no new keys. + if (heldState.dissolved) return DirectInviteAcceptPlan.NothingNew + if (heldState.authority.isBanned(me)) return DirectInviteAcceptPlan.Banned + return DirectInviteAcceptPlan.CatchUp(adopted) + } + + /** + * What a UI shows out of [pending], given the communities this account already holds + * ([joined]): newest first, with + * - an invite for a community already held on the SAME base that carries a Private Channel + * key it lacks kept as a [ConcordDirectInviteView.catchUp]; + * - any other invite for a held community (nothing new, or a different base — which may + * never move the held one) hidden; + * - one invite per community (newest `sentAt`, ties by wrap id), catch-ups keyed by their + * channel set too since each may vend a key no other wrap carries (Armada + * `dedupeParkedInvites`). + */ + fun visible( + pending: Collection, + joined: List, + nowMs: Long = TimeUtils.nowMillis(), + ): List { + val heldById = joined.associateBy { it.id.lowercase() } + val byKey = LinkedHashMap() + for (opened in pending) { + val communityId = opened.invite.communityId.lowercase() + val held = heldById[communityId] + val newChannels = ConcordInviteVend.catchUpChannelIds(held, opened.invite) + if (held != null && newChannels.isEmpty()) continue + val catchUp = held != null + val key = if (catchUp) communityId + "|" + newChannels.sorted().joinToString(",") else communityId + val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs)) + val existing = byKey[key] + if (existing == null || + opened.sentAt > existing.opened.sentAt || + (opened.sentAt == existing.opened.sentAt && opened.wrapId < existing.opened.wrapId) + ) { + byKey[key] = view + } + } + return byKey.values.sortedWith(compareByDescending { it.opened.sentAt }.thenBy { it.wrapId }) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt new file mode 100644 index 0000000000..0fecf0b490 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Per-account persistence for the Concord Direct Invites the user declined (CORD-05 §6), so a + * declined invite's wrap — which relays keep re-delivering until its NIP-40 expiration — never + * resurfaces after a restart. Mirrors [BuzzChannelStarStore]: loads this account's saved wrap ids + * into [inbox] on construction, then writes every later change back. Construct once per account. + */ +@Stable +class ConcordDirectInviteDeclineStore( + private val store: DataStore, + private val scope: CoroutineScope, + private val pubKeyHex: HexKey, + private val inbox: ConcordDirectInviteInbox, +) { + private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex") + + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + inbox.declined.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = store.data.first()[key] ?: return + if (raw.isNotEmpty()) inbox.restoreDeclined(raw + inbox.declined.value) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ConcordDirectInvites") { "Error reading declined invites: ${e.message}" } + } + } + + private suspend fun persist(ids: Set) { + try { + store.edit { prefs -> prefs[key] = ids } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ConcordDirectInvites") { "Error writing declined invites: ${e.message}" } + } + } + + companion object { + private const val KEY_PREFIX = "concord.declinedDirectInvites." + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt new file mode 100644 index 0000000000..05296629f5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleScope +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * CORD-05 §6 send side: a Direct Invite carries exactly the Private Channel keys the recipient's + * Roles entitle them to (Armada `vendableChannels`, audience "member"), and goes to the + * recipient's 10050 → NIP-65 read → stock relays. + */ +class ConcordDirectInviteActionsTest { + private val owner = NostrSignerInternal(KeyPair()) + private val mod = NostrSignerInternal(KeyPair()) + private val member = NostrSignerInternal(KeyPair()) + + private val modsChannel = "a1".repeat(32) + private val vipChannel = "b2".repeat(32) + private val modsRoleId = ByteArray(32) { 7 } + + private fun entryOf(community: NewConcordCommunity) = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + privateChannels = + listOf( + PrivateChannelKey(modsChannel, "ca".repeat(32), 2, "mods"), + PrivateChannelKey(vipChannel, "db".repeat(32), 0, "vip"), + ), + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + /** A community where [mod] holds a Role scoped to [modsChannel]; nobody is scoped to [vipChannel]. */ + private suspend fun foldWithModsRole(community: NewConcordCommunity): ConcordCommunityState { + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + fun add(wrap: Event) { + editions += ConcordActions.controlEditions(listOf(wrap), cp) + } + val role = + RoleEntity( + roleId = modsRoleId.toHexKey(), + name = "Mods", + position = 5, + permissions = ConcordPermissions.of(ConcordPermissions.MENTION_EVERYONE).toWire(), + scope = RoleScope(kind = "channel", channelId = modsChannel), + ) + add(ConcordModeration.defineRole(owner, cp, community.communityId, modsRoleId, role, editions, createdAt = 2L, owner = community.ownerPubKey)) + add(ConcordModeration.grant(owner, cp, community.communityId, mod.pubKey, listOf(modsRoleId.toHexKey()), editions, createdAt = 3L, owner = community.ownerPubKey)) + return ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) + } + + @Test + fun aDirectInviteCarriesOnlyTheChannelsTheRecipientIsEntitledTo() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val state = foldWithModsRole(community) + assertTrue(modsRoleId.toHexKey() in state.authority.rolesOf(mod.pubKey)) + val entry = entryOf(community) + + // A plain member holds no channel-scoped Role: no Private Channel keys. + val toMember = ConcordActions.directInviteFor(entry, state.authority, member.pubKey, creator = owner.pubKey) + assertTrue(toMember.channels.isEmpty()) + + // The mod gets #mods (their Role's scope) and nothing else. + val toMod = ConcordActions.directInviteFor(entry, state.authority, mod.pubKey, creator = owner.pubKey, expiresAtMs = 1_900_000_000_000L) + assertEquals(listOf(modsChannel), toMod.channels.map { it.id }) + assertEquals("ca".repeat(32), toMod.channels.single().key) + assertEquals(2L, toMod.channels.single().epoch) + assertEquals(1_900_000_000_000L, toMod.expiresAt) + assertEquals(owner.pubKey, toMod.creatorNpub) + + // The owner is entitled to every channel. + val toOwner = ConcordActions.directInviteFor(entry, state.authority, owner.pubKey, creator = mod.pubKey) + assertEquals(setOf(modsChannel, vipChannel), toOwner.channels.map { it.id }.toSet()) + + // The bundle is the held base, and it validates as a fetched one would. + assertEquals(entry.root, toMember.communityRoot) + assertEquals(entry.rootEpoch, toMember.rootEpoch) + assertEquals(entry.controlPk, toMember.controlPk) + } + + @Test + fun theBuiltWrapOpensForTheRecipient() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val state = foldWithModsRole(community) + val invite = ConcordActions.directInviteFor(entryOf(community), state.authority, mod.pubKey, creator = owner.pubKey) + val wrap = ConcordActions.buildDirectInvite(owner, mod.pubKey, invite) + + // The indexed lookup a recipient runs matches the wrap's tags. + val filter = ConcordActions.directInvitesFilter(mod.pubKey, since = 5L) + assertEquals(listOf(mod.pubKey), filter.tags?.get("p")) + assertEquals(listOf("3313"), filter.tags?.get("k")) + assertEquals(5L, filter.since) + assertTrue(filter.match(wrap)) + + val opened = assertNotNull(ConcordActions.openDirectInvite(wrap, mod)) + assertEquals(owner.pubKey, opened.sender) + assertEquals(listOf(modsChannel), ConcordActions.privateChannelKeysOf(opened.invite).map { it.channelId }) + } + + @Test + fun deliveryGoesTo10050ThenNip65ReadThenStock() { + val dm = RelayUrlNormalizer.normalizeOrNull("wss://dm.example")!! + val withDm = RecipientRelayFetcher.Lists(dmInbox = listOf(dm), keyPackage = emptyList(), nip65 = null) + assertEquals(setOf(dm), ConcordActions.directInviteDeliveryRelays(withDm)) + + val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + assertEquals(stock, ConcordActions.directInviteDeliveryRelays(null)) + assertEquals(stock, ConcordActions.directInviteDeliveryRelays(RecipientRelayFetcher.Lists(emptyList(), emptyList(), null))) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt new file mode 100644 index 0000000000..d50201fabb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The headless Direct Invite inbox (CORD-05 §6): collects wraps, dedupes by wrap id, skips expired + * handoffs, validates, parks — and never joins. Plus the accept decision (expired → refuse; held → + * catch-up keys only on the same base, never a base move). + */ +class ConcordDirectInviteInboxTest { + private val owner = NostrSignerInternal(KeyPair()) + private val sender = NostrSignerInternal(KeyPair()) + private val me = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private val vip = "b2".repeat(32) + + private suspend fun community(): NewConcordCommunity = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + + private fun inviteFor( + c: NewConcordCommunity, + expiresAt: Long? = null, + channels: List = emptyList(), + root: String = c.communityRoot.toHexKey(), + ) = CommunityInvite( + communityId = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + communityRoot = root, + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + channels = channels, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + expiresAt = expiresAt, + ) + + private fun heldEntryOf(c: NewConcordCommunity) = + ConcordCommunityListEntry( + id = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + root = c.communityRoot.toHexKey(), + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + inviteRef = "anchor", + ) + + private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey) + + @Test + fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + + val first = assertNotNull(inbox.offer(wrap)) + assertEquals(sender.pubKey, first.sender) + assertEquals(c.communityIdHex, first.invite.communityId) + assertEquals(setOf(wrap.id), inbox.pending.value.keys) + + // The same wrap again (a re-delivery, or the DM pipeline seeing it too) is the same entry. + assertSame(first, inbox.offer(wrap)) + assertEquals(1, inbox.pending.value.size) + assertEquals(wrap.createdAt, inbox.newestWrapCreatedAt) + } + + @Test + fun wrapsForSomeoneElseOrForgedOrExpiredAreNotParked() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + // Addressed to someone else. + assertNull(inbox.offer(ConcordActions.buildDirectInvite(sender, stranger.pubKey, inviteFor(c)))) + // A bundle whose owner proof fails. + assertNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c).copy(owner = stranger.pubKey)))) + // A handoff whose NIP-40 expiration passed is never decrypted. + val expired = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, expiresAt = 1_000_000L)) + assertNull(inbox.offer(expired, nowSecs = 1_000L)) + assertTrue(inbox.pending.value.isEmpty()) + } + + @Test + fun theDmPipelineSealPathParksTheSameInvite() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + val seal = assertIs(wrap.unwrapOrNull(me)) + val opened = assertNotNull(inbox.offerSeal(wrap.copyNoContent(), seal)) + assertEquals(sender.pubKey, opened.sender) + assertEquals(wrap.id, opened.wrapId) + // The sweep delivering the full wrap later doesn't duplicate it. + assertSame(opened, inbox.offer(wrap)) + } + + @Test + fun declineDiscardsAndTheWrapNeverResurfaces() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + inbox.offer(wrap) + + assertTrue(inbox.decline(wrap.id)) + assertTrue(inbox.pending.value.isEmpty()) + assertEquals(setOf(wrap.id), inbox.declined.value) + assertNull(inbox.offer(wrap)) + assertFalse(inbox.decline(wrap.id)) + + // After a restart the persisted declines are restored and still win. + val fresh = ConcordDirectInviteInbox(me) + fresh.restoreDeclined(inbox.declined.value) + assertNull(fresh.offer(wrap)) + assertTrue(fresh.pending.value.isEmpty()) + } + + @Test + fun sinceRewindsTheCursorByTheBackdateWindow() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + assertNull(inbox.since()) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + inbox.offer(wrap) + assertEquals(wrap.createdAt - 2 * 24 * 60 * 60L, inbox.since()) + } + + @Test + fun visibleHidesJoinedCommunitiesButKeepsCatchUpsAndFlagsExpiry() = + runTest { + val joinedCommunity = community() + val newCommunity = community() + val inbox = ConcordDirectInviteInbox(me) + + val toNew = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(newCommunity, expiresAt = 5_000L)), nowSecs = 1L)) + val plainForJoined = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity)))) + val catchUp = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))))) + val baseMove = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity, root = "99".repeat(32), channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))))) + + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, listOf(heldEntryOf(joinedCommunity)), nowMs = 10_000L) + val byWrap = views.associateBy { it.wrapId } + assertEquals(setOf(toNew.wrapId, catchUp.wrapId), byWrap.keys) + assertFalse(plainForJoined.wrapId in byWrap) + assertFalse(baseMove.wrapId in byWrap) + assertTrue(byWrap.getValue(catchUp.wrapId).catchUp) + assertFalse(byWrap.getValue(toNew.wrapId).catchUp) + assertTrue(byWrap.getValue(toNew.wrapId).expired) + assertFalse(byWrap.getValue(catchUp.wrapId).expired) + assertEquals(listOf("vip"), byWrap.getValue(catchUp.wrapId).channelNames) + } + + @Test + fun visibleKeepsOneInvitePerCommunity() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val older = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = 1_700_000_000L))) + val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = 1_700_000_100L))) + assertEquals(2, inbox.pending.value.size) + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList()) + assertEquals(listOf(newer.wrapId), views.map { it.wrapId }) + assertFalse(older.wrapId in views.map { it.wrapId }) + } + + @Test + fun acceptRefusesAnExpiredInvite() = + runTest { + val c = community() + val opened = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, expiresAt = 5_000L)), me)) + assertEquals(DirectInviteAcceptPlan.Expired, ConcordDirectInviteInbox.acceptPlan(opened, null, null, me.pubKey, nowMs = 5_001L)) + assertEquals(DirectInviteAcceptPlan.Join, ConcordDirectInviteInbox.acceptPlan(opened, null, null, me.pubKey, nowMs = 4_999L)) + } + + @Test + fun acceptOnAHeldCommunityOnlyAddsKeysAndNeverMovesTheBase() = + runTest { + val c = community() + val held = heldEntryOf(c) + val state = stateOf(c) + val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")) + + // Same base, new key: a catch-up that keeps the held base and anchor. + val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me)) + val plan = assertIs(ConcordDirectInviteInbox.acceptPlan(catchUp, held, state, me.pubKey)) + assertEquals(held.root, plan.entry.root) + assertEquals(held.rootEpoch, plan.entry.rootEpoch) + assertEquals(held.controlPk, plan.entry.controlPk) + assertEquals("anchor", plan.entry.inviteRef) + assertEquals(listOf(vip), plan.entry.privateChannels.map { it.channelId }) + + // No fold yet: the ban verdict is unknown, so it waits. + assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey)) + + // Already holding that key: nothing new. + val holding = held.let { ConcordCommunityListEntry(it.id, it.owner, it.ownerSalt, it.root, it.rootEpoch, it.controlPk, privateChannels = listOf(PrivateChannelKey(vip, "db".repeat(32), 0, "vip")), relays = it.relays, name = it.name) } + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, holding, state, me.pubKey)) + + // A different base for a held community is never adopted, keys or not. + val baseMove = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, root = "99".repeat(32), channels = grant)), me)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(baseMove, held, state, me.pubKey)) + + // A dissolved community takes no new keys. + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, state.withDissolved(true), me.pubKey)) + } + + @Test + fun acceptRefusesACatchUpWhenTheHeldRosterBansUs() = + runTest { + val c = community() + val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, c.controlPlane, c.communityId, me.pubKey, editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane) + val banned = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey) + assertTrue(banned.authority.isBanned(me.pubKey)) + + val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))), me)) + assertEquals(DirectInviteAcceptPlan.Banned, ConcordDirectInviteInbox.acceptPlan(catchUp, heldEntryOf(c), banned, me.pubKey)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index ceda69c3cd..7f4d0d6661 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -704,4 +704,27 @@ object ConcordCommunityList { excludedAtEpoch = excludedAtEpoch, residue = residue, ) + + /** + * Copy of this entry holding [privateChannels] — e.g. after a Direct Invite catch-up delivered a + * Private Channel key (CORD-05 §6). Every other field, the base included, untouched. + */ + fun ConcordCommunityListEntry.withPrivateChannels(privateChannels: List) = + ConcordCommunityListEntry( + id = id, + owner = owner, + ownerSalt = ownerSalt, + root = root, + rootEpoch = rootEpoch, + controlPk = controlPk, + controlRoot = controlRoot, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + residue = residue, + ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt index ac0ce7bd16..d3c74c06ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -24,18 +24,51 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils /** - * Direct invites (CORD-05): for a known npub, the invite skips the public bundle - * and is delivered as a standard NIP-59 giftwrap — a kind-3313 rumor carrying the - * [CommunityInvite], sealed (kind 13) to the recipient and wrapped (kind 1059) - * with `["p", recipient]` and a `["k", "3313"]` index tag so the recipient can - * query for pending invites without decrypting every giftwrap. + * A Direct Invite opened by its recipient (CORD-05 §6): the bundle plus the seal-verified [sender]. + * + * [invite] is already bounded and owner-proof validated ([ConcordInviteBundle.validate]); expiry is + * NOT enforced here — a parked invite still renders, only joining refuses ([isExpired]). + * [sentAt] is the rumor's `created_at` (unix seconds), the sender's word: fine for ordering, never + * for authority. + */ +class OpenedDirectInvite( + val wrapId: HexKey, + val sender: HexKey, + val invite: CommunityInvite, + val sentAt: Long, +) { + /** True when the bundle's `expires_at` (unix ms) has passed: the preview renders, joining refuses. */ + fun isExpired(nowMs: Long = TimeUtils.nowMillis()): Boolean = ConcordInviteBundle.isExpired(invite, nowMs) +} + +/** + * Direct invites (CORD-05 §6): for a known npub, the invite skips the public bundle + * and is delivered as a *standard* NIP-59 giftwrap — a kind-3313 rumor carrying the + * [CommunityInvite], sealed (kind 13, signed by the inviter's real key) to the recipient and + * wrapped (kind 1059, ephemeral single-use author) with `["p", recipient]` and a `["k", "3313"]` + * index tag so the recipient can query for pending invites without decrypting every giftwrap. + * Not the reversed stream wrap of CORD-01. + * + * Wire details pinned to Armada's `directInvite.ts`: + * - seal and wrap `created_at` are each tweaked into the past by up to [MAX_BACKDATE_SECS] + * (NIP-59), so the wrap leaks only "roughly when"; the rumor keeps the real send time; + * - when the bundle has an `expires_at` (unix ms) the wrap carries the matching NIP-40 + * `["expiration", expires_at / 1000]`, so relays can prune a handoff that can no longer be used; + * - opening requires the rumor's claimed author to equal the seal's author (NIP-59 anti-spoofing), + * and the seal's signature to verify — the seal is what proves who invited. * * It cannot be revoked — the recipient holds the keys the moment it lands. */ @@ -44,46 +77,125 @@ object ConcordDirectInvite { const val TAG_P = "p" const val TAG_K = "k" + /** NIP-59: outer (seal + wrap) timestamps are tweaked into the past by up to two days. */ + const val MAX_BACKDATE_SECS: Long = 2 * 24 * 60 * 60L + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) + /** [now] minus a uniformly random `0 until` [MAX_BACKDATE_SECS] seconds (NIP-59's timestamp tweak). */ + fun tweakedPast(now: Long = TimeUtils.now()): Long = now - RandomInstance.int(MAX_BACKDATE_SECS.toInt()) + /** * Builds a giftwrapped direct invite from [senderSigner] to [recipientPubKey]. - * Returns the kind-1059 wrap to publish to the recipient's inbox relays. + * Returns the kind-1059 wrap to publish to the recipient's inbox relays (their kind-10050 DM + * relays, else their NIP-65 read relays). [createdAt] is the rumor's real send time; the seal and + * the wrap are each backdated from it independently ([tweakedPast]). */ suspend fun build( senderSigner: NostrSigner, recipientPubKey: HexKey, invite: CommunityInvite, - createdAt: Long, + createdAt: Long = TimeUtils.now(), ): GiftWrapEvent { val rumor = RumorAssembler.assembleRumor(senderSigner.pubKey, createdAt, KIND, emptyArray(), json(invite)) - val seal = SealEvent.create(rumor, recipientPubKey, senderSigner, createdAt = createdAt) + val seal = SealEvent.create(rumor, recipientPubKey, senderSigner, createdAt = tweakedPast(createdAt)) - // Wrap with a random ephemeral key, adding the ["k","3313"] index tag. + // Wrap with a random single-use key, adding the ["k","3313"] index tag and, when the bundle + // expires, the NIP-40 expiration matching it. val wrapSigner = NostrSignerInternal(KeyPair()) val content = wrapSigner.nip44Encrypt(seal.toJson(), recipientPubKey) + val tags = + listOfNotNull( + arrayOf(TAG_P, recipientPubKey), + arrayOf(TAG_K, KIND.toString()), + invite.expiresAt?.let { arrayOf(ExpirationTag.TAG_NAME, (it / 1000).toString()) }, + ).toTypedArray() return wrapSigner.sign( - createdAt = createdAt, + createdAt = tweakedPast(createdAt), kind = GiftWrapEvent.KIND, - tags = arrayOf(arrayOf(TAG_P, recipientPubKey), arrayOf(TAG_K, KIND.toString())), + tags = tags, content = content, ) } + /** + * True when [wrap]'s NIP-40 `expiration` (unix seconds) is at or before [nowSecs]: an expired + * handoff is never decrypted or surfaced. + */ + fun isWrapExpired( + wrap: Event, + nowSecs: Long = TimeUtils.now(), + ): Boolean = wrap.tags.isExpirationBefore(nowSecs) + + /** + * The `since` to query invite wraps from, given the newest wrap `created_at` already seen: + * rewound by [MAX_BACKDATE_SECS] because wraps are backdated (a wrap published after the last + * sweep can carry an older timestamp). Null on a cold inbox — fetch everything. + */ + fun inboxSince(newestWrapCreatedAt: Long?): Long? = newestWrapCreatedAt?.takeIf { it > MAX_BACKDATE_SECS }?.let { it - MAX_BACKDATE_SECS } + + /** + * Opens a direct-invite giftwrap addressed to [recipientSigner]. Null — never a throw — unless + * every layer checks out: a kind-1059 wrap that decrypts to a kind-13 seal with a valid + * signature, whose rumor claims the seal's author (anti-spoofing), is kind 3313 (the rumor kind + * is the authority, not the outer `k` hint), and carries a [CommunityInvite] that passes the §1 + * bounds and the owner proof ([ConcordInviteBundle.validate]). + */ + suspend fun open( + wrap: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? { + if (wrap.kind != GiftWrapEvent.KIND) return null + val seal = + try { + Event.fromJson(recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey)) + } catch (_: Exception) { + return null + } + return openSeal(wrap.id, seal, recipientSigner) + } + + /** + * [open] from the kind-13 [seal] down, for a pipeline that already peeled the wrap [wrapId] + * (e.g. the general NIP-17 giftwrap inbox, which honours an untagged invite all the same). + */ + suspend fun openSeal( + wrapId: HexKey, + seal: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? { + if (seal !is SealEvent) return null + return try { + if (!seal.verify()) return null + val rumor = Rumor.fromJson(recipientSigner.nip44Decrypt(seal.content, seal.pubKey)) + // NIP-59 anti-spoofing: the rumor's claimed author must be the seal's signer. The generic + // unseal path overwrites the rumor's pubkey with the seal's, which hides a mismatch; here + // a mismatch is a forgery and the whole invite is refused. + val claimed = rumor.pubKey ?: return null + if (!claimed.equals(seal.pubKey, ignoreCase = true)) return null + if (rumor.kind != KIND) return null + // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"), and validated + // exactly as one: the community_id must self-certify the owner. + val content = rumor.content ?: return null + val invite = + ConcordJson + .decodeOrNull(content) + ?.let { ConcordInviteBundle.bound(it) } + ?.takeIf { ConcordInviteBundle.validate(it) } + ?: return null + OpenedDirectInvite(wrapId, seal.pubKey.lowercase(), invite, rumor.createdAt ?: seal.createdAt) + } catch (_: Exception) { + null + } + } + /** * Opens a direct-invite giftwrap addressed to [recipientSigner] and returns the - * [CommunityInvite], or null if it isn't a valid direct invite for this user. - * Callers should still [ConcordInviteBundle.validate] the result. + * [CommunityInvite], or null if it isn't a valid direct invite for this user. See [open], which + * also returns the verified sender. */ suspend fun parse( wrap: GiftWrapEvent, recipientSigner: NostrSigner, - ): CommunityInvite? { - val seal = wrap.unwrapOrNull(recipientSigner) ?: return null - if (seal !is SealEvent) return null - val rumor = seal.unsealOrNull(recipientSigner) ?: return null - if (rumor.kind != KIND) return null - // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"). - return ConcordJson.decodeOrNull(rumor.content)?.let { ConcordInviteBundle.bound(it) } - } + ): CommunityInvite? = open(wrap, recipientSigner)?.invite } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt new file mode 100644 index 0000000000..c0cb4df4d9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withPrivateChannels +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Which Private Channel keys an invite bundle may carry (CORD-05 §1, CORD-03 §1, CORD-04 §2), and + * what a bundle for an already-joined community may contribute. Pinned to Armada's + * `channelAccess.ts` (`isEntitled`, `vendableChannels`) and `directInvite.ts` (`catchUpChannelIds`). + * + * The Roles scoped to a channel (`scope: {kind:"channel", channel_id}`) ARE its access list. Read + * access is enforced by key possession alone; this decides who a key is delivered TO. + */ +object ConcordInviteVend { + private const val SCOPE_CHANNEL = "channel" + + /** The live Role ids conferring read access to [channelIdHex] (Roles scoped to that channel). */ + fun channelRoleIds( + authority: AuthorityResolver, + channelIdHex: HexKey, + ): Set = + authority + .roles() + .filter { (_, role) -> !role.deleted && role.scope?.kind == SCOPE_CHANNEL && role.scope.channelId.equals(channelIdHex, ignoreCase = true) } + .keys + + /** + * Is [memberHex] entitled to Private Channel [channelIdHex]'s key? The owner always is + * (CORD-04 §2); anyone else must hold a Role scoped to that channel. + */ + fun isEntitled( + authority: AuthorityResolver, + memberHex: HexKey, + channelIdHex: HexKey, + ): Boolean { + if (authority.isOwner(memberHex)) return true + val held = authority.rolesOf(memberHex) + if (held.isEmpty()) return false + return channelRoleIds(authority, channelIdHex).any { it in held } + } + + /** + * The held Private Channel keys a bundle may carry for its audience (CORD-05 §1): + * - a **link** ([memberHex] null) has no recipient and holds no Role, so it gets none; + * - a **member** (a Direct Invite's recipient) gets exactly the channels their Roles entitle + * them to ([isEntitled]) — that CORD-05 §6 can't *prevent* an unentitled whisper doesn't make + * one right. + * + * Keyless listings are never vended. + */ + fun vendableChannels( + held: List, + authority: AuthorityResolver, + memberHex: HexKey?, + ): List { + if (memberHex == null) return emptyList() + return held.filter { it.key.isNotBlank() && isEntitled(authority, memberHex, it.channelId) } + } + + /** The [held] keys as bundle channel grants (lowercase hex, as Armada writes them). */ + fun toInviteChannels(held: List): List = held.map { InviteChannel(it.channelId.lowercase(), it.key.lowercase(), it.epoch, it.name) } + + /** + * The Private Channel ids (lowercase hex) a [bundle] for an already-joined community would NEWLY + * contribute to [held] — empty when it is not a catch-up. Armada `catchUpChannelIds`. + * + * A catch-up may never move the base: nothing binds `community_root` to `community_id` + * (CORD-02 §1/§2), so a hostile bundle carrying a real id/owner/salt could otherwise relocate + * the member onto attacker-read streams. So it counts only on the SAME `community_root`, + * `root_epoch` and `control_pk` (swapping `control_pk` alone would eclipse the member onto an + * attacker's Control Plane); the base advances only by a CORD-06 rekey. + */ + fun catchUpChannelIds( + held: ConcordCommunityListEntry?, + bundle: CommunityInvite, + ): List { + if (held == null) return emptyList() + if (!bundle.communityId.equals(held.id, ignoreCase = true)) return emptyList() + if (!bundle.communityRoot.equals(held.root, ignoreCase = true)) return emptyList() + if (bundle.rootEpoch != held.rootEpoch) return emptyList() + if (!sameOptionalHex(bundle.controlPk, held.controlPk)) return emptyList() + val heldEpochs = held.privateChannels.filter { it.key.isNotBlank() }.associate { it.channelId.lowercase() to it.epoch } + return bundle.channels + .filter { HEX64.matches(it.id) && HEX64.matches(it.key) } + .filter { c -> + val heldEpoch = heldEpochs[c.id.lowercase()] + heldEpoch == null || c.epoch > heldEpoch + }.map { it.id.lowercase() } + .distinct() + } + + /** + * [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds]) merged + * in — a newer epoch replaces the held one — or null when the bundle contributes nothing. The + * base, epoch, control keys and every other field stay exactly as held. + */ + fun adoptCatchUp( + held: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): ConcordCommunityListEntry? { + val newIds = catchUpChannelIds(held, bundle).toSet() + if (newIds.isEmpty()) return null + val delivered = + bundle.channels + .filter { it.id.lowercase() in newIds && HEX64.matches(it.key) } + .groupBy { it.id.lowercase() } + .map { (id, grants) -> grants.maxBy { it.epoch }.let { PrivateChannelKey(id, it.key.lowercase(), it.epoch, it.name) } } + val kept = held.privateChannels.filterNot { it.channelId.lowercase() in newIds } + return held.withPrivateChannels(kept + delivered) + } + + private val HEX64 = Regex("^[0-9a-fA-F]{64}$") + + private fun sameOptionalHex( + a: String?, + b: String?, + ): Boolean = a?.lowercase() == b?.lowercase() +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt index 396ffbad1b..985da96538 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt @@ -20,47 +20,203 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class ConcordDirectInviteTest { + private val owner = NostrSignerInternal(KeyPair()) private val sender = NostrSignerInternal(KeyPair()) private val recipient = NostrSignerInternal(KeyPair()) private val stranger = NostrSignerInternal(KeyPair()) - private val invite = - CommunityInvite( - communityId = "11".repeat(32), - owner = "0f".repeat(32), - ownerSalt = "aa".repeat(32), - communityRoot = "bb".repeat(32), - name = "Nostrichs", + private suspend fun community(): NewConcordCommunity = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + + private fun inviteFor( + community: NewConcordCommunity, + expiresAt: Long? = null, + relays: List = listOf("wss://relay.example"), + channels: List = emptyList(), + ) = CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + channels = channels, + relays = relays, + name = "Nostrichs", + expiresAt = expiresAt, + ) + + /** Wraps an arbitrary [seal] to [to] exactly like [ConcordDirectInvite.build] does (ephemeral author, p + k tags). */ + private suspend fun wrapSeal( + seal: Event, + to: String, + ): GiftWrapEvent { + val eph = NostrSignerInternal(KeyPair()) + return eph.sign( + createdAt = seal.createdAt, + kind = GiftWrapEvent.KIND, + tags = arrayOf(arrayOf("p", to), arrayOf("k", "3313")), + content = eph.nip44Encrypt(seal.toJson(), to), ) + } + + /** A seal from [sealer] carrying a kind-[kind] rumor that CLAIMS [claimedAuthor]. */ + private suspend fun forgedSeal( + sealer: NostrSigner, + claimedAuthor: String, + content: String, + kind: Int = ConcordDirectInvite.KIND, + ): SealEvent { + val rumor = RumorAssembler.assembleRumor(claimedAuthor, 1_700_000_000L, kind, emptyArray(), content) + return SealEvent.create(rumor, recipient.pubKey, sealer, createdAt = 1_700_000_000L) + } + + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) @Test - fun directInviteRoundTripsToTheRecipient() = + fun directInviteRoundTripsWithTheVerifiedSender() = runTest { - val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1_700_000_000L) + val c = community() + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L) - // Wrap is a giftwrap tagged for the recipient and indexable by k=3313. + // Wrap is a giftwrap tagged for the recipient and indexable by k=3313, from an ephemeral author. + assertEquals(GiftWrapEvent.KIND, wrap.kind) assertEquals(recipient.pubKey, wrap.tags.first { it[0] == "p" }[1]) assertEquals("3313", wrap.tags.first { it[0] == "k" }[1]) + assertFalse(wrap.pubKey == sender.pubKey) - val parsed = ConcordDirectInvite.parse(wrap, recipient) - assertNotNull(parsed) - assertEquals("Nostrichs", parsed.name) - assertEquals("11".repeat(32), parsed.communityId) + val opened = ConcordDirectInvite.open(wrap, recipient) + assertNotNull(opened) + assertEquals(sender.pubKey, opened.sender) + assertEquals(wrap.id, opened.wrapId) + assertEquals(1_700_000_000L, opened.sentAt) + assertEquals("Nostrichs", opened.invite.name) + assertEquals(c.communityIdHex, opened.invite.communityId) + assertEquals(c.controlPkHex, opened.invite.controlPk) + + // The legacy parse keeps working. + assertEquals(c.communityIdHex, ConcordDirectInvite.parse(wrap, recipient)?.communityId) } @Test fun strangersCannotOpenIt() = runTest { - val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1L) - assertNull(ConcordDirectInvite.parse(wrap, stranger)) + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(community()), createdAt = 1L) + assertNull(ConcordDirectInvite.open(wrap, stranger)) } + + @Test + fun aRumorClaimingSomeoneElseIsRefused() = + runTest { + // The attacker seals (and so is the verified sender) a rumor claiming the owner wrote it. + val c = community() + val spoofed = wrapSeal(forgedSeal(stranger, claimedAuthor = owner.pubKey, content = json(inviteFor(c))), recipient.pubKey) + assertNull(ConcordDirectInvite.open(spoofed, recipient)) + + // The very same rumor claiming its real sealer opens. + val honest = wrapSeal(forgedSeal(stranger, claimedAuthor = stranger.pubKey, content = json(inviteFor(c))), recipient.pubKey) + assertEquals(stranger.pubKey, ConcordDirectInvite.open(honest, recipient)?.sender) + } + + @Test + fun theRumorKindIsTheAuthorityNotTheKTag() = + runTest { + // A k=3313-tagged wrap whose rumor is a kind-14 DM is not an invite. + val c = community() + val dm = wrapSeal(forgedSeal(sender, claimedAuthor = sender.pubKey, content = json(inviteFor(c)), kind = 14), recipient.pubKey) + assertNull(ConcordDirectInvite.open(dm, recipient)) + } + + @Test + fun wrapCarriesNip40ExpirationMatchingExpiresAt() = + runTest { + val c = community() + val expiresAtMs = 1_800_000_123_456L + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, expiresAt = expiresAtMs), createdAt = 1_700_000_000L) + assertEquals(1_800_000_123L, wrap.tags.expiration()) + + assertFalse(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_122L)) + assertTrue(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_123L)) + + // No expires_at, no expiration tag. + val open = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L) + assertNull(open.tags.expiration()) + assertFalse(ConcordDirectInvite.isWrapExpired(open, nowSecs = Long.MAX_VALUE)) + + // An expired bundle still opens (a parked invite renders), but reports itself expired. + val opened = ConcordDirectInvite.open(wrap, recipient) + assertNotNull(opened) + assertTrue(opened.isExpired(nowMs = expiresAtMs + 1)) + assertFalse(opened.isExpired(nowMs = expiresAtMs - 1)) + } + + @Test + fun sealAndWrapAreBackdatedWithinTwoDaysButTheRumorKeepsTheRealTime() = + runTest { + val c = community() + val now = 1_700_000_000L + val outer = mutableListOf() + repeat(6) { + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = now) + val seal = wrap.unwrapOrNull(recipient) + assertIs(seal) + for (t in listOf(wrap.createdAt, seal.createdAt)) { + assertTrue(t <= now, "outer timestamp $t is in the future") + assertTrue(t > now - ConcordDirectInvite.MAX_BACKDATE_SECS, "outer timestamp $t is backdated past two days") + outer += t + } + assertEquals(now, ConcordDirectInvite.open(wrap, recipient)?.sentAt) + } + // Twelve independent draws over a two-day range are not all "now". + assertTrue(outer.any { it < now }) + } + + @Test + fun theSection1BoundsApply() = + runTest { + val c = community() + val sixRelays = (1..6).map { "wss://r$it.example" } + val bounded = ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, relays = sixRelays), createdAt = 1L), recipient) + assertEquals(sixRelays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), bounded?.invite?.relays) + + val tooMany = (0..ConcordInviteBundle.MAX_BUNDLE_CHANNELS).map { InviteChannel(id = it.toString(16).padStart(64, '0'), key = "cd".repeat(32), epoch = 0) } + assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, channels = tooMany), createdAt = 1L), recipient)) + } + + @Test + fun aBundleWhoseOwnerProofFailsIsRefused() = + runTest { + // A real community's id with someone else's owner: the id does not self-certify it. + val c = community() + val forged = inviteFor(c).copy(owner = stranger.pubKey) + assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, forged, createdAt = 1L), recipient)) + } + + @Test + fun inboxSinceRewindsByTheBackdateWindow() { + assertNull(ConcordDirectInvite.inboxSince(null)) + assertNull(ConcordDirectInvite.inboxSince(100L)) + assertEquals(1_700_000_000L - ConcordDirectInvite.MAX_BACKDATE_SECS, ConcordDirectInvite.inboxSince(1_700_000_000L)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt new file mode 100644 index 0000000000..fc7577a4c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A Direct Invite for an already-joined community is a catch-up: it may only add Private Channel + * keys on the SAME base (root, epoch, control_pk) — never move the base (Armada `catchUpChannelIds`). + */ +class ConcordInviteVendTest { + private val communityId = "11".repeat(32) + private val root = "22".repeat(32) + private val controlPk = "33".repeat(32) + private val chanA = "a1".repeat(32) + private val chanB = "b2".repeat(32) + private val keyA = "ca".repeat(32) + private val keyB = "db".repeat(32) + + private val held = + ConcordCommunityListEntry( + id = communityId, + owner = "44".repeat(32), + ownerSalt = "55".repeat(32), + root = root, + rootEpoch = 3, + controlPk = controlPk, + privateChannels = listOf(PrivateChannelKey(chanA, keyA, 1, "mods")), + relays = listOf("wss://relay.example"), + name = "Nostrichs", + inviteRef = "naddr1ref", + ) + + private fun bundle( + root: String = this.root, + epoch: Long = 3, + controlPk: String? = this.controlPk, + channels: List, + ) = CommunityInvite( + communityId = communityId, + owner = held.owner, + ownerSalt = held.ownerSalt, + communityRoot = root, + rootEpoch = epoch, + controlPk = controlPk, + channels = channels, + name = "Nostrichs", + ) + + @Test + fun aNewPrivateChannelKeyOnTheSameBaseIsACatchUp() { + val b = bundle(channels = listOf(InviteChannel(chanA, keyA, 1, "mods"), InviteChannel(chanB.uppercase(), keyB, 0, "vip"))) + assertEquals(listOf(chanB), ConcordInviteVend.catchUpChannelIds(held, b)) + + val adopted = ConcordInviteVend.adoptCatchUp(held, b) + assertNotNull(adopted) + // The base never moves. + assertEquals(root, adopted.root) + assertEquals(3, adopted.rootEpoch) + assertEquals(controlPk, adopted.controlPk) + assertEquals(held.inviteRef, adopted.inviteRef) + assertEquals(setOf(chanA to keyA, chanB to keyB), adopted.privateChannels.map { it.channelId to it.key }.toSet()) + } + + @Test + fun aNewerEpochOfAHeldChannelReplacesIt() { + val newer = "ee".repeat(32) + val b = bundle(channels = listOf(InviteChannel(chanA, newer, 2, "mods"))) + assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(held, b)) + val adopted = assertNotNull(ConcordInviteVend.adoptCatchUp(held, b)) + assertEquals(listOf(Triple(chanA, newer, 2L)), adopted.privateChannels.map { Triple(it.channelId, it.key, it.epoch) }) + + // Same or older epoch contributes nothing. + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanA, newer, 1)))).isEmpty()) + } + + @Test + fun aBundleOnAnotherBaseIsNeverACatchUp() { + val grant = listOf(InviteChannel(chanB, keyB, 0, "vip")) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(root = "99".repeat(32), channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(epoch = 4, channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = "98".repeat(32), channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = null, channels = grant)).isEmpty()) + assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(root = "99".repeat(32), channels = grant))) + } + + @Test + fun nothingHeldMeansNoCatchUpAndKeylessGrantsDeliverNothing() { + assertTrue(ConcordInviteVend.catchUpChannelIds(null, bundle(channels = listOf(InviteChannel(chanB, keyB, 0)))).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanB, "", 0)))).isEmpty()) + assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(channels = emptyList()))) + } +} From 0cfcec6d5690fd0ce15e6c5af83d9a3b19d013ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:30:25 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(concord):=20Direct=20Invite=20UI=20and?= =?UTF-8?q?=20amy=20verbs=20(CORD-05=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commons: ConcordActions.draftDirectInvite holds the send-side refusals (dissolved, banned sender, banned or invalid recipient) so the app and amy share them. - commonsUI: "Invite by npub" dialog (user typeahead) and a pending Direct Invites card (bundle name + robohash preview, no icon or profile fetch, Accept / Decline), with new strings in commonsUI resources. - amethyst: the dialog behind the community overflow menu; the invites card on the Concord hub, including its empty state. - amy: `concord invite COMMUNITY --to USER [--expires-in SECS]`, `concord invites`, `concord accept WRAP-ID`, `concord decline WRAP-ID`; the link join is factored into joinBundle and shared with accept. - Conformance review: F6 fixed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../concord/ConcordChannelListScreen.kt | 17 + .../concord/ConcordHomeScreen.kt | 23 +- cli/README.md | 5 + .../com/vitorpamplona/amethyst/cli/Config.kt | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 3 + .../cli/commands/ConcordChannelCommands.kt | 2 +- .../amethyst/cli/commands/ConcordCommands.kt | 327 +++++++++++++++--- .../cli/stores/ConcordInviteInboxStore.kt | 55 +++ .../commons/actions/ConcordActions.kt | 33 ++ .../commons/model/AccountConcordActions.kt | 19 +- .../model/ConcordDirectInviteSendResult.kt | 13 + .../actions/ConcordDirectInviteActionsTest.kt | 26 ++ .../composeResources/values/strings.xml | 17 + .../concord/ConcordDirectInvites.kt | 267 ++++++++++++++ .../2026-09-29-concord-spec-conformance.md | 2 +- 15 files changed, 736 insertions(+), 74 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 9f67f7629f..9c2a42b63e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.resources.concord_channel_no_messages import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename_save import com.vitorpamplona.amethyst.commons.resources.concord_channels_empty +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_action import com.vitorpamplona.amethyst.commons.resources.concord_edit_title import com.vitorpamplona.amethyst.commons.resources.concord_invite_action import com.vitorpamplona.amethyst.commons.resources.concord_invite_links_action @@ -108,6 +109,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.commons.ui.note.timeAgo import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordDirectInviteDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -179,6 +181,11 @@ fun ConcordChannelListScreen( // Read once here (it is @Composable) so the post-leave navigation can use it from a callback. val canPop = nav.canPop() var showLeave by remember { mutableStateOf(false) } + var showDirectInvite by remember { mutableStateOf(false) } + + if (showDirectInvite) { + ConcordDirectInviteDialog(communityId, accountViewModel, onDismiss = { showDirectInvite = false }) + } if (showLeave) { ConcordLeaveDialog( @@ -327,6 +334,16 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(Res.string.more_options)) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // A Direct Invite (CORD-05 §6) hands keys to one known npub. No permission gates + // it — none could, any keyholder can whisper keys — so neither does this item; + // what it carries is bounded by the recipient's roles instead. + DropdownMenuItem( + text = { Text(stringRes(Res.string.concord_direct_invite_action)) }, + onClick = { + menuOpen = false + showDirectInvite = true + }, + ) // Deliberately not gated on CREATE_INVITE, unlike minting: the links listed // there are this account's own, authored by link-signer keys only we hold. // Gating on the bit would mean a demoted admin could no longer retire the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 62045385f4..d7ba2251db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.timeAgo import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPendingDirectInvites import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel @@ -158,13 +159,18 @@ fun ConcordHomeScreen( }, ) { padding -> if (communities.isEmpty()) { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - Text( - stringRes(Res.string.concord_home_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 32.dp), - ) + // Direct Invites (CORD-05 §6) are how a first community usually arrives, so they show + // above the empty state rather than being hidden by it. + Column(Modifier.fillMaxSize().padding(padding)) { + ConcordPendingDirectInvites(accountViewModel, nav) + Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + Text( + stringRes(Res.string.concord_home_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } } return@Scaffold } @@ -187,6 +193,9 @@ fun ConcordHomeScreen( } LazyColumn(Modifier.fillMaxSize().padding(padding)) { + // Pending Direct Invites (CORD-05 §6), parked until the user accepts or declines. + item(key = "concord-direct-invites") { ConcordPendingDirectInvites(accountViewModel, nav) } + sorted.forEach { entry -> val state = account.concordSessions diff --git a/cli/README.md b/cli/README.md index 519e277554..cf8a7a3dc8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -680,6 +680,10 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. | | `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator). | +| `amy concord invite COMMUNITY --to USER [--expires-in SECS]` | Send a Direct Invite (CORD-05 §6): the bundle giftwrapped as standard NIP-59 (kind-3313 rumor, `k=3313` wrap tag, NIP-40 expiration when `--expires-in` is set) to USER (npub, hex, nprofile or NIP-05) on their kind-10050 relays, else NIP-65 read relays, else the stock set. Carries only the private-channel keys USER's roles grant; refused for a banned recipient. No registry entry, never flips the community Public, cannot be revoked. | +| `amy concord invites` | List Direct Invites waiting for this account (sender, community name/icon, expired, catch-up). Read-only: nothing joins or contacts the community's relays. Communities you already hold are hidden unless the invite carries new channel keys on the same base (a catch-up). | +| `amy concord accept WRAP-ID` | Accept a Direct Invite (full wrap id or a unique prefix): the same join path as a link (ban-gated, Guestbook Join attributed to the seal-verified sender); refused past `expires_at`. For a community you hold, only adopts newly granted private-channel keys on the same root/epoch/control_pk, never moving the base. | +| `amy concord decline WRAP-ID` | Discard a Direct Invite; its wrap id is remembered in `concord-invites.json` so it never resurfaces. | | `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. | | `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). | | `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). | @@ -987,6 +991,7 @@ matches that: │ ├── aliases.json # local name → npub map │ ├── cashu.json # NIP-60 NUT-13 counters │ ├── concord.json # Concord community secrets +│ ├── concord-invites.json # declined Concord Direct Invite wrap ids │ └── marmot/ # MLS state per group └── bob/ └── … diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index efc4c20f40..95dd68e291 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -224,6 +224,7 @@ class DataDir( val aliasesFile = File(root, "aliases.json") val cashuFile = File(root, "cashu.json") val concordFile = File(root, "concord.json") + val concordInvitesFile = File(root, "concord-invites.json") val marmotDir = File(root, "marmot") val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 89f9929c45..87caabad5f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -884,6 +884,9 @@ private fun printUsage() { | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord invite COMMUNITY --to USER send a Direct Invite (giftwrapped bundle) + | concord invites list Direct Invites waiting for you + | concord accept|decline WRAP-ID join from / discard a Direct Invite | concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone) | concord join URL redeem an invite link and save the community | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 312c3b88d3..554e549582 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -172,7 +172,7 @@ object ConcordChannelCommands { } /** Drain the control plane and fold it into the current community state. */ - private suspend fun foldState( + suspend fun foldState( ctx: Context, sc: StoredCommunity, ): ConcordCommunityState { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index dd7a23c1f4..683bcb54d2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -24,12 +24,18 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.ConcordInviteInboxStore import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot import com.vitorpamplona.amethyst.cli.stores.StoredPrivateChannel import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView +import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent @@ -41,6 +47,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry @@ -49,6 +56,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -75,6 +83,14 @@ object ConcordCommands { | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50); | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord invite COMMUNITY --to USER send a Direct Invite (CORD-05 §6): the bundle + | [--expires-in SECS] giftwrapped to USER (npub|hex|nprofile|nip05), + | to their 10050 / NIP-65 read / stock relays, + | with only the private channels their roles grant + | concord invites list Direct Invites waiting for you (never joins) + | concord accept WRAP-ID accept a Direct Invite: join (or, for a community + | you hold, adopt newly granted channel keys) + | concord decline WRAP-ID discard a Direct Invite; it never resurfaces | concord revoke COMMUNITY TOKEN|URL retire a link you minted: publishes a vsk=9 | tombstone at its coordinate, then tombstones | it in your invite list so it stays retired @@ -105,7 +121,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -116,6 +132,9 @@ object ConcordCommands { "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, + "invites" to { rest -> invites(dataDir, rest) }, + "accept" to { rest -> accept(dataDir, rest) }, + "decline" to { rest -> decline(dataDir, rest) }, "revoke" to { rest -> revoke(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, "recover" to { rest -> recover(dataDir, rest) }, @@ -282,9 +301,13 @@ object ConcordCommands { val args = Args(rest) val handle = args.positional(0, "community") val base = args.flag("base", "https://vector.chat")!! + val to = args.flag("to") + val expiresInSecs = args.flag("expires-in")?.let { it.toLongOrNull()?.takeIf { secs -> secs > 0 } ?: throw IllegalArgumentException("--expires-in expects a positive number of seconds, got '$it'") } args.rejectUnknown() val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle) + if (to != null) return directInvite(dataDir, sc, to, expiresInSecs) + if (expiresInSecs != null) return Output.error("bad_args", "--expires-in applies to a Direct Invite (--to)").let { 2 } Context.open(dataDir).use { ctx -> ctx.prepare() // The joiner cannot derive the Control Plane address, so the invite carries it @@ -447,62 +470,264 @@ object ConcordCommands { InviteBundleStatus.Absent -> return Output.error("not_found", "no bundle for this link on any of its relays") } - // Refuse a link that readmits us after we were removed. A Refounding re-mints every - // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its - // unlock token forever — so without this check the rotation that was supposed to expel - // them hands them the new keys instead. `recover` has always been ban-gated; `join` is - // the other door into the same room. - // - // Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable - // after the bundle yields the root, which is why the check lives here rather than before. - val joinKeys = - ConcordActions.controlPlaneKeys( - communityRoot = bundle.communityRoot.hexToByteArray(), - communityId = bundle.communityId.hexToByteArray(), - rootEpoch = bundle.rootEpoch, - controlPk = bundle.controlPk, - ) - val joinRelays = normalize(bundle.relays).ifEmpty { relays } - val joinEditions = - ConcordActions.controlEditions( - ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second }, - joinKeys, - ) - if (joinEditions.isEmpty()) { - return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join") - } - if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) { - return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)") - } + return joinBundle( + ctx = ctx, + dataDir = dataDir, + bundle = bundle, + fallbackRelays = relays, + // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving + // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. + inviteRef = ConcordActions.bareInviteRef(url) ?: "", + inviteCreator = bundle.creatorNpub, + inviteLabel = bundle.label, + ) + } + } - val stored = - StoredCommunity( - name = bundle.name, - communityId = bundle.communityId, - owner = bundle.owner, - ownerSalt = bundle.ownerSalt, - root = bundle.communityRoot, - rootEpoch = bundle.rootEpoch, - // Read access to the Control Plane, never write (CORD-05 §1). Absent = the - // community is still pre-split and folds at the legacy address. - controlPk = bundle.controlPk ?: "", - relays = bundle.relays, - // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving - // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. - inviteRef = ConcordActions.bareInviteRef(url) ?: "", - privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, - ) - ConcordStore(dataDir.concordFile).upsert(stored) + /** + * The join half shared by `join` (a link) and `accept` (a Direct Invite): [bundle] is already + * opened, bounded, owner-proof validated and not expired. Ban-gates against the community's own + * Control Plane (read over the bundle's relays, else [fallbackRelays]), stores the membership and + * announces the Guestbook Join with [inviteCreator]/[inviteLabel] attribution. + */ + private suspend fun joinBundle( + ctx: Context, + dataDir: DataDir, + bundle: CommunityInvite, + fallbackRelays: Set, + inviteRef: String, + inviteCreator: String?, + inviteLabel: String?, + ): Int { + // Refuse a link that readmits us after we were removed. A Refounding re-mints every + // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its + // unlock token forever — so without this check the rotation that was supposed to expel + // them hands them the new keys instead. `recover` has always been ban-gated; `join` is + // the other door into the same room. + // + // Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable + // after the bundle yields the root, which is why the check lives here rather than before. + val joinKeys = + ConcordActions.controlPlaneKeys( + communityRoot = bundle.communityRoot.hexToByteArray(), + communityId = bundle.communityId.hexToByteArray(), + rootEpoch = bundle.rootEpoch, + controlPk = bundle.controlPk, + ) + val joinRelays = normalize(bundle.relays).ifEmpty { fallbackRelays } + val joinEditions = + ConcordActions.controlEditions( + ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second }, + joinKeys, + ) + if (joinEditions.isEmpty()) { + return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join") + } + if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) { + return Output.error("banned", "this community has banned this account; the invite opens but the roster does not admit you (CORD-04)") + } - // Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later - // Refounding finds this member to re-key, and it echoes the link's attribution so link - // holders can count per-link joins. Best-effort, like every Guestbook motion. - val announced = announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label) - Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced)) + val stored = + StoredCommunity( + name = bundle.name, + communityId = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + // Read access to the Control Plane, never write (CORD-05 §1). Absent = the + // community is still pre-split and folds at the legacy address. + controlPk = bundle.controlPk ?: "", + relays = bundle.relays, + // The stranded-recovery anchor; blank for a Direct Invite, which has no link. + inviteRef = inviteRef, + privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, + ) + ConcordStore(dataDir.concordFile).upsert(stored) + + // Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later + // Refounding finds this member to re-key, and it echoes the link's attribution so link + // holders can count per-link joins. Best-effort, like every Guestbook motion. + val announced = announceGuestbookJoin(ctx, stored, inviteCreator, inviteLabel) + Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced)) + return 0 + } + + // ---- Direct Invites (CORD-05 §6) ------------------------------------------- + + /** + * `concord invite COMMUNITY --to USER` — hands the community's keys straight to USER as a + * Direct Invite: the §1 bundle giftwrapped (standard NIP-59, `k=3313`) to their inbox relays. + * Which Private Channel keys ride along, and who is refused, is [ConcordActions.draftDirectInvite]. + */ + private suspend fun directInvite( + dataDir: DataDir, + sc: StoredCommunity, + to: String, + expiresInSecs: Long?, + ): Int { + Context.open(dataDir).use { ctx -> + ctx.prepare() + val recipient = ctx.requireUserHex(to) + // The fold decides which Private Channel keys the recipient's Roles entitle them to and + // whether either side is banned; no fold, no verdict, no send. + val state = ConcordChannelCommands.foldState(ctx, sc) + if (state.metadata == null) { + return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so which keys the recipient may receive is unknown — not sending") + } + val expiresAtMs = expiresInSecs?.let { TimeUtils.nowMillis() + it * 1000 } + val invite = + when (val draft = ConcordActions.draftDirectInvite(entryFor(sc), state, ctx.signer.pubKey, recipient, expiresAtMs)) { + is ConcordDirectInviteDraft.Ready -> draft.invite + is ConcordDirectInviteDraft.Refused -> + return when (draft.reason) { + ConcordDirectInviteSendResult.RECIPIENT_BANNED -> Output.error("recipient_banned", "this community has banned $recipient; their join would be refused") + ConcordDirectInviteSendResult.INVALID_RECIPIENT -> Output.error("bad_args", "'$to' is not a 32-byte pubkey").let { 2 } + else -> Output.error("not_member", "this account is banned from, or no longer holds, this community") + } + } + val wrap = ConcordActions.buildDirectInvite(ctx.signer, recipient, invite) + // Their kind-10050 DM relays, else NIP-65 read relays, else the stock set (CORD-05 §6). + val lists = ctx.cachedRelayListsOf(recipient) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, ctx.bootstrapRelays()) + val relays = ConcordActions.directInviteDeliveryRelays(lists) + val ack = ctx.publish(wrap, relays) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + Output.emit( + mapOf( + "sent" to true, + "wrap_id" to wrap.id, + "recipient" to recipient, + "community_id" to sc.communityId, + "channels" to invite.channels.map { mapOf("id" to it.id, "name" to it.name, "epoch" to it.epoch) }, + "expires_at" to invite.expiresAt, + ) + RawEventSupport.ackFields(ack), + ) return 0 } } + /** + * Collects this account's Direct Invite wraps (`{"kinds":[1059],"#p":[me],"#k":["3313"]}`) from + * where senders deliver them — our 10050 / NIP-65 read / stock relays, plus the DM inbox — into + * the shared headless inbox, with the declines this account already made restored. + */ + private suspend fun sweepDirectInvites( + ctx: Context, + dataDir: DataDir, + ): ConcordDirectInviteInbox { + val inbox = ConcordDirectInviteInbox(ctx.signer) + inbox.restoreDeclined(ConcordInviteInboxStore(dataDir.concordInvitesFile).declined()) + val me = ctx.signer.pubKey + val relays = ConcordActions.directInviteDeliveryRelays(ctx.cachedRelayListsOf(me)) + ctx.inboxRelays() + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.directInvitesFilter(me)) }).map { it.second } + wraps.distinctBy { it.id }.forEach { inbox.offer(it) } + return inbox + } + + private fun directInviteJson(view: ConcordDirectInviteView): Map = + mapOf( + "wrap_id" to view.wrapId, + "sender" to view.sender, + "community_id" to view.communityId, + "name" to view.name, + "icon" to view.icon?.url, + "relays" to view.invite.relays, + "channels" to + view.invite.channels + .filter { it.key.isNotBlank() } + .map { mapOf("id" to it.id, "name" to it.name, "epoch" to it.epoch) }, + "sent_at" to view.opened.sentAt, + "expires_at" to view.invite.expiresAt, + "expired" to view.expired, + "catch_up" to view.catchUp, + ) + + /** `concord invites` — the Direct Invites waiting for this account. Read-only: nothing joins. */ + private suspend fun invites( + dataDir: DataDir, + rest: Array, + ): Int { + Args(rest).rejectUnknown() + Context.open(dataDir).use { ctx -> + ctx.prepare() + val inbox = sweepDirectInvites(ctx, dataDir) + val joined = ConcordStore(dataDir.concordFile).load().map { entryFor(it) } + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, joined) + Output.emit(mapOf("invites" to views.map { directInviteJson(it) })) { + if (views.isEmpty()) { + "no pending direct invites" + } else { + views.joinToString(System.lineSeparator()) { v -> + val flags = listOfNotNull("expired".takeIf { v.expired }, "catch-up".takeIf { v.catchUp }).joinToString(" ") { "[$it]" } + "${v.wrapId} ${v.name.ifBlank { v.communityId.take(12) }} from ${v.sender}" + if (flags.isNotEmpty()) " $flags" else "" + } + } + } + return 0 + } + } + + /** + * `concord accept WRAP-ID` — accepts a Direct Invite through the same join path as a link: + * refused past `expires_at` or when the roster bans us; for a community already held, only a + * catch-up adopting newly granted Private Channel keys on the same base (never a base move). + */ + private suspend fun accept( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val ref = args.positional(0, "wrap-id").lowercase() + args.rejectUnknown() + Context.open(dataDir).use { ctx -> + ctx.prepare() + val pending = sweepDirectInvites(ctx, dataDir).pending.value.values + val opened = + pending.firstOrNull { it.wrapId == ref } + ?: pending.singleOrNull { it.wrapId.startsWith(ref) } + ?: return Output.error("not_found", "no pending direct invite with wrap id '$ref' (see `amy concord invites`)") + + val store = ConcordStore(dataDir.concordFile) + val heldSc = store.load().firstOrNull { it.communityId.equals(opened.invite.communityId, ignoreCase = true) } + // An unreadable held plane is no verdict (metadata is written at genesis), so it waits. + val heldState = heldSc?.let { ConcordChannelCommands.foldState(ctx, it) }?.takeIf { it.metadata != null } + + fun done(extra: Map) = mapOf("wrap_id" to opened.wrapId, "community_id" to opened.invite.communityId, "name" to opened.invite.name) + extra + return when (val plan = ConcordDirectInviteInbox.acceptPlan(opened, heldSc?.let { entryFor(it) }, heldState, ctx.signer.pubKey)) { + DirectInviteAcceptPlan.Expired -> Output.error("expired", "this direct invite has expired and can no longer be joined") + DirectInviteAcceptPlan.Banned -> Output.error("banned", "this community has banned this account (CORD-04)") + DirectInviteAcceptPlan.RosterNotLoaded -> Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to adopt") + DirectInviteAcceptPlan.NothingNew -> { + Output.emit(done(mapOf("joined" to true, "already_member" to true, "catch_up" to false))) + 0 + } + is DirectInviteAcceptPlan.CatchUp -> { + val held = heldSc!! + store.upsert(storedFrom(held, plan.entry)) + val added = plan.entry.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } } + Output.emit(done(mapOf("joined" to true, "catch_up" to true, "channels" to added.map { mapOf("id" to it.channelId, "name" to it.name, "epoch" to it.epoch) }))) + 0 + } + // The Join is attributed to the seal-verified sender, never the bundle's claim. + DirectInviteAcceptPlan.Join -> joinBundle(ctx, dataDir, opened.invite, emptySet(), inviteRef = "", inviteCreator = opened.sender, inviteLabel = opened.invite.label) + } + } + } + + /** `concord decline WRAP-ID` — discards a Direct Invite locally; it is never listed again. */ + private fun decline( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val wrapId = args.positional(0, "wrap-id").lowercase() + args.rejectUnknown() + if (!HEX64.matches(wrapId)) return Output.error("bad_args", "expected the invite's full 64-hex wrap id, got '$wrapId'").let { 2 } + ConcordInviteInboxStore(dataDir.concordInvitesFile).decline(wrapId) + Output.emit(mapOf("declined" to wrapId)) + return 0 + } + // ---- shared helpers (used by ConcordChannelCommands too) ------------------ private val HEX64 = Regex("^[0-9a-f]{64}$") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt new file mode 100644 index 0000000000..b9ec3a7116 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.stores + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.SecureFileIO +import java.io.File + +/** amy's bookkeeping for Concord Direct Invites (CORD-05 §6): the wrap ids the user declined. */ +data class StoredInviteInbox( + val declined: List = emptyList(), +) + +/** + * `~/.amy//concord-invites.json` — the declined Direct Invite wrap ids, so a declined + * invite (whose wrap relays keep serving until its NIP-40 expiration) never resurfaces in + * `amy concord invites`. + */ +class ConcordInviteInboxStore( + private val file: File, +) { + fun load(): StoredInviteInbox = + if (file.exists()) { + runCatching { Output.mapper.readValue(file.readText()) }.getOrDefault(StoredInviteInbox()) + } else { + StoredInviteInbox() + } + + fun declined(): Set = load().declined.toSet() + + fun decline(wrapId: String) { + val current = load() + if (wrapId in current.declined) return + SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(current.copy(declined = current.declined + wrapId))) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 86f7a9d918..ebc616bf87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState @@ -630,6 +632,37 @@ object ConcordActions { creatorNpub = creator, ) + /** + * The Direct Invite [sender] may hand [recipient] for the held [entry] whose Control Plane folds + * to [state] (CORD-05 §6), or why not. No community permission gates a Direct Invite — none + * could — but a dissolved community, a [sender] its roster bans (like minting a link), and a + * banned [recipient] (whose join would be refused anyway) are refused; the bundle's name/icon + * preview comes from the folded metadata. + */ + fun draftDirectInvite( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + sender: HexKey, + recipient: HexKey, + expiresAtMs: Long? = null, + ): ConcordDirectInviteDraft { + val to = recipient.lowercase() + if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT) + if (state.dissolved || state.authority.isBanned(sender)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.NOT_MEMBER) + if (state.authority.isBanned(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.RECIPIENT_BANNED) + return ConcordDirectInviteDraft.Ready( + directInviteFor( + entry = entry, + authority = state.authority, + recipient = to, + creator = sender.lowercase(), + expiresAtMs = expiresAtMs, + name = state.metadata?.name ?: entry.name, + icon = state.metadata?.icon, + ), + ) + } + /** Giftwraps [invite] to [recipient] as a Direct Invite (see [ConcordDirectInvite.build]). */ suspend fun buildDirectInvite( senderSigner: NostrSigner, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index e844c8b791..5a2d0a93dd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -776,29 +776,20 @@ class AccountConcordActions( ): ConcordDirectInviteSendResult { if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE val recipient = recipientPubKey.lowercase() - if (!HEX64.matches(recipient)) return ConcordDirectInviteSendResult.INVALID_RECIPIENT val entry = account.concordChannelList.liveCommunities.value .firstOrNull { it.id == communityId } ?: return ConcordDirectInviteSendResult.NOT_MEMBER + // The fold decides which Private Channel keys the recipient may receive; no fold, no send. val state = account.concordSessions .sessionFor(communityId) ?.state ?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED - if (state.dissolved) return ConcordDirectInviteSendResult.NOT_MEMBER - if (state.authority.isBanned(account.signer.pubKey)) return ConcordDirectInviteSendResult.NOT_MEMBER - if (state.authority.isBanned(recipient)) return ConcordDirectInviteSendResult.RECIPIENT_BANNED - val invite = - ConcordActions.directInviteFor( - entry = entry, - authority = state.authority, - recipient = recipient, - creator = account.signer.pubKey, - expiresAtMs = expiresAtMs, - name = state.metadata?.name ?: entry.name, - icon = state.metadata?.icon, - ) + when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) { + is ConcordDirectInviteDraft.Refused -> return draft.reason + is ConcordDirectInviteDraft.Ready -> draft.invite + } val wrap = ConcordActions.buildDirectInvite(account.signer, recipient, invite) val relays = concordDirectInviteDeliveryRelays(recipient) if (relays.isEmpty()) return ConcordDirectInviteSendResult.NOT_DELIVERED diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt index d82eb09e5c..81f93237a7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt @@ -20,6 +20,19 @@ */ package com.vitorpamplona.amethyst.commons.model +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite + +/** A Direct Invite bundle ready to wrap, or why this account may not send one (see `ConcordActions.draftDirectInvite`). */ +sealed interface ConcordDirectInviteDraft { + class Ready( + val invite: CommunityInvite, + ) : ConcordDirectInviteDraft + + class Refused( + val reason: ConcordDirectInviteSendResult, + ) : ConcordDirectInviteDraft +} + /** The outcome of sending a Concord Direct Invite (CORD-05 §6), so the UI can say why it failed. */ enum class ConcordDirectInviteSendResult { /** At least one of the recipient's inbox relays accepted the wrap. */ diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt index 05296629f5..63ff78adc8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState @@ -39,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -125,6 +128,29 @@ class ConcordDirectInviteActionsTest { assertEquals(entry.controlPk, toMember.controlPk) } + @Test + fun draftRefusesBannedPartiesAndBadRecipients() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, community.communityId, member.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp) + val state = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) + val entry = entryOf(community) + + fun refusal(draft: ConcordDirectInviteDraft) = (draft as? ConcordDirectInviteDraft.Refused)?.reason + + assertEquals(ConcordDirectInviteSendResult.RECIPIENT_BANNED, refusal(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, member.pubKey))) + assertEquals(ConcordDirectInviteSendResult.NOT_MEMBER, refusal(ConcordActions.draftDirectInvite(entry, state, member.pubKey, mod.pubKey))) + assertEquals(ConcordDirectInviteSendResult.NOT_MEMBER, refusal(ConcordActions.draftDirectInvite(entry, state.withDissolved(true), owner.pubKey, mod.pubKey))) + assertEquals(ConcordDirectInviteSendResult.INVALID_RECIPIENT, refusal(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, "npub1notahexkey"))) + + // The folded metadata names the preview. + val ready = assertIs(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, mod.pubKey.uppercase())) + assertEquals("Nostrichs", ready.invite.name) + assertEquals(owner.pubKey, ready.invite.creatorNpub) + } + @Test fun theBuiltWrapOpensForTheRecipient() = runTest { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 1003a3ee26..76fbbd37c8 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3617,6 +3617,23 @@ Relays Relays that store this community's encrypted messages. Leave empty to use your own. New Concord Channel + Accept + Could not reach this community. Try again in a moment. + Invite by npub… + New channels for a community you are in: %1$s + Decline + The invite is encrypted to this person and delivered to their inbox relays. It carries only the private channels their roles give them, and it cannot be revoked once sent. + This invite has expired + The invite could not be delivered to this person's inbox relays. + This person is banned from this community. + This community is still loading. Try again in a moment. + You can't invite people to this community. + Invited by %1$s + Name, npub or NIP-05 + Send invite to %1$s + Invite sent. + Invite someone directly + Community invites Edit community Editing message Concord Channels diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt new file mode 100644 index 0000000000..e373ccb0f7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult +import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_accept +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_accept_failed +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_catch_up +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_decline +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_expired +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_explainer +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_banned +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_loading +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_member +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_from +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_hint +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_send +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_sent +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_title +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invites_title +import com.vitorpamplona.amethyst.commons.resources.concord_home_title +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_banned +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_expired +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_invalid +import com.vitorpamplona.amethyst.commons.ui.components.ConcordInvitePreviewRow +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import kotlinx.coroutines.launch + +/** + * "Invite by npub" (CORD-05 §6): pick a person with the app's ordinary user typeahead (cache, relay + * search, NIP-05, a pasted npub/nprofile), then hand them the community's keys as a Direct Invite — + * a giftwrap to their inbox relays carrying only the private channels their roles grant. + */ +@Composable +fun ConcordDirectInviteDialog( + communityId: String, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var query by remember { mutableStateOf("") } + var picked by remember { mutableStateOf(null) } + var sending by remember { mutableStateOf(false) } + val userSuggestions = + remember(accountViewModel) { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + LaunchedEffect(query) { userSuggestions.processCurrentWord(query) } + + AlertDialog( + onDismissRequest = { if (!sending) onDismiss() }, + title = { Text(stringRes(Res.string.concord_direct_invite_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringRes(Res.string.concord_direct_invite_explainer), style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = query, + onValueChange = { + query = it + picked = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !sending, + label = { Text(stringRes(Res.string.concord_direct_invite_hint)) }, + ) + if (picked == null && query.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + picked = user + query = user.toBestDisplayName() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + itemColors = ListItemDefaults.colors(containerColor = Color.Transparent), + showDividers = false, + contentPadding = PaddingValues(0.dp), + ) + } + } + }, + confirmButton = { + val target = picked + TextButton( + enabled = target != null && !sending, + onClick = { + if (target == null) return@TextButton + sending = true + scope.launch { + try { + val result = accountViewModel.account.concord.sendConcordDirectInvite(communityId, target.pubkeyHex) + accountViewModel.toastManager.toast(Res.string.concord_direct_invite_title, sendResultMessage(result)) + if (result == ConcordDirectInviteSendResult.SENT) onDismiss() + } finally { + sending = false + } + } + }, + ) { + Text(stringRes(Res.string.concord_direct_invite_send, picked?.toBestDisplayName() ?: "…")) + } + }, + dismissButton = { + TextButton(enabled = !sending, onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} + +private fun sendResultMessage(result: ConcordDirectInviteSendResult) = + when (result) { + ConcordDirectInviteSendResult.SENT -> Res.string.concord_direct_invite_sent + ConcordDirectInviteSendResult.ROSTER_NOT_LOADED -> Res.string.concord_direct_invite_failed_loading + ConcordDirectInviteSendResult.RECIPIENT_BANNED -> Res.string.concord_direct_invite_failed_banned + ConcordDirectInviteSendResult.NOT_MEMBER, ConcordDirectInviteSendResult.NOT_WRITEABLE -> Res.string.concord_direct_invite_failed_member + ConcordDirectInviteSendResult.INVALID_RECIPIENT, ConcordDirectInviteSendResult.NOT_DELIVERED -> Res.string.concord_direct_invite_failed + } + +/** + * The Direct Invites waiting for this account (CORD-05 §6), as cards with Accept / Decline — shown + * at the top of the Concord communities list. Renders nothing when there are none. + * + * Opening the hub sweeps the inbox relays once; wraps the DM pipeline sees arrive on their own. + * The preview is the bundle's own name and a robohash of the community id — **no** icon fetch, no + * relay connection to the community, no Join happens before the user taps Accept. The sender is + * shown by whatever name the cache already has, without fetching their profile. + */ +@Composable +fun ConcordPendingDirectInvites( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val concord = accountViewModel.account.concord + LaunchedEffect(concord) { runCatching { concord.refreshConcordDirectInvites() } } + + val invites by concord.pendingConcordDirectInvites.collectAsStateWithLifecycle() + if (invites.isEmpty()) return + + Column(modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringRes(Res.string.concord_direct_invites_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + invites.forEach { invite -> + ConcordDirectInviteCard(invite, accountViewModel, nav) + } + } +} + +@Composable +private fun ConcordDirectInviteCard( + invite: ConcordDirectInviteView, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + var working by remember(invite.wrapId) { mutableStateOf(false) } + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val senderName = remember(invite.sender) { LocalCache.checkGetOrCreateUser(invite.sender)?.toBestDisplayName() ?: invite.sender.take(12) } + + val subtitle = + when { + invite.expired -> stringRes(Res.string.concord_direct_invite_expired) + invite.catchUp -> stringRes(Res.string.concord_direct_invite_catch_up, invite.channelNames.joinToString(", ") { "#$it" }) + else -> stringRes(Res.string.concord_direct_invite_from, senderName) + } + + ElevatedCard(Modifier.fillMaxWidth()) { + ConcordInvitePreviewRow( + robotSeed = invite.communityId, + title = invite.name.ifBlank { stringRes(Res.string.concord_home_title) }, + subtitle = subtitle, + accountViewModel = accountViewModel, + autoPlayGif = autoPlayGif, + ) + Row( + Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.End), + ) { + OutlinedButton( + enabled = !working, + onClick = { accountViewModel.account.concord.declineConcordDirectInvite(invite.wrapId) }, + ) { + Text(stringRes(Res.string.concord_direct_invite_decline)) + } + Button( + enabled = !working && !invite.expired, + onClick = { + working = true + scope.launch { + try { + when (val result = accountViewModel.account.concord.acceptConcordDirectInvite(invite.wrapId)) { + is ConcordInviteResult.Joined -> nav.nav(Route.ConcordServer(result.communityId)) + is ConcordInviteResult.Expired -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_expired) + is ConcordInviteResult.Banned -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_banned) + is ConcordInviteResult.InvalidLink -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_invalid) + else -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_direct_invite_accept_failed) + } + } finally { + working = false + } + } + }, + ) { + Text(stringRes(Res.string.concord_direct_invite_accept)) + } + } + } +} diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index b4a9ec4eb9..a4dd16ec67 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -87,7 +87,7 @@ Ranked security > interop > feature inside each group. | F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass | | F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | open → chat-plane batch (quartz only) | | F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | open | -| F6 | 05 §6 | Direct invites: wire format only, no send/receive | open | +| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. Not done: Armada's auto-adopt of staff-sent catch-ups (`judgeCatchUp`) and `channel_cuts` (not modeled here) | | F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) | | F8 | 06 §2, 02 §8 | Walk forward from `seed`; we still keep intermediate roots in a `held_roots` List extension the spec says doesn't belong there | open | | F9 | 04 §6 | Kick (kind 3309) | open |