feat(concord): CORD-05 §6 Direct Invites — wire fixes, send, and a headless inbox

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
Claude
2026-09-29 18:11:18 +00:00
parent 88670034af
commit d73348d19b
14 changed files with 1711 additions and 49 deletions
@@ -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)
},
)
@@ -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<HexKey>): 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<NormalizedRelayUrl> {
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,
@@ -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<NormalizedRelayUrl>,
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<List<ConcordDirectInviteView>> =
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<NormalizedRelayUrl> =
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<NormalizedRelayUrl> {
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),
@@ -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,
}
@@ -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)
@@ -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<String> 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<HexKey>()
private val _pending = MutableStateFlow<Map<HexKey, OpenedDirectInvite>>(emptyMap())
/** Parked invites by wrap id, as opened. See [visible] for what a UI should show. */
val pending: StateFlow<Map<HexKey, OpenedDirectInvite>> = _pending.asStateFlow()
private val _declined = MutableStateFlow<Set<HexKey>>(emptySet())
/** Wrap ids the user declined; persisted by the front end so they stay declined across restarts. */
val declined: StateFlow<Set<HexKey>> = _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<HexKey>) {
_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<OpenedDirectInvite>,
joined: List<ConcordCommunityListEntry>,
nowMs: Long = TimeUtils.nowMillis(),
): List<ConcordDirectInviteView> {
val heldById = joined.associateBy { it.id.lowercase() }
val byKey = LinkedHashMap<String, ConcordDirectInviteView>()
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<ConcordDirectInviteView> { it.opened.sentAt }.thenBy { it.wrapId })
}
}
}
@@ -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<Preferences>,
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<String>) {
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."
}
}
@@ -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<ControlEdition>()
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)))
}
}
@@ -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<InviteChannel> = 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<SealEvent>(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<DirectInviteAcceptPlan.CatchUp>(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))
}
}
@@ -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<PrivateChannelKey>) =
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,
)
}
@@ -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<Event>(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<CommunityInvite>(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<CommunityInvite>(rumor.content)?.let { ConcordInviteBundle.bound(it) }
}
): CommunityInvite? = open(wrap, recipientSigner)?.invite
}
@@ -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<String> =
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<PrivateChannelKey>,
authority: AuthorityResolver,
memberHex: HexKey?,
): List<PrivateChannelKey> {
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<PrivateChannelKey>): List<InviteChannel> = 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<HexKey> {
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()
}
@@ -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<String> = listOf("wss://relay.example"),
channels: List<InviteChannel> = 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<Event>(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<Long>()
repeat(6) {
val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = now)
val seal = wrap.unwrapOrNull(recipient)
assertIs<SealEvent>(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))
}
}
@@ -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<InviteChannel>,
) = 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())))
}
}