mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+17
@@ -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
|
||||
|
||||
+16
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
|
||||
|
||||
+1
-1
@@ -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)))
|
||||
}
|
||||
}
|
||||
+108
-2
@@ -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,
|
||||
|
||||
+200
-10
@@ -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),
|
||||
|
||||
+58
@@ -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,
|
||||
}
|
||||
+12
@@ -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)
|
||||
|
||||
+278
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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."
|
||||
}
|
||||
}
|
||||
+184
@@ -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)))
|
||||
}
|
||||
}
|
||||
+263
@@ -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>
|
||||
|
||||
+267
@@ -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 |
|
||||
|
||||
+23
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+133
-21
@@ -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
|
||||
}
|
||||
|
||||
+141
@@ -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()
|
||||
}
|
||||
+172
-16
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+117
@@ -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())))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user