Merge branch 'worktree-agent-a41d5019d651d0a44' into claude/hopeful-brown-1suxdw

Direct Invites send/receive, inbox, UI and amy verbs (CORD-05 §6).

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:38:07 +00:00
25 changed files with 2433 additions and 109 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)
},
)
@@ -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
@@ -111,6 +112,7 @@ 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.pluralStringRes
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
@@ -182,6 +184,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(
@@ -347,6 +354,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
@@ -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
+5
View File
@@ -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), then publish this account's Invite Registry (`vsk 8`, CORD-05 §5) listing its live link signers — expired links pruned. Output adds `registry_published`, `public` and `live_invite_links`. |
| `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, records it in your Invite List, then republishes your Invite Registry without it. When it was the community's last live link the output carries `privatized: true` / `refound_required: true`: the community is Private now, and `concord refound COMMUNITY --privatize` rotates its keys (CORD-05 §2). |
| `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). |
@@ -990,6 +994,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/
└── …
@@ -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")
@@ -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
|
@@ -173,7 +173,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 {
@@ -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
@@ -117,7 +133,7 @@ object ConcordCommands {
route(
"concord",
tail,
"concord <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|pins|pin|unpin|refound|dissolve|timer>",
"concord <create|list|import|channels|send|read|invite|invites|accept|decline|revoke|join|recover|rekey|roles|role|grant|ban|unban|pins|pin|unpin|refound|dissolve|timer>",
help = USAGE,
routes =
mapOf(
@@ -128,6 +144,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) },
@@ -298,9 +317,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
@@ -474,62 +497,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<NormalizedRelayUrl>,
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<String, Any?> =
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<String>,
): 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<String>,
): 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<String, Any?>) = 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<String>,
): 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}$")
@@ -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<String> = emptyList(),
)
/**
* `~/.amy/<account>/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<StoredInviteInbox>(file.readText()) }.getOrDefault(StoredInviteInbox())
} else {
StoredInviteInbox()
}
fun declined(): Set<String> = 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)))
}
}
@@ -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
@@ -35,14 +37,18 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
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
@@ -52,13 +58,17 @@ 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.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
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
@@ -320,8 +330,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 --------------------------------------------------
@@ -643,6 +660,95 @@ 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,
)
/**
* 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,
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,
@@ -30,12 +30,16 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
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
@@ -73,6 +77,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
@@ -98,8 +103,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
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -638,6 +646,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
@@ -699,15 +739,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)
}
@@ -728,15 +767,166 @@ 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()
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
val invite =
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
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,58 @@
/*
* 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
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. */
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,184 @@
/*
* 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.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
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.assertIs
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 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<ConcordDirectInviteDraft.Ready>(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, mod.pubKey.uppercase()))
assertEquals("Nostrichs", ready.invite.name)
assertEquals(owner.pubKey, ready.invite.creatorNpub)
}
@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))
}
}
@@ -3642,6 +3642,23 @@
<string name="concord_create_relays">Relays</string>
<string name="concord_create_relays_desc">Relays that store this community's encrypted messages. Leave empty to use your own.</string>
<string name="concord_create_title">New Concord Channel</string>
<string name="concord_direct_invite_accept">Accept</string>
<string name="concord_direct_invite_accept_failed">Could not reach this community. Try again in a moment.</string>
<string name="concord_direct_invite_action">Invite by npub…</string>
<string name="concord_direct_invite_catch_up">New channels for a community you are in: %1$s</string>
<string name="concord_direct_invite_decline">Decline</string>
<string name="concord_direct_invite_explainer">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.</string>
<string name="concord_direct_invite_expired">This invite has expired</string>
<string name="concord_direct_invite_failed">The invite could not be delivered to this person's inbox relays.</string>
<string name="concord_direct_invite_failed_banned">This person is banned from this community.</string>
<string name="concord_direct_invite_failed_loading">This community is still loading. Try again in a moment.</string>
<string name="concord_direct_invite_failed_member">You can't invite people to this community.</string>
<string name="concord_direct_invite_from">Invited by %1$s</string>
<string name="concord_direct_invite_hint">Name, npub or NIP-05</string>
<string name="concord_direct_invite_send">Send invite to %1$s</string>
<string name="concord_direct_invite_sent">Invite sent.</string>
<string name="concord_direct_invite_title">Invite someone directly</string>
<string name="concord_direct_invites_title">Community invites</string>
<string name="concord_edit_title">Edit community</string>
<string name="concord_editing_banner">Editing message</string>
<string name="concord_home_title">Concord Channels</string>
@@ -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<User?>(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))
}
}
}
}
@@ -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 | **fixed** — `ConcordInviteRegistry` (builder, strict-array decode, `nextLinks` pruning expired/tombstoned links) + `ConcordCommunityState.inviteRegistries`/`liveInviteLinks`/`isPublic`/`hasForeignLiveLinks`/`banRequiresRefounding`/`retiringWouldPrivatize` (gated on CREATE_INVITE, coordinate bound to author); mint/revoke publish the registry (app + amy); a Private ban Refounds, a Public one is the Banlist alone; retiring the last live link runs a privatizing Refounding (`privatizeConcordCommunity`; amy reports it and adds `refound --privatize`); Public/Private shown in the server view and warned in the revoke dialog. Deviation from Armada, following the spec: a ban Refounds iff the community is Private without the targets' registries (Armada rotates whenever no *foreign* link exists, and only warns on privatizing revokes) |
| 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 |
@@ -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())))
}
}