Merge pull request #4262 from vitorpamplona/claude/hopeful-brown-1suxdw

Implement CORD spec conformance: fragments, pins, rekey, dissolution
This commit is contained in:
Vitor Pamplona
2026-09-29 12:30:16 -04:00
committed by GitHub
108 changed files with 9167 additions and 1100 deletions
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -965,6 +966,11 @@ object LocalPreferences {
LatestEventSlot.EPHEMERAL_LIST to settings.backupEphemeralChatList?.let { OptimizedJsonMapper.toJson(it) },
LatestEventSlot.RELAY_GROUP_LIST to settings.backupRelayGroupList?.let { OptimizedJsonMapper.toJson(it) },
LatestEventSlot.CONCORD_LIST to settings.backupConcordList?.let { OptimizedJsonMapper.toJson(it) },
// Event JSON never holds a raw newline, so one event per line is unambiguous.
LatestEventSlot.CONCORD_LIST_FRAGMENTS to
settings.backupConcordListFragments
.takeIf { it.isNotEmpty() }
?.joinToString("\n") { OptimizedJsonMapper.toJson(it) },
LatestEventSlot.TRUST_PROVIDER_LIST to settings.backupTrustProviderList?.let { OptimizedJsonMapper.toJson(it) },
LatestEventSlot.KEY_PACKAGE_RELAY_LIST to settings.backupKeyPackageRelayList?.let { OptimizedJsonMapper.toJson(it) },
LatestEventSlot.FAVORITE_ALGO_FEEDS_LIST to settings.backupFavoriteAlgoFeedsList?.let { OptimizedJsonMapper.toJson(it) },
@@ -1457,6 +1463,7 @@ object LocalPreferences {
backupEphemeralChatList = latestEphemeralListResolved,
backupRelayGroupList = latestRelayGroupListResolved,
backupConcordList = latestConcordListResolved,
backupConcordListFragments = parseConcordListFragments(stores.latestEvents[LatestEventSlot.CONCORD_LIST_FRAGMENTS]),
backupTrustProviderList = latestTrustProviderListResolved,
backupKeyPackageRelayList = latestKeyPackageRelayListResolved,
backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved,
@@ -1623,6 +1630,16 @@ object LocalPreferences {
}
}
/**
* The saved kind-33302 Community List fragments, one event JSON per line. Kept out of the
* account loader's coroutine body, which already sits at the JVM's method-size limit.
*/
private fun parseConcordListFragments(value: String?): List<ConcordCommunityListFragmentEvent> =
value
?.split('\n')
?.mapNotNull { parseEventOrNull<ConcordCommunityListFragmentEvent>(it) }
.orEmpty()
private inline fun <reified T> parseEventOrNull(value: String?): T? {
if (value.isNullOrEmpty() || value == "null") {
return null
@@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -278,15 +279,15 @@ fun ChatMessageActionSheet(
// Editing my own chat message. Two surfaces publish an edit today, gated by type:
// - Buzz: kind-40002 stream message → a kind-40003 edit.
// - Concord: kind-9 channel message (carries a ConcordChannel gatherer) → a
// kind-1010 edit wrapped on the channel plane.
// - Concord: kind-9 channel message or kind-1111 thread reply (carries a
// ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane.
// Both restrict to my own messages; a note is only ever one of the two, so at
// most one tile shows and both route through the same edit callback.
val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine
val canEditConcord =
onWantsToEditChatMessage != null &&
note.event is ChatEvent &&
(note.event is ChatEvent || note.event is CommentEvent) &&
isMine &&
note.inGatherers?.any { it is ConcordChannel } == true
// Marmot: my own text message in a group -> a kind-1009 edit inside the group. A
@@ -399,12 +399,8 @@ fun ConcordChannelListScreen(
items(channels, key = { it.key }) { entry ->
val def = entry.value.definition
val name = def.name.ifBlank { entry.key }
val icon =
when {
def.voice == true -> MaterialSymbols.Mic
def.private == true -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
}
// Every Channel is callable (CORD-07), so there is no voice-only icon.
val icon = if (def.private) MaterialSymbols.Lock else MaterialSymbols.Tag
val typingAuthors =
remember(typingMap, typingNow, entry.key) {
(typingMap[entry.key] ?: emptyMap())
@@ -417,7 +413,6 @@ fun ConcordChannelListScreen(
channelKey = entry.key,
channelName = name,
icon = icon,
isVoice = def.voice == true,
typingAuthors = typingAuthors,
canManageChannels = canManageChannels,
accountViewModel = accountViewModel,
@@ -445,7 +440,6 @@ private fun ConcordChannelListRow(
channelKey: String,
channelName: String,
icon: MaterialSymbol,
isVoice: Boolean,
typingAuthors: List<HexKey>,
canManageChannels: Boolean,
accountViewModel: AccountViewModel,
@@ -506,7 +500,7 @@ private fun ConcordChannelListRow(
// Line 2: the last-message preview (or a live "typing…"), then the unread-message badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
ConcordChannelPreviewLine(lastNote, typingAuthors, accountViewModel)
}
ConcordUnreadBadge(unread)
}
@@ -535,7 +529,6 @@ private val FAB_CLEARANCE = 96.dp
@Composable
private fun ConcordChannelPreviewLine(
lastNote: Note?,
isVoice: Boolean,
typingAuthors: List<HexKey>,
accountViewModel: AccountViewModel,
) {
@@ -572,8 +565,6 @@ private fun ConcordChannelPreviewLine(
} else if (event != null) {
event.content.take(80)
} else {
// Voice channels never carry chat notes, so "No messages yet" would read oddly — leave blank.
if (isVoice) return
stringRes(Res.string.concord_channel_no_messages)
}
Text(
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only
import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_no_key
import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title
import com.vitorpamplona.amethyst.commons.resources.concord_typing_many
import com.vitorpamplona.amethyst.commons.resources.concord_typing_one
@@ -123,6 +124,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
@@ -264,6 +266,10 @@ fun ConcordChannelScreen(
// CORD-02 §9: an owner-signed tombstone seals the community read-only — the composer is
// gone (canPost() is false) and this replaces it so the seal is explained, not silent.
ConcordDissolvedNotice()
} else if (!channel.keyHeld) {
// CORD-03 §1: a Private Channel is keyed independently; without its key there is no
// plane only its members can read, so nothing may be posted (never to the root plane).
ConcordReadOnlyNotice(Res.string.concord_private_channel_no_key)
}
}
}
@@ -274,9 +280,13 @@ fun ConcordChannelScreen(
* The tombstone seals the community: history stays readable, but no member may post again.
*/
@Composable
private fun ConcordDissolvedNotice() {
private fun ConcordDissolvedNotice() = ConcordReadOnlyNotice(Res.string.concord_dissolved_read_only)
/** A one-line explanation shown where the composer would be when this channel cannot be posted to. */
@Composable
private fun ConcordReadOnlyNotice(message: StringResource) {
Text(
text = stringRes(Res.string.concord_dissolved_read_only),
text = stringRes(message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
@@ -224,12 +224,7 @@ fun ConcordHomeScreen(
communityId = entry.id,
channelKey = ch.key,
channelName = def.name.ifBlank { ch.key },
icon =
when {
def.voice == true -> MaterialSymbols.Mic
def.private == true -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
},
icon = if (def.private) MaterialSymbols.Lock else MaterialSymbols.Tag,
hideIfRead = mode == ChannelExpand.UNREAD,
accountViewModel = accountViewModel,
onClick = { nav.nav(Route.Concord(entry.id, ch.key)) },
@@ -685,12 +685,7 @@ private fun ConcordServerPickerGroup(
channels.forEach { channel ->
val entry = BottomBarEntry.ConcordChannel(community.id, channel.channelIdHex, community.relays)
val def = channel.definition
val icon =
when {
def.voice -> MaterialSymbols.Mic
def.private -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
}
val icon = if (def.private) MaterialSymbols.Lock else MaterialSymbols.Tag
AvailableRow(
leading = { LeadingGlyph(icon) },
label = def.name.ifBlank { channel.channelIdHex.take(8) },
+13 -8
View File
@@ -668,23 +668,28 @@ author** — every 46010 gate names its approver in a `p` tag — and matched to
Encrypted, serverless communities (the CORD specs). Community secrets
persist in `~/.amy/<account>/concord.json`; your joined-community list is
also carried on-relay as an encrypted kind:13302.
also carried on-relay as the encrypted, fragmented kind:33302 Community List
(CORD-02 §8; the retired kind:13302 is still read on import).
| Command | What it does |
|---|---|
| `amy concord create --name NAME [--about T] [--relay wss://a,wss://b]` | Create an encrypted Concord community. `--relay` is canonical; `--relays` is accepted as an alias. |
| `amy concord list` | List joined Concord communities. |
| `amy concord import` | Fetch + decrypt this account's kind:13302 community list (carries heldRoots, CORD-06). |
| `amy concord channels COMMUNITY` | List a community's channels. |
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). |
| `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. |
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. |
| `amy concord import` | Fetch + decrypt this account's Community List — the kind:33302 fragments plus the retired kind:13302 (carries heldRoots, CORD-06). |
| `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). |
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. |
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). |
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator). |
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. |
| `amy concord join URL` | Redeem an invite link and save the community. |
| `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). |
| `amy concord recover [COMMUNITY] [--rejoin]` | Report whether a Refounding left us behind (our joined-through link resolves to a higher epoch). A bundle never moves the base on its own (CORD-06 §2); `--rejoin` explicitly re-accepts the link. Ban-gated, fails closed. |
| `amy concord refound COMMUNITY --remove U[,U…]` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. |
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). |
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`). |
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). |
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. |
| `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. |
| `amy concord dissolve COMMUNITY --yes` | Owner only, irreversible: publish the `eid`-bound dissolution tombstone that seals the community read-only (CORD-02 §9). |
### cordn (MLS over an MCP coordinator)
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -56,7 +57,13 @@ object ConcordChannelCommands {
"banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
"channels" to
state.channels.values.map {
mapOf("id" to it.channelIdHex, "name" to it.definition.name, "voice" to it.definition.voice, "private" to it.definition.private)
mapOf(
"id" to it.channelIdHex,
"name" to it.definition.name,
"private" to it.definition.private,
// False for a Private Channel whose key this account does not hold (CORD-03 §1).
"readable" to ConcordActions.canAccessChannel(ConcordCommands.entryFor(sc), state, it.channelIdHex),
)
},
),
)
@@ -79,12 +86,18 @@ object ConcordChannelCommands {
ctx.prepare()
// CORD-02 §9: a dissolved community is sealed read-only — held keys still open history, but
// nothing new is honored, so refuse to post before we ever build/publish a wrap.
if (foldState(ctx, sc).dissolved) {
val state = foldState(ctx, sc)
if (state.dissolved) {
return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)")
}
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch)
val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now())
// The channel's own plane (CORD-03 §1): root-derived when Public, its held key when
// Private — and a refusal, never the root plane, for a Private Channel we hold no key for.
val plane =
ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId)
?: return Output.error("no_channel_key", "channel '$channelRef' is not folded, or is private and this account holds no key for it (CORD-03 §1)")
val channel = plane.key
val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, plane.epoch, text, TimeUtils.now())
val relays = ConcordCommands.relaysFor(ctx, sc)
// A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated.
ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey))
@@ -126,7 +139,20 @@ object ConcordChannelCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val channel = ConcordActions.publicChannel(rootHex.hexToByteArray(), channelId.hexToByteArray(), epoch)
val state = foldState(ctx, sc)
// A Private Channel is read only on its own key's plane (CORD-03 §1); --root/--epoch pick a
// root-derived plane and so apply to Public Channels only.
val privatePlane =
if (state.channels[channelId]?.definition?.private == true) {
ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId)
?: return Output.error("no_channel_key", "channel '$channelRef' is private and this account holds no key for it (CORD-03 §1)")
} else {
null
}
val channel = privatePlane?.key ?: ConcordActions.publicChannel(rootHex.hexToByteArray(), channelId.hexToByteArray(), epoch)
@Suppress("NAME_SHADOWING")
val epoch = privatePlane?.epoch ?: epoch
val relays = ConcordCommands.relaysFor(ctx, sc)
// The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates.
ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey))
@@ -152,13 +178,23 @@ object ConcordChannelCommands {
): ConcordCommunityState {
val controlPlane = ConcordCommands.controlPlaneKeysFor(sc)
val relays = ConcordCommands.relaysFor(ctx, sc)
// The relays gate the plane's kind-1059 behind NIP-42 as the stream key — register it so
// the drain's AUTH challenge is answered as the control plane, not the account. On a split
// epoch only staff hold that secret (CORD-02 §2); a plain member registers nothing and
// relies on the relay serving the plane unauthenticated.
ctx.registerConcordStreamKeys(relays, listOfNotNull(controlPlane.signer?.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.address)) }, pendingOnAuthRequired = true).map { it.second }
return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner)
// The dissolution tombstone lives at its own id-derived address (CORD-02 §9), drained alongside.
val dissolved = ConcordDissolution.planeKey(sc.communityId)
val dissolvedAddress = dissolved.publicKeyHex
// The relays gate each plane's kind-1059 behind NIP-42 as the stream key — register them so
// the drain's AUTH challenge is answered as the plane, not the account. On a split epoch
// only staff hold the control secret (CORD-02 §2); a plain member relies on the relay
// serving that plane unauthenticated. The dissolved plane's key derives from the public
// community id, so every member can always answer for it.
ctx.registerConcordStreamKeys(relays, listOfNotNull(controlPlane.signer?.secretKey, dissolved.secretKey))
val wraps =
ctx
.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(listOf(controlPlane.address, dissolvedAddress))) }, pendingOnAuthRequired = true)
.map { it.second }
val (graveWraps, controlWraps) = wraps.partition { it.pubKey == dissolvedAddress }
return ConcordActions
.foldCommunity(controlWraps, controlPlane, sc.communityId.hexToByteArray(), sc.owner)
.withDissolved(ConcordDissolution.isDissolved(graveWraps, sc.communityId, sc.owner))
}
/** Resolve a channel handle: the `general` shortcut, a full hex id, or a folded name/id-prefix match. */
@@ -27,12 +27,19 @@ import com.vitorpamplona.amethyst.cli.Output
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.quartz.concord.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
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.ConcordInviteList
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
@@ -40,6 +47,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
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.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -60,7 +68,7 @@ object ConcordCommands {
| concord create --name NAME [--about T] create an encrypted Concord community
| [--relay wss://a,wss://b] (--relays is accepted as an alias)
| concord list list joined Concord communities
| concord import fetch + decrypt this account's kind:13302
| concord import fetch + decrypt this account's kind:33302
| community list (carries heldRoots, CORD-06)
| concord channels COMMUNITY list a community's channels
| concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id)
@@ -73,9 +81,11 @@ object ConcordCommands {
| concord join URL redeem an invite link and save the community
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for:
| open our blob and adopt the new epoch
| concord recover [COMMUNITY] re-resolve the joined-through invite link and
| follow a Refounding we were left out of
| (CORD-06); refuses if that epoch banned us
| concord recover [COMMUNITY] [--rejoin] re-resolve the joined-through invite link and
| report whether a Refounding left us behind;
| --rejoin re-accepts that link (a bundle never
| moves the base on its own, CORD-06 §2);
| refuses if that epoch banned us
| concord roles COMMUNITY list live roles + current banlist (CORD-04)
| concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK)
| concord grant COMMUNITY USER ROLE-ID grant a role to a member
@@ -84,6 +94,8 @@ object ConcordCommands {
| concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the
| control_root) so removed members lose every
| key — the hard removal a ban cannot give
| concord dissolve COMMUNITY --yes CORD-02 §9: owner-only, IRREVERSIBLE tombstone
| that seals the community read-only for everyone
""".trimMargin()
suspend fun dispatch(
@@ -93,7 +105,7 @@ object ConcordCommands {
route(
"concord",
tail,
"concord <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound>",
"concord <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound|dissolve>",
help = USAGE,
routes =
mapOf(
@@ -114,6 +126,7 @@ object ConcordCommands {
"ban" to { rest -> ConcordModCommands.ban(dataDir, rest) },
"unban" to { rest -> ConcordModCommands.unban(dataDir, rest) },
"refound" to { rest -> ConcordModCommands.refound(dataDir, rest) },
"dissolve" to { rest -> ConcordModCommands.dissolve(dataDir, rest) },
),
)
@@ -129,6 +142,9 @@ object ConcordCommands {
val relaysAlias = args.flag("relays")
val relayArg = parseRelays(args.flag("relay") ?: relaysAlias)
args.rejectUnknown()
// CORD-02 §6 caps, which every reader also enforces at fold.
if (!ConcordLimits.nameFits(name)) return Output.error("bad_args", "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes").let { 2 }
if (!ConcordLimits.descriptionFits(about)) return Output.error("bad_args", "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes").let { 2 }
Context.open(dataDir).use { ctx ->
ctx.prepare()
@@ -181,7 +197,8 @@ object ConcordCommands {
}
/**
* Fetch this account's own encrypted kind-13302 Concord community list, decrypt it, and
* Fetch this account's own encrypted Concord Community List (the kind-33302 fragments, plus the
* retired kind-13302 event as a rescue source), decrypt it, and
* upsert every community into the local store — crucially carrying each community's
* `heldRoots` (the prior-epoch access roots Amethyst accumulates across Refoundings, CORD-06).
* With those persisted, `amy concord read --epoch <n>` can re-derive a pre-refounding Chat
@@ -194,18 +211,20 @@ object ConcordCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val relays = (ctx.outboxRelays() + ctx.bootstrapRelays())
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(ctx.signer.pubKey))
// The fragmented List (33302, CORD-02 §8) plus the retired single event (13302), which is
// still read as a rescue source for memberships only it carries.
val filter = Filter(kinds = listOf(ConcordCommunityListFragmentEvent.KIND, ConcordCommunityListEvent.KIND), authors = listOf(ctx.signer.pubKey))
val events = ctx.drain(relays.associateWith { listOf(filter) }).map { it.second }
val newest =
events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
?: return Output.error("not_found", "no kind-13302 Concord list published by this account").let { 1 }
val entries =
try {
newest.decrypt(ctx.signer)
} catch (e: Exception) {
return Output.error("decrypt_failed", "could not decrypt kind-13302: ${e.message}").let { 1 }
}
val set = ConcordListFragmentSet.resolve(events.filterIsInstance<ConcordCommunityListFragmentEvent>(), ctx.signer)
val legacy = events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
if (set.isEmpty && legacy == null) {
return Output.error("not_found", "no Concord Community List (kind 33302 or 13302) published by this account").let { 1 }
}
val legacyPlaintext = legacy?.decryptPlaintext(ctx.signer)
if (set.held.isEmpty() && legacyPlaintext == null) {
return Output.error("decrypt_failed", "could not decrypt this account's Concord Community List").let { 1 }
}
val entries = ConcordCommunityList.decodeDocument(ConcordCommunityList.readWithLegacy(set, legacyPlaintext)).entries
val store = ConcordStore(dataDir.concordFile)
val existing = store.load().associateBy { it.communityId }
val imported =
@@ -239,6 +258,7 @@ object ConcordCommands {
// Survives every merge: losing the anchor makes the NEXT exclusion
// unrecoverable, so a list entry without one must not clear ours.
inviteRef = e.inviteRef ?: prior?.inviteRef ?: "",
privateChannels = e.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
),
)
mapOf(
@@ -269,7 +289,9 @@ object ConcordCommands {
ctx.prepare()
// The joiner cannot derive the Control Plane address, so the invite carries it
// (CORD-05 §1); omitted for a legacy community, which has none to carry.
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null })
// The creator rides in the bundle so joiners echo it in their Guestbook Join (CORD-05 §1).
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }, creator = ctx.signer.pubKey)
// At most 3 bootstrap relays ride in the fragment (CORD-05 §3); the codec truncates.
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
// Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose
// `signer_sk` was never stored can never be refreshed, so the next Refounding orphans
@@ -417,7 +439,7 @@ object ConcordCommands {
// relay that kept the old version hand out a link its creator revoked — and it cannot
// tell the user which of "revoked", "expired" or "gone" they are looking at.
val bundle =
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) {
is InviteBundleStatus.Live -> status.invite
is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined")
InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator")
@@ -449,11 +471,11 @@ object ConcordCommands {
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.owner).isBanned(ctx.signer.pubKey)) {
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)")
}
ConcordStore(dataDir.concordFile).upsert(
val stored =
StoredCommunity(
name = bundle.name,
communityId = bundle.communityId,
@@ -468,15 +490,54 @@ object ConcordCommands {
// 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) ?: "",
),
)
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays))
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, bundle.creatorNpub, bundle.label)
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced))
return 0
}
}
// ---- shared helpers (used by ConcordChannelCommands too) ------------------
private val HEX64 = Regex("^[0-9a-f]{64}$")
/** Publishes a Guestbook Join for [sc] at its current epoch, echoing invite attribution; true if a relay took it. */
suspend fun announceGuestbookJoin(
ctx: Context,
sc: StoredCommunity,
inviteCreator: String?,
inviteLabel: String?,
): Boolean {
val creator = inviteCreator?.lowercase()?.takeIf { HEX64.matches(it) }
val label = inviteLabel?.takeIf { creator != null && it.isNotBlank() }
val guestbook = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
val wrap = ConcordActions.buildGuestbookJoin(ctx.signer, guestbook, TimeUtils.now(), creator, label)
val relays = relaysFor(ctx, sc)
ctx.registerConcordStreamKeys(relays, listOf(guestbook.secretKey))
return ctx.publish(wrap, relays).values.any { it.accepted }
}
/**
* Whether [sc] carries a valid owner tombstone (CORD-02 §9). Death wins every race: no rekey,
* recovery or Refounding moves a dissolved community forward.
*/
suspend fun isDissolved(
ctx: Context,
sc: StoredCommunity,
): Boolean {
val grave = ConcordDissolution.planeKey(sc.communityId)
val relays = relaysFor(ctx, sc)
ctx.registerConcordStreamKeys(relays, listOf(grave.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(grave.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
return ConcordDissolution.isDissolved(wraps, sc.communityId, sc.owner)
}
fun parseRelays(csv: String?): List<String> = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
fun normalize(urls: List<String>): Set<NormalizedRelayUrl> = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
@@ -519,7 +580,7 @@ object ConcordCommands {
editions: List<ControlEdition>,
): Pair<StoredCommunity, ControlPlaneKeys>? {
val entry = entryFor(sc)
val authority = AuthorityResolver.resolve(editions, sc.owner)
val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null
val updated = sc.copy(controlRoot = delivered)
ConcordStore(dataDir.concordFile).upsert(updated)
@@ -537,6 +598,7 @@ object ConcordCommands {
controlPk = sc.controlPk.ifBlank { null },
controlRoot = sc.controlRoot.ifBlank { null },
heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }, it.controlRoot.ifBlank { null }) },
privateChannels = sc.privateChannels.map { PrivateChannelKey(it.channelId, it.key, it.epoch, it.name) },
relays = sc.relays,
name = sc.name,
inviteRef = sc.inviteRef.ifBlank { null },
@@ -555,26 +617,25 @@ object ConcordCommands {
relays = entry.relays,
name = entry.name.ifBlank { sc.name },
inviteRef = entry.inviteRef ?: sc.inviteRef,
privateChannels = entry.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
)
/**
* `concord recover [COMMUNITY]` — the stranded-recovery receive path (CORD-05/06 A2).
* `concord recover [COMMUNITY] [--rejoin]` — stranded detection (CORD-05/06).
*
* A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a
* member simply left out of the rekey receives nothing and sits on the dead epoch forever while
* everyone else moves on. There is no message to miss, which is why the rekey drain cannot help.
* The way back is the invite link the membership was joined through: the community keeps
* re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly
* higher** epoch than ours proves we were left behind — and carries the new root.
* member simply left out of the rekey receives nothing and sits on the dead epoch while
* everyone else moves on. The invite link the membership was joined through is re-minted at
* the current epoch, so a live bundle there at a **strictly higher** epoch says we were left
* behind.
*
* Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and
* scriptable rather than a background loop.
* A bundle is NOT proof of continuity (nothing binds `community_root` to `community_id`), so
* this verb only reports by default — a link creator must not be able to relocate everyone who
* joined through their link (CORD-06 §2: the base moves only by a verifiable rekey).
* `--rejoin` is the user explicitly re-accepting that link: the same trust decision as `join`.
*
* The ban gate is the point of care. A removed member keeps the link's unlock token forever, so
* without it this walks them straight back into the epoch they were rotated out of. It reads the
* banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails
* closed**: a community whose plane will not fold yields no verdict and is skipped, never
* recovered.
* Ban-gated at the epoch we are leaving and **fails closed** (no fold, no verdict, no rejoin);
* a dissolved community is never moved (CORD-02 §9).
*/
private suspend fun recover(
dataDir: DataDir,
@@ -582,6 +643,7 @@ object ConcordCommands {
): Int {
val args = Args(rest)
val handle = args.positionalOrNull(0)
val rejoin = args.bool("rejoin")
args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile)
val targets =
@@ -595,54 +657,68 @@ object ConcordCommands {
ctx.prepare()
val results = mutableListOf<Map<String, Any?>>()
for (sc in targets) {
fun skip(reason: String) = mapOf("community_id" to sc.communityId, "name" to sc.name, "stranded" to false, "recovered" to false, "reason" to reason)
val inviteRef = sc.inviteRef.ifBlank { null }
if (inviteRef == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref")
results += skip("no_invite_ref")
continue
}
val parsed = ConcordActions.parseInviteLink(inviteRef)
if (parsed == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref")
results += skip("bad_invite_ref")
continue
}
if (isDissolved(ctx, sc)) {
results += skip("dissolved")
continue
}
val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() }
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
// Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
// Only a LIVE bundle counts: an expired or revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
if (bundle == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle")
results += skip("no_live_bundle")
continue
}
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict,
// no recovery — the gate fails closed rather than assuming "not banned".
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict.
val cp = controlPlaneKeysFor(sc)
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
val editions = ConcordActions.controlEditions(controlWraps, cp)
if (editions.isEmpty()) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded")
results += skip("control_plane_not_folded")
continue
}
val bannedHere = AuthorityResolver.resolve(editions, sc.owner).isBanned(ctx.signer.pubKey)
val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere)
if (merged == null) {
val bannedHere = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner).isBanned(ctx.signer.pubKey)
val entry = entryFor(sc)
if (!ConcordActions.isStranded(entry, bundle, bannedHere)) {
results += skip(if (bannedHere) "banned" else "already_current") + ("root_epoch" to sc.rootEpoch)
continue
}
if (!rejoin) {
results +=
mapOf(
"community_id" to sc.communityId,
"name" to sc.name,
"stranded" to true,
"recovered" to false,
"reason" to if (bannedHere) "banned" else "already_current",
"reason" to "rejoin_required",
"root_epoch" to sc.rootEpoch,
"link_epoch" to bundle.rootEpoch,
)
continue
}
store.upsert(storedFrom(sc, merged))
val merged = ConcordActions.rejoinStranded(entry, bundle, bannedHere) ?: continue
val stored = storedFrom(sc, merged)
store.upsert(stored)
announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label)
results +=
mapOf(
"community_id" to sc.communityId,
"name" to sc.name,
"stranded" to true,
"recovered" to true,
"from_epoch" to sc.rootEpoch,
"root_epoch" to merged.rootEpoch,
@@ -662,8 +738,11 @@ object ConcordCommands {
* strands every other CLI member even though their blob is sitting on the relay.
*
* The rotator is authorized against the roster of the epoch being **left** — `hasPermission`,
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed:
* a plane that will not fold yields no verdict and the community is skipped.
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us — and must cite the
* Grant it acts under (`vac`, CORD-06 §3) at a version that fold has synced; the owner cites
* nothing. Racing honored rotations converge on the lowest new root. Fails closed: a plane that
* will not fold yields no verdict and the community is skipped. A dissolved community is never
* moved (CORD-02 §9).
*/
private suspend fun rekey(
dataDir: DataDir,
@@ -679,21 +758,12 @@ object ConcordCommands {
ctx.prepare()
val results = mutableListOf<Map<String, Any?>>()
for (sc in targets) {
if (isDissolved(ctx, sc)) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "dissolved", "root_epoch" to sc.rootEpoch)
continue
}
val relays = relaysFor(ctx, sc)
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
val received =
ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
if (received == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch)
continue
}
if (received.newEpoch <= sc.rootEpoch) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
continue
}
// Authorize the rotator against the epoch we are LEAVING — the last plane we can fold.
// Authorize against the epoch we are LEAVING — the last plane we can fold.
val cp = controlPlaneKeysFor(sc)
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
@@ -702,12 +772,28 @@ object ConcordCommands {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded")
continue
}
if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator)
val entry = entryFor(sc)
val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
val honored = { r: ReceivedRefounding -> ConcordReceive.isHonoredRotation(entry, editions, authority, r) }
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
val received = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch, accept = honored)
if (received == null) {
// Distinguish "no blob at all" from "only rotations we refuse to honor".
val any = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
val reason = if (any == null) "no_blob_for_us" else "unauthorized_rotator"
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to reason, "root_epoch" to sc.rootEpoch) + (if (any != null) mapOf("rotator" to any.rotator) else emptyMap())
continue
}
val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
store.upsert(storedFrom(sc, adopted))
if (received.newEpoch <= sc.rootEpoch) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
continue
}
val adopted = ConcordReceive.withAdoptedRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
// A rotation we adopted supersedes any Refounding of ours still reserved.
store.upsert(storedFrom(sc, adopted).copy(pendingRefounding = null))
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator)
}
Output.emit(mapOf("communities" to results))
@@ -26,14 +26,20 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.cli.stores.StoredPendingRefounding
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
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.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -55,7 +61,7 @@ object ConcordModCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (_, editions) = load(ctx, sc, dataDir)
val state = ConcordCommunityState.fold(editions, sc.owner)
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
Output.emit(
mapOf(
"roles" to
@@ -91,6 +97,9 @@ object ConcordModCommands {
val position = args.positional(2, "position").toLongOrNull() ?: return Output.error("bad_args", "position must be an integer").let { 2 }
val permBits = args.positional.drop(3).mapNotNull { permByName(it) }
args.rejectUnknown()
// The CORD-04 §2/§3 rules every reader enforces at fold, refused here as bad input.
if (!ConcordLimits.nameFits(name)) return Output.error("bad_args", "role name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes").let { 2 }
if (position < 1) return Output.error("bad_args", "position must be 1 or greater (position 0 is the owner's)").let { 2 }
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
@@ -99,7 +108,7 @@ object ConcordModCommands {
writeGuard(cp)?.let { return it }
val roleId = RandomInstance.bytes(32)
val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire())
val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now(), owner = sc.owner)
val wrap = ConcordModeration.defineRole(ctx.signer, cp, sc.communityId.hexToByteArray(), roleId, role, editions, TimeUtils.now(), owner = sc.owner)
val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc))
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
Output.emit(mapOf("role_id" to roleId.toHexKey(), "name" to name, "position" to position) + RawEventSupport.ackFields(ack))
@@ -157,6 +166,38 @@ object ConcordModCommands {
rest: Array<String>,
): Int = banOrUnban(dataDir, rest, ban = true)
/**
* Dissolves a community (CORD-02 §9): `dissolve <community> --yes`. Publishes the owner-signed,
* `eid`-bound tombstone at the community's dissolved address. Owner-only and irreversible, hence
* the mandatory `--yes`.
*/
suspend fun dissolve(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val confirmed = args.bool("yes")
args.rejectUnknown()
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
if (!confirmed) return Output.error("confirm", "dissolving '$handle' is irreversible; re-run with --yes")
Context.open(dataDir).use { ctx ->
ctx.prepare()
if (!sc.owner.equals(ctx.signer.pubKey, ignoreCase = true)) {
return Output.error("not_owner", "only the owner can dissolve '$handle' (CORD-02 §9)")
}
val wrap = ConcordDissolution.build(ctx.signer, sc.communityId)
val relays = ConcordCommands.relaysFor(ctx, sc)
// A relay that gates the plane on NIP-42 wants AUTH as the stream key the wrap is signed by.
ctx.registerConcordStreamKeys(relays, listOf(ConcordDissolution.planeKey(sc.communityId).secretKey))
val ack = ctx.publish(wrap, relays)
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
Output.emit(mapOf("community" to sc.communityId, "dissolved" to true) + RawEventSupport.ackFields(ack))
return 0
}
}
/** Unbans a member: `unban <community> <user>`. */
suspend fun unban(
dataDir: DataDir,
@@ -249,9 +290,14 @@ object ConcordModCommands {
.toSet()
if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user")
// Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored.
if (ConcordCommands.isDissolved(ctx, sc)) {
return Output.error("dissolved", "community '$handle' has been dissolved; a Refounding cannot cross the tombstone (CORD-02 §9)")
}
val loaded = load(ctx, sc, dataDir)
val (cp, editions) = loaded
val state = ConcordCommunityState.fold(editions, sc.owner)
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
val authority = state.authority
val me = ctx.signer.pubKey
@@ -269,8 +315,22 @@ object ConcordModCommands {
// split epoch it takes the current control_root (CORD-02 §2).
writeGuard(cp)?.let { return it }
// The rotation cites the Grant it acts under (CORD-06 §3 "Authority"), or no receiver
// honors it; the owner cites nothing.
val citation = ConcordReceive.rotationCitation(ConcordCommands.entryFor(loaded.community), editions, me)
if (citation == null && !authority.isOwner(me)) {
return Output.error("forbidden", "no Grant of yours in this community's fold to cite; receivers would drop the rotation (CORD-06 §3)")
}
val relays = ConcordCommands.relaysFor(ctx, sc)
// 0. Acquire the WHOLE plane before the first publish (CORD-06 §3: a Refounder that cannot
// fold every Control event must abort). Paged to completion, not a single capped REQ.
val swept = ctx.drainAllPages(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }).map { it.second }
if (swept.isEmpty()) {
return Output.error("control_plane_unreadable", "could not page this community's Control Plane; refusing to compact a partial plane (CORD-06 §3)")
}
// 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the
// new epoch — carries the ban. Each edition chains onto the updated banlist head.
var chain = editions
@@ -297,45 +357,67 @@ object ConcordModCommands {
// 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff
// get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one).
val newRoot = RandomInstance.bytes(32)
val newControlRoot = RandomInstance.bytes(32)
// Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just
// The keys are RESERVED and persisted before anything is published, so a retried
// `refound` re-delivers the same root instead of minting a sibling (CORD-06 §3).
val priorRoot = sc.root.hexToByteArray()
val keys =
ConcordRefounding.reserveKeys(
loaded.community.pendingRefounding?.let { PendingRefounding(sc.communityId, it.rootEpoch, it.prevCommit, it.newRoot.hexToByteArray(), it.newControlRoot.hexToByteArray()) },
sc.communityId,
sc.rootEpoch,
priorRoot,
)
val reserved = loaded.community.copy(pendingRefounding = StoredPendingRefounding(keys.rootEpoch, keys.prevCommit, keys.newRoot.toHexKey(), keys.newControlRoot.toHexKey()))
ConcordStore(dataDir.concordFile).upsert(reserved)
// Compact from what we KNOW the plane holds: the paged sweep plus the bans we just
// published. Re-draining alone would race the relay's indexing, and a relay that has not
// yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch
// whose roster never banned the member we are removing.
val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
val controlWraps = (drained + banWraps).distinctBy { it.id }
val controlWraps = (swept + banWraps).distinctBy { it.id }
val build =
ConcordActions.buildRefounding(
rotatorSigner = ctx.signer,
communityId = sc.communityId,
priorRoot = sc.root.hexToByteArray(),
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = sc.rootEpoch,
priorControlWraps = controlWraps,
priorControlKeys = cp,
recipientsXOnly = recipients,
staffXOnly = authority.staffMembers(),
createdAt = TimeUtils.now(),
ownerPubKey = sc.owner,
)
try {
ConcordActions.buildRefounding(
rotatorSigner = ctx.signer,
communityId = sc.communityId,
priorRoot = priorRoot,
newRoot = keys.newRoot,
newControlRoot = keys.newControlRoot,
rootEpoch = sc.rootEpoch,
priorControlWraps = controlWraps,
priorControlKeys = cp,
recipientsXOnly = recipients,
staffXOnly = authority.staffMembers(),
createdAt = TimeUtils.now(),
ownerPubKey = sc.owner,
authority = citation,
// Every head our own fold honors (bans included) must survive the compaction.
mustCarry = ConcordRefounding.headVersions(chain, sc.communityId.hexToByteArray(), sc.owner),
)
} catch (e: IncompleteControlPlaneException) {
return Output.error("control_plane_incomplete", "${e.missing.size} Control Plane head(s) could not be carried into the new epoch; aborted before publishing the rotation (CORD-06 §3)")
}
// 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it).
build.controlWraps.forEach { ctx.publish(it, relays) }
build.rekeyWraps.forEach { ctx.publish(it, relays) }
// 4. The root roll FIRST, every chunk confirmed; the compacted plane only after it
// (CORD-06 §3). A chunk no relay took aborts with nothing adopted — the reserved keys
// make re-running this command re-deliver the same root.
for (wrap in build.rekeyWraps) {
if (ctx.publish(wrap, relays).values.none { it.accepted }) {
return Output.error("rekey_not_published", "a rekey chunk was not accepted by any relay; re-run to resume with the same keys")
}
}
val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } }
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
// epoch we are leaving for the anti-rollback floor.
// epoch we are leaving for the anti-rollback floor — and drop the reservation.
val adopted =
ConcordReceive.withAdoptedRoot(
ConcordCommands.entryFor(loaded.community),
newRoot,
keys.newRoot,
build.newEpoch,
build.newControlKeys.address.hexToByteArray(),
newControlRoot,
keys.newControlRoot,
)
val stored = ConcordCommands.storedFrom(loaded.community, adopted)
val stored = ConcordCommands.storedFrom(loaded.community, adopted).copy(pendingRefounding = null)
ConcordStore(dataDir.concordFile).upsert(stored)
// 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new
@@ -363,7 +445,7 @@ object ConcordModCommands {
// grants, icon, label — survive the rotation, and so a coordinate whose newest
// event is a revocation tombstone is left revoked instead of being re-opened.
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second }
val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching
val live = ConcordActions.classifyInvite(wraps, link.signerPubKeyHex(), token) as? InviteBundleStatus.Live ?: return@runCatching
val moved =
live.invite.copy(
communityRoot = stored.root,
@@ -385,6 +467,7 @@ object ConcordModCommands {
"recipients" to recipients.size,
"control_wraps" to build.controlWraps.size,
"rekey_wraps" to build.rekeyWraps.size,
"compaction_failures" to compactionFailures,
"invites_refreshed" to refreshed,
),
)
@@ -453,12 +536,15 @@ object ConcordModCommands {
): Set<String> {
val out = HashSet<String>()
val relays = ConcordCommands.relaysFor(ctx, sc)
for ((channelIdHex, _) in state.channels) {
val entry = ConcordCommands.entryFor(sc)
for (channelIdHex in state.channels.keys) {
// A Private Channel we hold no key for has no plane we may read (CORD-03 §1).
val plane = ConcordActions.currentChannelPlane(entry, state, channelIdHex) ?: continue
runCatching {
val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch)
val key = plane.key
ctx.registerConcordStreamKeys(relays, listOf(key.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() }
ConcordActions.channelMessages(wraps, key, channelIdHex, plane.epoch).mapTo(out) { it.author.lowercase() }
}
}
return out
@@ -513,6 +599,9 @@ object ConcordModCommands {
"BAN" -> ConcordPermissions.BAN
"MANAGE_MESSAGES" -> ConcordPermissions.MANAGE_MESSAGES
"CREATE_INVITE" -> ConcordPermissions.CREATE_INVITE
"VIEW_AUDIT_LOG" -> ConcordPermissions.VIEW_AUDIT_LOG
"MENTION_EVERYONE" -> ConcordPermissions.MENTION_EVERYONE
"PIN_MESSAGES" -> ConcordPermissions.PIN_MESSAGES
else -> null
}
}
@@ -53,6 +53,29 @@ data class StoredCommunity(
// rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct
// invite or a community joined before amy stored it.
val inviteRef: String = "",
// Private Channel keys this account holds (CORD-03 §1), from the Community List or an invite.
// A private channel is read and written ONLY on the plane its own key derives; without one it
// is unreadable and `send` refuses rather than fall back to the root-derived plane.
val privateChannels: List<StoredPrivateChannel> = emptyList(),
// Keys reserved for a Refounding this account started but has not yet adopted (CORD-06 §3): a
// retried `refound` must re-deliver the SAME root, never mint a sibling that splits the members.
val pendingRefounding: StoredPendingRefounding? = null,
)
/** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */
data class StoredPrivateChannel(
val channelId: String = "",
val key: String = "",
val epoch: Long = 0,
val name: String = "",
)
/** A Refounding's reserved keys, mirroring quartz `PendingRefounding`. */
data class StoredPendingRefounding(
val rootEpoch: Long = 0,
val prevCommit: String = "",
val newRoot: String = "",
val newControlRoot: String = "",
)
/** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
@@ -29,8 +29,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
@@ -68,15 +70,19 @@ data class ConcordChatMessage(
)
/**
* One channel's Chat Plane at a prior epoch: the epoch-invariant [channelIdHex], the [epoch] the
* wraps are bound to (for `isBoundTo` validation), and the derived [key] to decrypt them.
* One channel's Chat Plane: the epoch-invariant [channelIdHex], the [epoch] its rumors are bound to
* (for `isBoundTo` validation — the root epoch for a Public Channel, the channel's own epoch for a
* Private one, CORD-03 §1), and the derived [key] its wraps are addressed by and decrypt under.
*/
data class HistoricalChannelPlane(
data class ChannelPlane(
val channelIdHex: HexKey,
val epoch: Long,
val key: GroupKey,
)
/** A [ChannelPlane] at a prior epoch (pre-Refounding history). */
typealias HistoricalChannelPlane = ChannelPlane
/**
* Concord community verbs — pure builders, plane-key derivation, relay-filter
* assembly, and event folding usable from amy CLI, the Android app, and any other
@@ -142,6 +148,92 @@ object ConcordActions {
rootEpoch: Long,
): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch)
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
/**
* The independent key this account holds for Private Channel [channelIdHex] (delivered on grant
* and carried in the Community List's `privateChannels`, CORD-03 §1 / CORD-02 §8), or null when
* it holds none. A keyless entry (a writer listing a public channel as `{id, epoch}`) is not a key.
*/
fun heldPrivateChannelKey(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
): PrivateChannelKey? = entry.privateChannels.firstOrNull { it.channelId.equals(channelIdHex, ignoreCase = true) && HEX64.matches(it.key) }
/**
* The Chat Plane a channel is **written** on, or null when this account cannot write it
* (CORD-03 §1):
* - Public: `group_key("concord/channel", community_root, channel_id, root_epoch)`, bound to
* the root epoch;
* - Private: `group_key("concord/channel", channel_key, channel_id, channel_epoch)` from the
* held key, bound to the **channel** epoch — and null when no key is held. A Private Channel
* must never fall back to the root-derived plane: every member decrypts that one, so a post
* there would be public to the whole community under a Lock icon.
*/
fun currentChannelPlane(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
isPrivate: Boolean,
): ChannelPlane? {
val channelId = channelIdHex.hexToByteArray()
if (isPrivate) {
val held = heldPrivateChannelKey(entry, channelIdHex) ?: return null
return ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch))
}
return ChannelPlane(channelIdHex, entry.rootEpoch, publicChannel(entry.root.hexToByteArray(), channelId, entry.rootEpoch))
}
/**
* [currentChannelPlane] for a channel of the folded [state], or null when the channel is not in
* the fold (unknown or deleted) or is Private with no held key.
*/
fun currentChannelPlane(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
channelIdHex: HexKey,
): ChannelPlane? {
val def = state.channels[channelIdHex]?.definition ?: return null
return currentChannelPlane(entry, channelIdHex, def.private)
}
/**
* The older Chat Planes of a channel this account can still read, beside [currentChannelPlane]:
* - Public: its plane under every held prior root ([historicalChannelPlanes]), plus the
* private-era plane when a channel key is held (a channel that was Private before);
* - Private: none. Only the channel-key planes are its own; the root-derived plane is readable
* by every member, so showing it would present public content as private (Armada
* `channelsView`). With no priors kept per channel key, that leaves nothing.
*/
fun historicalChannelPlanes(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
isPrivate: Boolean,
): List<ChannelPlane> {
if (isPrivate) return emptyList()
val rootEras = historicalChannelPlanes(entry.heldRoots, listOf(channelIdHex))
val privateEra =
heldPrivateChannelKey(entry, channelIdHex)?.let { held ->
ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelIdHex.hexToByteArray(), held.epoch))
}
return rootEras + listOfNotNull(privateEra)
}
/**
* The Private Channel keys an [invite] delivers (CORD-05 §1), as Community List entries. A
* keyless listing (a public channel written as `{id, epoch}`) delivers nothing.
*/
fun privateChannelKeysOf(invite: CommunityInvite): List<PrivateChannelKey> =
invite.channels
.filter { HEX64.matches(it.id) && HEX64.matches(it.key) }
.map { PrivateChannelKey(it.id.lowercase(), it.key.lowercase(), it.epoch, it.name) }
/** True when this account can read and write [channelIdHex] as folded in [state]. */
fun canAccessChannel(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
channelIdHex: HexKey,
): Boolean = currentChannelPlane(entry, state, channelIdHex) != null
/**
* How many prior epochs of channel history to backfill. A CORD-06 Refounding rotates the
* `community_root` and bumps the epoch, so pre-refounding messages live under a *different*
@@ -190,6 +282,19 @@ object ConcordActions {
rootEpoch: Long,
): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1)
/**
* The base-rekey address the rotation INTO [entry]'s current epoch rode on, derived from the
* prior epoch's (canonical) held root — or null when we hold none (a fresh joiner at this
* epoch). Watching it after adopting is what lets the same-epoch race heal (CORD-06 §3): a
* racing sibling rotation sealed under the same prior root arrives here, and a strictly lower
* one replaces the root we adopted.
*/
fun siblingBaseRekeyPlane(entry: ConcordCommunityListEntry): GroupKey? {
if (entry.rootEpoch <= 0) return null
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).firstOrNull { it.epoch == entry.rootEpoch - 1 } ?: return null
return ConcordKeyDerivation.baseRekeyAddress(prior.key.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch)
}
// ---- relay filters (what to REQ) -----------------------------------------
/** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */
@@ -226,21 +331,26 @@ object ConcordActions {
icon: ImagePointer? = null,
): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays, icon)
/** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */
/**
* Opens the control-plane [wraps] into their [ControlEdition]s, dropping any that don't open or
* parse — including an edition under an encrypted seal, which the Control Plane never carries
* (CORD-02 §5: its seals MUST be plaintext kind 20014).
*/
fun controlEditions(
wraps: List<Event>,
controlPlane: ControlPlaneKeys,
): List<ControlEdition> =
wraps.mapNotNull { wrap ->
ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) }
ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromOpened(it) }
}
/** Opens the control-plane [wraps] and folds them into the live community state. */
fun foldCommunity(
wraps: List<Event>,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
ownerPubKey: HexKey,
): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), ownerPubKey)
): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), communityId, ownerPubKey)
// ---- channel chat ---------------------------------------------------------
@@ -344,6 +454,24 @@ object ConcordActions {
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/**
* Builds an encrypted-seal **delete** wrap (kind-5 [ChannelChat.delete] of the author's own
* [targets]) on the [channel] plane — the in-stream delete of CORD-01. Never publish a Concord
* delete any other way: a signed kind 5 or a NIP-17 delete would carry the rumor ids outside
* the community.
*/
suspend fun buildChannelDelete(
authorSigner: NostrSigner,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
targets: List<Event>,
createdAt: Long,
): Event {
val rumor = ChannelChat.delete(authorSigner.pubKey, channelId, epoch, targets, createdAt)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */
suspend fun buildChannelReaction(
authorSigner: NostrSigner,
@@ -376,8 +504,9 @@ object ConcordActions {
}
/**
* Opens the channel [wraps], keeps the kind-9 messages correctly bound to
* [channelId]/[epoch], and returns them oldest-first (createdAt, then id).
* Opens the channel [wraps], keeps the kind-9 messages that pass the Chat ingest gate
* ([channelRumors]), and returns them oldest-first by their CORD-02 §4 send time
* (`created_at * 1000 + ms`), then id.
*/
fun channelMessages(
wraps: List<Event>,
@@ -385,11 +514,11 @@ object ConcordActions {
channelId: HexKey,
epoch: Long,
): List<ConcordChatMessage> =
wraps
.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor }
.filter { it.kind == ChatEvent.KIND && ChannelChat.isBoundTo(it, channelId, epoch) }
channelRumors(wraps, channel, channelId, epoch)
.filter { it.kind == ChatEvent.KIND }
.distinctBy { it.id }
.sortedWith(compareBy({ ChannelChat.orderingMs(it) }, { it.id }))
.map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) }
.sortedWith(compareBy({ it.createdAt }, { it.id }))
/**
* Opens the channel [wraps] and returns every validated inner rumor bound to
@@ -404,10 +533,20 @@ object ConcordActions {
channel: GroupKey,
channelId: HexKey,
epoch: Long,
): List<Event> =
wraps
.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor }
.filter { ChannelChat.isBoundTo(it, channelId, epoch) }
): List<Event> = wraps.mapNotNull { wrap -> openChannelRumor(wrap, channel, channelId, epoch) }
/**
* Opens one channel [wrap] and returns its rumor only when it passes the Chat ingest gate
* ([ChannelChat.acceptOpened]): an encrypted 20013 seal, a Chat kind (never another plane's
* kind), a strict `channel`/`epoch` binding, and a well-formed `ms`. Anything else is dropped
* here, before it can reach the store.
*/
fun openChannelRumor(
wrap: Event,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch) }
// ---- invites --------------------------------------------------------------
@@ -426,6 +565,8 @@ object ConcordActions {
name: String,
relays: List<String>,
controlPk: HexKey? = null,
creator: HexKey? = null,
label: String? = null,
): CommunityInvite =
CommunityInvite(
communityId = communityIdHex,
@@ -436,6 +577,10 @@ object ConcordActions {
controlPk = controlPk,
relays = relays,
name = name,
// Optional attribution (CORD-05 §1): echoed in the joiner's Guestbook Join, so link
// holders can count per-link usage. Inside the token-encrypted bundle only.
creatorNpub = creator,
label = label,
)
/** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */
@@ -495,15 +640,25 @@ object ConcordActions {
fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url)
/**
* Merges a stranded membership forward onto a higher-epoch [bundle] resolved at
* its own stored invite link, or null when there is nothing to recover. See
* [ConcordStrandedRecovery].
* True when a live [bundle] resolved at [entry]'s own stored invite link says a Refounding
* left us behind (a higher epoch, and we are not banned). Detection only: a bundle may never
* move a held community's base on its own (CORD-06 §2) — see [ConcordStrandedRecovery].
*/
fun recoverStranded(
fun isStranded(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
bannedAtCurrentEpoch: Boolean,
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch)
): Boolean = ConcordStrandedRecovery.isStranded(entry, bundle, bannedAtCurrentEpoch)
/**
* The entry after the user **explicitly** re-accepts the invite link a stranded [entry] was
* joined through, or null when not stranded. Only ever from a user action — never a sweep.
*/
fun rejoinStranded(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
bannedAtCurrentEpoch: Boolean,
): ConcordCommunityListEntry? = ConcordStrandedRecovery.rejoinForward(entry, bundle, bannedAtCurrentEpoch)
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
fun openBundle(
@@ -516,13 +671,15 @@ object ConcordActions {
* [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05
* §2, so a redeeming client honours a `vsk=9` revocation tombstone and an
* `expires_at` in the past, and reports why a link can't be opened instead of
* retrying blindly. [nowMs] is unix milliseconds.
* retrying blindly. [nowMs] is unix milliseconds. Only events genuinely at the link's
* coordinate count — signed by [linkSignerPubKey], `d == ""` — never what a relay claims is.
*/
fun classifyInvite(
wraps: List<Event>,
linkSignerPubKey: HexKey,
token: ByteArray,
nowMs: Long = TimeUtils.nowMillis(),
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs)
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, linkSignerPubKey, token, nowMs)
/**
* The Control Plane keys described by a redeemed [invite] so the joiner can
@@ -610,6 +767,8 @@ object ConcordActions {
staffXOnly: Set<HexKey>,
createdAt: Long,
ownerPubKey: HexKey,
authority: AuthorityCitation? = null,
mustCarry: Map<String, Long> = emptyMap(),
): RefoundingBuild =
ConcordRefounding.build(
rotatorSigner = rotatorSigner,
@@ -624,6 +783,8 @@ object ConcordActions {
staffXOnly = staffXOnly,
createdAt = createdAt,
ownerPubKey = ownerPubKey,
authority = authority,
mustCarry = mustCarry,
)
/**
@@ -632,7 +793,9 @@ object ConcordActions {
* scope, epoch and continuity against the [priorRoot] the member holds — and,
* on a staff blob, that the delivered `control_root` derives to the delivered
* `control_pk` (CORD-06 §1). Returns the new root + Control keys + rotator
* (for the caller to authorize) or null if not re-keyed.
* or null if not re-keyed. [accept] is the caller's authority check (see
* [ConcordReceive.isHonoredRotation]); racing rotations it admits converge on
* the lowest new root (CORD-06 §3).
*/
suspend fun openBaseRekey(
wraps: List<Event>,
@@ -641,5 +804,6 @@ object ConcordActions {
communityId: HexKey,
priorRoot: ByteArray,
rootEpoch: Long,
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch)
accept: (ReceivedRefounding) -> Boolean = { true },
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch, accept)
}
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitations
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
@@ -41,6 +43,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
@@ -52,12 +55,19 @@ import kotlinx.serialization.builtins.serializer
* re-encryption across epochs) and wrapped on the community's Control Plane. The
* caller passes the community's **current** editions so this can chain the next
* version onto the entity's head (`version = head.version + 1`, `prevHash =
* head.hash`) and union the banlist. Authority is enforced at *fold* time by the
* `AuthorityResolver`, not here — an edition whose author doesn't outrank its
* target (or trace to the owner via [citation]) is simply dropped by every client.
* head.hash`, a new entity starting at version 1) and read the banlist. Authority is
* enforced at *fold* time by the `AuthorityResolver`, not here — an edition whose
* author doesn't outrank its target is simply dropped by every client.
*
* The owner needs no [citation]; a delegated moderator must cite the grant they
* act under so the fold can verify the chain terminates at the owner.
* Every non-owner edition carries the `vac` authority citation (CORD-04 §1/§5): the
* actor's own Grant head as the same [current] editions fold it
* ([AuthorityCitations.forActor]), which every reader, the reference client included,
* requires before honoring the action. The owner cites nothing. A caller may still pass
* an explicit [AuthorityCitation] to override it.
*
* The CORD-04 §2 / CORD-02 §6 caps ([ConcordLimits]) are enforced here too: an edition
* every reader would drop is refused with an [IllegalArgumentException] rather than
* published.
*/
object ConcordModeration {
/**
@@ -83,42 +93,66 @@ object ConcordModeration {
*/
private fun headOf(
current: List<ControlEdition>,
communityId: ByteArray,
entityId: ByteArray,
owner: HexKey,
): ControlEdition? = ConcordCommunityState.authorizedHeads(current, owner)[entityId.toHexKey()]?.known
): ControlEdition? = ConcordCommunityState.authorizedHeads(current, communityId, owner)[entityId.toHexKey()]?.known
/** version/prevHash to chain onto the current head of [entityId], or genesis. */
private fun versioning(
current: List<ControlEdition>,
entityId: ByteArray,
owner: HexKey,
): Pair<Long, ByteArray?> {
val head = headOf(current, entityId, owner)
return if (head != null) (head.version + 1) to head.hash else 0L to null
}
/** version/prevHash to chain onto the current head of [entityId], or a genesis at version 1 (CORD-04 §1). */
private fun versioning(head: ControlEdition?): Pair<Long, ByteArray?> = if (head != null) (head.version + 1) to head.hash else 1L to null
private suspend fun wrap(
actor: NostrSigner,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
kind: ControlEntityKind,
entityId: ByteArray,
version: Long,
prevHash: ByteArray?,
head: ControlEdition?,
content: String,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation?,
owner: HexKey,
): Event {
val rumor = ControlEditionBuilder.rumor(actor.pubKey, kind, entityId, version, prevHash, content, createdAt, citation)
val (version, prevHash) = versioning(head)
val vac = citation ?: AuthorityCitations.forActor(current, communityId, owner, actor.pubKey)
val rumor = ControlEditionBuilder.rumor(actor.pubKey, kind, entityId, version, prevHash, content, createdAt, vac)
return ConcordStreamEnvelope.wrap(rumor, controlPlane, actor, encrypted = false, createdAt = createdAt)
}
/**
* Writes [value] as the next edition of [entityId]: laid over the current authorized head's
* content, so every field the head carries that [serializer] does not model survives the edit
* (CORD-02 §6 — renaming never wipes another client's `custom` or a newer protocol field),
* chained onto that head and cited.
*/
private suspend fun <T> edit(
actor: NostrSigner,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
kind: ControlEntityKind,
entityId: ByteArray,
serializer: KSerializer<T>,
value: T,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation?,
owner: HexKey,
): Event {
val head = headOf(current, communityId, entityId, owner)
val content = ConcordJson.encodePreserving(serializer, value, head?.content)
return wrap(actor, controlPlane, communityId, kind, entityId, head, content, current, createdAt, citation, owner)
}
/**
* Defines (or updates) a role. [roleId] is the role's stable 32-byte entity id
* — generate one for a new role and reuse it to edit or [RoleEntity.deleted] it.
* — generate one for a new role and reuse it to edit or [RoleEntity.deleted] it. The
* content always carries it as `role_id` (CORD-04 §2), which the reference client requires.
*/
suspend fun defineRole(
actor: NostrSigner,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
roleId: ByteArray,
role: RoleEntity,
current: List<ControlEdition>,
@@ -126,21 +160,23 @@ object ConcordModeration {
citation: AuthorityCitation? = null,
owner: HexKey,
): Event {
val (version, prev) = versioning(current, roleId, owner)
val content = ConcordJson.instance.encodeToString(RoleEntity.serializer(), role)
return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation)
require(ConcordLimits.nameFits(role.name)) { "role name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" }
// CORD-04 §3: position 0 is the owner's alone — refuse here rather than have every reader drop it.
require(role.deleted || role.position >= 1) { "role position must be 1 or greater (position 0 is the owner's)" }
val stamped = role.copy(roleId = roleId.toHexKey())
return edit(actor, controlPlane, communityId, ControlEntityKind.ROLE, roleId, RoleEntity.serializer(), stamped, current, createdAt, citation, owner)
}
/**
* Defines (or updates) a channel (CORD-03/04, `vsk=2`). [channelId] is the channel's stable
* 32-byte entity id — generate one for a new channel and reuse it to rename, flip its
* private/voice flags, or [ChannelEntity.deleted] it (terminal; the id is never reused).
* Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner) tracing to the owner
* via [citation].
* Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner).
*/
suspend fun defineChannel(
actor: NostrSigner,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
channelId: ByteArray,
channel: ChannelEntity,
current: List<ControlEdition>,
@@ -148,16 +184,34 @@ object ConcordModeration {
citation: AuthorityCitation? = null,
owner: HexKey,
): Event {
val (version, prev) = versioning(current, channelId, owner)
val content = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), channel)
return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation)
// Every reader drops an edition naming an empty or over-cap Channel (CORD-03 §2), so
// refuse to mint one rather than publish an edition nobody will honor.
require(channel.hasValidName()) { "Channel name must be 1..${ChannelEntity.NAME_MAX_BYTES} UTF-8 bytes" }
return edit(actor, controlPlane, communityId, ControlEntityKind.CHANNEL, channelId, ChannelEntity.serializer(), channel, current, createdAt, citation, owner)
}
/**
* Sets the community's disappearing-messages timer (CORD-08 §1) to [secs] seconds, or turns it
* off when null. It is a metadata edition like any other — same chain, same MANAGE_METADATA
* gate — laid over the folded [standing] metadata so nothing else changes.
*/
suspend fun setMessageExpiration(
actor: NostrSigner,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
standing: MetadataEntity,
secs: Long?,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
owner: HexKey,
): Event = editMetadata(actor, controlPlane, communityId, standing.withMessageExpiration(secs), current, createdAt, citation, owner)
/**
* Replaces the community metadata (name / icon / description / relays). The
* metadata entity id is the community id itself (as in genesis), so this chains
* the next version onto the metadata head. Honored at fold only when [actor]
* holds MANAGE_METADATA (or is the owner) tracing to the owner via [citation].
* holds MANAGE_METADATA (or is the owner), and only within the CORD-02 §6 caps.
*/
suspend fun editMetadata(
actor: NostrSigner,
@@ -169,9 +223,9 @@ object ConcordModeration {
citation: AuthorityCitation? = null,
owner: HexKey,
): Event {
val (version, prev) = versioning(current, communityId, owner)
val content = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), metadata)
return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation)
require(ConcordLimits.nameFits(metadata.name)) { "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" }
require(ConcordLimits.descriptionFits(metadata.description)) { "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes" }
return edit(actor, controlPlane, communityId, ControlEntityKind.METADATA, communityId, MetadataEntity.serializer(), metadata, current, createdAt, citation, owner)
}
/**
@@ -195,10 +249,10 @@ object ConcordModeration {
owner: HexKey,
controlWrap: String? = null,
): Event {
require(roleIds.size <= ConcordLimits.MAX_ROLES_PER_MEMBER) { "a member holds at most ${ConcordLimits.MAX_ROLES_PER_MEMBER} roles" }
val entityId = ConcordKeyDerivation.grantCoordinate(communityId, member.hexToByteArray())
val (version, prev) = versioning(current, entityId, owner)
val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap))
return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation)
val grant = GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap)
return edit(actor, controlPlane, communityId, ControlEntityKind.GRANT, entityId, GrantEntity.serializer(), grant, current, createdAt, citation, owner)
}
/**
@@ -227,7 +281,7 @@ object ConcordModeration {
epoch: Long,
): Event {
val wrap =
if (controlRoot != null && makesStaff(roleIds, current, owner)) {
if (controlRoot != null && makesStaff(roleIds, current, communityId, owner)) {
ControlRootWrap.build(actor, member, epoch, controlRoot)
} else {
null
@@ -239,14 +293,19 @@ object ConcordModeration {
fun makesStaff(
roleIds: List<String>,
current: List<ControlEdition>,
communityId: ByteArray,
owner: HexKey,
): Boolean {
if (roleIds.isEmpty()) return false
val roles = AuthorityResolver.resolve(current, owner).roles()
val roles = AuthorityResolver.resolve(current, communityId, owner).roles()
return roleIds.any { roles[it]?.permissionBits()?.hasAny(ConcordPermissions.STAFF_BITS) == true }
}
/** Adds [member] to the banlist (union with the current head). */
/**
* Adds [member] to the banlist, written over the current folded head. Another admin's
* concurrent edition at the same version may win the fold (CORD-04 §4); calling this again
* after the refold re-applies the ban atop the winner — the spec's re-heal.
*/
suspend fun ban(
actor: NostrSigner,
controlPlane: ControlPlaneKeys,
@@ -271,19 +330,19 @@ object ConcordModeration {
): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId, owner) - member.lowercase(), current, createdAt, citation, owner)
/**
* The current banlist union across the head editions (lowercase hex).
* The current banlist (lowercase hex): the folded head's list.
*
* Read through the [AuthorityResolver] rather than by decoding the head's content directly, so
* this is the *honored* banlist: the resolver heals concurrent forks into the union (CORD-04 §4
* re-heal) and drops entries whose signer did not outrank them (§3's rank rule, enforced as a
* delta rule). Decoding the raw head instead would make every ban/unban we author re-publish
* entries our own fold refuses — laundering an unauthorized ban into a list signed by us.
* this is the *honored* banlist: the resolver drops entries whose signer did not outrank them
* (§3's rank rule, enforced as a delta rule). Decoding the raw head instead would make every
* ban/unban we author re-publish entries our own fold refuses — laundering an unauthorized ban
* into a list signed by us.
*/
fun currentBanned(
current: List<ControlEdition>,
communityId: ByteArray,
owner: HexKey,
): Set<HexKey> = AuthorityResolver.resolve(current, owner).bannedMembers()
): Set<HexKey> = AuthorityResolver.resolve(current, communityId, owner).bannedMembers()
private suspend fun setBanlist(
actor: NostrSigner,
@@ -296,8 +355,8 @@ object ConcordModeration {
owner: HexKey,
): Event {
val entityId = ConcordKeyDerivation.banlistCoordinate(communityId)
val (version, prev) = versioning(current, entityId, owner)
val head = headOf(current, communityId, entityId, owner)
val content = ConcordJson.instance.encodeToString(ListSerializer(String.serializer()), banned.sorted())
return wrap(actor, controlPlane, ControlEntityKind.BANLIST, entityId, version, prev, content, createdAt, citation)
return wrap(actor, controlPlane, communityId, ControlEntityKind.BANLIST, entityId, head, content, current, createdAt, citation, owner)
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
@@ -29,6 +30,9 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -106,6 +110,71 @@ object ConcordReceive {
rotator: HexKey,
): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN)
/**
* Whether a received base rotation may be adopted (CORD-06 §3 "Authority"): its rotator holds
* BAN (or is the owner) in our fold, AND it cites the Grant it acts under (`vac`) at a version
* our fold has synced — so a just-demoted admin's rotation is never honored by a client that
* lags the demotion, and a rotation citing a Grant we have not seen yet waits for it. The owner
* cites nothing. [editions] are the current epoch's Control editions the citation is checked
* against.
*/
fun isHonoredRotation(
entry: ConcordCommunityListEntry,
editions: Collection<ControlEdition>,
authority: AuthorityResolver,
received: ReceivedRefounding,
): Boolean {
if (!isAuthorizedRotator(authority, received.rotator)) return false
val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner)
return ConcordRotationAuthority.citationSatisfied(entry.id, received.rotator, entry.owner, received.authority, heads)
}
/**
* The `vac` citation [actor] stamps on a rotation it launches (CORD-06 §3): their own Grant's
* head in our fold, or null for the owner (who cites nothing).
*/
fun rotationCitation(
entry: ConcordCommunityListEntry,
editions: Collection<ControlEdition>,
actor: HexKey,
): AuthorityCitation? = ConcordRotationAuthority.citationFor(entry.id, actor, entry.owner, ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner))
/**
* The down-only same-epoch heal (CORD-06 §3): [entry] holds a root at its current epoch, and
* [sibling] is a rotation to that same epoch (from the same prior root) that we did not adopt.
* Returns the entry moved onto the sibling when its root is **strictly lower** — the losing
* (higher) root we held is kept as a held root of the same epoch, so the messages sent into
* that fork stay readable — or null when the sibling does not win.
*
* The losing root keeps no control material: its Control Plane is the losing fork's
* compaction, not the community's ([ConcordRefounding.canonicalHeldRoots] never folds it).
*/
fun withHealedRoot(
entry: ConcordCommunityListEntry,
sibling: ReceivedRefounding,
): ConcordCommunityListEntry? {
if (sibling.newEpoch != entry.rootEpoch) return null
if (!ConcordRefounding.healsTo(entry.root.hexToByteArray(), sibling.newRoot)) return null
return ConcordCommunityListEntry(
id = entry.id,
owner = entry.owner,
ownerSalt = entry.ownerSalt,
root = sibling.newRoot.toHexKey(),
rootEpoch = entry.rootEpoch,
// The control pair is the winner's blob's, never inherited from the losing fork.
controlPk = sibling.newControlPk?.toHexKey(),
controlRoot = sibling.newControlRoot?.toHexKey(),
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch to it.key.lowercase() },
privateChannels = entry.privateChannels,
relays = entry.relays,
name = entry.name,
addedAt = entry.addedAt,
inviteRef = entry.inviteRef,
excludedAtEpoch = entry.excludedAtEpoch,
residue = entry.residue,
)
}
/**
* The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the
* caller can diff, persist and publish it however its platform does.
@@ -133,7 +202,9 @@ object ConcordReceive {
rootEpoch = newEpoch,
controlPk = newControlPk?.toHexKey(),
controlRoot = newControlRoot?.toHexKey(),
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch },
// Keyed by (epoch, key), not epoch alone: a healed race leaves a losing fork's root at the
// same epoch, kept so its messages stay readable (CORD-06 §3).
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() },
privateChannels = entry.privateChannels,
relays = entry.relays,
name = entry.name,
@@ -25,7 +25,9 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -77,7 +79,9 @@ object ConcordSubscriptionPlanner {
// the entry, per epoch, exactly as it was delivered.
val cp = ConcordActions.controlPlaneKeysFor(e)
val historical =
e.heldRoots
// Losing-fork roots of a healed race are kept for their messages only (CORD-06 §3).
ConcordRefounding
.canonicalHeldRoots(e.heldRoots)
.filter { it.epoch < e.rootEpoch }
.sortedByDescending { it.epoch }
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
@@ -92,7 +96,8 @@ object ConcordSubscriptionPlanner {
* The off-channel planes every joined community subscribes to upfront (known
* from the entry alone): the Guestbook Plane (membership motions) and the
* next-epoch base-rekey address (so an inbound Refounding is received live,
* CORD-06). Both are kind-1059 wraps authored by their derived stream address.
* CORD-06), and the dissolution tombstone address (CORD-02 §9). All are kind-1059
* wraps authored by their derived stream address.
*/
fun auxiliaryPlaneSubs(entries: List<ConcordCommunityListEntry>): List<ConcordPlaneSub> =
entries.flatMap { e ->
@@ -101,9 +106,14 @@ object ConcordSubscriptionPlanner {
val relays = normalize(e.relays)
val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch)
val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch)
listOf(
val dissolved = ConcordDissolution.planeKey(e.id)
val sibling = ConcordActions.siblingBaseRekeyPlane(e)
listOfNotNull(
ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays),
ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays),
ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays),
// The current epoch's own rekey address, so a racing sibling can heal us (CORD-06 §3).
sibling?.let { ConcordPlaneSub(channelId = null, pubKeyHex = it.publicKeyHex, relays = relays) },
)
}
@@ -118,24 +128,28 @@ object ConcordSubscriptionPlanner {
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
): List<ConcordPlaneSub> {
val root = entry.root.hexToByteArray()
val relays = normalize(entry.relays)
// A Private Channel is subscribed on its own key's plane only, and not at all without a held
// key (CORD-03 §1): never on the root-derived plane every member can read.
val current =
state.channels.keys.map { channelIdHex ->
val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, channelIdHex),
pubKeyHex = ch.publicKeyHex,
relays = relays,
)
state.channels.values.mapNotNull { channel ->
ConcordActions.currentChannelPlane(entry, channel.channelIdHex, channel.definition.private)?.let { plane ->
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, plane.channelIdHex),
pubKeyHex = plane.key.publicKeyHex,
relays = relays,
)
}
}
val historical =
ConcordActions.historicalChannelPlanes(entry.heldRoots, state.channels.keys).map { plane ->
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, plane.channelIdHex),
pubKeyHex = plane.key.publicKeyHex,
relays = relays,
)
state.channels.values.flatMap { channel ->
ConcordActions.historicalChannelPlanes(entry, channel.channelIdHex, channel.definition.private).map { plane ->
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, plane.channelIdHex),
pubKeyHex = plane.key.publicKeyHex,
relays = relays,
)
}
}
return current + historical
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
@@ -783,7 +784,9 @@ class Account(
// Invalidate the channel's metadata flow only on a real change so the Messages-row
// name + community chip recompose when the fold first resolves them (they observe
// metadata.stateFlow via observeChannel), without churning every row every tick.
if (channel.updateFrom(state, relays, myPubKey)) channel.updateChannelInfo()
// A Private Channel is readable/postable only with its held key (CORD-03 §1).
val keyHeld = ConcordActions.canAccessChannel(session.entry, state, channelIdHex)
if (channel.updateFrom(state, relays, myPubKey, keyHeld)) channel.updateChannelInfo()
channel.notes
.filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true }
.forEach { channel.removeNote(it) }
@@ -1747,11 +1750,18 @@ class Account(
// Marmot messages are retracted inside their group. A public NIP-09 here would e-tag
// the group's private rumor ids onto public relays.
val (marmotNotes, otherNotes) = notes.partition { marmot.marmotGroupOf(it) != null }
val (marmotNotes, nonMarmotNotes) = notes.partition { marmot.marmotGroupOf(it) != null }
marmotNotes.groupBy { marmot.marmotGroupOf(it)!! }.forEach { (groupId, groupNotes) ->
marmot.deleteMarmotMessages(groupId, groupNotes)
}
// Concord rumors are retracted inside their channel's plane (CORD-01 Deletions), for the
// same reason: any other route carries the community's rumor ids outside it.
val (concordNotes, otherNotes) = nonMarmotNotes.partition { concord.concordChannelOf(it) != null }
concordNotes.groupBy { concord.concordChannelOf(it)!! }.forEach { (channel, channelNotes) ->
concord.deleteConcordRumors(channel, channelNotes)
}
val (myRumors, myNotes) =
otherNotes
.filter { it.author == userProfile() && it.event != null }
@@ -1796,6 +1806,13 @@ class Account(
return
}
// In a Concord channel it is an in-channel kind-5 on the channel's plane (CORD-01), never a
// NIP-17 DM to the p-tagged users, which would leak the rumor ids outside the community.
concord.concordChannelOf(target)?.let { channel ->
concord.deleteConcordRumors(channel, notes)
return
}
val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event }
if (myRumors.isEmpty()) return
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -211,6 +212,8 @@ class AccountSettings(
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupRelayGroupList: SimpleGroupListEvent? = null,
var backupConcordList: ConcordCommunityListEvent? = null,
/** The newest kind-33302 Community List fragment per index (CORD-02 §8). */
var backupConcordListFragments: List<ConcordCommunityListFragmentEvent> = emptyList(),
var backupTrustProviderList: TrustProviderListEvent? = null,
var backupCashuWallet: CashuWalletEvent? = null,
var backupNutzapInfo: NutzapInfoEvent? = null,
@@ -1381,6 +1384,17 @@ class AccountSettings(
override fun concordList() = backupConcordList
override fun concordListFragments() = backupConcordListFragments
override fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) {
val index = fragment.index() ?: return
val prior = backupConcordListFragments.firstOrNull { it.index() == index }
// Newest copy per index, as a relay resolves the coordinate (ties to the lower id).
if (prior != null && (prior.createdAt > fragment.createdAt || (prior.createdAt == fragment.createdAt && prior.id <= fragment.id))) return
backupConcordListFragments = backupConcordListFragments.filterNot { it.index() == index } + fragment
saveAccountSettings()
}
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {
// The joined list lives entirely in NIP-44-encrypted content (secrets),
// so an empty `tags` is NOT an empty list — guard only on null.
@@ -137,6 +137,8 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent
@@ -970,6 +972,11 @@ open class EventCache :
rumor: Event,
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
) {
// Defense in depth behind the session's Chat ingest gate: a channel plane carries Chat kinds
// only (CORD-02 Appendix B). Another plane's kind — a Control edition, a Guestbook motion, a
// rekey blob — must never land in the store as if it came from its own plane.
if (!ChannelChat.isChatKind(rumor.kind)) return
// Attach to the channel BEFORE justConsume sets the event and notifies feeds,
// so the note already carries its ConcordChannel gatherer when it flows through
// the Messages-list incremental filter (which routes rows by that gatherer).
@@ -3837,6 +3844,8 @@ open class EventCache :
// so — exactly like the 10009 list above — it must be stored replaceably or the Concord
// hub stays empty even after the event arrives.
is ConcordCommunityListEvent,
// Its successor (CORD-02 §8): the List split into addressable fragments at d = index.
is ConcordCommunityListFragmentEvent,
// The relay-signed NIP-29 39004 AV-participants addressable is durable group state.
is GroupParticipantsEvent,
is ExternalIdentitiesEvent,
@@ -52,10 +52,15 @@ class ConcordChannel(
var channelName: String? = null
private set
var isVoice: Boolean = false
var isPrivate: Boolean = false
private set
var isPrivate: Boolean = false
/**
* False for a Private Channel whose independent key this account does not hold (CORD-03 §1):
* its plane can be neither read nor written, and it must never fall back to the root-derived
* plane every member can decrypt. Always true for a Public Channel.
*/
var keyHeld: Boolean = true
private set
/** The parent community's display name, from its folded metadata. */
@@ -110,30 +115,30 @@ class ConcordChannel(
state: ConcordCommunityState,
relays: Set<NormalizedRelayUrl>,
myPubKey: HexKey,
keyHeld: Boolean = true,
): Boolean {
val def = state.channels[channelId.channelId]?.definition
// Channel fields keep their prior value until the channel edition folds.
val newChannelName = def?.name ?: channelName
val newVoice = def?.voice ?: isVoice
val newPrivate = def?.private ?: isPrivate
val newCommunityName = state.metadata?.name
val newCommunityIcon = state.metadata?.icon
val newCommunityBanner = state.metadata?.banner
val newMembership = ConcordMembership.of(state.authority, myPubKey)
val newDissolved = state.dissolved
val newKeyHeld = !newPrivate || keyHeld
val changed =
channelName != newChannelName ||
isVoice != newVoice ||
isPrivate != newPrivate ||
communityName != newCommunityName ||
communityIcon != newCommunityIcon ||
communityBanner != newCommunityBanner ||
membership != newMembership ||
dissolved != newDissolved
dissolved != newDissolved ||
this.keyHeld != newKeyHeld
channelName = newChannelName
isVoice = newVoice
isPrivate = newPrivate
communityName = newCommunityName
communityIcon = newCommunityIcon
@@ -141,6 +146,7 @@ class ConcordChannel(
communityRelays = relays
membership = newMembership
dissolved = newDissolved
this.keyHeld = newKeyHeld
return changed
}
@@ -154,8 +160,11 @@ class ConcordChannel(
* ([ConcordMembership.isMember]) **and** the community must not have been dissolved (CORD-02 §9 —
* a tombstone seals it read-only for everyone). Deleting one's own past message stays allowed even
* after dissolution and does not go through this gate.
*
* A Private Channel whose key this account does not hold is never postable ([keyHeld]): there
* is no plane to write to that only its members can read.
*/
fun canPost(): Boolean = membership.isMember() && !dissolved
fun canPost(): Boolean = membership.isMember() && !dissolved && keyHeld
// Synthetic note representing this channel in the Messages list before any
// message has loaded (so a just-joined channel appears immediately). Mirrors
@@ -20,44 +20,72 @@
*/
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withAddedAt
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDocument
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListIncompleteException
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListResidue
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.concurrent.Volatile
/** Persistence hook for the last-known kind 13302 event (offline backup). */
/**
* Persistence hook for the account's Community List (offline backup): the kind-33302 fragments
* (CORD-02 §8) plus the retired kind-13302 single event, which is still read as a rescue source.
*/
interface ConcordListRepository {
fun concordList(): ConcordCommunityListEvent?
fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?)
fun concordListFragments(): List<ConcordCommunityListFragmentEvent> = emptyList()
fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) {}
}
/**
* The account's home base for Concord Channels: the kind-13302
* [ConcordCommunityListEvent] (self-encrypted joined-communities list). This is
* the Concord analog of NIP-29's [RelayGroupListState], but the entries carry the
* community secrets (root/salt/epoch/private-channel keys), so decryption yields
* everything needed to re-derive each plane on any device.
* The account's home base for Concord Channels: the member's Community List (CORD-02 §8) — kind
* 33302 fragments, NIP-44-encrypted to self, at `d` = the fragment index. The entries carry the
* community secrets (root/salt/epoch/private-channel keys), so decryption yields everything needed
* to re-derive each plane on any device.
*
* Exposes [liveCommunities] (the joined [ConcordCommunityListEntry] set) and
* [liveServers] (the distinct community ids — the "server" rail). [follow]/
* [unfollow] read-modify-write the list; the caller publishes the returned event.
* The List reads as the union of every fragment below the declared count, merged with the retired
* kind-13302 event if one exists — so a membership only the old event carries is still joined, and
* the next write migrates it into the fragments. Nothing ever writes 13302 again.
*
* Exposes [liveCommunities] (the joined [ConcordCommunityListEntry] set) and [liveServers] (the
* distinct community ids — the "server" rail). [follow]/[unfollow] read-modify-write the List and
* return the fragment events to publish: a full repack when every fragment is held, or a write
* scoped to the fragment holding the change when some are still missing (so a fragment stranded on
* an unreachable relay never blocks a join or a leave).
*/
class ConcordChannelListState(
val signer: NostrSigner,
@@ -65,35 +93,103 @@ class ConcordChannelListState(
val scope: CoroutineScope,
val settings: ConcordListRepository,
) {
// Long-term reference so the GC doesn't collect the note itself.
// Long-term references so the GC doesn't collect the notes themselves.
val concordListNote = cache.getOrCreateAddressableNote(getConcordListAddress())
private val fragmentNotes = HashMap<Int, AddressableNote>()
private val fragmentNotesLock = KmpLock()
/** How many fragment coordinates we watch: at least index 0, and every index the List declares. */
private val watchedFragments = MutableStateFlow(1)
/** Serializes read-modify-writes so two quick edits can't both build on the same base. */
private val writeLock = Mutex()
/**
* Plaintext per fragment/legacy event id. Every List change re-reads the whole List, and each
* decrypt is a signer round trip (an IPC hop to Amber, a relay hop to a bunker); an event id's
* plaintext never changes, so each is decrypted once. Failures are not cached, so a transient
* signer error is retried on the next read.
*/
private val plaintextById = LinkedHashMap<String, String>()
private val plaintextLock = KmpLock()
private suspend fun plaintextOf(
id: String,
decrypt: suspend () -> String?,
): String? {
plaintextLock.withLock { plaintextById[id] }?.let { return it }
val plaintext = decrypt() ?: return null
plaintextLock.withLock {
plaintextById[id] = plaintext
// Bounded: only the newest copy per index is ever read, so a few dozen ids is plenty.
while (plaintextById.size > MAX_CACHED_PLAINTEXTS) plaintextById.remove(plaintextById.keys.first())
}
return plaintext
}
/**
* Whether the relays have been asked for this account's fragments since start-up
* ([markRelaysConfirmed], after the import fetch). Until then an empty fragment set means
* "not loaded yet", not "no List", and a write would replace fragments another device or
* client published — so [follow]/[unfollow] refuse rather than guess.
*/
@Volatile
var relaysConfirmed = false
private set
fun markRelaysConfirmed() {
relaysConfirmed = true
}
/** The retired single-event list's coordinate, still read for migration. */
fun getConcordListAddress() = ConcordCommunityListEvent.createAddress(signer.pubKey)
fun getConcordListFlow(): StateFlow<NoteState> = concordListNote.flow().metadata.stateFlow
fun getConcordList(): ConcordCommunityListEvent? = concordListNote.event as? ConcordCommunityListEvent
/**
* Decrypts the current list (or the offline backup) into the full document — entries plus
* the residue (unknown keys, tombstones) a read-modify-write has to hand back untouched.
*/
suspend fun documentWithBackup(note: Note): ConcordCommunityListDocument {
val event = note.event as? ConcordCommunityListEvent ?: settings.concordList()
return event?.decryptDocument(signer) ?: ConcordCommunityListDocument(emptyList())
private fun fragmentNote(index: Int): AddressableNote =
fragmentNotesLock.withLock {
fragmentNotes.getOrPut(index) { cache.getOrCreateAddressableNote(ConcordCommunityListFragmentEvent.createAddress(signer.pubKey, index)) }
}
/** Every fragment event we hold, from the cache and the offline backup (newest per index wins later). */
private fun heldFragments(): List<ConcordCommunityListFragmentEvent> {
val fromCache = (0 until watchedFragments.value).mapNotNull { fragmentNote(it).event as? ConcordCommunityListFragmentEvent }
return fromCache + settings.concordListFragments()
}
/** Decrypts the current list (or the offline backup) into its entries. */
suspend fun entriesWithBackup(note: Note): List<ConcordCommunityListEntry> = documentWithBackup(note).entries
/** Resolves the fragments we hold, widening the watch when the List declares more of them. */
suspend fun fragmentSet(): ConcordListFragmentSet {
val set = ConcordListFragmentSet.resolve(heldFragments(), signer.pubKey) { e -> plaintextOf(e.id) { e.decryptPlaintext(signer) } }
if (set.declared > watchedFragments.value) watchedFragments.value = set.declared
return set
}
/** The fragments plus the merged, decoded List a read-modify-write starts from. */
private suspend fun snapshot(): Pair<ConcordListFragmentSet, ConcordCommunityListDocument> {
val set = fragmentSet()
val legacyEvent = getConcordList() ?: settings.concordList()
val legacy = legacyEvent?.let { e -> plaintextOf(e.id) { e.decryptPlaintext(signer) } }
return set to ConcordCommunityList.decodeDocument(ConcordCommunityList.readWithLegacy(set, legacy))
}
/** The whole decoded List — entries plus the residue a read-modify-write must hand back. */
suspend fun document(): ConcordCommunityListDocument = snapshot().second
/** The joined entries. */
suspend fun entries(): List<ConcordCommunityListEntry> = document().entries
@OptIn(ExperimentalCoroutinesApi::class)
private val listChanges: Flow<Any> =
watchedFragments.flatMapLatest { n ->
combine((0 until n).map { fragmentNote(it).flow().metadata.stateFlow } + concordListNote.flow().metadata.stateFlow) { it }
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveCommunities: StateFlow<List<ConcordCommunityListEntry>> =
getConcordListFlow()
.transformLatest { noteState ->
emit(entriesWithBackup(noteState.note))
}.onStart {
emit(entriesWithBackup(concordListNote))
}.flowOn(Dispatchers.IO)
listChanges
.transformLatest { emit(entries()) }
.onStart { emit(entries()) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
@@ -108,38 +204,91 @@ class ConcordChannelListState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
/** Add or replace [entry] (by community id) and return the new signed list event to publish. */
suspend fun follow(entry: ConcordCommunityListEntry): ConcordCommunityListEvent {
// Seed from the offline backup as well as the live cache event: the saved list is
// consumed into the cache asynchronously in `init`, so a join that races that load
// would otherwise start from an empty `current` and wipe every prior membership.
val doc = documentWithBackup(concordListNote)
val next = doc.entries.filterNot { it.id == entry.id } + entry
return ConcordCommunityListEvent.create(signer, next, residue = doc.residue)
/**
* Encrypts and signs the fragments that make the wire hold [entries] + [residue]. The new
* document replaces the current memberships wholesale (never a merge), so a same-epoch
* update — a delivered `control_root`, a rename — can't lose the snapshot tie-break to the
* state it is replacing.
*/
private suspend fun write(
set: ConcordListFragmentSet,
entries: List<ConcordCommunityListEntry>,
residue: ConcordListResidue,
): List<ConcordCommunityListFragmentEvent> {
if (set.isEmpty && !relaysConfirmed) {
throw ConcordListIncompleteException("the Community List has not been fetched from relays yet; refusing to overwrite it")
}
val newDoc = ConcordCommunityList.encodeInternal(entries, residue)
return set.planWrites(newDoc, TimeUtils.now()).map { w ->
ConcordCommunityListFragmentEvent.create(signer, w.index, w.plaintext, w.createdAt).also { settings.updateConcordListFragmentTo(it) }
}
}
/** Drop the community with [communityId] and return the new list event, or null if none existed. */
suspend fun unfollow(communityId: String): ConcordCommunityListEvent? {
val doc = documentWithBackup(concordListNote)
if (doc.entries.none { it.id == communityId }) return null
val next = doc.entries.filterNot { it.id == communityId }
return ConcordCommunityListEvent.create(signer, next, residue = doc.residue)
/**
* Add or replace [entry] (by community id) and return the fragment events to publish.
*
* Adding a community we once left is a re-join, which must outrank the tombstone
* (`added_at > removed_at`, CORD-02 §8): an entry that doesn't — a join in the same second as
* the leave, or a stale snapshot — gets its `added_at` lifted just past the removal.
*
* Throws [ConcordListIncompleteException] when the List isn't loaded well enough to write
* without destroying a fragment, and [ConcordListTooLargeException] when a fragment would
* pass the event ceiling.
*/
suspend fun follow(entry: ConcordCommunityListEntry): List<Event> =
writeLock.withLock {
val (set, doc) = snapshot()
val removedAt = doc.residue.removedAt(entry.id)
val live = if (removedAt != null && entry.addedAt <= removedAt) entry.withAddedAt(maxOf(TimeUtils.nowMillis(), removedAt + 1)) else entry
write(set, doc.entries.filterNot { it.id == entry.id } + live, doc.residue)
}
/**
* Leave [communityId]: drop its membership and tombstone it (CORD-02 §8 — only a tombstone
* subtracts a membership; a missing entry is just unseen news another fragment may still
* carry). Returns the fragment events to publish, or empty when we were not a member.
*/
suspend fun unfollow(communityId: String): List<Event> =
writeLock.withLock {
val (set, doc) = snapshot()
if (doc.entries.none { it.id == communityId }) return@withLock emptyList()
write(set, doc.entries.filterNot { it.id == communityId }, doc.residue.withTombstone(communityId, TimeUtils.nowMillis()))
}
/**
* Writes the List as it currently reads — used to seed the fragments from the retired 13302
* event once relays confirmed none exist yet. Empty when there is nothing to write.
*/
suspend fun republish(): List<Event> =
writeLock.withLock {
val (set, doc) = snapshot()
if (doc.entries.isEmpty() && doc.residue.tombstones.isEmpty()) return@withLock emptyList()
write(set, doc.entries, doc.residue)
}
companion object {
private const val MAX_CACHED_PLAINTEXTS = 64
}
init {
settings.concordList()?.let { event ->
val savedLegacy = settings.concordList()
val savedFragments = settings.concordListFragments()
if (savedLegacy != null || savedFragments.isNotEmpty()) {
Log.d("AccountRegisterObservers", "Loading saved concord list")
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
cache.justConsumeMyOwnEvent(event)
savedLegacy?.let { cache.justConsumeMyOwnEvent(it) }
savedFragments.forEach { cache.justConsumeMyOwnEvent(it) }
if (savedFragments.isNotEmpty()) watchedFragments.value = maxOf(watchedFragments.value, savedFragments.mapNotNull { it.index() }.max() + 1)
}
}
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "ConcordList Collector Start")
getConcordListFlow().collect { noteState ->
(noteState.note.event as? ConcordCommunityListEvent)?.let {
settings.updateConcordListTo(it)
listChanges.collect {
getConcordList()?.let { settings.updateConcordListTo(it) }
for (i in 0 until watchedFragments.value) {
(fragmentNote(i).event as? ConcordCommunityListFragmentEvent)?.let { settings.updateConcordListFragmentTo(it) }
}
}
}
@@ -20,16 +20,19 @@
*/
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ChannelPlane
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
@@ -137,15 +140,41 @@ class ConcordCommunitySession(
*/
private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch)
/**
* The rekey address the rotation INTO this epoch rode on (from the prior held root), or null
* for a joiner who holds no prior root. A racing sibling rotation to this same epoch lands
* here; the app drains it for the down-only heal (CORD-06 §3).
*/
private val siblingBaseRekeyKey: GroupKey? = ConcordActions.siblingBaseRekeyPlane(entry)
/**
* The dissolution tombstone address (CORD-02 §9): derived from the community id alone, so it
* is the same for every epoch and every member past or present.
*/
private val dissolvedKey: GroupKey = ConcordDissolution.planeKey(entry.id)
/**
* Set once a valid owner tombstone bound to this community arrives at [dissolvedAddress]. One
* way: there is no un-dissolve, so a later fold can never clear it.
*/
@Volatile
private var dissolved = false
/** The Control Plane stream address to subscribe to (known from the entry alone). */
val controlPlaneAddress: HexKey get() = controlKeys.address
/** The dissolution tombstone stream address to subscribe to (known from the community id alone). */
val dissolvedAddress: HexKey get() = dissolvedKey.publicKeyHex
/** The Guestbook Plane stream address to subscribe to (known from the entry alone). */
val guestbookAddress: HexKey get() = guestbookKey.publicKeyHex
/** The next-epoch base-rekey stream address to watch for an inbound Refounding. */
val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex
/** The current epoch's own base-rekey address (see [siblingBaseRekeyKey]), or null. */
val siblingBaseRekeyAddress: HexKey? get() = siblingBaseRekeyKey?.publicKeyHex
/**
* The Control Plane of every **prior** epoch we still hold a root for (address ->
* key + epoch), newest-held first and bounded like the channel backfill.
@@ -159,7 +188,9 @@ class ConcordCommunitySession(
* survives a process restart without any new storage.
*/
private val historicalControlKeys: Map<HexKey, Pair<ControlPlaneKeys, Long>> =
entry.heldRoots
// Losing-fork roots of a healed race carry no Control Plane of the community's (CORD-06 §3).
ConcordRefounding
.canonicalHeldRoots(entry.heldRoots)
.filter { it.epoch < entry.rootEpoch }
.sortedByDescending { it.epoch }
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
@@ -221,15 +252,22 @@ class ConcordCommunitySession(
private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // channelIdHex -> (wrapId -> wrap)
private val guestbookWraps = LinkedHashMap<HexKey, Event>()
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
private val siblingRekeyWraps = LinkedHashMap<HexKey, Event>()
// channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold.
private var channelKeysByAddress = HashMap<HexKey, Pair<HexKey, GroupKey>>()
// Current channel plane pubkey -> plane (channel id, key, bound epoch), refreshed on each control
// re-fold. A Public Channel's plane derives from the root at the root epoch; a Private one's from
// its held channel key at the channel epoch (CORD-03 §1). A Private Channel we hold no key for has
// no entry at all: it is neither subscribed, read, nor written.
private var channelKeysByAddress = HashMap<HexKey, ChannelPlane>()
// Prior-epoch channel plane pubkey -> (channelIdHex, key, epoch), for pre-Refounding history.
// A CORD-06 Refounding rotates the root per epoch, so older messages live under a different
// plane per held root; we re-derive those here so historical wraps are subscribed, AUTHed, and
// decrypted alongside the current epoch. Empty when the account holds no prior roots.
private var historicalChannelKeysByAddress = HashMap<HexKey, Triple<HexKey, GroupKey, Long>>()
// Older channel plane pubkey -> plane, for history: a Public Channel's plane under each held
// prior root (a CORD-06 Refounding rotates the root per epoch) plus its private-era plane when a
// channel key is held. Subscribed, AUTHed and decrypted alongside the current planes.
private var historicalChannelKeysByAddress = HashMap<HexKey, ChannelPlane>()
// The held Private Channel keys the current channel planes were derived from, so a later list
// entry carrying different keys re-derives them (see [adoptPrivateChannels]).
private var derivedPrivateKeys = privateKeySet(entry)
private val _state = MutableStateFlow<ConcordCommunityState?>(null)
val state: StateFlow<ConcordCommunityState?> = _state
@@ -311,11 +349,32 @@ class ConcordCommunitySession(
address == controlPlaneAddress ||
address == guestbookAddress ||
address == nextBaseRekeyAddress ||
address == siblingBaseRekeyAddress ||
address == dissolvedAddress ||
address in historicalControlKeys ||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
/** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key }
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key }
/**
* The plane [channelIdHex] is written on now, or null when it is not folded yet or is a Private
* Channel this account holds no key for (CORD-03 §1) — the caller must then refuse to post.
*/
fun currentChannelPlane(channelIdHex: HexKey): ChannelPlane? = lock.withLock { channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex } }
/**
* The plane of [channelIdHex] bound to [epoch] — current or historical — or null when this
* account holds none. A delete of an older message goes back onto the plane that carried it.
*/
fun channelPlaneFor(
channelIdHex: HexKey,
epoch: Long,
): ChannelPlane? =
lock.withLock {
channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex && it.epoch == epoch }
?: historicalChannelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex && it.epoch == epoch }
}
/**
* Every Chat Plane stream address for [channelIdHex] across epochs: the current one plus each
@@ -326,8 +385,8 @@ class ConcordCommunitySession(
*/
fun channelPlaneAddressesAllEpochs(channelIdHex: HexKey): List<HexKey> =
lock.withLock {
val current = channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key
val historical = historicalChannelKeysByAddress.entries.filter { it.value.first == channelIdHex }.map { it.key }
val current = channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key
val historical = historicalChannelKeysByAddress.entries.filter { it.value.channelIdHex == channelIdHex }.map { it.key }
(listOfNotNull(current) + historical)
}
@@ -337,6 +396,12 @@ class ConcordCommunitySession(
/** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */
fun pendingBaseRekeyWraps(): List<Event> = lock.withLock { baseRekeyWraps.values.toList() }
/** The base-rekey [GroupKey] of the rotation into this epoch (sibling heal), or null. */
fun siblingBaseRekeyKey(): GroupKey? = siblingBaseRekeyKey
/** The buffered kind-3303 wraps seen at [siblingBaseRekeyAddress], for the account's heal drain. */
fun pendingSiblingRekeyWraps(): List<Event> = lock.withLock { siblingRekeyWraps.values.toList() }
/**
* Every stream key whose kind-1059 wraps this session reads: the Control Plane plus
* one per folded channel. These are the identities a NIP-42 relay must see the
@@ -362,13 +427,16 @@ class ConcordCommunitySession(
// Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the
// gated relays must serve their wraps too.
historicalControlKeys.values.mapNotNull { it.first.signer } +
channelKeysByAddress.values.map { it.second } +
channelKeysByAddress.values.map { it.key } +
// Prior-epoch channel stream keys so the gated relays serve their older wraps too.
historicalChannelKeysByAddress.values.map { it.second }
historicalChannelKeysByAddress.values.map { it.key }
}
/** The CORD-06 auxiliary plane keys (Guestbook + next base-rekey) for their own isolated AUTH. */
fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey)
/**
* The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address)
* for their own isolated AUTH.
*/
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey)
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) }
@@ -414,6 +482,33 @@ class ConcordCommunitySession(
true
}
/**
* Adopt a change to the Private Channel keys the Community List carries for this same community,
* root and epoch (a key delivered on grant, CORD-03 §1): the entry is swapped in place and the
* channel planes re-derived, so a newly held Private Channel is subscribed, read and written on
* its own plane without dropping the buffered Control Plane wraps a rebuild would lose.
*
* Returns false, changing nothing, when [newEntry] is not the same community at the same root,
* epoch and Control Plane material (the caller rebuilds, or adopts that first), or when the held
* channel keys did not change.
*/
fun adoptPrivateChannels(newEntry: ConcordCommunityListEntry): Boolean {
val changed =
lock.withLock {
val cur = entry
if (newEntry.id != cur.id || newEntry.root != cur.root || newEntry.rootEpoch != cur.rootEpoch) return false
if (newEntry.controlPk != cur.controlPk || newEntry.controlRoot != cur.controlRoot) return false
// Compared with what the planes were derived from, not with [entry]: an adoption of
// Control material may already have swapped in an entry carrying the new keys.
if (privateKeySet(newEntry) == derivedPrivateKeys) return false
entry = newEntry
true
}
// Nothing folded yet: the first control wrap derives the planes from the swapped-in entry.
if (changed && lock.withLock { controlWraps.isNotEmpty() }) refold()
return changed
}
/** This account's standing, from the current fold. */
fun membership(): ConcordMembership {
val s = _state.value ?: return ConcordMembership.MEMBER
@@ -449,6 +544,17 @@ class ConcordCommunitySession(
refoldGuestbook()
return ConcordIngestOutcome.STRUCTURAL
}
dissolvedAddress -> {
// Anyone holding the (public) community id can sign here, so only an owner-signed,
// eid-bound tombstone counts (CORD-02 §9); everything else is noise we still claim.
if (dissolved || !ConcordDissolution.isTombstoneWrap(wrap, entry.id, entry.owner)) return ConcordIngestOutcome.NON_STRUCTURAL
lock.withLock {
dissolved = true
_state.value = _state.value?.withDissolved(true)
}
// The state watcher bumps the revision off the changed fold, as for a control wrap.
return ConcordIngestOutcome.STRUCTURAL_FOLD
}
nextBaseRekeyAddress -> {
// Buffer only — decrypting a base-rotation blob needs the account signer, so the
// app layer drains [pendingBaseRekeyWraps] with it and authorizes the rotator. That
@@ -457,6 +563,11 @@ class ConcordCommunitySession(
lock.withLock { baseRekeyWraps[wrap.id] = wrap }
return ConcordIngestOutcome.STRUCTURAL
}
siblingBaseRekeyAddress -> {
// Same as above for a racing rotation into THIS epoch (the down-only heal).
lock.withLock { siblingRekeyWraps[wrap.id] = wrap }
return ConcordIngestOutcome.STRUCTURAL
}
else -> {
// A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback
// floor rises as the old epochs drain in. Structural — the floor can change the
@@ -471,15 +582,13 @@ class ConcordCommunitySession(
}
val current = lock.withLock { channelKeysByAddress[wrap.pubKey] }
if (current != null) {
val (channelIdHex, key) = current
return ingestChannelWrap(wrap, channelIdHex, key, entry.rootEpoch, seenOnRelays)
return ingestChannelWrap(wrap, current.channelIdHex, current.key, current.epoch, seenOnRelays)
}
// A prior-epoch plane (pre-Refounding history). Decrypt with that epoch's key and
// bind-check against that epoch. Keyed separately from the current buffer so a re-fold
// (which rebuilds only the current-epoch keys) never re-projects the historical ones.
// An older plane (pre-Refounding history, or a Public Channel's private era). Decrypt
// with that plane's key and bind-check against its epoch. Keyed separately from the
// current buffer so a re-fold never re-projects the historical ones.
val historical = lock.withLock { historicalChannelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE
val (channelIdHex, key, epoch) = historical
return ingestChannelWrap(wrap, channelIdHex, key, epoch, seenOnRelays)
return ingestChannelWrap(wrap, historical.channelIdHex, historical.key, historical.epoch, seenOnRelays)
}
}
}
@@ -519,8 +628,10 @@ class ConcordCommunitySession(
key: GroupKey,
epoch: Long,
) {
val rumor = ConcordStreamEnvelope.openOrNull(wrap, key)?.rumor ?: return
if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return
val opened = ConcordStreamEnvelope.openOrNull(wrap, key) ?: return
// The same Chat gate as a stored rumor: encrypted seal, strict binding, well-formed ms.
val rumor = ChannelChat.acceptOpened(opened, channelIdHex, epoch) ?: return
if (!ChannelChat.isTyping(rumor)) return
val who = rumor.pubKey.lowercase()
if (who == myPubKey.lowercase()) return // never show my own typing back to me
// A banned member's messages are dropped everywhere, so their typing heartbeat must be too —
@@ -552,35 +663,37 @@ class ConcordCommunitySession(
val folded =
ConcordCommunityState.fold(
editionsLocked(wraps, controlKeys),
communityIdBytes,
entry.owner,
controlFloorsLocked(),
)
val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first }
val next = HashMap<HexKey, Pair<HexKey, GroupKey>>()
for (channelIdHex in folded.channels.keys) {
val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
next[key.publicKeyHex] = channelIdHex to key
val prevAddresses = channelKeysByAddress.keys.toHashSet()
val next = HashMap<HexKey, ChannelPlane>()
// Re-derive the older planes for the same (epoch-invariant) channel ids, so older
// history is subscribed/AUTHed/decrypted. Channels are known only after a fold, hence
// derived here rather than up front.
val historical = HashMap<HexKey, ChannelPlane>()
for ((channelIdHex, channel) in folded.channels) {
val isPrivate = channel.definition.private
// Null for a Private Channel with no held key: never the root-derived plane.
ConcordActions.currentChannelPlane(entry, channelIdHex, isPrivate)?.let { next[it.key.publicKeyHex] = it }
for (plane in ConcordActions.historicalChannelPlanes(entry, channelIdHex, isPrivate)) {
historical[plane.key.publicKeyHex] = plane
}
}
channelKeysByAddress = next
// Re-derive the prior-epoch planes for the same (epoch-invariant) channel ids, so older
// pre-Refounding history is subscribed/AUTHed/decrypted. Channels are known only after a
// fold, hence derived here rather than up front.
val historical = HashMap<HexKey, Triple<HexKey, GroupKey, Long>>()
for (plane in ConcordActions.historicalChannelPlanes(entry.heldRoots, folded.channels.keys)) {
historical[plane.key.publicKeyHex] = Triple(plane.channelIdHex, plane.key, plane.epoch)
}
historicalChannelKeysByAddress = historical
derivedPrivateKeys = privateKeySet(entry)
_state.value = folded
folded.channels.keys.filterNot { it in prevChannels }
_state.value = folded.withDissolved(dissolved)
next.filterKeys { it !in prevAddresses }.values.map { it.channelIdHex }
}
// Project only channels appearing for the first time. Existing channels' wraps were already
// emitted incrementally as they arrived (a channel plane is only subscribed after it folds, so
// a channel's buffer never pre-dates its first fold) — re-projecting all channels on every
// control edition would be O(channels × history) of redundant decryption.
// Project only channels whose current plane is new (a first fold, or a plane that moved when a
// Private Channel's key arrived). Existing planes' wraps were already emitted incrementally as
// they arrived — re-projecting all channels on every control edition would be
// O(channels × history) of redundant decryption.
for (channelIdHex in newChannels) reprojectChannel(channelIdHex)
}
@@ -596,7 +709,7 @@ class ConcordCommunitySession(
if (editionByWrapId.containsKey(wrap.id)) {
editionByWrapId[wrap.id]
} else {
val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKeys)?.let { ControlEdition.fromRumor(it.rumor) }
val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKeys)?.let { ControlEdition.fromOpened(it) }
editionByWrapId[wrap.id] = edition
edition
}
@@ -621,7 +734,7 @@ class ConcordCommunitySession(
val wraps = historicalControlWraps[address]?.values?.toList() ?: continue
val editions = editionsLocked(wraps, keyAtEpoch.first)
if (editions.isEmpty()) continue
floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors)
floors = ConcordCommunityState.authorizedHeads(editions, communityIdBytes, entry.owner, floors)
}
return floors
}
@@ -645,9 +758,9 @@ class ConcordCommunitySession(
* re-fold (keys may change). Prior-epoch wraps in the buffer simply won't open under the current
* key and are skipped — they were already emitted when they landed (the sink dedups by id). */
private fun reprojectChannel(channelIdHex: HexKey) {
val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return
val plane = currentChannelPlane(channelIdHex) ?: return
val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return
emitChannelRumors(channelIdHex, key, entry.rootEpoch, wraps)
emitChannelRumors(channelIdHex, plane.key, plane.epoch, wraps)
}
/**
@@ -675,6 +788,8 @@ class ConcordCommunitySession(
}
companion object {
private fun privateKeySet(e: ConcordCommunityListEntry) = e.privateChannels.mapTo(HashSet()) { Triple(it.channelId.lowercase(), it.key.lowercase(), it.epoch) }
/** A typing heartbeat is considered current for this many seconds after it's seen. */
const val TYPING_STALE_SECS = 8L
}
@@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
/** What kind of plane an address belongs to. */
enum class ConcordPlaneKind {
@@ -105,17 +104,18 @@ class ConcordPlaneRegistry {
}
}
/** Registers the Chat Plane address of every channel in a folded community [state]. */
/**
* Registers the current Chat Plane address of every channel in a folded community [state] this
* account can read: a Public Channel's root-derived plane, a Private Channel's held-key plane,
* and nothing for a Private Channel whose key is not held (CORD-03 §1).
*/
fun registerChannels(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
) = lock.withLock {
val root = entry.root.hexToByteArray()
for (channelIdHex in state.channels.keys) {
val ch =
com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
planes[ch.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channelIdHex), ch)
for (channel in state.channels.values) {
val plane = ConcordActions.currentChannelPlane(entry, channel.channelIdHex, channel.definition.private) ?: continue
planes[plane.key.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, plane.channelIdHex), plane.key)
}
}
@@ -85,6 +85,9 @@ class ConcordSessionRegistry(
// buffered wraps and fold the community empty, and the plane's address is
// invariant under adoption anyway (CORD-02 §5).
existing.adoptControlMaterial(entry)
// Likewise a Private Channel key delivered on grant (CORD-03 §1): re-derive the
// channel planes in place so the channel becomes readable and writable.
existing.adoptPrivateChannels(entry)
}
}
created
@@ -41,6 +41,8 @@ import okio.IOException
*/
enum class LatestEventSlot(
val prefKey: String,
/** False for a slot added after the SharedPreferences era: there is nothing to migrate. */
val existedInLegacyPrefs: Boolean = true,
) {
CONTACT_LIST("latestContactList"),
USER_METADATA("latestUserMetadata"),
@@ -61,6 +63,9 @@ enum class LatestEventSlot(
EPHEMERAL_LIST("latestEphemeralChatList"),
RELAY_GROUP_LIST("latestRelayGroupList"),
CONCORD_LIST("latestConcordList"),
/** The kind-33302 Community List fragments (CORD-02 §8), one event JSON per line. */
CONCORD_LIST_FRAGMENTS("latestConcordListFragments", existedInLegacyPrefs = false),
TRUST_PROVIDER_LIST("latestTrustProviderList"),
KEY_PACKAGE_RELAY_LIST("latestKeyPackageRelayList"),
FAVORITE_ALGO_FEEDS_LIST("latestFavoriteAlgoFeedsList"),
@@ -94,7 +99,7 @@ class LatestEventCacheStore(
val legacyTable =
LegacyKeyTable(
"migrated.latestEvents",
LatestEventSlot.entries.map { LegacyStringKey(it.prefKey, it.key) },
LatestEventSlot.entries.filter { it.existedInLegacyPrefs }.map { LegacyStringKey(it.prefKey, it.key) },
)
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterUserAsser
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -98,6 +99,7 @@ val AccountInfoAndListsFromKeyKinds2 =
// Armada reference client, sharing this key) surface in the Concord hub at login,
// instead of only appearing after creating/redeeming an invite in Amethyst itself.
ConcordCommunityListEvent.KIND,
ConcordCommunityListFragmentEvent.KIND,
// NIP-60 Cashu wallet + NIP-61 nutzap info. Replaceables, always
// useful to have available — wallet event holds the user's P2PK key
// + mint list, nutzap info tells other clients which mints to lock
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -38,7 +39,7 @@ class ConcordActionsTest {
val community = ConcordActions.createCommunity(owner, "Test Server", createdAt = 1L, relays = listOf("wss://r.example"))
// Fold genesis -> live state
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
assertEquals("Test Server", state.metadata?.name)
assertTrue(state.channels.containsKey(community.generalChannelIdHex))
@@ -78,7 +79,7 @@ class ConcordActionsTest {
// The joiner can derive the control plane and read the genesis.
val controlPlane = ConcordActions.controlPlaneFor(opened)
val state = ConcordActions.foldCommunity(community.genesisWraps, controlPlane, opened.owner)
val state = ConcordActions.foldCommunity(community.genesisWraps, controlPlane, opened.communityId.hexToByteArray(), opened.owner)
assertEquals("Nostrichs", state.metadata?.name)
}
@@ -149,7 +150,7 @@ class ConcordActionsTest {
rootEpoch = aliceGot.newEpoch,
controlPk = deliveredControlPk.toHexKey(),
)
val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.ownerPubKey)
val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.communityId, community.ownerPubKey)
assertEquals("Test", state.metadata?.name)
assertTrue(state.channels.isNotEmpty())
}
@@ -29,13 +29,18 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@@ -70,7 +75,7 @@ class ConcordModerationTest {
position = 1,
permissions = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.KICK).toWire(),
)
add(ConcordModeration.defineRole(owner, cp, roleId, adminRole, editions, createdAt = 2L, owner = community.ownerPubKey))
add(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, adminRole, editions, createdAt = 2L, owner = community.ownerPubKey))
// Owner grants that role to the admin user.
add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleIdHex), editions, createdAt = 3L, owner = community.ownerPubKey))
@@ -78,7 +83,7 @@ class ConcordModerationTest {
// Owner bans the troll.
add(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 4L, owner = community.ownerPubKey))
val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.ownerPubKey)
val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
// The role exists, the admin holds BAN + the role id, and the troll is banned.
assertTrue(state.roles.containsKey(roleIdHex))
@@ -89,19 +94,19 @@ class ConcordModerationTest {
// Revoking (an empty grant, as "Remove admin" does) strips the role and its permissions.
add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, emptyList(), editions, createdAt = 7L, owner = community.ownerPubKey))
val demoted = ConcordCommunityState.fold(editions, community.ownerPubKey)
val demoted = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
assertFalse(demoted.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN))
assertTrue(demoted.authority.rolesOf(admin.pubKey).isEmpty())
// Unbanning the troll clears the flag (version chains onto the ban).
add(ConcordModeration.unban(owner, cp, communityId, troll.pubKey, editions, createdAt = 5L, owner = community.ownerPubKey))
val healed = ConcordCommunityState.fold(editions, community.ownerPubKey)
val healed = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
assertFalse(healed.authority.isBanned(troll.pubKey))
// A grant forged by the troll (who outranks nobody) is dropped by the fold.
val forged = ConcordModeration.grant(troll, cp, communityId, troll.pubKey, listOf(roleIdHex), editions, createdAt = 6L, owner = community.ownerPubKey)
val forgedEditions: List<ControlEdition> = editions + ConcordActions.controlEditions(listOf(forged), cp)
val afterForgery = ConcordCommunityState.fold(forgedEditions, community.ownerPubKey)
val afterForgery = ConcordCommunityState.fold(forgedEditions, community.communityId, community.ownerPubKey)
assertFalse(afterForgery.authority.effectivePermissions(troll.pubKey).has(ConcordPermissions.BAN))
}
@@ -115,8 +120,8 @@ class ConcordModerationTest {
* masked by the down-only healing union, but an unban is not, so an unban simply
* failed to apply.
*
* Here the banlist has two editions (v0 bans [troll], v1 also bans [stranger]) before
* the unban. The unban must chain off v1 — the folded head — at v2, not off v0.
* Here the banlist has two editions (v1 bans [troll], v2 also bans [stranger]) before
* the unban. The unban must chain off v2 — the folded head — at v3, not off v1.
*/
@Test
fun thirdBanlistEditionChainsOffTheFoldedHeadNotTheFirstArrival() =
@@ -127,36 +132,36 @@ class ConcordModerationTest {
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
// v0: ban the troll. v1: ban the stranger too (chains onto v0).
// v1: ban the troll. v2: ban the stranger too (chains onto v1). Versions start at 1 (CORD-04 §1).
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
val banlistSoFar = editions.filter { it.entityKind == ControlEntityKind.BANLIST }
assertEquals(2, banlistSoFar.size)
val head = EditionFold.foldEntity(banlistSoFar)!!
assertEquals(1L, head.version)
// The stale v0 sorts first in arrival order — exactly what the old firstOrNull picked up.
assertEquals(0L, banlistSoFar.first().version)
assertEquals(2L, head.version)
// The stale v1 sorts first in arrival order — exactly what the old firstOrNull picked up.
assertEquals(1L, banlistSoFar.first().version)
// Now unban the troll. The head must be found regardless of arrival order — in
// particular in the natural order, where the stale v0 comes first and is exactly
// particular in the natural order, where the stale v1 comes first and is exactly
// what the old firstOrNull latched onto.
for (arrival in listOf(editions.toList(), editions.reversed())) {
val unbanWrap = ConcordModeration.unban(owner, cp, communityId, troll.pubKey, arrival, createdAt = 4L, owner = community.ownerPubKey)
val unban = ConcordActions.controlEditions(listOf(unbanWrap), cp).single()
// Chains onto the folded head (v1), not the first-arrival v0.
assertEquals(2L, unban.version)
// Chains onto the folded head (v2), not the first-arrival v1.
assertEquals(3L, unban.version)
assertEquals(head.hashHex, unban.prevHash!!.toHexKey())
// And the resulting state is the one the moderator asked for: troll freed, stranger still banned.
val state = ConcordCommunityState.fold(editions + unban, community.ownerPubKey)
val state = ConcordCommunityState.fold(editions + unban, community.communityId, community.ownerPubKey)
assertFalse(state.authority.isBanned(troll.pubKey))
assertTrue(state.authority.isBanned(stranger.pubKey))
}
}
/** The same stale-head trap on a versioned entity: a third role edition must be v2. */
/** The same stale-head trap on a versioned entity: a third role edition must be v3. */
@Test
fun thirdRoleEditionChainsOffTheFoldedHead() =
runTest {
@@ -168,14 +173,14 @@ class ConcordModerationTest {
fun role(name: String) = RoleEntity(name = name, position = 1, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire())
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Mod"), editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Admin"), editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role("Mod"), editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role("Admin"), editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
for (arrival in listOf(editions.toList(), editions.reversed())) {
val third = ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Owner"), arrival, createdAt = 4L, owner = community.ownerPubKey)), cp).single()
assertEquals(2L, third.version)
val third = ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role("Owner"), arrival, createdAt = 4L, owner = community.ownerPubKey)), cp).single()
assertEquals(3L, third.version)
val state = ConcordCommunityState.fold(editions + third, community.ownerPubKey)
val state = ConcordCommunityState.fold(editions + third, community.communityId, community.ownerPubKey)
assertEquals("Owner", state.roles[roleId.toHexKey()]?.name)
}
}
@@ -195,27 +200,27 @@ class ConcordModerationTest {
val communityId = community.communityId
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
// v0: the owner bans the troll.
// v1: the owner bans the troll.
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
// v1: the stranger — who holds nothing — forges an empty banlist at the tip.
// v2: the stranger — who holds nothing — forges an empty banlist at the tip.
val rogue = ConcordActions.controlEditions(listOf(ConcordModeration.unban(stranger, cp, communityId, troll.pubKey, editions, createdAt = 3L, owner = community.ownerPubKey)), cp).single()
assertEquals(1L, rogue.version)
assertEquals(2L, rogue.version)
val poisoned = editions + rogue
// The owner now bans the stranger. It must chain onto v0 — the head the fold
// honors — not onto the rogue v1, so the version is 1, not 2.
// The owner now bans the stranger. It must chain onto v1 — the head the fold
// honors — not onto the rogue v2, so the version is 2, not 3.
val next =
ConcordActions
.controlEditions(
listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, poisoned, createdAt = 4L, owner = community.ownerPubKey)),
cp,
).single()
assertEquals(1L, next.version, "the rogue tip must not inflate the honest edition's version")
assertEquals(2L, next.version, "the rogue tip must not inflate the honest edition's version")
// And, critically, the owner's banlist is computed from the GATED head, so it still
// carries the troll. Reading the rogue's content instead would launder the forged
// unban into an owner-signed edition and free the troll for good.
val state = ConcordCommunityState.fold(poisoned + next, community.ownerPubKey)
val state = ConcordCommunityState.fold(poisoned + next, community.communityId, community.ownerPubKey)
assertTrue(state.authority.isBanned(troll.pubKey), "the forged unban must not free the troll")
assertTrue(state.authority.isBanned(stranger.pubKey))
}
@@ -242,22 +247,22 @@ class ConcordModerationTest {
editions +=
ConcordActions.controlEditions(
listOf(
ConcordModeration.defineRole(owner, cp, staffRoleId, RoleEntity(name = "Mod", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire()), editions, createdAt = 2L, owner = community.ownerPubKey),
ConcordModeration.defineRole(owner, cp, community.communityId, staffRoleId, RoleEntity(name = "Mod", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire()), editions, createdAt = 2L, owner = community.ownerPubKey),
),
cp,
)
editions +=
ConcordActions.controlEditions(
listOf(
ConcordModeration.defineRole(owner, cp, kickRoleId, RoleEntity(name = "Bouncer", position = 3, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()), editions, createdAt = 3L, owner = community.ownerPubKey),
ConcordModeration.defineRole(owner, cp, community.communityId, kickRoleId, RoleEntity(name = "Bouncer", position = 3, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()), editions, createdAt = 3L, owner = community.ownerPubKey),
),
cp,
)
assertTrue(ConcordModeration.makesStaff(listOf(staffRoleIdHex), editions, community.ownerPubKey))
assertFalse(ConcordModeration.makesStaff(listOf(kickRoleIdHex), editions, community.ownerPubKey))
assertFalse(ConcordModeration.makesStaff(emptyList(), editions, community.ownerPubKey), "a revoke hands out nothing")
assertFalse(ConcordModeration.makesStaff(listOf("ee".repeat(32)), editions, community.ownerPubKey), "an unresolvable role must not trigger a delivery")
assertTrue(ConcordModeration.makesStaff(listOf(staffRoleIdHex), editions, community.communityId, community.ownerPubKey))
assertFalse(ConcordModeration.makesStaff(listOf(kickRoleIdHex), editions, community.communityId, community.ownerPubKey))
assertFalse(ConcordModeration.makesStaff(emptyList(), editions, community.communityId, community.ownerPubKey), "a revoke hands out nothing")
assertFalse(ConcordModeration.makesStaff(listOf("ee".repeat(32)), editions, community.communityId, community.ownerPubKey), "an unresolvable role must not trigger a delivery")
suspend fun grantEntity(
roleIds: List<String>,
@@ -296,4 +301,96 @@ class ConcordModerationTest {
assertNull(grantEntity(listOf("ee".repeat(32)), community.controlRoot).controlWrap, "an unresolved role conservatively delivers nothing")
assertNull(grantEntity(listOf(staffRoleIdHex), controlRoot = null).controlWrap, "no held secret, no delivery (legacy community or keyless granter)")
}
/**
* CORD-04 §1/§5: every non-owner authority action cites the Grant it acts under (`vac`), and a
* reader — this client and the reference one alike — drops a non-owner edition that doesn't. We
* never wrote it, so every edition a delegated admin authored here was invisible to Armada.
*/
@Test
fun aDelegatedAdminsEditionsCiteTheirGrantAndAreHonored() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
val cp = community.controlPlane
val communityId = community.communityId
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
fun add(wrap: Event) = ConcordActions.controlEditions(listOf(wrap), cp).single().also { editions += it }
val roleId = ByteArray(32) { 0x31 }
val adminBits = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.MANAGE_METADATA, ConcordPermissions.MANAGE_ROLES)
val role = add(ConcordModeration.defineRole(owner, cp, communityId, roleId, RoleEntity(name = "Admin", position = 1, permissions = adminBits.toWire()), editions, 2L, owner = community.ownerPubKey))
val grant = add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleId.toHexKey()), editions, 3L, owner = community.ownerPubKey))
assertNull(role.authorityCitation, "the owner cites nothing")
assertNull(grant.authorityCitation)
val ban = add(ConcordModeration.ban(admin, cp, communityId, troll.pubKey, editions, 4L, owner = community.ownerPubKey))
val vac = ban.authorityCitation
assertNotNull(vac, "a delegated admin's edition must cite its Grant")
assertEquals(ConcordKeyDerivation.grantCoordinate(communityId, admin.pubKey.hexToByteArray()).toHexKey(), vac.grantId.toHexKey())
assertEquals(grant.version, vac.grantVersion)
assertEquals(grant.hashHex, vac.grantHash.toHexKey())
val rename = add(ConcordModeration.editMetadata(admin, cp, communityId, MetadataEntity(name = "Renamed"), editions, 5L, owner = community.ownerPubKey))
assertNotNull(rename.authorityCitation)
val state = ConcordCommunityState.fold(editions, communityId, community.ownerPubKey)
assertTrue(state.authority.isBanned(troll.pubKey), "the cited ban is honored")
assertEquals("Renamed", state.metadata?.name, "and so is the cited metadata edit")
// The same actions stripped of their citation are dropped.
val uncited = editions.map { if (it.author == admin.pubKey) it.withCitation(null) else it }
val stripped = ConcordCommunityState.fold(uncited, communityId, community.ownerPubKey)
assertFalse(stripped.authority.isBanned(troll.pubKey))
assertEquals("Nostrichs", stripped.metadata?.name)
}
/** CORD-04 §1/§2: a new entity starts at version 1, and a Role carries its own id as `role_id`. */
@Test
fun newEntitiesStartAtVersionOneAndRolesCarryTheirRoleId() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
val cp = community.controlPlane
val editions = ConcordActions.controlEditions(community.genesisWraps, cp)
val roleId = ByteArray(32) { 0x41 }
val role =
ConcordActions
.controlEditions(
listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, RoleEntity(name = "Mod", position = 2), editions, 2L, owner = community.ownerPubKey)),
cp,
).single()
assertEquals(1L, role.version)
assertNull(role.prevHash)
assertEquals(roleId.toHexKey(), ConcordJson.decodeOrNull<RoleEntity>(role.content)?.roleId)
val ban = ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, community.communityId, troll.pubKey, editions, 3L, owner = community.ownerPubKey)), cp).single()
assertEquals(1L, ban.version)
}
/** CORD-04 §2 / CORD-02 §6: a write every reader would drop is refused instead of published. */
@Test
fun writesPastTheProtocolCapsAreRefused() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
val cp = community.controlPlane
val cid = community.communityId
val editions = ConcordActions.controlEditions(community.genesisWraps, cp)
assertFailsWith<IllegalArgumentException> {
ConcordModeration.defineRole(owner, cp, cid, ByteArray(32) { 1 }, RoleEntity(name = "r".repeat(65), position = 2), editions, 2L, owner = community.ownerPubKey)
}
assertFailsWith<IllegalArgumentException> {
ConcordModeration.defineRole(owner, cp, cid, ByteArray(32) { 1 }, RoleEntity(name = "Peer", position = 0), editions, 2L, owner = community.ownerPubKey)
}
assertFailsWith<IllegalArgumentException> {
ConcordModeration.editMetadata(owner, cp, cid, MetadataEntity(name = "n".repeat(65)), editions, 2L, owner = community.ownerPubKey)
}
assertFailsWith<IllegalArgumentException> {
ConcordModeration.editMetadata(owner, cp, cid, MetadataEntity(name = "ok", description = "d".repeat(10_001)), editions, 2L, owner = community.ownerPubKey)
}
assertFailsWith<IllegalArgumentException> {
ConcordModeration.grant(owner, cp, cid, admin.pubKey, (1..65).map { it.toString(16).padStart(64, '0') }, editions, 2L, owner = community.ownerPubKey)
}
}
}
@@ -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.actions
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
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.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
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.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
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 kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The receive side of CORD-06 rotations as commons wires it: the `vac`-cited authority check a
* receiver runs before adopting (I9), racing rotations converging on the lowest authorized root
* and the down-only same-epoch heal (I12), and the sibling address a session watches for it.
*/
class ConcordRotationReceiveTest {
private val owner = NostrSignerInternal(KeyPair())
private val admin = NostrSignerInternal(KeyPair())
private val member = NostrSignerInternal(KeyPair())
private val now = 1_700_000_000L
private class Fixture(
val community: NewConcordCommunity,
val editions: List<ControlEdition>,
)
/** A community whose owner granted [admin] a BAN role. */
private suspend fun withAdmin(): Fixture {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val cp = community.controlPlane
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
val roleId = ByteArray(32) { (it + 1).toByte() }
val role = RoleEntity(name = "Admin", position = 1, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire())
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role, editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
editions += ConcordActions.controlEditions(listOf(ConcordModeration.grant(owner, cp, community.communityId, admin.pubKey, listOf(roleId.toHexKey()), editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
return Fixture(community, editions)
}
private fun entryFor(
community: NewConcordCommunity,
root: String = community.communityRoot.toHexKey(),
epoch: Long = community.rootEpoch,
heldRoots: List<HeldRoot> = emptyList(),
) = ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = root,
rootEpoch = epoch,
controlPk = community.controlPkHex,
heldRoots = heldRoots,
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
private suspend fun rotate(
community: NewConcordCommunity,
rotator: NostrSigner,
newRoot: ByteArray,
authority: AuthorityCitation?,
): List<Event> {
val newEpoch = community.rootEpoch + 1
val controlRoot = ByteArray(32) { 0x6B }
return ConcordRefounding.buildBaseRekeyWraps(
rotatorSigner = rotator,
baseRekeyKey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch),
recipientsXOnly = listOf(member.pubKey),
staffXOnly = emptySet(),
newRoot = newRoot,
newControlPk = ConcordKeyDerivation.controlSignerKey(controlRoot, community.communityId, newEpoch).publicKey,
newControlRoot = controlRoot,
newEpoch = newEpoch,
prevEpoch = community.rootEpoch,
prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey(),
createdAt = now,
authority = authority,
)
}
private suspend fun receive(
f: Fixture,
wraps: List<Event>,
): ReceivedRefounding? {
val entry = entryFor(f.community)
val authority = ConcordCommunityState.fold(f.editions, f.community.communityId, f.community.ownerPubKey).authority
return ConcordActions.openBaseRekey(
wraps = wraps,
baseRekey = ConcordActions.nextBaseRekeyPlane(f.community.communityRoot, f.community.communityId, f.community.rootEpoch),
recipientSigner = member,
communityId = f.community.communityIdHex,
priorRoot = f.community.communityRoot,
rootEpoch = f.community.rootEpoch,
accept = { ConcordReceive.isHonoredRotation(entry, f.editions, authority, it) },
)
}
// ---- I9 ----------------------------------------------------------------------------------
@Test
fun anAdminsRotationIsHonoredOnlyWithItsGrantCited() =
runTest {
val f = withAdmin()
val entry = entryFor(f.community)
val citation = ConcordReceive.rotationCitation(entry, f.editions, admin.pubKey)
assertNotNull(citation, "a BAN-holding admin has a Grant to cite")
assertNull(ConcordReceive.rotationCitation(entry, f.editions, owner.pubKey), "the owner cites nothing")
val root = ByteArray(32) { 0x22 }
assertContentEquals(root, receive(f, rotate(f.community, admin, root, citation))?.newRoot)
assertNull(receive(f, rotate(f.community, admin, root, authority = null)), "an uncited delegated rotation is dropped")
assertContentEquals(root, receive(f, rotate(f.community, owner, root, authority = null))?.newRoot, "the owner needs no citation")
}
@Test
fun aRotationCitingAGrantWeHaveNotSyncedIsParked() =
runTest {
val f = withAdmin()
val real = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)!!
val ahead = AuthorityCitation(real.grantId, real.grantVersion + 1, real.grantHash)
assertNull(receive(f, rotate(f.community, admin, ByteArray(32) { 0x22 }, ahead)))
}
@Test
fun aStrangerCannotRotateEvenCitingSomeonesGrant() =
runTest {
val f = withAdmin()
val stranger = NostrSignerInternal(KeyPair())
val adminsCitation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)
assertNull(receive(f, rotate(f.community, stranger, ByteArray(32) { 0x22 }, adminsCitation)))
}
// ---- I12 ---------------------------------------------------------------------------------
@Test
fun racingHonoredRotationsConvergeOnTheLowestRoot() =
runTest {
val f = withAdmin()
val citation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)
val high = ByteArray(32) { 0x70 }
val low = ByteArray(32) { 0x05 }
val wraps = rotate(f.community, owner, high, null) + rotate(f.community, admin, low, citation)
assertContentEquals(low, receive(f, wraps)?.newRoot)
assertContentEquals(low, receive(f, wraps.reversed())?.newRoot)
// An uncited (dishonored) lower root never wins the race.
val rogue = rotate(f.community, admin, ByteArray(32) { 0x01 }, null)
assertContentEquals(high, receive(f, rotate(f.community, owner, high, null) + rogue)?.newRoot)
}
@Test
fun theHealMovesOnlyToAStrictlyLowerSiblingAndKeepsTheLoser() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
val prior = HeldRoot(0, "aa".repeat(32), community.controlPkHex)
val adopted = entryFor(community, root = "70".repeat(32), epoch = 1, heldRoots = listOf(prior))
fun sibling(root: String) = ReceivedRefounding(root.hexToByteArray(), 1, owner.pubKey, ByteArray(32) { 3 }, null)
val healed = ConcordReceive.withHealedRoot(adopted, sibling("05".repeat(32)))
assertNotNull(healed)
assertEquals("05".repeat(32), healed.root)
assertEquals(1L, healed.rootEpoch)
assertEquals(ByteArray(32) { 3 }.toHexKey(), healed.controlPk, "the control pair is the winner's")
assertTrue(healed.heldRoots.any { it.epoch == 1L && it.key == "70".repeat(32) && it.controlPk == null }, "the losing fork's root is kept for its messages")
assertEquals(prior.key, ConcordRefounding.canonicalHeldRoots(healed.heldRoots).first { it.epoch == 0L }.key)
assertNull(ConcordReceive.withHealedRoot(adopted, sibling("90".repeat(32))), "down-only: a higher sibling never re-forks the epoch")
assertNull(ConcordReceive.withHealedRoot(adopted, sibling("70".repeat(32))))
// The next adoption keeps both same-epoch roots instead of collapsing them by epoch.
val next = ConcordReceive.withAdoptedRoot(healed, ByteArray(32) { 9 }, 2)
assertEquals(
setOf("05".repeat(32), "70".repeat(32)),
next.heldRoots
.filter { it.epoch == 1L }
.map { it.key }
.toSet(),
)
assertEquals("05".repeat(32), ConcordRefounding.canonicalHeldRoots(next.heldRoots).first { it.epoch == 1L }.key)
}
@Test
fun aSessionWatchesTheRotationIntoItsOwnEpoch() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
val prior = HeldRoot(community.rootEpoch, community.communityRoot.toHexKey(), community.controlPkHex)
val entry = entryFor(community, root = "70".repeat(32), epoch = community.rootEpoch + 1, heldRoots = listOf(prior))
val sibling = ConcordActions.siblingBaseRekeyPlane(entry)
assertNotNull(sibling)
assertEquals(ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex, sibling.publicKeyHex)
assertNull(ConcordActions.siblingBaseRekeyPlane(entryFor(community)), "a joiner holding no prior root has nothing to watch")
val session = ConcordCommunitySession(entry, member.pubKey)
assertEquals(sibling.publicKeyHex, session.siblingBaseRekeyAddress)
assertTrue(session.ownsPlane(sibling.publicKeyHex))
assertTrue(session.auxStreamKeys().any { it.publicKeyHex == sibling.publicKeyHex })
val wraps = rotate(community, owner, ByteArray(32) { 0x05 }, null)
wraps.forEach { session.ingest(it) }
assertEquals(wraps.map { it.id }.toSet(), session.pendingSiblingRekeyWraps().map { it.id }.toSet())
assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == sibling.publicKeyHex })
}
@Test
fun aLosingForkRootIsNotFoldedAsAControlPlane() {
val community = "11".repeat(32)
val entry =
ConcordCommunityListEntry(
id = community,
owner = "22".repeat(32),
ownerSalt = "33".repeat(32),
root = "44".repeat(32),
rootEpoch = 2,
heldRoots = listOf(HeldRoot(1, "05".repeat(32)), HeldRoot(1, "70".repeat(32))),
relays = listOf("wss://r.example"),
)
// One Control Plane for epoch 1 (the winner's), plus the current one.
assertEquals(2, ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)).size)
assertFalse(ConcordRefounding.canonicalHeldRoots(entry.heldRoots).any { it.key == "70".repeat(32) })
}
}
@@ -58,7 +58,7 @@ class ConcordSubscriptionPlannerTest {
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
val subs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state)
// Both the current-epoch and the prior-epoch #general planes are subscribed.
@@ -93,7 +93,7 @@ class ConcordSubscriptionPlannerTest {
assertTrue(controlSubs[0].channelId == null)
// Channel-plane subs cover the folded #general channel.
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
val channelSubs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state)
val general = channelSubs.firstOrNull { it.channelId?.channelId == community.generalChannelIdHex }
assertTrue(general != null)
@@ -127,7 +127,7 @@ class ConcordSubscriptionPlannerTest {
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
// Never read (lastRead == 0) ⇒ the newest few wraps (previewLimit), no `since`.
val previews = ConcordSubscriptionPlanner.channelPreviewFilters(entry, state, lastReadFor = { 0L }, previewLimit = 10)
@@ -158,7 +158,7 @@ class ConcordSubscriptionPlannerTest {
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
// Read before (lastRead > 0) ⇒ everything since, capped, so the unread badge is accurate.
val lastRead = 1_700_000_000L
@@ -225,7 +225,7 @@ class ConcordSubscriptionPlannerTest {
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
val controlPk = community.controlPlane.address
val guestbookPk = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex
@@ -46,14 +46,11 @@ class ConcordChannelDissolvedTest {
author: String = owner,
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0)
private fun state(dissolved: Boolean): ConcordCommunityState {
val editions =
buildList {
add(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}"""))
if (dissolved) add(ed(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""))
}
return ConcordCommunityState.fold(editions, owner)
}
// The tombstone lives on its own plane (CORD-02 §9); the session sets the flag from there.
private fun state(dissolved: Boolean): ConcordCommunityState =
ConcordCommunityState
.fold(listOf(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}""")), "cc".repeat(32).hexToByteArray(), owner)
.withDissolved(dissolved)
@Test
fun liveCommunityLetsTheOwnerPost() {
@@ -26,8 +26,13 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragments
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListIncompleteException
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -40,14 +45,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* The "leave a Concord community" path: `unfollow` read-modify-writes the private kind-13302 list.
* Everything that makes leaving safe lives here — it must drop only the named community, keep the
* other memberships (and their secrets) intact, and be a pure local list edit that never depends on
* the community's own (possibly dead) relays.
* The "leave a Concord community" path: `unfollow` read-modify-writes the Community List (CORD-02 §8).
* Everything that makes leaving safe lives here — it must remove only the named community, keep the
* other memberships (and their secrets) intact, leave a tombstone behind (only a tombstone subtracts
* a membership), and be a pure local list edit that never depends on the community's own (possibly
* dead) relays.
*/
class ConcordChannelListLeaveTest {
private val signer = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray()))
@@ -72,12 +78,19 @@ class ConcordChannelListLeaveTest {
/** Serves the list only from the offline backup — the state a dead-relay community lands in. */
private class BackupOnlyRepository(
var saved: ConcordCommunityListEvent?,
var fragments: List<ConcordCommunityListFragmentEvent> = emptyList(),
) : ConcordListRepository {
override fun concordList() = saved
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {
saved = newConcordList
}
override fun concordListFragments() = fragments
override fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) {
fragments = fragments.filterNot { it.index() == fragment.index() } + fragment
}
}
private class StubCache : ICacheProvider {
@@ -106,64 +119,123 @@ class ConcordChannelListLeaveTest {
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
private suspend fun state(vararg entries: ConcordCommunityListEntry) =
ConcordChannelListState(
signer = signer,
cache = StubCache(),
scope = CoroutineScope(Dispatchers.Unconfined),
// The cached note is empty (nothing folded from relays), so every read falls back to the
// offline backup — exactly the situation for a community whose relays no longer answer.
settings = BackupOnlyRepository(ConcordCommunityListEvent.create(signer, entries.toList())),
)
/** The cached notes are empty (nothing folded from relays), so every read falls back to the offline backup. */
private suspend fun state(vararg entries: ConcordCommunityListEntry): Pair<ConcordChannelListState, BackupOnlyRepository> {
val repo = BackupOnlyRepository(null)
val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = repo)
list.markRelaysConfirmed()
for (e in entries) list.follow(e)
return list to repo
}
private suspend fun readBack(fragments: List<Event>) = ConcordListFragmentSet.resolve(fragments.map { it as ConcordCommunityListFragmentEvent }, signer)
@Test
fun leavingDropsOnlyThatCommunity() =
fun leavingDropsOnlyThatCommunityAndTombstonesIt() =
runTest {
val list = state(entry(alpha, "Alpha"), entry(beta, "Beta"))
val (list, repo) = state(entry(alpha, "Alpha"), entry(beta, "Beta"))
val left = list.unfollow(alpha)!!
val remaining = left.decrypt(signer)
val left = list.unfollow(alpha)
assertEquals(1, left.size)
assertEquals(1, remaining.size)
assertEquals(beta, remaining[0].id)
val remaining = list.entries()
assertEquals(listOf(beta), remaining.map { it.id })
// The surviving membership keeps its secrets — leaving one community must not damage another.
assertEquals("2".repeat(64), remaining[0].root)
assertEquals(3L, remaining[0].rootEpoch)
val doc = readBack(repo.fragments).doc
val tombstoned = ConcordListFragments.removals(doc)
assertTrue(alpha in tombstoned, "a leave must leave a tombstone, or another fragment can re-add it")
}
@Test
fun leavingTheLastCommunityEmptiesTheList() =
runTest {
val list = state(entry(alpha, "Alpha"))
val left = list.unfollow(alpha)!!
assertTrue(left.decrypt(signer).isEmpty())
val (list, _) = state(entry(alpha, "Alpha"))
list.unfollow(alpha)
assertTrue(list.entries().isEmpty())
}
/** Nothing to publish when we weren't a member: the caller's publish is a no-op on null. */
/** Nothing to publish when we weren't a member. */
@Test
fun leavingSomethingWeNeverJoinedIsANoOp() =
runTest {
val list = state(entry(alpha, "Alpha"))
val (list, _) = state(entry(alpha, "Alpha"))
assertTrue(list.unfollow(beta).isEmpty())
}
assertNull(list.unfollow(beta))
@Test
fun reJoiningAfterALeaveResurrectsTheMembership() =
runTest {
val (list, _) = state(entry(alpha, "Alpha"))
list.unfollow(alpha)
val rejoin =
ConcordCommunityListEntry(
id = alpha,
owner = signer.pubKey,
ownerSalt = "1".repeat(64),
root = "2".repeat(64),
rootEpoch = 3,
name = "Alpha",
addedAt = Long.MAX_VALUE / 2,
)
list.follow(rejoin)
assertEquals(listOf(alpha), list.entries().map { it.id })
}
@Test
fun reJoiningInTheSameMillisecondStillOutranksTheLeave() =
runTest {
val (list, _) = state(entry(alpha, "Alpha"))
list.unfollow(alpha)
// A stale entry (added long before the leave) re-followed: it must come back live.
list.follow(entry(alpha, "Alpha"))
assertEquals(listOf(alpha), list.entries().map { it.id })
}
@Test
fun anUnloadedListRefusesToWrite() =
runTest {
// Nothing held and the relays not asked yet: writing would replace fragments we never saw.
val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = BackupOnlyRepository(null))
assertFailsWith<ConcordListIncompleteException> { list.follow(entry(alpha, "Alpha")) }
}
@Test
fun aMembershipOnlyTheRetiredListCarriesIsMigratedByTheNextWrite() =
runTest {
val repo = BackupOnlyRepository(ConcordCommunityListEvent.create(signer, listOf(entry(alpha, "Alpha"))))
val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = repo)
list.markRelaysConfirmed()
assertEquals(listOf(alpha), list.entries().map { it.id })
list.follow(entry(beta, "Beta"))
val migrated =
ConcordCommunityList
.decodeDocument(readBack(repo.fragments).doc)
.entries
.map { it.id }
.toSet()
assertEquals(setOf(alpha, beta), migrated)
}
/**
* The list is only readable by its owner, so the leave write must stay self-encrypted — a leave
* The list is only readable by its owner, so every fragment must stay self-encrypted — a leave
* that leaked the remaining memberships in cleartext would be worse than not leaving at all.
*/
@Test
fun theRewrittenListStaysSelfEncrypted() =
runTest {
val list = state(entry(alpha, "Alpha"), entry(beta, "Beta"))
val (list, _) = state(entry(alpha, "Alpha"), entry(beta, "Beta"))
val left = list.unfollow(alpha)!!
val left = list.unfollow(alpha).single() as ConcordCommunityListFragmentEvent
assertEquals(ConcordCommunityListEvent.KIND, left.kind)
assertEquals(ConcordCommunityListFragmentEvent.KIND, left.kind)
assertEquals("0", left.dTag())
assertTrue(beta !in left.content)
assertTrue(ConcordListFragments.hexToB64(beta) !in left.content)
val stranger = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000009".hexToByteArray()))
assertTrue(left.decrypt(stranger).isEmpty())
assertEquals(null, left.decryptPlaintext(stranger))
}
}
@@ -0,0 +1,308 @@
/*
* 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.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
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.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The Chat Plane batch at the commons layer: Private Channels on their own keys (S2), in-stream
* deletes (S3), the Chat ingest gate (S9) and the channel-name build cap (I14).
*/
class ConcordChatPlaneConformanceTest {
private val owner = NostrSignerInternal(KeyPair())
private val secretId = ByteArray(32) { 0x5C }
private val secretIdHex = secretId.toHexKey()
private val channelKey = ByteArray(32) { 0x3C }
private fun entryFor(
community: NewConcordCommunity,
privateChannels: List<PrivateChannelKey> = emptyList(),
heldRoots: List<HeldRoot> = emptyList(),
) = 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(),
heldRoots = heldRoots,
privateChannels = privateChannels,
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
/** Genesis plus a `private:true` channel edition, as the Control Plane delivers them. */
private suspend fun communityWithPrivateChannel(): Pair<NewConcordCommunity, List<Event>> {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val define =
ConcordModeration.defineChannel(
owner,
community.controlPlane,
community.communityId,
secretId,
ChannelEntity(name = "secret", private = true),
community.genesisEditions,
createdAt = 2L,
owner = community.ownerPubKey,
)
return community to (community.genesisWraps + define)
}
@Test
fun aPrivateChannelWithoutAHeldKeyIsNeverDerivedOnTheRootPlane() =
runTest {
val (community, control) = communityWithPrivateChannel()
val captured = mutableListOf<Event>()
val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor }
control.forEach { session.ingest(it) }
val state = session.state.value!!
assertTrue(state.channels[secretIdHex]!!.definition.private)
val rootPlane = ConcordActions.publicChannel(community.communityRoot, secretId, community.rootEpoch)
assertFalse(rootPlane.publicKeyHex in session.channelAddresses(), "a private channel must never be subscribed on the root plane")
assertTrue(session.streamKeys().none { it.publicKeyHex == rootPlane.publicKeyHex })
assertNull(session.currentChannelPlane(secretIdHex), "no plane to write without the key")
assertNull(ConcordActions.currentChannelPlane(session.entry, state, secretIdHex))
assertFalse(ConcordActions.canAccessChannel(session.entry, state, secretIdHex))
// The planner and the plane registry agree: nothing for the keyless private channel.
val subs = ConcordSubscriptionPlanner.channelPlaneSubs(session.entry, state)
assertTrue(subs.none { it.channelId?.channelId == secretIdHex })
val registry = ConcordPlaneRegistry().apply { registerChannels(session.entry, state) }
assertFalse(registry.isKnownPlane(rootPlane.publicKeyHex))
// A post someone made on the root-derived plane never reaches the store.
val leaked = ConcordActions.buildChannelMessage(owner, rootPlane, secretIdHex, community.rootEpoch, "psst", 3L)
assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(leaked))
assertTrue(captured.none { it.content == "psst" })
// The channel object is locked: no composer, no post.
val channel = ConcordChannel(ConcordChannelId(community.communityIdHex, secretIdHex))
channel.updateFrom(state, emptySet(), owner.pubKey, keyHeld = false)
assertFalse(channel.keyHeld)
assertFalse(channel.canPost())
// The public #general is untouched.
assertNotNull(session.currentChannelPlane(community.generalChannelIdHex))
}
@Test
fun aHeldPrivateKeyReadsAndWritesOnItsOwnPlaneAtTheChannelEpoch() =
runTest {
val (community, control) = communityWithPrivateChannel()
val channelEpoch = 3L
val entry = entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), channelEpoch, "secret")))
val captured = mutableListOf<Event>()
val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> captured += rumor }
control.forEach { session.ingest(it) }
val privatePlane = ConcordChannelKeys.privateChannel(channelKey, secretId, channelEpoch)
val plane = session.currentChannelPlane(secretIdHex)!!
assertEquals(privatePlane.publicKeyHex, plane.key.publicKeyHex)
assertEquals(channelEpoch, plane.epoch, "a private channel binds to its own epoch, not the root epoch")
assertTrue(privatePlane.publicKeyHex in session.channelAddresses())
assertTrue(session.streamKeys().any { it.publicKeyHex == privatePlane.publicKeyHex })
val rootPlane = ConcordActions.publicChannel(community.communityRoot, secretId, community.rootEpoch)
assertFalse(rootPlane.publicKeyHex in session.channelAddresses())
val subs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, session.state.value!!)
assertEquals(listOf(privatePlane.publicKeyHex), subs.filter { it.channelId?.channelId == secretIdHex }.map { it.pubKeyHex })
val msg = ConcordActions.buildChannelMessage(owner, privatePlane, secretIdHex, channelEpoch, "members only", 4L)
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(msg))
assertEquals(1, captured.count { it.content == "members only" })
// Bound to the ROOT epoch on the private plane: a binding mismatch, dropped.
val wrongEpoch = ConcordActions.buildChannelMessage(owner, privatePlane, secretIdHex, community.rootEpoch, "wrong epoch", 5L)
session.ingest(wrongEpoch)
assertTrue(captured.none { it.content == "wrong epoch" })
}
@Test
fun aKeyDeliveredLaterIsAdoptedInPlace() =
runTest {
val (community, control) = communityWithPrivateChannel()
val session = ConcordCommunitySession(entryFor(community), owner.pubKey)
control.forEach { session.ingest(it) }
assertNull(session.currentChannelPlane(secretIdHex))
val withKey = entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), 1L)))
assertTrue(session.adoptPrivateChannels(withKey))
assertEquals(ConcordChannelKeys.privateChannel(channelKey, secretId, 1L).publicKeyHex, session.currentChannelPlane(secretIdHex)?.key?.publicKeyHex)
assertFalse(session.adoptPrivateChannels(withKey), "adopting the same keys again is a no-op")
// The buffered Control Plane survived: still folded.
assertEquals(
"Nostrichs",
session.state.value
?.metadata
?.name,
)
}
@Test
fun aKeyArrivingWithControlMaterialIsStillAdoptedThroughTheRegistry() =
runTest {
val (community, control) = communityWithPrivateChannel()
// A plain member: holds the control_pk but not the control_root.
val member =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
relays = listOf("wss://r.example"),
)
val registry = ConcordSessionRegistry()
registry.sync(listOf(member), owner.pubKey)
val session = registry.sessionFor(community.communityIdHex)!!
control.forEach { session.ingest(it) }
assertNull(session.currentChannelPlane(secretIdHex))
// One list update delivers both the staff write key and the private channel key: the
// Control adoption swaps the entry first, and the channel planes must still follow.
registry.sync(listOf(entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), 2L)))), owner.pubKey)
assertEquals(session, registry.sessionFor(community.communityIdHex), "adopted in place, not rebuilt")
assertEquals(ConcordChannelKeys.privateChannel(channelKey, secretId, 2L).publicKeyHex, session.currentChannelPlane(secretIdHex)?.key?.publicKeyHex)
}
@Test
fun aDeleteRidesTheChannelPlaneAndLandsAsAChannelBoundKind5() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val captured = mutableListOf<Event>()
val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor }
community.genesisWraps.forEach { session.ingest(it) }
val plane = session.currentChannelPlane(community.generalChannelIdHex)!!
session.ingest(ConcordActions.buildChannelMessage(owner, plane.key, plane.channelIdHex, plane.epoch, "oops", 2L))
val message = captured.single { it.content == "oops" }
val delete = ConcordActions.buildChannelDelete(owner, plane.key, plane.channelIdHex, plane.epoch, listOf(message), 3L)
// The wrap is authored by the channel plane, never the author: nothing outside the
// community learns the rumor id.
assertEquals(plane.key.publicKeyHex, delete.pubKey)
assertEquals(ConcordStreamEnvelope.KIND_WRAP, delete.kind)
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(delete))
val kind5 = captured.single { it.kind == 5 }
assertEquals(listOf(message.id), kind5.tags.filter { it[0] == "e" }.map { it[1] })
assertEquals(listOf("9"), kind5.tags.filter { it[0] == "k" }.map { it[1] })
assertTrue(ChannelChat.isBoundTo(kind5, plane.channelIdHex, plane.epoch))
// A delete of an older message goes back to the plane that carried it.
assertEquals(plane, session.channelPlaneFor(plane.channelIdHex, plane.epoch))
}
@Test
fun theHistoricalPlaneOfAnOlderMessageIsFoundForItsDelete() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val priorRoot = KeyPair().pubKey
val session = ConcordCommunitySession(entryFor(community, heldRoots = listOf(HeldRoot(7L, priorRoot.toHexKey()))), owner.pubKey)
community.genesisWraps.forEach { session.ingest(it) }
val prior = session.channelPlaneFor(community.generalChannelIdHex, 7L)
assertEquals(ConcordActions.publicChannel(priorRoot, community.generalChannelId, 7L).publicKeyHex, prior?.key?.publicKeyHex)
}
@Test
fun chatIngestDropsOtherPlanesKindsAndPlaintextSeals() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val captured = mutableListOf<Event>()
val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor }
community.genesisWraps.forEach { session.ingest(it) }
val plane = session.currentChannelPlane(community.generalChannelIdHex)!!
val binding = arrayOf(arrayOf("channel", plane.channelIdHex), arrayOf("epoch", plane.epoch.toString()))
// A channel key-holder forging a Control edition (kind 3308) into the chat plane.
val forgedControl = RumorAssembler.assembleRumor<Event>(owner.pubKey, 2L, 3308, binding, "{}")
session.ingest(ConcordStreamEnvelope.wrap(forgedControl, plane.key, owner, encrypted = true))
// A Guestbook join (3306) likewise.
val forgedJoin = RumorAssembler.assembleRumor<Event>(owner.pubKey, 3L, 3306, binding, "join")
session.ingest(ConcordStreamEnvelope.wrap(forgedJoin, plane.key, owner, encrypted = true))
// A proper chat message in a plaintext (Control-only) seal.
val plaintextSeal = ChannelChat.message(owner.pubKey, plane.channelIdHex, plane.epoch, "plaintext", 4L)
session.ingest(ConcordStreamEnvelope.wrap(plaintextSeal, plane.key, owner, encrypted = false))
// A chat message with a malformed ms.
val badMs = RumorAssembler.assembleRumor<Event>(owner.pubKey, 5L, 9, binding + arrayOf(arrayOf("ms", "01")), "bad ms")
session.ingest(ConcordStreamEnvelope.wrap(badMs, plane.key, owner, encrypted = true))
assertTrue(captured.isEmpty(), "none of these may reach the store: ${captured.map { it.kind }}")
// And the good path still lands.
session.ingest(ConcordActions.buildChannelMessage(owner, plane.key, plane.channelIdHex, plane.epoch, "ok", 6L))
assertEquals(listOf("ok"), captured.map { it.content })
}
@Test
fun channelMessagesSortByTheMillisecondBasis() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val plane = ConcordActions.currentChannelPlane(entryFor(community), community.generalChannelIdHex, isPrivate = false)!!
suspend fun wrap(
text: String,
secs: Long,
ms: Int,
) = ConcordStreamEnvelope.wrap(ChannelChat.message(owner.pubKey, plane.channelIdHex, plane.epoch, text, secs, ms = ms), plane.key, owner, encrypted = true)
val msgs = ConcordActions.channelMessages(listOf(wrap("third", 11, 0), wrap("second", 10, 950), wrap("first", 10, 100)), plane.key, plane.channelIdHex, plane.epoch)
assertEquals(listOf("first", "second", "third"), msgs.map { it.content })
}
@Test
fun aChannelNameOverTheCapIsNeverMinted() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
assertFailsWith<IllegalArgumentException> {
ConcordModeration.defineChannel(owner, community.controlPlane, community.communityId, secretId, ChannelEntity(name = "x".repeat(65)), community.genesisEditions, 2L, owner = community.ownerPubKey)
}
assertFailsWith<IllegalArgumentException> {
ConcordModeration.defineChannel(owner, community.controlPlane, community.communityId, secretId, ChannelEntity(name = ""), community.genesisEditions, 2L, owner = community.ownerPubKey)
}
}
}
@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -31,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ConcordCommunitySessionTest {
@@ -162,4 +165,39 @@ class ConcordCommunitySessionTest {
val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example"))
assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(outsider.genesisWraps.first()))
}
private fun entryFor(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(),
relays = listOf("wss://r.example"),
name = "Doomed",
)
@Test
fun anOwnerTombstoneAtTheDissolvedAddressSealsTheCommunity() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Doomed", createdAt = 1L, relays = listOf("wss://r.example"))
val session = ConcordCommunitySession(entryFor(community), owner.pubKey)
community.genesisWraps.forEach { session.ingest(it) }
assertFalse(session.state.value!!.dissolved)
assertTrue(session.auxStreamKeys().any { it.publicKeyHex == session.dissolvedAddress }, "the grave must be AUTHed for")
// A stranger can sign at the (public) address, but only the owner's tombstone counts.
val stranger = NostrSignerInternal(KeyPair())
session.ingest(ConcordDissolution.build(stranger, community.communityIdHex))
assertFalse(session.state.value!!.dissolved)
session.ingest(ConcordDissolution.build(owner, community.communityIdHex))
assertTrue(session.state.value!!.dissolved)
// One-way: a later control fold never clears it.
community.genesisWraps.forEach { session.ingest(it) }
assertTrue(session.state.value!!.dissolved)
}
}
@@ -26,8 +26,11 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragments
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -131,4 +134,29 @@ class ConcordListLateArrivalTest {
assertEquals(1, state.liveCommunities.value.size)
assertEquals(alpha, state.liveCommunities.value[0].id)
}
@Test
fun lateArrivingFragmentDecryptsAndSurfaces() =
runTest {
val cache = StubCache()
val state =
ConcordChannelListState(
signer = signer,
cache = cache,
scope = CoroutineScope(Dispatchers.Unconfined),
settings = NoBackupRepository(),
)
assertEquals(emptyList(), state.liveCommunities.value)
// A relay delivers fragment 0 of the kind-33302 List into the note the state watches.
val internal = ConcordCommunityList.encodeInternal(listOf(entry(alpha, "Alpha")))
val fragment = ConcordCommunityListFragmentEvent.create(signer, 0, ConcordListFragments.pack(internal).single())
val author = User(signer.pubKey) { addr -> Note(addr.toValue()) }
cache.getOrCreateAddressableNote(fragment.address()).loadEvent(fragment, author, emptyList())
withTimeout(5000) {
while (state.liveCommunities.value.isEmpty()) yield()
}
assertEquals(listOf(alpha), state.liveCommunities.value.map { it.id })
}
}
@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -42,13 +44,20 @@ class ConcordMembershipTest {
author: String = owner,
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0)
private val communityId = "cc".repeat(32).hexToByteArray()
private val authority =
AuthorityResolver.resolve(
listOf(
ed(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), // KICK|BAN|MANAGE_ROLES
ed(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$admin","role_ids":["$adminRole"]}"""),
ed(ControlEntityKind.BANLIST, "44".repeat(32), """["$banned"]"""),
ed(
ControlEntityKind.GRANT,
ConcordKeyDerivation.grantCoordinate(communityId, admin.hexToByteArray()).toHexKey(),
"""{"member":"$admin","role_ids":["$adminRole"]}""",
),
ed(ControlEntityKind.BANLIST, ConcordKeyDerivation.banlistCoordinate(communityId).toHexKey(), """["$banned"]"""),
),
communityId,
owner,
)
@@ -66,7 +66,7 @@ class ConcordPlaneRegistryTest {
assertEquals(community.communityIdHex, routedControl.plane.communityId)
// After folding + registering channels, a channel message routes to CHANNEL.
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
registry.registerChannels(entry, state)
val channel = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch)
@@ -78,7 +78,7 @@ class ConcordRollbackFloorTest {
// new epoch's plane is split and addressed by the derived signer, not the root.
val newControlRoot = ByteArray(32) { 0x44 }
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey)
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, community.ownerPubKey)
val entry =
ConcordCommunityListEntry(
@@ -145,7 +145,7 @@ class ConcordRollbackFloorTest {
val newControlRoot = ByteArray(32) { 0x44 }
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
// Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives.
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.ownerPubKey)
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.communityId, community.ownerPubKey)
val entry =
ConcordCommunityListEntry(
@@ -186,7 +186,7 @@ class ConcordRollbackFloorTest {
// new epoch's plane is split and addressed by the derived signer, not the root.
val newControlRoot = ByteArray(32) { 0x44 }
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey)
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, community.ownerPubKey)
val entry =
ConcordCommunityListEntry(
@@ -246,10 +246,11 @@ class ConcordRollbackFloorTest {
val floors =
ConcordCommunityState.authorizedHeads(
ConcordActions.controlEditions(community.genesisWraps + rogueEdit, community.controlPlane),
community.communityId,
community.ownerPubKey,
)
val metadataFloor = floors[community.communityIdHex]
assertEquals(0L, metadataFloor?.version, "an unauthorized edition must not raise the floor")
assertEquals(1L, metadataFloor?.version, "an unauthorized edition must not raise the floor")
}
}
@@ -107,10 +107,11 @@ class LatestEventCacheStoreTest {
assertEquals("latestUserMetadata", LatestEventSlot.USER_METADATA.prefKey)
assertEquals("latestContactList", LatestEventSlot.CONTACT_LIST.prefKey)
assertEquals("latestNIP65RelayList", LatestEventSlot.NIP65_RELAY_LIST.prefKey)
assertEquals(26, LatestEventSlot.entries.size)
assertEquals("latestConcordListFragments", LatestEventSlot.CONCORD_LIST_FRAGMENTS.prefKey)
assertEquals(27, LatestEventSlot.entries.size)
assertEquals(
"prefKeys must be unique",
26,
27,
LatestEventSlot.entries
.map { it.prefKey }
.toSet()
@@ -209,7 +209,7 @@ class LegacyKeyTableTest {
)
assertEquals(FollowListSlot.entries.size, TopNavFollowListStore.legacyTable.keys.size)
assertEquals(LatestEventSlot.entries.size, LatestEventCacheStore.legacyTable.keys.size)
assertEquals(LatestEventSlot.entries.count { it.existedInLegacyPrefs }, LatestEventCacheStore.legacyTable.keys.size)
}
/** Several tables share one store, so their markers must not collide. */
@@ -599,6 +599,7 @@
<string name="concord_edit_banner_hint">Add a banner</string>
<string name="concord_edit_relays_desc">Where this community's encrypted planes are published and read.</string>
<string name="concord_dissolved_read_only">This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.</string>
<string name="concord_private_channel_no_key">This is a private channel and you don't hold its key, so you can't read it or post here.</string>
<string name="concord_create_name">Name</string>
<string name="concord_create_about">About (optional)</string>
<string name="concord_ban_user">Ban</string>
@@ -515,10 +515,10 @@ class AccountViewModel(
reaction: String,
) {
// Concord messages are encrypted: a public kind-7 would e-tag the private rumor id onto
// public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an
// existing Concord reaction is a follow-up; for now this only adds one.)
// public relays. Route the reaction through a channel-plane wrap instead; tapping it again
// retracts it with an in-channel kind-5 delete (CORD-01), never a NIP-17 one.
if (note.inGatherers?.any { it is ConcordChannel } == true) {
launchSigner { account.concord.reactToConcordMessage(note, reaction) }
launchSigner { account.concord.toggleConcordReaction(note, reaction) }
return
}
@@ -0,0 +1,108 @@
# Concord spec conformance review (spec `b84554e`, 2026-08-15)
## Why
Our Concord implementation last tracked the spec at `bbc67b6` (2026-08-06, CORD-02 §2
`control_root`). Since then the spec moved on, and several sections that predate that
point were never implemented. This review compares every CORD against
<https://github.com/concord-protocol/concord> at `b84554e`, cross-checked against the
Armada reference client (`soapbox-pub/armada` at `7588884`, 2026-09-28), and records what
we fixed and what is left.
Method: one audit per spec area (CORD-01/03, CORD-02 §2/§5/§6 + CORD-04, CORD-02 §7/§9 +
CORD-05/06, CORD-07/08), each reading our code against the spec text and against
Armada. CORD-02 §8 (the Community List) was reviewed by hand against Armada's
`listFrag.ts` / `communityList.ts`. Status legend: **fixed** (this branch, with tests),
**open** (not done, with the reason).
## What changed upstream since our last pass
| Commit | Date | Change | Our state before this review |
|---|---|---|---|
| `d584686` | 07-27 | CORD-02 §9: the dissolution tombstone's `eid` is the `community_id`; verifiers MUST refuse anything else, including the old all-zero `eid` | Not implemented at all: we never derived `dissolved_pk`, never read or wrote a tombstone, and folded a vsk-10 *Control Plane* edition as dissolution with no `eid` check |
| `e8c4eeb` | 08-02 | CORD-04 §7: provable, rotation-proof Pins (vsk 11, `PIN_MESSAGES` bit 11, `concord/pins` coordinate, NIP-44 key-disclosure proof bundles) | Missing (only the permission bit existed) |
| `93fbecf` | 08-03 | CORD-08: Disappearing Messages (`message_expiration` in metadata, NIP-40 tags on rumor + wrap, kind 1740 timer notice) | Missing, and worse: a metadata edit dropped the field, silently turning off Armada's default 30-day timer for everyone |
| `96f0647`, `bbc67b6` | 08-06 | CORD-02 §2 `control_root` write gate | Implemented (v1.14.0) |
| `759448a`, `ee6f639` | 08-11/15 | CORD-02 §8: the Community List becomes fragmented kind **33302** (13302 retired), unpadded base64url for every 32-byte value, `seed` omitted when equal to `current`, mandatory tombstones, read-modify-write, a byte ceiling instead of a 50-entry cap | Still on 13302, hex, no tombstones (leaving just dropped the entry) |
| `01.md` | 08-11 | Encrypted application documents may choose their own encoding (only §8 does) | n/a |
Still-open upstream PRs worth tracking: **#23** (authenticate `community_root` at accept;
would make our stranded-recovery root move an explicit MUST violation), **#22**
(community-owned `av_brokers` in metadata), **#17** (Community Signals, vsk 12),
**#16** (CORD-09 blinded mention locators), **#7** (drop the `ms` tag). #12 (split
read/write planes) is superseded by the merged `control_root` design.
## Findings
Ranked security > interop > feature inside each group.
### Security
| # | Spec | Finding | Status |
|---|---|---|---|
| S1 | 02 §9 | Dissolution: no `dissolved_pk` plane, no `eid` binding, spec tombstone (chainless, no `ev`) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check | **fixed** — `ConcordDissolution` (derive, build, verify with `eid == community_id`, 20014 seal, owner author); session + planner subscribe the plane; CLI `amy concord dissolve`; the Control Plane fold no longer reads vsk 10 |
| S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | **fixed** — `ConcordActions.currentChannelPlane`/`historicalChannelPlanes` derive a private channel only from the entry's held `privateChannels` key at its channel epoch (never the root plane); session, planner, plane registry, app verbs and CLI all go through it; a keyless private channel has no plane (`ConcordChannel.keyHeld`/`canPost()` false, composer replaced by a notice, `amy concord send` → `no_channel_key`); held keys adopt in place, and invite-delivered keys are stored on join. Creating private channels stays open (F7) |
| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | **fixed** — `ChannelChat.delete` (examples §2.4: binding + `e` per target + `k` per kind) sealed 20013 on the channel plane (`ConcordActions.buildChannelDelete`); `Account.delete`/`deletePrivately` route Concord notes (messages, replies, reactions) to `AccountConcordActions.deleteConcordRumors`, each target on the plane of its bound epoch (Armada always uses the current plane); tapping an own Concord reaction retracts it the same way |
| S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | **fixed** — `ConcordStrandedRecovery` only detects (`isStranded`); the background sweep never moves the base (exposes `strandedConcordCommunities`); moving forward from a bundle needs the user to re-open the link (`joinConcordViaInvite`, `amy concord recover --rejoin`). PR #23's accept-time root gate not implemented (text unavailable; genuine owner editions re-wrap into any plane, so it needs the PR's exact rule) |
| S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | **fixed** — `AuthorityResolver.resolve` / `ConcordCommunityState.fold` / `authorizedHeads` take the `community_id`; a Grant edition counts only when its `eid == grant_locator(cid, content.member)` |
| S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | **fixed** — the Banlist folds to ONE authority-gated head at `banlist_locator(cid)`; the rank-delta rule is kept; re-heal = the writer re-applying atop the winner (`ConcordModeration.ban` again after refold) |
| S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | **fixed** — `EditionFold.foldEntityGated`/`foldGated` take an author rank: authority first, then rumor id, both in the head pick (Armada `pickHead`) and in the walk's anchor/next-link choice. The walk part goes past Armada (whose `version.fold` walks on rumor id only, so a lower-id fork can strand an edition chained on the owner's sibling) — spec followed |
| S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | **fixed** — metadata is read only at `eid == community_id` (a fold gate, `AuthorityResolver.isWellFormed`) |
| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | **fixed** — control: `ControlEdition.fromOpened` refuses a non-20014 seal; used by the session, `ConcordActions.controlEditions` (CLI) and Refounding compaction; chat: `ChannelChat.acceptOpened` requires a 20013 seal, a `CHAT_KINDS` rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed `ms`, for stored and typing wraps; `EventCache.consumeConcordRumor` refuses non-chat kinds too |
| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | **fixed** — `ConcordStreamEnvelope` seal/wrap refuse a plaintext over 65,535 UTF-8 bytes (Armada `encryptChecked`), and open refuses an extended-format payload before decrypting |
| S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | **fixed** — `ConcordInviteBundle.bound`: >256 channels refused, `relays` de-duplicated + truncated to 5, applied in `parse` (link bundles) and `ConcordDirectInvite.parse`; fragments decode ≤3 relays |
| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | **fixed** — `compactControlPlane(…, mustCarry)` throws `IncompleteControlPlaneException` on a missing honored head; app sweeps the plane paged (majority of relays DRAINED) and CLI pages it; rekey chunks are `publishAndConfirm`ed first, compaction published only after |
| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | **fixed** — dissolved check in `drainConcordRekeys`, `recoverStrandedConcordCommunities`, `refoundConcordCommunity`, the explicit rejoin, and CLI `rekey`/`recover`/`refound` (`ConcordCommands.isDissolved`) |
| S14 | 05 §2 | `classify` trusts the relay filter: no signature, `pubkey == link_signer`, or `d == ""` check; a relay can forge a revocation | **fixed** — `classify(wraps, linkSignerPubKey, token)` keeps only events with kind 33301, author == link signer, `d == ""` and a valid signature (`isAtCoordinate`); every caller passes the signer |
### Interop (Armada drops or diverges)
| # | Spec | Finding | Status |
|---|---|---|---|
| I1 | 02 §8 | Community List on retired 13302, hex, no fragments, no tombstones | in progress (this branch) |
| I2 | 02 §6 | Metadata/Channel edits rebuilt from scratch, wiping `custom`, `message_expiration` (CORD-08), `av_brokers` | **fixed** — `ConcordJson.encodePreserving` lays every edit over the authorized head; metadata/channel forms start from the folded entity |
| I3 | 03 §2 | Per-channel `voice` flag still modeled and rendered (every Channel is callable since `23dcea5`) | **fixed** — field removed (rides through as an unknown key), Mic icon and blank-preview special case removed |
| I4 | 04 §1/§5 | `vac` never written or verified — Armada drops every non-owner edition we author | **fixed** — `ConcordModeration` stamps every non-owner edition with `AuthorityCitations.forActor` (own grant coordinate, folded head version + hash); every fold gate (roles, grants, banlist, metadata, channels, unmodeled kinds, floors/compaction) requires it per Armada `citationSatisfied` |
| I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | **fixed** — `RoleEntity.roleId` written by `defineRole`; read accepts a legacy role without it, refuses a mismatching one |
| I6 | 04 §1 | First edition is v0; spec says versions start at 1 | **fixed** — genesis and every new entity start at v1; v0 chains still read |
| I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | **fixed** — a canonical unmodeled `vsk` parses with `entityKind == null` + raw `vsk`; floors and compaction carry its gated head verbatim (11 by `PIN_MESSAGES`, 12 by `MANAGE_CHANNELS`, others by any staff bit). vsk 6/7/9/10 are no longer parsed as Control editions |
| I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | **fixed** — `ConcordRekey.tags` takes a 1-based index (`require 1..n`); `chunkOf` parses strict decimals and refuses 0 / `i > n`; receivers drop malformed chunks |
| I9 | 06 §3 | Rotations carry no `vac` | **fixed** — rotations carry `vac` on every chunk (`ConcordRotationAuthority.citationFor`, owner none); receivers require `ConcordReceive.isHonoredRotation` (BAN + `citationSatisfied`, Armada semantics) in the app drain and CLI `rekey`; a rotator whose chunks cite different Grants is dropped |
| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | **fixed** — `ConcordRekey.chunkBlobs` budgets the rumor JSON at 40,960 bytes (Armada `REKEY_RUMOR_MAX_BYTES`) plus the 120 count cap; test pins the seal (wrap plaintext) ≤ 65,535 with 136-byte blobs |
| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | **fixed** — `encodeFragment` truncates non-stock lists to 3 (stock set stays a flag); `decodeFragment` refuses count > 3 |
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys (no private channels yet, F7) |
| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | **fixed** — exactly one `channel` and one `epoch` tag, epoch compared as its canonical decimal string (Armada `uniqueTag`/`checkChannelBinding`); builders drop binding tags smuggled in `extraTags` |
| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | **fixed** — any gated channel edition with `deleted:true` retires the channel for good (Armada `everDeleted`); the channel gate refuses an empty or >64-byte name so the fold falls back to the previous candidate, and `defineChannel`/create/rename refuse to mint one |
| I15 | 02 §4 | No `ms` tag on chat rumors | **fixed** — every `ChannelChat` rumor carries `["ms", 0..999]` after the binding; malformed/duplicated `ms` drops the rumor; `channelMessages` and edit recency order by `created_at*1000+ms` (the shared feed still sorts by `created_at`; open PR #7 may drop `ms`) |
| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | **fixed** — four-element `q` (the `p` credit stays, as Armada keeps it). Also CORD-03 §3: your own kind-1111 thread replies can now be edited (kind 3302) like kind-9 messages |
| I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | **fixed** — `ConcordLimits`; refused on write (factory, `ConcordModeration`, app verbs, CLI) and enforced at fold like Armada: over-cap role/metadata editions fall back, a Grant's `role_ids` trim to 64, the Community keeps its 100 lowest `role_id`s. Also: `ev`/`vac`/`vsk` must be canonical decimals and a duplicate `vsk`/`eid`/`ev`/`ep`/`vac` invalidates the edition; CLI knows `VIEW_AUDIT_LOG`/`MENTION_EVERYONE`/`PIN_MESSAGES`; the app's Admin role matches Armada's `ADMIN_ALL` |
| I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | **fixed** — join echoes `creator_npub`/`label` in the Guestbook Join (mints now set `creator_npub`); `amy concord join` publishes a Guestbook Join; Invite List merge is first-wins per token; untyped tombstones carried as `opaqueTombstones` and still retire their token |
### Features
| # | Spec | Finding | Status |
|---|---|---|---|
| F1 | 04 §7 | Pins | open → pins batch |
| F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | metadata field + parse **fixed**; the rest open → chat-plane batch |
| F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass |
| F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | open → chat-plane batch (quartz only) |
| F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | open |
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | open |
| 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 |
| F10 | 03 | WebXDC (kind 3310) | open |
## Spec issues to raise upstream
- CORD-06 §1 counts rekey capacity in blobs ("up to 120 participants per event"), but 120 base
blobs overflow NIP-44's 65,535-byte plaintext once wrapped; the spec should state the byte budget
(Armada uses a 40,960-byte rumor ceiling).
- The rekey blob plaintext is base64-encoded before NIP-44 (signer APIs take strings); unpinned by
the spec, as Armada's own comment notes.
- Rekey seal kind (20013) and the `chunk` indexing base are implied by examples only; worth a MUST.
- 02 §8 and examples §6.2 cite "a dissolution payload (CORD-06 §1)", but CORD-06 defines no
such payload, and Armada has none. Dangling reference.
- CORD-07 §2 should require a nonce in the 27235 grant (Armada already adds one): two
members requesting in the same second otherwise produce the same event id and collide
in the broker's mandatory replay set.
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
@@ -86,6 +87,10 @@ object ConcordCommunityFactory {
relays: List<String> = emptyList(),
icon: ImagePointer? = null,
): NewConcordCommunity {
// CORD-02 §6 caps, which every reader also enforces at fold: an over-long genesis name
// would leave the community with no metadata at all.
require(ConcordLimits.nameFits(name)) { "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" }
require(ConcordLimits.descriptionFits(description)) { "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes" }
val ownerXOnly = ownerSigner.pubKey.hexToByteArray()
val ownerSalt = ConcordKeyDerivation.newOwnerSalt()
val communityId = ConcordKeyDerivation.communityId(ownerXOnly, ownerSalt)
@@ -114,7 +119,7 @@ object ConcordCommunityFactory {
authorPubKey = ownerSigner.pubKey,
entityKind = ControlEntityKind.METADATA,
entityId = communityId, // metadata eid == community id
version = 0,
version = 1,
prevHash = null,
content = metadataJson,
createdAt = createdAt,
@@ -124,7 +129,7 @@ object ConcordCommunityFactory {
authorPubKey = ownerSigner.pubKey,
entityKind = ControlEntityKind.CHANNEL,
entityId = generalChannelId, // channel eid == channel id
version = 0,
version = 1,
prevHash = null,
content = channelJson,
createdAt = createdAt,
@@ -126,6 +126,29 @@ class ConcordListResidue(
val tombstones: List<JsonObject> = emptyList(),
val unparsedEntries: List<JsonObject> = emptyList(),
) {
/**
* This residue with [communityId] tombstoned at [removedAtMs] (CORD-02 §8). There is exactly
* one tombstone per community: a later removal replaces an earlier one (keeping its unknown
* keys), an earlier one changes nothing.
*/
fun withTombstone(
communityId: String,
removedAtMs: Long,
): ConcordListResidue {
val prior = tombstones.firstOrNull { (it["community_id"] as? JsonPrimitive)?.contentOrNull == communityId }
val priorAt = (prior?.get("removed_at") as? JsonPrimitive)?.longOrNull
if (priorAt != null && priorAt >= removedAtMs) return this
val next = JsonObject((prior ?: NoExtras) + mapOf("community_id" to JsonPrimitive(communityId), "removed_at" to JsonPrimitive(removedAtMs)))
return ConcordListResidue(extras, tombstones.filterNot { it === prior } + next, unparsedEntries)
}
/** The latest `removed_at` this residue holds for [communityId], or null when it was never left. */
fun removedAt(communityId: String): Long? =
tombstones
.filter { (it["community_id"] as? JsonPrimitive)?.contentOrNull == communityId }
.mapNotNull { (it["removed_at"] as? JsonPrimitive)?.longOrNull }
.maxOrNull()
companion object {
val EMPTY = ConcordListResidue()
}
@@ -434,6 +457,39 @@ object ConcordCommunityList {
return ConcordJson.instance.encodeToString(JsonObject.serializer(), merged)
}
/**
* [encode] as a JSON object: the internal document shape [ConcordListFragments] merges and
* packs into kind-33302 fragments.
*/
fun encodeInternal(
entries: List<ConcordCommunityListEntry>,
residue: ConcordListResidue = ConcordListResidue.EMPTY,
): JsonObject = ConcordJson.instance.parseToJsonElement(encode(entries, residue)).jsonObject
/**
* The List as a reader sees it (CORD-02 §8): the union of the kind-33302 fragments in [set]
* and, when one exists, the retired kind-13302 document's [legacyPlaintext] — read as a rescue
* source, so a membership only the old event carries stays joined until a write migrates it.
* A legacy document that does not parse contributes nothing.
*/
fun readWithLegacy(
set: ConcordListFragmentSet,
legacyPlaintext: String?,
): JsonObject {
val legacy =
legacyPlaintext?.let {
try {
ConcordJson.instance.parseToJsonElement(it) as? JsonObject
} catch (_: Exception) {
null
}
} ?: return set.doc
return ConcordListFragments.mergeDocs(legacy, set.doc)
}
/** Decodes an internal document (a merged fragment set, or a legacy 13302 plaintext). */
fun decodeDocument(doc: JsonObject): ConcordCommunityListDocument = decodeDocument(ConcordJson.instance.encodeToString(JsonObject.serializer(), doc))
/**
* Parses the decrypted plaintext JSON document back into live entries, or empty on
* failure. An entry is live unless a tombstone for the same community removed it
@@ -585,6 +641,26 @@ object ConcordCommunityList {
residue = residue,
)
/** Copy of this entry with [addedAt] (ms); every other field untouched. */
fun ConcordCommunityListEntry.withAddedAt(addedAt: Long) =
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,
)
/** Copy of this entry carrying [inviteRef]; every other field untouched. */
fun ConcordCommunityListEntry.withInviteRef(inviteRef: String?) =
ConcordCommunityListEntry(
@@ -29,8 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* **Retired** (CORD-02 §8): the single-event Community List, superseded by the fragmented kind
* 33302 [ConcordCommunityListFragmentEvent] once it outgrew one event. Still read, as a rescue
* source unioned into the fragments, so memberships only this event carries are migrated by the
* next write; never written.
*
* The member's private, self-encrypted list of joined Concord communities (kind
* 13302, CORD-05). A replaceable event whose
* `content` is the NIP-44 self-encryption of the [ConcordCommunityListEntry] JSON
@@ -59,6 +65,16 @@ class ConcordCommunityListEvent(
override fun isContentEncoded() = true
/** The decrypted plaintext (the internal document shape), or null when it does not open. */
suspend fun decryptPlaintext(signer: NostrSigner): String? =
try {
signer.nip44Decrypt(content, signer.pubKey)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
/** Decrypts this list's entries with [signer], or empty on failure / wrong key. */
suspend fun decrypt(signer: NostrSigner): List<ConcordCommunityListEntry> = decryptDocument(signer).entries
@@ -0,0 +1,108 @@
/*
* 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.cord02Community
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* One fragment of a member's Community List (CORD-02 §8, kind 33302): addressable at
* `d` = the fragment index in decimal, NIP-44-encrypted to self, signed by the member's real key.
*
* The List is split across as many of these as it needs — it has no membership limit, only a
* per-event byte ceiling — and a reader unions every fragment below the declared `frags` count
* ([ConcordListFragmentSet]). It supersedes the single replaceable kind-13302
* [ConcordCommunityListEvent], which a replaceable kind could never fragment.
*/
@Immutable
class ConcordCommunityListFragmentEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
DiffableEvent<ConcordCommunityListDiff> {
override fun diffFrom(older: Event): ConcordCommunityListDiff? {
if (older !is ConcordCommunityListFragmentEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
return ConcordCommunityListDiff(ContentChange.between(older.content, content))
}
override fun isContentEncoded() = true
/**
* The fragment index this event occupies, or null when its `d` is not a canonical decimal
* (no sign, no leading zeros) — such an event sits at no index and is ignored.
*/
fun index(): Int? = parseIndex(dTag())
/**
* The decrypted plaintext, or null when it does not open for [signer]. A null makes the
* fragment's index unreadable, which blocks any repack — so a transient signer failure (a
* timed-out bunker, a backgrounded signer app) costs a write, never a membership.
* Cancellation is rethrown.
*/
suspend fun decryptPlaintext(signer: NostrSigner): String? =
try {
signer.nip44Decrypt(content, signer.pubKey)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
companion object {
const val KIND = 33302
const val ALT = "Private list of joined Concord communities"
fun createAddress(
pubKey: HexKey,
index: Int,
) = Address(KIND, pubKey, index.toString())
fun parseIndex(d: String): Int? {
if (d.isEmpty() || d.length > 9) return null
if (d.length > 1 && d[0] == '0') return null
if (!d.all { it in '0'..'9' }) return null
return d.toInt()
}
/** Encrypts [plaintext] to self and signs it as fragment [index]. */
suspend fun create(
signer: NostrSigner,
index: Int,
plaintext: String,
createdAt: Long = TimeUtils.now(),
): ConcordCommunityListFragmentEvent {
val content = signer.nip44Encrypt(plaintext, signer.pubKey)
return signer.sign(createdAt, KIND, arrayOf(arrayOf("d", index.toString())), content)
}
}
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord04Roles.asFloor
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
/** A channel id paired with its current folded definition. */
data class ConcordChannel(
@@ -57,7 +58,16 @@ data class ConcordCommunityState(
val authority: AuthorityResolver,
val dissolved: Boolean,
) {
/**
* This state with [dissolved] set from the community's dissolution plane
* ([ConcordDissolution.isDissolved]). One-way by the caller's contract: there is no un-dissolve.
*/
fun withDissolved(dissolved: Boolean): ConcordCommunityState = if (dissolved == this.dissolved) this else copy(dissolved = dissolved)
companion object {
/** The Community Signals sub-kind (CORD-04 §8, upstream PR #17), carried but not modeled here. */
private const val VSK_SIGNALS = "12"
/**
* The permission bit an edition of each entity kind must be authored under.
* `null` means owner-only (no bit grants it). Mirrors the per-kind gating
@@ -71,12 +81,40 @@ data class ConcordCommunityState(
ControlEntityKind.BANLIST -> ConcordPermissions.BAN
ControlEntityKind.INVITE_LIVE, ControlEntityKind.INVITE_REGISTRY, ControlEntityKind.INVITE_REVOKED -> ConcordPermissions.CREATE_INVITE
ControlEntityKind.DISSOLVED -> null
ControlEntityKind.PIN_LIST -> ConcordPermissions.PIN_MESSAGES
}
/**
* Whether a reader honors [edition] as its entity's head: well-formed at its coordinate,
* authored by the owner or a holder of the kind's bit, citing the Grant it acts under.
*
* A sub-kind we do not model is still gated — a floor or a compaction must only remember
* editions some reader honors: a Signal by `MANAGE_CHANNELS` (the one gate Armada implements, `pause`), and anything newer by any
* staff bit, the set whose actions are Control editions at all (CORD-04 §3).
*/
private fun honors(
authority: AuthorityResolver,
edition: ControlEdition,
): Boolean {
val kind = edition.entityKind ?: return honorsUnmodeled(authority, edition)
return authority.admits(edition, requiredPermission(kind))
}
private fun honorsUnmodeled(
authority: AuthorityResolver,
edition: ControlEdition,
): Boolean =
when (edition.vsk) {
VSK_SIGNALS -> authority.admits(edition, ConcordPermissions.MANAGE_CHANNELS)
else -> authority.isOwner(edition.author) || (authority.isStaff(edition.author) && authority.citationSatisfied(edition))
}
/**
* The authority-gated structural head of **every** control entity, keyed by
* [ControlEdition.entityIdHex] — the source of the anti-rollback [EntityFloor]s a
* client carries across a CORD-06 Refounding.
* client carries across a CORD-06 Refounding, and the set of heads a Refounding's
* compaction re-wraps (sub-kinds we do not model included, so another client's Pins
* or Signals survive our Refounding).
*
* It is deliberately gated the same way [fold] gates each entity kind (and, for
* [ControlEntityKind.DISSOLVED], owner-only): an *ungated* head map would let any
@@ -86,6 +124,7 @@ data class ConcordCommunityState(
*/
fun authorizedHeads(
editions: Collection<ControlEdition>,
communityId: ByteArray,
ownerPubKey: String,
floors: Map<String, EntityFloor> = emptyMap(),
): Map<String, EntityFloor> {
@@ -96,15 +135,14 @@ data class ConcordCommunityState(
// mentioned correctly keeps chain-walk semantics.
val snapshot = editions.mapTo(HashSet(editions.size)) { it.rumorId }
val pool = EditionFold.admissible(editions, floors, snapshot = snapshot)
val authority = AuthorityResolver.resolve(pool, ownerPubKey)
val authority = AuthorityResolver.resolve(pool, communityId, ownerPubKey)
val out = HashMap<String, EntityFloor>(floors)
for ((kind, list) in pool.groupBy { it.entityKind }) {
val bit = requiredPermission(kind)
for ((_, list) in pool.groupBy { it.entityKind }) {
// Gate the CANDIDATES, don't pre-filter the chain: a rejected edition mid-chain must
// stay inert instead of orphaning the authorized editions above it (EditionFold.candidates).
val heads =
EditionFold.foldGated(list, floors, snapshot = snapshot) {
authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit))
EditionFold.foldGated(list, floors, snapshot = snapshot, rank = authority::tieBreakRank) {
honors(authority, it)
}
for ((entity, head) in heads) {
// Monotonic: a floor only ever rises. Folding epoch by epoch, an entity the
@@ -116,8 +154,14 @@ data class ConcordCommunityState(
return out
}
/**
* Folds one epoch's Control Plane [editions] of the community [communityId] (which pins
* every derived entity coordinate, CORD-04 §1) owned by [ownerPubKey] into its current
* state, honoring the anti-rollback [floors] carried from earlier epochs.
*/
fun fold(
editions: Collection<ControlEdition>,
communityId: ByteArray,
ownerPubKey: String,
floors: Map<String, EntityFloor> = emptyMap(),
): ConcordCommunityState {
@@ -133,11 +177,10 @@ data class ConcordCommunityState(
@Suppress("NAME_SHADOWING")
val editions = EditionFold.admissible(editions, floors, snapshot = snapshot)
val heads = EditionFold.fold(editions, floors, snapshot = snapshot).values
// Resolve authority from the FULL edition set (not the structural heads): the resolver
// folds each role/grant chain through authorized editions only, so a rogue higher-version
// edition can't supersede a legit one before authority is even judged.
val authority = AuthorityResolver.resolve(editions, ownerPubKey)
val authority = AuthorityResolver.resolve(editions, communityId, ownerPubKey)
// CORD-04 §1: "an edition whose signer isn't authorized is dropped." Authority is
// owner-rooted (the AuthorityResolver resolves it from the owner outward via the grant
@@ -149,25 +192,46 @@ data class ConcordCommunityState(
// remaining editions version-descending), never as a pre-filter on the chain: dropping a
// rejected edition out of the middle of a chain permanently orphans every honest edition
// above it, freezing the entity. See EditionFold.candidates.
//
// Every gate also demands the edition sit at its derived coordinate and cite the Grant
// its author acts under (CORD-04 §5, `vac`) — AuthorityResolver.admits — and an
// equal-version tie goes to the higher-ranked author before the rumor id (§1).
fun foldGatedBy(
kind: ControlEntityKind,
bit: Int,
): Map<String, ControlEdition> =
EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors, snapshot = snapshot) {
authority.isOwner(it.author) || authority.hasPermission(it.author, bit)
EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors, snapshot = snapshot, rank = authority::tieBreakRank) {
authority.admits(it, bit)
}
// Metadata is one entity (== community id), gated by MANAGE_METADATA. Take the
// highest-version gated head (guarding against strays).
// Metadata is ONE entity, at the community_id itself (CORD-04 §1): an edition at any
// other coordinate is not this community's metadata however high its version, so it can
// neither shadow the chain nor bypass it (S8). Name and description caps are fold gates.
val metadata =
foldGatedBy(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)
.values
.maxByOrNull { it.version }
foldGatedBy(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)[communityId.toHexKey()]
?.let { ConcordJson.decodeOrNull<MetadataEntity>(it.content) }
// Channels are gated by MANAGE_CHANNELS, per channel entity, dropping the tombstoned ones.
// The gate also enforces the name rule (non-empty, <= 64 UTF-8 bytes, CORD-03 §2): an
// edition breaking it is unauthorized and the fold falls back to the previous candidate.
val channelEditions = editions.filter { it.entityKind == ControlEntityKind.CHANNEL }
// The same gate every entity folds under (well-formed, owner or MANAGE_CHANNELS holder, `vac`
// satisfied), plus the Channel name rule: an empty or over-cap name is an edition no reader honors.
val channelGate = { edition: ControlEdition ->
authority.admits(edition, ConcordPermissions.MANAGE_CHANNELS) &&
ConcordJson.decodeOrNull<ChannelEntity>(edition.content)?.hasValidName() == true
}
// Deletion is terminal (CORD-03 §2): any gated edition anywhere in a channel's accepted
// chain that says `deleted` retires it for good, even if a later edition "restores" it —
// members may already have discarded its keys, so a resurrection would split them.
val everDeleted =
channelEditions
.filter { edition ->
channelGate(edition) && ConcordJson.decodeOrNull<ChannelEntity>(edition.content)?.deleted == true
}.mapTo(HashSet()) { it.entityIdHex }
val channels = LinkedHashMap<String, ConcordChannel>()
for (head in foldGatedBy(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS).values) {
for (head in EditionFold.foldGated(channelEditions, floors, snapshot = snapshot, rank = authority::tieBreakRank, gate = channelGate).values) {
if (head.entityIdHex in everDeleted) continue
val def = ConcordJson.decodeOrNull<ChannelEntity>(head.content) ?: continue
if (def.deleted) continue
channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def)
@@ -179,8 +243,12 @@ data class ConcordCommunityState(
// so we take the roles the AuthorityResolver actually accepted from the owner outward.
val roles = authority.roles()
// Dissolution is owner-only — a rogue tombstone must not appear to kill the community.
val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED && authority.isOwner(it.author) }
// Dissolution is NOT read from the Control Plane. The tombstone is chainless and lives at its
// own address (CORD-02 §9, [ConcordDissolution]) where it must also name this community in its
// `eid`; a vsk-10 edition folded here would skip that binding check, so an owner's tombstone
// for another community re-wrapped onto this plane would kill this one. The caller that reads
// the dissolved plane sets [dissolved] via [withDissolved].
val dissolved = false
return ConcordCommunityState(
ownerPubKey = ownerPubKey.lowercase(),
@@ -0,0 +1,137 @@
/*
* 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.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
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.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Community Dissolution (CORD-02 §9): the owner-signed, chainless tombstone that ends a
* Community for good.
*
* The tombstone is a kind-3308 rumor carrying only `["vsk","10"]` and `["eid", community_id]`,
* plaintext-sealed (20014) by the owner and wrapped at [planeKey] — an address derived from the
* `community_id` alone, so no epoch or key is needed to find it and a Refounding can never strand
* the grave. It is **not** a Control Plane edition: it has no `ev`/`ep`/`vac`, and presence of one
* valid tombstone is the whole state.
*
* ## The `eid` binding is the security boundary
*
* The plane is public: anyone holding the `community_id` (it ships in every invite) derives the
* whole keypair and can sign wraps at it. The one thing they cannot make is an owner-signed
* `vsk 10` rumor — but a plaintext seal re-wraps verbatim, so an owner's genuine tombstone for
* community X could be lifted and re-wrapped at the address of any other community Y the same
* owner runs. [isTombstoneFor] therefore requires `eid == community_id`, and refuses anything
* else, including the all-zero placeholder earlier spec revisions used. Refusing that legacy
* value leaves an old dissolution reading alive (the owner re-dissolves); accepting it lets any
* multi-community owner's other communities be killed irrecoverably.
*/
object ConcordDissolution {
/** The dissolution tombstone's address + keys for [communityIdHex] (CORD-02 §9, A.6). */
fun planeKey(communityIdHex: HexKey): GroupKey = ConcordKeyDerivation.dissolvedPlaneKey(communityIdHex.hexToByteArray())
/** The unsigned tombstone rumor for [communityIdHex], authored by [ownerPubKey]. */
fun rumor(
ownerPubKey: HexKey,
communityIdHex: HexKey,
createdAt: Long = TimeUtils.now(),
): Event =
RumorAssembler.assembleRumor(
ownerPubKey,
// Chainless: exactly vsk + eid, no ev/ep/vac (CORD-02 §9).
eventTemplate<ControlEditionEvent>(ControlEditionEvent.KIND, "", createdAt) {
add(VskTag.assemble(ControlEntityKind.DISSOLVED))
add(EidTag.assemble(communityIdHex.hexToByteArray()))
},
)
/**
* Seals the tombstone with [ownerSigner] (plaintext 20014, so the owner's signature survives
* any re-wrap) and wraps it at the community's dissolved address. Only the owner's signature
* counts, so a non-owner [ownerSigner] produces a wrap every verifier ignores.
*/
suspend fun build(
ownerSigner: NostrSigner,
communityIdHex: HexKey,
createdAt: Long = TimeUtils.now(),
): Event {
val plane = planeKey(communityIdHex)
val seal = ConcordStreamEnvelope.seal(rumor(ownerSigner.pubKey, communityIdHex, createdAt), plane, ownerSigner, encrypted = false)
return ConcordStreamEnvelope.wrapSeal(seal, plane, createdAt = createdAt)
}
/**
* Whether [opened] is a valid tombstone for [communityIdHex] owned by [ownerPubKey]: a
* plaintext-sealed kind-3308 rumor, authored (seal signer) by the owner, `vsk 10`, and an
* `eid` equal to this community's id. Anything else — a wrong or all-zero `eid`, an encrypted
* seal, another author — is noise.
*/
fun isTombstoneFor(
opened: OpenedStreamEvent,
communityIdHex: HexKey,
ownerPubKey: HexKey,
): Boolean {
if (!opened.author.equals(ownerPubKey, ignoreCase = true)) return false
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT) return false
val rumor = opened.rumor
if (rumor.kind != ControlEditionEvent.KIND) return false
val vsk = rumor.tags.firstOrNull { it.size >= 2 && it[0] == VskTag.TAG_NAME }?.get(1)
val eid = rumor.tags.firstOrNull { it.size >= 2 && it[0] == EidTag.TAG_NAME }?.get(1)
return vsk == ControlEntityKind.DISSOLVED.wire && eid != null && eid.equals(communityIdHex, ignoreCase = true)
}
/** Opens [wrap] at [communityIdHex]'s dissolved address and checks it with [isTombstoneFor]. */
fun isTombstoneWrap(
wrap: Event,
communityIdHex: HexKey,
ownerPubKey: HexKey,
): Boolean {
val opened = ConcordStreamEnvelope.openOrNull(wrap, planeKey(communityIdHex)) ?: return false
return isTombstoneFor(opened, communityIdHex, ownerPubKey)
}
/** True when any of [wraps] is a valid tombstone for this community (CORD-02 §9). */
fun isDissolved(
wraps: Collection<Event>,
communityIdHex: HexKey,
ownerPubKey: HexKey,
): Boolean {
if (wraps.isEmpty()) return false
val plane = planeKey(communityIdHex)
return wraps.any { wrap ->
val opened = ConcordStreamEnvelope.openOrNull(wrap, plane) ?: return@any false
isTombstoneFor(opened, communityIdHex, ownerPubKey)
}
}
}
@@ -0,0 +1,260 @@
/*
* 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.cord02Community
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/** Thrown when a write would need fragments this client does not hold. */
class ConcordListIncompleteException(
message: String,
) : IllegalStateException(message)
/** Thrown when a fragment would exceed the event ceiling (CORD-02 §8) and cannot be split here. */
class ConcordListTooLargeException(
message: String,
) : IllegalStateException(message)
/**
* The fragments of a member's Community List as this client holds them (CORD-02 §8): per index,
* the newest copy (newest `created_at`, ties to the lowest id, as relays resolve an addressable
* coordinate), the declared fragment count, whether the set is complete, and their union.
*
* "Absence is never a fact": a missing or unreadable fragment is news not yet heard. Reading an
* incomplete set is safe — every merge is commutative and idempotent — but a **repack** (any
* write that changes `frags`) needs the complete set, or it silently drops every membership in
* the fragments it never read. [planWrites] enforces that, falling back to a scoped write that
* only rewrites the fragments holding the changed memberships.
*/
class ConcordListFragmentSet private constructor(
/** The fragment count the newest held fragment declares (ties to the larger), 0 when none is held. */
val declared: Int,
/** Newest readable copy per index. */
val held: Map<Int, Held>,
/** Newest `created_at` per index, readable or not — a write must exceed it. */
private val createdAtFloor: Map<Int, Long>,
/** Indices whose newest copy did not decrypt or parse. */
val unreadable: Set<Int>,
/** A declared count past [ConcordListFragments.MAX_DECLARED_FRAGS] was clamped. */
private val overflow: Boolean,
) {
class Held(
val createdAt: Long,
val plaintext: String,
val fragment: ConcordListFragments.DecodedFragment,
)
/** One copy of a fragment as fetched; [plaintext] is null when it did not decrypt. */
class Copy(
val index: Int,
val createdAt: Long,
val id: String,
val plaintext: String?,
)
/** One fragment to (re)publish. */
class Write(
val index: Int,
val plaintext: String,
val createdAt: Long,
)
/**
* True when every index below [declared] is held and readable **and** no fragment we saw failed
* to open. An unreadable copy may declare a larger count than any readable one, or be the only
* copy of its index, so its presence always means "not the whole List" — never grounds for a
* repack that would overwrite it. Vacuously true only when no fragment was seen at all.
*/
val complete: Boolean = !overflow && unreadable.isEmpty() && (0 until declared).all { it in held }
/** True when no fragment has been seen at all. */
val isEmpty: Boolean get() = createdAtFloor.isEmpty()
/**
* The union of every held fragment below [declared], in the internal shape. Fragments at or
* past [declared] are out of range — an emptied index's stale memberships stay dormant.
* Fragment-level unknown keys belong to the List; where two fragments carry the same key the
* lowest index wins.
*/
val doc: JsonObject by lazy {
held.keys
.filter { it < declared }
.sortedDescending()
.fold(EMPTY_DOC) { acc, i -> ConcordListFragments.mergeDocs(acc, held.getValue(i).fragment.doc) }
}
/**
* The fragments to publish so the wire holds [newDoc] (internal shape: this set's [doc] with
* the caller's change applied — a read-modify-write, never local state alone).
*
* With the complete List this repacks: every fragment whose bytes change, plus an emptied
* copy of each index a shrinking count drops (an abandoned index would come back into range
* later). Without it, it only rewrites the held fragments that mention a changed membership
* (or the lowest held one for a new membership), keeping the declared count.
*
* Every write carries a `created_at` strictly above that index's previous one.
*/
fun planWrites(
newDoc: JsonObject,
now: Long,
): List<Write> {
val out = ArrayList<Write>()
fun stamp(index: Int) = maxOf(now, (createdAtFloor[index] ?: 0L) + 1)
if (complete) {
val packed = ConcordListFragments.pack(newDoc)
for ((i, plaintext) in packed.withIndex()) {
guardSize(i, plaintext)
if (held[i]?.plaintext != plaintext) out.add(Write(i, plaintext, stamp(i)))
}
val empty = ConcordListFragments.emptyFragment(packed.size)
for (i in packed.size until maxOf(declared, packed.size)) {
if (held[i]?.plaintext != empty) out.add(Write(i, empty, stamp(i)))
}
return out
}
val changed = changedIds(newDoc)
if (changed.isEmpty()) return out
val inRange = held.keys.filter { it < declared }.sorted()
if (inRange.isEmpty()) throw ConcordListIncompleteException("no readable Community List fragment is held; refusing to write")
val targets = HashMap<Int, MutableSet<String>>()
for (id in changed) {
val holders = inRange.filter { id in ConcordListFragments.idsIn(held.getValue(it).fragment.doc) }
for (i in holders.ifEmpty { listOf(inRange.first()) }) targets.getOrPut(i) { HashSet() }.add(id)
}
for ((i, ids) in targets.entries.sortedBy { it.key }) {
val plaintext = ConcordListFragments.rewriteFragment(held.getValue(i).fragment.doc, newDoc, ids, declared)
guardSize(i, plaintext)
if (held[i]?.plaintext != plaintext) out.add(Write(i, plaintext, stamp(i)))
}
return out
}
private fun guardSize(
index: Int,
plaintext: String,
) {
val projected = ConcordListFragments.projectedEventBytes(plaintext.encodeToByteArray().size)
if (projected <= ConcordListFragments.EVENT_CEILING_BYTES) return
// A write that strictly shrinks the fragment is exempt: leaving must stay possible for a
// member already over the ceiling (CORD-02 §8).
val previous = held[index]?.plaintext ?: throw ConcordListTooLargeException("fragment $index would be $projected bytes")
if (plaintext.encodeToByteArray().size >= previous.encodeToByteArray().size) {
throw ConcordListTooLargeException("fragment $index would be $projected bytes; a split needs the complete List")
}
}
/** Community ids whose entry or tombstone differs between [doc] and [newDoc]. */
private fun changedIds(newDoc: JsonObject): Set<String> {
fun byId(
d: JsonObject,
key: String,
) = (d[key] as? JsonArray)
.orEmpty()
.mapNotNull { it as? JsonObject }
.associateBy { (it["community_id"] as? JsonPrimitive)?.content.orEmpty() }
val out = HashSet<String>()
for (key in listOf("entries", "tombstones")) {
val before = byId(doc, key)
val after = byId(newDoc, key)
for (id in before.keys + after.keys) {
val a = before[id]?.let { ConcordListFragments.canonicalString(it) }
val b = after[id]?.let { ConcordListFragments.canonicalString(it) }
if (a != b && id.isNotEmpty()) out.add(id)
}
}
return out
}
companion object {
private val EMPTY_DOC = JsonObject(mapOf("entries" to JsonArray(emptyList()), "tombstones" to JsonArray(emptyList())))
/** Resolves already-decrypted [copies]. Pure, for tests and callers that decrypt elsewhere. */
fun of(copies: Collection<Copy>): ConcordListFragmentSet {
val newest =
copies
.groupBy { it.index }
.mapValues { (_, list) -> list.sortedWith(compareByDescending<Copy> { it.createdAt }.thenBy { it.id }).first() }
val held = HashMap<Int, Held>()
val unreadable = HashSet<Int>()
for ((i, copy) in newest) {
// Resolve the coordinate BEFORE decrypting: an unreadable head is a missing index,
// never a cue to fall back to an older copy a write would then be based on.
val text = copy.plaintext
val decoded =
text?.let {
try {
ConcordListFragments.decodeFragment(it)
} catch (_: Exception) {
null
}
}
if (text == null || decoded == null) unreadable.add(i) else held[i] = Held(copy.createdAt, text, decoded)
}
var best: Held? = null
for (h in held.values) {
val b = best
if (b == null || h.createdAt > b.createdAt || (h.createdAt == b.createdAt && h.fragment.frags > b.fragment.frags)) best = h
}
val wireDeclared = best?.fragment?.frags?.coerceAtLeast(1) ?: 0
return ConcordListFragmentSet(
declared = minOf(wireDeclared, ConcordListFragments.MAX_DECLARED_FRAGS),
held = held,
createdAtFloor = newest.mapValues { it.value.createdAt },
unreadable = unreadable,
overflow = wireDeclared > ConcordListFragments.MAX_DECLARED_FRAGS,
)
}
/** Decrypts [events] with [signer] and resolves them. Events at a non-canonical `d` are ignored. */
suspend fun resolve(
events: Collection<ConcordCommunityListFragmentEvent>,
signer: NostrSigner,
): ConcordListFragmentSet = resolve(events, signer.pubKey) { it.decryptPlaintext(signer) }
/**
* [resolve] with a caller-supplied [decrypt], so a caller can memoize plaintext by event id
* instead of paying a signer round trip per fragment per read. Each event id is decrypted
* at most once per call, and only the newest copy per index is decrypted at all.
*/
suspend fun resolve(
events: Collection<ConcordCommunityListFragmentEvent>,
owner: String,
decrypt: suspend (ConcordCommunityListFragmentEvent) -> String?,
): ConcordListFragmentSet {
val newestPerIndex =
events
.asSequence()
.filter { it.pubKey == owner }
.mapNotNull { e -> e.index()?.let { it to e } }
.groupBy({ it.first }, { it.second })
.mapValues { (_, list) -> list.sortedWith(compareByDescending<ConcordCommunityListFragmentEvent> { it.createdAt }.thenBy { it.id }).first() }
return of(newestPerIndex.map { (index, e) -> Copy(index, e.createdAt, e.id, decrypt(e)) })
}
val EMPTY: ConcordListFragmentSet = of(emptyList())
}
}
@@ -0,0 +1,613 @@
/*
* 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.cord02Community
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlin.io.encoding.Base64
/**
* The wire layer of the fragmented Community List (CORD-02 §8, kind 33302).
*
* Two JSON shapes meet here:
*
* - **internal** — what [ConcordCommunityList] reads and writes, and what the retired kind-13302
* document always was: 32-byte values in lowercase hex, every snapshot carrying its own
* `community_id`, `seed` always present. Merges ([mergeDocs]) and the canonical-bytes
* tie-break run on this shape, as they do in the reference client.
* - **wire** — one fragment's plaintext: `{"frags": n, "entries": […], "tombstones": […]}`, every
* 32-byte value **this section names** as unpadded base64url (43 chars) at any depth, embedded
* snapshots without `community_id`, `seed` omitted when it equals `current`, and entries a
* tombstone outranks left out.
*
* ## Byte identity is the contract
*
* Two devices holding identical state must serialize identical bytes, or the tie-break flaps
* between their republishes. So the emitter here is hand-ordered to match the reference
* implementations (Armada `listFrag.ts`, Vector `list_frag.rs`): named fields in their declared
* order, optional ones omitted rather than null, then unknown fields in sorted key order with
* recursively key-sorted values. The packer is the same greedy first-fit, against the same
* 56 KiB target, so identical state also fragments identically.
*
* Unknown fields are never decoded or re-encoded: base64url and hex are indistinguishable for
* a string we don't know the meaning of, so an unknown field keeps its author's spelling.
*/
object ConcordListFragments {
/** Pack target for one fragment's fully encoded event (the reference client's 56 KiB). */
const val PACK_TARGET_BYTES = 57_344
/** The refusal line: no fragment event may exceed this many bytes, fully encoded. */
const val EVENT_CEILING_BYTES = 65_536
/** Junk ceiling on a declared `frags`, so one bad fragment can't drive an unbounded scan. */
const val MAX_DECLARED_FRAGS = 4096
/** Non-content event bytes (id, pubkey, sig, tags, scaffolding) — deliberately generous. */
private const val EVENT_ENVELOPE_BYTES = 320
private val MATERIAL_KEYS = setOf("owner", "owner_salt", "community_root", "root_epoch", "control_pk", "control_root", "channels", "relays", "name")
private val CHANNEL_KEYS = setOf("id", "key", "epoch", "name")
private val ENTRY_KEYS = setOf("community_id", "seed", "current", "added_at")
private val TOMBSTONE_KEYS = setOf("community_id", "removed_at")
private val LIST_KEYS = setOf("frags", "entries", "tombstones")
private val json = Json { prettyPrint = false }
private val B64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
private val B64URL43 = Regex("^[A-Za-z0-9_-]{43}$")
// ---- encoding --------------------------------------------------------------------------
/** 32 bytes of hex to unpadded base64url; anything else passes through untouched. */
fun hexToB64(value: String): String = if (HEX64.matches(value)) B64.encode(value.hexToByteArray()) else value
/** Unpadded base64url of 32 bytes back to lowercase hex; anything else passes through. */
fun b64ToHex(value: String): String {
if (!B64URL43.matches(value)) return value
val bytes =
try {
B64.decode(value)
} catch (_: Exception) {
return value
}
return if (bytes.size == 32) bytes.toHexKey() else value
}
// ---- canonical JSON --------------------------------------------------------------------
/** [element] with every object's keys sorted, recursively — a total order for tie-breaks. */
fun canonical(element: JsonElement): JsonElement =
when (element) {
is JsonObject -> JsonObject(element.keys.sorted().associateWithTo(LinkedHashMap()) { canonical(element.getValue(it)) })
is JsonArray -> JsonArray(element.map { canonical(it) })
else -> element
}
fun canonicalString(element: JsonElement): String = json.encodeToString(JsonElement.serializer(), canonical(element))
private fun str(element: JsonElement): String = json.encodeToString(JsonElement.serializer(), element)
/** Appends [source]'s keys outside [named] in sorted order, values canonicalized. */
private fun MutableMap<String, JsonElement>.putExtras(
source: JsonObject,
named: Set<String>,
) {
for (k in source.keys.filter { it !in named }.sorted()) {
val v = source.getValue(k)
if (v is JsonNull) continue
put(k, canonical(v))
}
}
// ---- small typed readers (strict, like the reference parser) -------------------------------
private class FragmentParseException(
message: String,
) : IllegalArgumentException(message)
private fun obj(
v: JsonElement?,
what: String,
): JsonObject = v as? JsonObject ?: throw FragmentParseException("$what is not an object")
private fun string(
v: JsonElement?,
what: String,
): String {
val p = v as? JsonPrimitive
if (p == null || !p.isString) throw FragmentParseException("$what is not a string")
return p.content
}
private fun u64(
v: JsonElement?,
what: String,
): Long {
val p = v as? JsonPrimitive
if (p == null || p.isString) throw FragmentParseException("$what is not an unsigned integer")
val n = p.longOrNull ?: throw FragmentParseException("$what is not an unsigned integer")
if (n < 0) throw FragmentParseException("$what is negative")
return n
}
private fun absent(v: JsonElement?) = v == null || v is JsonNull
private fun array(
v: JsonElement?,
what: String,
): List<JsonElement> =
when {
v == null -> emptyList()
v is JsonArray -> v
else -> throw FragmentParseException("$what is not an array")
}
// ---- wire -> internal ------------------------------------------------------------------
/** One decoded fragment: its declared `frags` and its content in the internal shape. */
class DecodedFragment(
val frags: Int,
val doc: JsonObject,
)
/**
* Parses one fragment's decrypted plaintext into the internal shape. Throws on anything the
* reference parser rejects; a caller treats that as "this index is missing", never as an
* empty fragment.
*/
fun decodeFragment(plaintext: String): DecodedFragment {
val root = obj(json.parseToJsonElement(plaintext), "fragment")
val frags = u64(root["frags"], "frags")
val entries = array(root["entries"], "entries").map { decodeEntry(it) }
val tombstones = array(root["tombstones"], "tombstones").map { decodeTombstone(it) }
val doc = LinkedHashMap<String, JsonElement>()
for ((k, v) in root) if (k !in LIST_KEYS) doc[k] = v
doc["entries"] = JsonArray(entries)
doc["tombstones"] = JsonArray(tombstones)
return DecodedFragment(frags.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), JsonObject(doc))
}
private fun decodeEntry(v: JsonElement): JsonObject {
val o = obj(v, "entry")
val cid = b64ToHex(string(o["community_id"], "entry.community_id"))
val current = decodeMaterial(o["current"], "entry.current", cid)
val addedAt = u64(o["added_at"], "entry.added_at")
val seed = if (absent(o["seed"])) current else decodeMaterial(o["seed"], "entry.seed", cid)
val out = LinkedHashMap<String, JsonElement>()
for ((k, value) in o) if (k !in ENTRY_KEYS) out[k] = value
out["community_id"] = JsonPrimitive(cid)
out["seed"] = seed
out["current"] = current
out["added_at"] = JsonPrimitive(addedAt)
return JsonObject(out)
}
private fun decodeMaterial(
v: JsonElement?,
what: String,
communityId: String,
): JsonObject {
val o = obj(v, what)
val out = LinkedHashMap<String, JsonElement>()
for ((k, value) in o) if (k !in MATERIAL_KEYS && k != "community_id") out[k] = value
out["community_id"] = JsonPrimitive(communityId)
out["owner"] = JsonPrimitive(b64ToHex(string(o["owner"], "$what.owner")))
out["owner_salt"] = JsonPrimitive(b64ToHex(string(o["owner_salt"], "$what.owner_salt")))
out["community_root"] = JsonPrimitive(b64ToHex(string(o["community_root"], "$what.community_root")))
out["root_epoch"] = JsonPrimitive(u64(o["root_epoch"], "$what.root_epoch"))
if (!absent(o["control_pk"])) out["control_pk"] = JsonPrimitive(b64ToHex(string(o["control_pk"], "$what.control_pk")))
if (!absent(o["control_root"])) out["control_root"] = JsonPrimitive(b64ToHex(string(o["control_root"], "$what.control_root")))
out["channels"] = JsonArray(array(o["channels"], "$what.channels").map { decodeChannel(it) })
out["relays"] = JsonArray(array(o["relays"], "$what.relays").map { JsonPrimitive(string(it, "$what.relays[]")) })
out["name"] = JsonPrimitive(string(o["name"], "$what.name"))
return JsonObject(out)
}
private fun decodeChannel(v: JsonElement): JsonObject {
val o = obj(v, "channel")
val out = LinkedHashMap<String, JsonElement>()
for ((k, value) in o) if (k !in CHANNEL_KEYS) out[k] = value
out["id"] = JsonPrimitive(b64ToHex(string(o["id"], "channel.id")))
if (!absent(o["key"])) out["key"] = JsonPrimitive(b64ToHex(string(o["key"], "channel.key")))
out["epoch"] = JsonPrimitive(u64(o["epoch"], "channel.epoch"))
out["name"] = JsonPrimitive(string(o["name"], "channel.name"))
return JsonObject(out)
}
private fun decodeTombstone(v: JsonElement): JsonObject {
val o = obj(v, "tombstone")
val out = LinkedHashMap<String, JsonElement>()
for ((k, value) in o) if (k !in TOMBSTONE_KEYS) out[k] = value
out["community_id"] = JsonPrimitive(b64ToHex(string(o["community_id"], "tombstone.community_id")))
out["removed_at"] = JsonPrimitive(u64(o["removed_at"], "tombstone.removed_at"))
return JsonObject(out)
}
// ---- internal -> wire ------------------------------------------------------------------
private fun stringOr(
v: JsonElement?,
default: String,
): String = (v as? JsonPrimitive)?.takeIf { it.isString }?.content ?: default
private fun number(v: JsonElement?): JsonPrimitive? = (v as? JsonPrimitive)?.takeIf { !it.isString && it.longOrNull != null }
/**
* An internal snapshot in the wire shape: named 32-byte values as base64url, `community_id`
* dropped (the entry carries it), empty `channels`/`relays` omitted. Throws on a snapshot
* missing its keys rather than sealing a corrupt membership.
*/
private fun materialToWire(m: JsonObject): JsonObject {
val owner = stringOr(m["owner"], "")
val ownerSalt = stringOr(m["owner_salt"], "")
val root = stringOr(m["community_root"], "")
val epoch = number(m["root_epoch"])
require(owner.isNotEmpty() && ownerSalt.isNotEmpty() && root.isNotEmpty() && epoch != null) {
"malformed join material — refusing to serialize a corrupt snapshot"
}
val out = LinkedHashMap<String, JsonElement>()
out["owner"] = JsonPrimitive(hexToB64(owner))
out["owner_salt"] = JsonPrimitive(hexToB64(ownerSalt))
out["community_root"] = JsonPrimitive(hexToB64(root))
out["root_epoch"] = epoch
(m["control_pk"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out["control_pk"] = JsonPrimitive(hexToB64(it.content)) }
(m["control_root"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out["control_root"] = JsonPrimitive(hexToB64(it.content)) }
val channels = (m["channels"] as? JsonArray)?.mapNotNull { (it as? JsonObject)?.let(::channelToWire) }.orEmpty()
if (channels.isNotEmpty()) out["channels"] = JsonArray(channels)
val relays = (m["relays"] as? JsonArray)?.filter { it is JsonPrimitive && it.isString }.orEmpty()
if (relays.isNotEmpty()) out["relays"] = JsonArray(relays)
out["name"] = JsonPrimitive(stringOr(m["name"], ""))
out.putExtras(m, MATERIAL_KEYS + "community_id")
return JsonObject(out)
}
private fun channelToWire(c: JsonObject): JsonObject? {
val id = stringOr(c["id"], "")
val epoch = number(c["epoch"])
if (id.isEmpty() || epoch == null) return null
val out = LinkedHashMap<String, JsonElement>()
out["id"] = JsonPrimitive(hexToB64(id))
(c["key"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out["key"] = JsonPrimitive(hexToB64(it.content)) }
out["epoch"] = epoch
out["name"] = JsonPrimitive(stringOr(c["name"], ""))
out.putExtras(c, CHANNEL_KEYS)
return JsonObject(out)
}
/**
* `seed` with its cosmetic fields (`name`, `relays`, each channel's `name`) overwritten from
* `current`: they are not the anchor's own, and leaving a stale label in place would fork the
* two snapshots forever after one rename (CORD-02 §8).
*/
private fun withCurrentCosmetics(
seed: JsonObject,
current: JsonObject,
): JsonObject {
val currentNames =
(current["channels"] as? JsonArray)
.orEmpty()
.mapNotNull { it as? JsonObject }
.associate { stringOr(it["id"], "") to (it["name"] ?: JsonPrimitive("")) }
// Rebuilt in declared order so the result serializes exactly like a fresh wire snapshot.
val out = LinkedHashMap<String, JsonElement>()
for (k in listOf("owner", "owner_salt", "community_root", "root_epoch", "control_pk", "control_root")) seed[k]?.let { out[k] = it }
(seed["channels"] as? JsonArray)?.let { channels ->
out["channels"] =
JsonArray(
channels.map { ch ->
val o = ch as? JsonObject ?: return@map ch
val name = currentNames[stringOr(o["id"], "")] ?: return@map o
JsonObject(o.mapValuesTo(LinkedHashMap()) { (k, v) -> if (k == "name") name else v })
},
)
}
current["relays"]?.let { out["relays"] = it }
out["name"] = current["name"] ?: JsonPrimitive("")
// The seed's unknown keys, already in sorted order behind the named ones.
for ((k, v) in seed) if (k !in MATERIAL_KEYS) out[k] = v
return JsonObject(out)
}
/**
* [entryToWire], or null when the entry's join material is too incomplete to encode — only a
* membership the typed reader already could not parse (kept verbatim from a retired 13302
* document). It stays in that document; the fragments simply don't re-emit it, rather than one
* unreadable legacy entry blocking every join and leave.
*/
private fun entryToWireOrNull(entry: JsonObject): JsonObject? =
try {
entryToWire(entry)
} catch (_: IllegalArgumentException) {
null
}
/** One internal entry in the wire shape, or throws on corrupt join material. */
fun entryToWire(entry: JsonObject): JsonObject {
val cid = stringOr(entry["community_id"], "")
require(cid.isNotEmpty()) { "entry without a community_id" }
val currentInternal = (entry["current"] as? JsonObject) ?: (entry["seed"] as? JsonObject) ?: throw IllegalArgumentException("entry without join material")
val seedInternal = (entry["seed"] as? JsonObject) ?: currentInternal
val current = materialToWire(currentInternal)
val seed = withCurrentCosmetics(materialToWire(seedInternal), current)
val out = LinkedHashMap<String, JsonElement>()
out["community_id"] = JsonPrimitive(hexToB64(cid))
if (str(seed) != str(current)) out["seed"] = seed
out["current"] = current
out["added_at"] = number(entry["added_at"]) ?: JsonPrimitive(0L)
out.putExtras(entry, ENTRY_KEYS)
return JsonObject(out)
}
fun tombstoneToWire(tombstone: JsonObject): JsonObject? {
val cid = stringOr(tombstone["community_id"], "")
if (cid.isEmpty()) return null
val out = LinkedHashMap<String, JsonElement>()
out["community_id"] = JsonPrimitive(hexToB64(cid))
out["removed_at"] = number(tombstone["removed_at"]) ?: JsonPrimitive(0L)
out.putExtras(tombstone, TOMBSTONE_KEYS)
return JsonObject(out)
}
/** A fragment's exact plaintext: what NIP-44 seals and relays store. */
fun serializeFragment(
frags: Int,
entries: List<JsonObject>,
tombstones: List<JsonObject>,
extras: JsonObject = JsonObject(emptyMap()),
): String {
val out = LinkedHashMap<String, JsonElement>()
out["frags"] = JsonPrimitive(frags)
if (entries.isNotEmpty()) out["entries"] = JsonArray(entries)
if (tombstones.isNotEmpty()) out["tombstones"] = JsonArray(tombstones)
out.putExtras(extras, LIST_KEYS)
return str(JsonObject(out))
}
/** The zero-element List an emptied index republishes (CORD-02 §8). */
fun emptyFragment(frags: Int): String = serializeFragment(frags, emptyList(), emptyList())
// ---- sizing ----------------------------------------------------------------------------
/** NIP-44 v2 padded plaintext length. */
fun nip44PaddedLen(unpadded: Int): Int {
if (unpadded <= 32) return 32
val nextPower = 1 shl (32 - (unpadded - 1).countLeadingZeroBits())
val chunk = if (nextPower <= 256) 32 else nextPower / 8
return chunk * ((unpadded - 1) / chunk + 1)
}
/** The encoded event a plaintext of [plaintextBytes] becomes (NIP-44 v2 payload, base64). */
fun projectedEventBytes(plaintextBytes: Int): Int {
val raw = 1 + 32 + 2 + nip44PaddedLen(plaintextBytes) + 32
return (raw + 2) / 3 * 4 + EVENT_ENVELOPE_BYTES
}
private fun byteLen(s: String) = s.encodeToByteArray().size
// ---- merge (internal shape) ------------------------------------------------------------
private fun epochOf(m: JsonObject?): Long = number(m?.get("root_epoch"))?.longOrNull ?: 0L
/** Higher epoch wins; a tie goes to the lexicographically lowest canonical bytes. */
private fun freshest(
a: JsonObject,
b: JsonObject,
): JsonObject {
val ea = epochOf(a)
val eb = epochOf(b)
if (ea != eb) return if (ea > eb) a else b
return if (canonicalString(a) <= canonicalString(b)) a else b
}
/** Lower epoch wins; a tie goes to the lowest canonical bytes. */
private fun earliest(
a: JsonObject,
b: JsonObject,
): JsonObject {
val ea = epochOf(a)
val eb = epochOf(b)
if (ea != eb) return if (ea < eb) a else b
return if (canonicalString(a) <= canonicalString(b)) a else b
}
private fun currentOf(e: JsonObject): JsonObject? = (e["current"] as? JsonObject) ?: (e["seed"] as? JsonObject)
private fun seedOf(e: JsonObject): JsonObject? = (e["seed"] as? JsonObject) ?: (e["current"] as? JsonObject)
private fun mergeEntry(
x: JsonObject,
y: JsonObject,
): JsonObject {
val cx = currentOf(x)
val cy = currentOf(y)
val current = if (cx != null && cy != null) freshest(cx, cy) else cx ?: cy
val sx = seedOf(x)
val sy = seedOf(y)
val seed = if (sx != null && sy != null) earliest(sx, sy) else sx ?: sy
val addedAt = maxOf(number(x["added_at"])?.longOrNull ?: 0L, number(y["added_at"])?.longOrNull ?: 0L)
val merged = LinkedHashMap<String, JsonElement>(x)
merged.putAll(y)
merged["community_id"] = x.getValue("community_id")
if (seed != null) merged["seed"] = seed
if (current != null) merged["current"] = current
merged["added_at"] = JsonPrimitive(addedAt)
// An exclusion marker only means something while it is beyond the held epoch.
val excluded = listOfNotNull(number(x["excluded_at_epoch"])?.longOrNull, number(y["excluded_at_epoch"])?.longOrNull).maxOrNull()
if (excluded != null && excluded > epochOf(current)) merged["excluded_at_epoch"] = JsonPrimitive(excluded) else merged.remove("excluded_at_epoch")
return JsonObject(merged)
}
private fun idOf(o: JsonObject): String? = stringOr(o["community_id"], "").ifEmpty { null }
/**
* Merges two internal documents: one membership per `community_id` (current keeps the higher
* epoch, seed the lower, `added_at` the later), one tombstone per id (the later `removed_at`),
* other keys with [b] winning. Commutative and idempotent on everything but those unknown
* document keys, which is what lets fragments and devices merge in any order.
*/
fun mergeDocs(
a: JsonObject,
b: JsonObject,
): JsonObject {
val entries = LinkedHashMap<String, JsonObject>()
for (e in listOf(a, b).flatMap { (it["entries"] as? JsonArray).orEmpty() }) {
val o = e as? JsonObject ?: continue
val id = idOf(o) ?: continue
entries[id] = entries[id]?.let { mergeEntry(it, o) } ?: o
}
val tombstones = LinkedHashMap<String, JsonObject>()
for (t in listOf(a, b).flatMap { (it["tombstones"] as? JsonArray).orEmpty() }) {
val o = t as? JsonObject ?: continue
val id = idOf(o) ?: continue
val prev = tombstones[id]
if (prev == null || (number(o["removed_at"])?.longOrNull ?: 0L) > (number(prev["removed_at"])?.longOrNull ?: 0L)) tombstones[id] = o
}
val out = LinkedHashMap<String, JsonElement>()
for ((k, v) in a) if (k != "entries" && k != "tombstones") out[k] = v
for ((k, v) in b) if (k != "entries" && k != "tombstones") out[k] = v
out["entries"] = JsonArray(entries.keys.sorted().map { entries.getValue(it) })
out["tombstones"] = JsonArray(tombstones.keys.sorted().map { tombstones.getValue(it) })
return JsonObject(out)
}
/** Per community id, the latest `removed_at` in [doc]. */
fun removals(doc: JsonObject): Map<String, Long> {
val out = HashMap<String, Long>()
for (t in (doc["tombstones"] as? JsonArray).orEmpty()) {
val o = t as? JsonObject ?: continue
val id = idOf(o) ?: continue
val at = number(o["removed_at"])?.longOrNull ?: 0L
if (at > (out[id] ?: Long.MIN_VALUE)) out[id] = at
}
return out
}
/** A membership is live only while its entry outranks its removal (CORD-02 §8). */
fun isLive(
entry: JsonObject,
removals: Map<String, Long>,
): Boolean {
val removedAt = removals[idOf(entry) ?: return false] ?: return true
return (number(entry["added_at"])?.longOrNull ?: 0L) > removedAt
}
// ---- packing ---------------------------------------------------------------------------
private class Packing(
val entries: MutableList<JsonObject> = ArrayList(),
val tombstones: MutableList<JsonObject> = ArrayList(),
val extras: JsonObject = JsonObject(emptyMap()),
) {
fun serialize(frags: Int) = serializeFragment(frags, entries, tombstones, extras)
}
/**
* Splits an internal document into fragment plaintexts, greedily (first fragment with room)
* under [PACK_TARGET_BYTES]. Entries a tombstone outranks are dropped — the tombstone alone
* carries the state — and document-level unknown keys ride on fragment 0.
*/
fun pack(doc: JsonObject): List<String> {
val removals = removals(doc)
val entries =
(doc["entries"] as? JsonArray)
.orEmpty()
.mapNotNull { it as? JsonObject }
.filter { isLive(it, removals) }
.sortedBy { idOf(it) }
.mapNotNull { entryToWireOrNull(it) }
val tombstones =
(doc["tombstones"] as? JsonArray)
.orEmpty()
.mapNotNull { it as? JsonObject }
.sortedBy { idOf(it) }
.mapNotNull { tombstoneToWire(it) }
val docExtras = JsonObject(doc.filterKeys { it != "entries" && it != "tombstones" && it !in LIST_KEYS })
val frags = mutableListOf(Packing(extras = docExtras))
fun fits(
f: Packing,
add: Int,
) = projectedEventBytes(byteLen(f.serialize(1)) + add) <= PACK_TARGET_BYTES
for (e in entries) {
val cost = byteLen(str(e)) + 1
val last = frags.last()
if (last.entries.isEmpty() || fits(last, cost)) last.entries.add(e) else frags.add(Packing(entries = mutableListOf(e)))
}
// A fragment's first tombstone is placed without a size check, exactly as the reference
// packer does: identical state must fragment identically across clients, and the overshoot
// is one tombstone (~80 bytes) against an 8 KiB margin under the ceiling.
for (t in tombstones) {
val cost = byteLen(str(t)) + 1
val last = frags.last()
if (last.tombstones.isEmpty() || fits(last, cost)) last.tombstones.add(t) else frags.add(Packing(tombstones = mutableListOf(t)))
}
val total = frags.size
return frags.map { it.serialize(total) }
}
/**
* Re-serializes one held fragment ([fragmentDoc], internal shape) after replacing its
* memberships and removals for [ids] with [merged]'s, keeping its declared [frags]. This is
* the scoped write (CORD-02 §8): it touches only the fragment holding the changed membership,
* so a fragment stranded on an unreachable relay never blocks a join or a leave.
*/
fun rewriteFragment(
fragmentDoc: JsonObject,
merged: JsonObject,
ids: Set<String>,
frags: Int,
): String {
fun pick(
doc: JsonObject,
key: String,
) = (doc[key] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject }
val keptEntries = pick(fragmentDoc, "entries").filter { idOf(it) !in ids } + pick(merged, "entries").filter { idOf(it) in ids }
val keptTombs = pick(fragmentDoc, "tombstones").filter { idOf(it) !in ids } + pick(merged, "tombstones").filter { idOf(it) in ids }
val doc =
JsonObject(
fragmentDoc.filterKeys { it != "entries" && it != "tombstones" } +
mapOf("entries" to JsonArray(keptEntries), "tombstones" to JsonArray(keptTombs)),
)
val removals = removals(doc)
return serializeFragment(
frags,
keptEntries.filter { isLive(it, removals) }.sortedBy { idOf(it) }.mapNotNull { entryToWireOrNull(it) },
keptTombs.sortedBy { idOf(it) }.mapNotNull { tombstoneToWire(it) },
JsonObject(doc.filterKeys { it != "entries" && it != "tombstones" && it !in LIST_KEYS }),
)
}
/** The community ids a document mentions, in entries or tombstones. */
fun idsIn(doc: JsonObject): Set<String> =
((doc["entries"] as? JsonArray).orEmpty() + (doc["tombstones"] as? JsonArray).orEmpty())
.mapNotNullTo(HashSet()) { (it as? JsonObject)?.let(::idOf) }
}
@@ -22,11 +22,15 @@ package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
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.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionAlgo
import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionKey
import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionNonce
@@ -66,12 +70,13 @@ object ChannelChat {
text: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
ms: Int = MsTag.remainderFor(createdAt),
): Event =
RumorAssembler.assembleRumor(
authorPubKey,
ChatEvent.build(text, createdAt) {
channelBinding(channelId, epoch)
extraTags.forEach { addUnique(it) }
channelBinding(channelId, epoch, ms)
withoutBinding(extraTags).forEach { add(it) }
},
)
@@ -82,6 +87,10 @@ object ChannelChat {
* into a minichat), an inline quote stays in the main chat timeline — the two
* reply modes the composer offers. Matches Armada, where a kind-9 `q` is an
* inline quote deliberately kept out of threads.
*
* The `q` tag is the four-element NIP-C7 form `["q", <rumor id>, "", <author>]` the spec's
* examples (§2.1) and Armada write: an empty relay hint (a rumor lives on no relay) and the
* quoted author, so a reader can render the card before the quoted rumor arrives.
*/
fun inlineReply(
authorPubKey: HexKey,
@@ -92,6 +101,7 @@ object ChannelChat {
parentAuthor: HexKey,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
ms: Int = MsTag.remainderFor(createdAt),
): Event =
message(
authorPubKey = authorPubKey,
@@ -99,7 +109,8 @@ object ChannelChat {
epoch = epoch,
text = text,
createdAt = createdAt,
extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)) + extraTags,
extraTags = arrayOf(arrayOf("q", parentId, "", parentAuthor), arrayOf("p", parentAuthor)) + extraTags,
ms = ms,
)
/**
@@ -124,20 +135,48 @@ object ChannelChat {
newText: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
ms: Int = MsTag.remainderFor(createdAt),
): Event =
RumorAssembler.assembleRumor<ConcordChatEditEvent>(
pubKey = authorPubKey,
createdAt = createdAt,
kind = ConcordChatEditEvent.KIND,
tags =
arrayOf(
ChannelTag.assemble(channelId),
EpochTag.assemble(epoch),
arrayOf("e", targetId),
) + extraTags,
tags = bindingTags(channelId, epoch, ms) + arrayOf(arrayOf("e", targetId)) + withoutBinding(extraTags),
content = newText,
)
/**
* Builds an unsigned kind-5 **delete** rumor (CORD-01 Deletions, examples §2.4) retracting
* the author's own [targets] inside the channel, bound to [channelId]/[epoch].
*
* NIP-09 shape: one `["e", <rumor id>]` per target, then one `["k", <kind>]` per distinct
* target kind (`9` for a message, `1111` for a thread reply, `7` for a reaction), and the
* optional [reason] as content. It names *rumor* ids relays never saw, so it must be wrapped
* on the channel plane like any other Chat rumor — never published as a signed kind 5 or a
* NIP-17 DM, both of which would leak the rumor ids outside the community. Receivers honor it
* only for targets the delete's own author wrote. A delete never expires (CORD-08).
*/
fun delete(
authorPubKey: HexKey,
channelId: HexKey,
epoch: Long,
targets: List<Event>,
createdAt: Long,
reason: String = "",
ms: Int = MsTag.remainderFor(createdAt),
): Event {
require(targets.isNotEmpty()) { "A delete must name at least one target" }
val eTags = targets.map { arrayOf("e", it.id) }
val kTags = targets.map { it.kind }.distinct().map { arrayOf("k", it.toString()) }
return RumorAssembler.assembleRumor<DeletionRequestEvent>(
pubKey = authorPubKey,
createdAt = createdAt,
kind = DeletionRequestEvent.KIND,
tags = bindingTags(channelId, epoch, ms) + eTags.toTypedArray() + kTags.toTypedArray(),
content = reason,
)
}
/**
* Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to
* [parent], bound to [channelId]/[epoch].
@@ -160,12 +199,13 @@ object ChannelChat {
parent: Event,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
ms: Int = MsTag.remainderFor(createdAt),
): Event =
RumorAssembler.assembleRumor(
authorPubKey,
CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) {
channelBinding(channelId, epoch)
extraTags.forEach { add(it) }
channelBinding(channelId, epoch, ms)
withoutBinding(extraTags).forEach { add(it) }
},
)
@@ -186,6 +226,7 @@ object ChannelChat {
parent: Event,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
ms: Int = MsTag.remainderFor(createdAt),
): Event {
val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) }
val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n")
@@ -197,6 +238,7 @@ object ChannelChat {
parent = parent,
createdAt = createdAt,
extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags,
ms = ms,
)
}
@@ -218,19 +260,19 @@ object ChannelChat {
content: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
ms: Int = MsTag.remainderFor(createdAt),
): Event =
RumorAssembler.assembleRumor<ReactionEvent>(
pubKey = authorPubKey,
createdAt = createdAt,
kind = ReactionEvent.KIND,
tags =
arrayOf(
ChannelTag.assemble(channelId),
EpochTag.assemble(epoch),
arrayOf("e", targetId),
arrayOf("p", targetAuthor),
arrayOf("k", targetKind.toString()),
) + extraTags,
bindingTags(channelId, epoch, ms) +
arrayOf(
arrayOf("e", targetId),
arrayOf("p", targetAuthor),
arrayOf("k", targetKind.toString()),
) + withoutBinding(extraTags),
content = content,
)
@@ -250,6 +292,7 @@ object ChannelChat {
imetas: List<IMetaTag>,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
ms: Int = MsTag.remainderFor(createdAt),
): Event {
val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) }
val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n")
@@ -260,6 +303,7 @@ object ChannelChat {
text = finalText,
createdAt = createdAt,
extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags,
ms = ms,
)
}
@@ -337,12 +381,13 @@ object ChannelChat {
channelId: HexKey,
epoch: Long,
createdAt: Long,
ms: Int = MsTag.remainderFor(createdAt),
): Event =
RumorAssembler.assembleRumor<Event>(
pubKey = authorPubKey,
createdAt = createdAt,
kind = KIND_TYPING,
tags = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)),
tags = bindingTags(channelId, epoch, ms),
content = "",
)
@@ -364,6 +409,75 @@ object ChannelChat {
channelId: HexKey,
epoch: Long,
): Boolean = rumor.tags.isConcordBoundTo(channelId, epoch)
/** Timer notice (CORD-08 §4), a Chat Plane kind. */
const val KIND_TIMER_NOTICE = 1740
/**
* Every rumor kind a Chat Plane may carry into the app (CORD-02 Appendix B): messages,
* thread replies, reactions, deletes, edits, the typing heartbeat and the CORD-08 timer
* notice. Chat ingest refuses anything else — above all the other planes' kinds (a Control
* edition 3308, a Guestbook 3306/3309/3312, a rekey 3303, a direct invite 3313): the planes
* share one store, so without this a channel key-holder could inject a rumor another reader
* would take for a Control edition (the reference client's `PLANE_KINDS` refusal).
*/
val CHAT_KINDS: Set<Int> =
setOf(
ChatEvent.KIND,
CommentEvent.KIND,
ReactionEvent.KIND,
DeletionRequestEvent.KIND,
ConcordChatEditEvent.KIND,
KIND_TYPING,
KIND_TIMER_NOTICE,
)
/** True when [kind] may ride a Chat Plane ([CHAT_KINDS]). */
fun isChatKind(kind: Int): Boolean = kind in CHAT_KINDS
/**
* The Chat Plane ingest gate for a wrap already opened under [channelId]'s key at [epoch]:
* returns its rumor only when every Chat rule holds, else null (drop it).
* - the seal is the encrypted kind 20013 (CORD-02 §5: a plaintext 20014 seal is Control-only);
* - the rumor kind is a Chat kind ([CHAT_KINDS]), never another plane's;
* - the binding is strict: exactly one `channel` and one `epoch`, equal to the plane's
* ([isBoundTo], CORD-03 §3);
* - its `ms` tag, if any, is well formed (CORD-02 §4/§5 — a malformed one is dropped, never
* interpreted).
*/
fun acceptOpened(
opened: OpenedStreamEvent,
channelId: HexKey,
epoch: Long,
): Event? {
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null
val rumor = opened.rumor
if (!isChatKind(rumor.kind)) return null
if (!isBoundTo(rumor, channelId, epoch)) return null
if (orderingMs(rumor) == null) return null
return rumor
}
/**
* The rumor's CORD-02 §4 ordering time, `createdAt * 1000 + ms`, or null when its `ms` tag is
* malformed or duplicated (such a rumor is dropped, never interpreted). See [MsTag].
*/
fun orderingMs(rumor: Event): Long? = MsTag.orderingMs(rumor.createdAt, rumor.tags)
/** The binding every Chat rumor commits, in the examples' order: channel, epoch, ms. */
private fun bindingTags(
channelId: HexKey,
epoch: Long,
ms: Int,
): Array<Array<String>> = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch), MsTag.assemble(ms))
private val BINDING_TAG_NAMES = setOf(ChannelTag.TAG_NAME, EpochTag.TAG_NAME, MsTag.TAG_NAME)
/**
* [extraTags] minus any binding tag: a caller's extra `channel`/`epoch`/`ms` would make the
* binding ambiguous, and strict receivers (ours included) drop a duplicated binding.
*/
private fun withoutBinding(extraTags: Array<Array<String>>): Array<Array<String>> = extraTags.filterNot { it.isNotEmpty() && it[0] in BINDING_TAG_NAMES }.toTypedArray()
}
/**
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.concord.cord03Channels
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent
@@ -65,16 +66,7 @@ class ConcordChatEditEvent(
* remainder tag (CORD-02 §4). Used to order competing edits at sub-second precision, matching the
* reference client (an absent/malformed `ms` tag reads as 0). "Latest edit wins" compares this.
*/
fun orderingMs(): Long {
val remainder =
tags
.firstOrNull { it.size > 1 && it[0] == "ms" }
?.get(1)
?.toIntOrNull()
?.takeIf { it in 0..999 }
?: 0
return createdAt * 1000 + remainder
}
fun orderingMs(): Long = MsTag.orderingMs(createdAt, tags) ?: (createdAt * 1000)
companion object {
const val KIND = 3302
@@ -0,0 +1,127 @@
/*
* 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.cord03Channels
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Disappearing Messages (CORD-08): one Community-wide timer, `message_expiration` in the metadata
* entity, after which every Chat-plane message expires.
*
* - **Sender (§2):** while the timer is set, every durable Chat rumor carries
* `["expiration", created_at + timer]` inside the signed rumor, and its kind-1059 wrap carries the
* same tag so NIP-40 relays delete the ciphertext. Deletes (5) and timer notices (1740) never
* carry it — an expiring delete would resurrect what it erased, an expiring notice would erase
* why history is missing — and ephemeral kinds (typing 23311, voice presence 23313) carry nothing.
* - **Reader (§3):** only the rumor's own tag counts (the wrap's is relay hygiene). An expired rumor
* is refused at ingest, never displayed, and purged from local storage.
* - **Notice (§4):** kind 1740 with `["timer", "<secs>"]` and the channel binding, shown only when
* its author holds MANAGE_METADATA.
*
* A timer change is never retroactive: a rumor keeps the expiry it was sent under.
*/
object ConcordDisappearing {
/** The timer-notice kind, shared with NIP-17 disappearing DMs (§4). */
const val KIND_TIMER_NOTICE = ChannelChat.KIND_TIMER_NOTICE
const val TIMER_TAG = "timer"
private const val KIND_DELETE = 5
private const val KIND_TYPING = 23311
private const val KIND_VOICE_PRESENCE = 23313
private val DECIMAL = Regex("^(0|[1-9][0-9]*)$")
/** True when a rumor of [kind] must carry the expiration tag while the timer is set (§2). */
fun expires(kind: Int): Boolean = kind != KIND_DELETE && kind != KIND_TIMER_NOTICE && kind != KIND_TYPING && kind != KIND_VOICE_PRESENCE
/**
* The expiration a rumor created at [createdAt] of [kind] must carry under [timerSecs], or null
* when the timer is off or the kind is exempt.
*/
fun expirationFor(
kind: Int,
createdAt: Long,
timerSecs: Long?,
): Long? {
if (timerSecs == null || timerSecs < 1 || !expires(kind)) return null
return createdAt + timerSecs
}
/**
* [tags] with the expiration tag applied: added (or replaced) when [expiration] is set, left as is
* otherwise. The rumor's copy is what readers enforce, so it must be in the signed tags.
*/
fun withExpiration(
tags: TagArray,
expiration: Long?,
): TagArray {
if (expiration == null) return tags
return tags.filterNot { it.isNotEmpty() && it[0] == ExpirationTag.TAG_NAME }.toTypedArray() + ExpirationTag.assemble(expiration)
}
/** The rumor's own expiration, the only one a reader judges by (§3). */
fun expirationOf(rumor: Event): Long? = rumor.tags.firstNotNullOfOrNull(ExpirationTag::parse)
/** True when [rumor] carries an expiration at or before [now] (NIP-40: `exp <= now`). */
fun isExpired(
rumor: Event,
now: Long = TimeUtils.now(),
): Boolean {
val exp = expirationOf(rumor) ?: return false
return exp <= now
}
/** The timer-notice rumor: the new value in seconds (`0` = off) bound to [channelId] at [epoch]. */
fun timerNotice(
authorPubKey: HexKey,
channelId: HexKey,
epoch: Long,
timerSecs: Long,
createdAt: Long = TimeUtils.now(),
): Event =
RumorAssembler.assembleRumor(
authorPubKey,
eventTemplate<Event>(KIND_TIMER_NOTICE, "", createdAt) {
channelBinding(channelId, epoch)
add(arrayOf(TIMER_TAG, timerSecs.coerceAtLeast(0).toString()))
},
)
/**
* The timer a notice announces, in seconds (`0` = turned off), or null when [rumor] isn't a
* well-formed notice — a malformed value is dropped, never guessed at.
*/
fun noticeTimerSecs(rumor: Event): Long? {
if (rumor.kind != KIND_TIMER_NOTICE) return null
val values = rumor.tags.filter { it.size >= 2 && it[0] == TIMER_TAG }
if (values.size != 1) return null
val raw = values[0][1]
if (!DECIMAL.matches(raw)) return null
return raw.toLongOrNull()
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
@@ -36,6 +37,9 @@ fun <T : Event> TagArrayBuilder<T>.channel(channelId: HexKey) = addUnique(Channe
fun <T : Event> TagArrayBuilder<T>.epoch(epoch: Long) = addUnique(EpochTag.assemble(epoch))
/** The CORD-02 §4 sub-second remainder (0..999) of the rumor's send time. */
fun <T : Event> TagArrayBuilder<T>.ms(ms: Int) = addUnique(MsTag.assemble(ms))
/** Binds an event to [channelId] at [epoch] — both tags every Chat Plane rumor carries. */
fun <T : Event> TagArrayBuilder<T>.channelBinding(
channelId: HexKey,
@@ -44,3 +48,14 @@ fun <T : Event> TagArrayBuilder<T>.channelBinding(
channel(channelId)
epoch(epoch)
}
/** [channelBinding] plus the `["ms", …]` remainder every Chat rumor carries (CORD-02 §4). */
fun <T : Event> TagArrayBuilder<T>.channelBinding(
channelId: HexKey,
epoch: Long,
ms: Int,
) = apply {
channel(channelId)
epoch(epoch)
ms(ms)
}
@@ -25,17 +25,39 @@ import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
/** The channel id this Chat Plane rumor is bound to, or null if unbound. */
fun TagArray.concordChannel(): HexKey? = firstNotNullOfOrNull(ChannelTag::parse)
/**
* The value of the one tag named [name], or null when it is absent **or appears more than once**
* (a binding must be unambiguous — the reference client's `uniqueTag`). Every tag whose name
* matches counts toward the duplicate check, even one too short to carry a value.
*/
private fun TagArray.uniqueTagValue(name: String): String? {
var found: String? = null
var count = 0
for (tag in this) {
if (tag.isEmpty() || tag[0] != name) continue
count++
if (count > 1) return null
found = tag.getOrNull(1)
}
return found
}
/** The epoch this Chat Plane rumor is bound to, or null if unbound/malformed. */
fun TagArray.concordEpoch(): Long? = firstNotNullOfOrNull(EpochTag::parse)
/** The channel id this Chat Plane rumor is bound to, or null if unbound or ambiguous (duplicated). */
fun TagArray.concordChannel(): HexKey? = uniqueTagValue(ChannelTag.TAG_NAME)?.takeIf { it.isNotEmpty() }
/**
* True when these tags bind to exactly [channelId] and [epoch]. Recipients must
* reject any Chat Plane event whose binding does not match the plane it arrived on.
* The epoch this Chat Plane rumor is bound to, or null if unbound, ambiguous (duplicated), or not
* in canonical decimal form ([EpochTag.parse]).
*/
fun TagArray.concordEpoch(): Long? = uniqueTagValue(EpochTag.TAG_NAME)?.let { EpochTag.parse(arrayOf(EpochTag.TAG_NAME, it)) }
/**
* True when these tags bind to exactly [channelId] and [epoch] (CORD-03 §3): exactly one
* `channel` tag strict-equal to [channelId], and exactly one `epoch` tag strict-equal to the
* canonical decimal of [epoch] (`"04"` or `"+4"` never match 4). Recipients must reject any Chat
* Plane event whose binding does not match the plane it arrived on.
*/
fun TagArray.isConcordBoundTo(
channelId: HexKey,
epoch: Long,
): Boolean = concordChannel() == channelId && concordEpoch() == epoch
): Boolean = uniqueTagValue(ChannelTag.TAG_NAME) == channelId && uniqueTagValue(EpochTag.TAG_NAME) == epoch.toString()
@@ -35,11 +35,19 @@ class EpochTag {
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/**
* The epoch, or null when the value is not its canonical decimal form (CORD-01 Encoding:
* "no leading zeros"). `"04"`, `"+4"`, `"-1"` and `" 4"` are all refused: the binding is a
* strict string comparison, so a spelling that merely parses to the same number is a
* different binding.
*/
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
val epoch = tag[1].toLongOrNull() ?: return null
ensure(epoch >= 0 && epoch.toString() == tag[1]) { return null }
return epoch
}
fun assemble(epoch: Long) = arrayOf(TAG_NAME, epoch.toString())
@@ -0,0 +1,86 @@
/*
* 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.cord03Channels.tags
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* The `["ms", "<0..999>"]` sub-second remainder every Concord Chat rumor carries (CORD-02 §4):
* `created_at` stays whole unix seconds, untweaked (CORD-01), and the true send time is
* `created_at * 1000 + ms`. Every comparison the protocol makes (message order, edit recency)
* uses that basis.
*
* Parsing is strict decimal, like the reference client's `resolveMs`: `"0"` or `"1"`…`"999"`
* with no leading zero, sign, whitespace or exponent. A tag outside that shape is malformed, and
* a malformed rumor is dropped rather than interpreted (CORD-02 §5), so the excess can never
* smuggle ordering the author's clock did not produce.
*/
class MsTag {
companion object {
const val TAG_NAME = "ms"
private val CANONICAL = Regex("^(0|[1-9][0-9]{0,2})$")
/** The remainder in [tag], or null when it is not an `ms` tag or is malformed. */
fun parse(tag: Array<String>): Int? {
if (tag.isEmpty() || tag[0] != TAG_NAME) return null
val raw = tag.getOrNull(1) ?: return null
if (!CANONICAL.matches(raw)) return null
return raw.toInt()
}
fun assemble(ms: Int): Array<String> {
require(ms in 0..999) { "ms remainder must be in 0..999, was $ms" }
return arrayOf(TAG_NAME, ms.toString())
}
/**
* The sub-second remainder of "now" when [createdAt] is the current second, else 0. A
* builder handed the current `TimeUtils.now()` thus stamps the real millisecond; one
* handed any other second (a fixed test time, a backdated rumor, a rollover between the
* two clock reads) stamps 0, which is still a well-formed, ordering-safe tag.
*/
fun remainderFor(createdAt: Long): Int {
val nowMs = TimeUtils.nowMillis()
return if (nowMs / 1000 == createdAt) (nowMs % 1000).toInt() else 0
}
/**
* The rumor's ordering basis `createdAt * 1000 + ms` (CORD-02 §4). A missing tag counts
* as 0; a malformed or duplicated one yields null, and the caller drops the rumor.
*/
fun orderingMs(
createdAt: Long,
tags: TagArray,
): Long? {
var found: Int? = null
var count = 0
for (tag in tags) {
if (tag.isEmpty() || tag[0] != TAG_NAME) continue
count++
found = parse(tag) ?: return null
}
if (count > 1) return null
return createdAt * 1000 + (found ?: 0)
}
}
}
@@ -0,0 +1,74 @@
/*
* 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.cord04Roles
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* The `vac` authority citation (CORD-04 §1/§5) for writers and for verifiers outside the
* Control Plane fold — Kicks, rekey rotations (kind 3303), and anything else that acts under
* a Grant.
*
* Every non-owner authority action cites the exact Grant edition its actor holds their rank
* under — their own `grant_locator(community_id, actor)` coordinate, at the version and edition
* hash of the head the roster folded. A reader drops (parks) the action until it holds that
* Grant at or past the cited version, then judges the actor's rank against its **current**
* roster, so a stale citation grandfathers nothing. The owner is proven by the `community_id`
* and cites nothing. Mirrors Armada `citationSatisfied` (control.ts).
*/
object AuthorityCitations {
/**
* The citation [actor] must attach to an authority action, resolved from the community's
* folded [authority] roster; null for the owner, and for an actor holding no honored Grant
* (no reader would honor their action anyway).
*/
fun forActor(
authority: AuthorityResolver,
actor: HexKey,
): AuthorityCitation? = authority.citationFor(actor)
/**
* [forActor] straight from the Control Plane [editions]: folds the roster of the community
* [communityId], owned by [ownerPubKey], and cites [actor]'s Grant head in it. Short-circuits
* for the owner without folding.
*/
fun forActor(
editions: Collection<ControlEdition>,
communityId: ByteArray,
ownerPubKey: HexKey,
actor: HexKey,
): AuthorityCitation? {
if (actor.equals(ownerPubKey, ignoreCase = true)) return null
return AuthorityResolver.resolve(editions, communityId, ownerPubKey).citationFor(actor)
}
/**
* Whether [citation] satisfies CORD-04 §5's sync floor for an action by [actor] against the
* folded [authority] roster: the owner needs none; anyone else must cite their own Grant
* coordinate, held at or past the cited version (hash matching at equality). Completeness
* only — the caller still checks the actor's bit and rank against [authority].
*/
fun isSatisfied(
authority: AuthorityResolver,
actor: HexKey,
citation: AuthorityCitation?,
): Boolean = authority.citationSatisfied(actor, citation)
}
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
@@ -30,28 +33,48 @@ import com.vitorpamplona.quartz.utils.Log
* "The Roster is owner-rooted: every Grant and Role is signed by an npub the
* Roster ranks strictly above it, and the chain terminates at the owner."
*
* Build one with [resolve] from the current entity heads (the values of
* [EditionFold.fold]) plus the community's known owner pubkey. It then answers:
* Build one with [resolve] from the community's Control Plane editions, its
* `community_id` (which pins every derived coordinate) and its known owner pubkey.
* It then answers:
* - [rank] — a member's authority (lower is higher; owner is [OWNER_RANK]; a
* member with no validly-granted role has no rank).
* - [effectivePermissions] — the union of a member's roles' bits (owner: all).
* - [isBanned] — membership in the healed banlist union.
* - [isBanned] — membership in the folded Banlist head.
* - [canActOn] — whether an actor may take a permissioned action on a target:
* the actor must hold the bit, must strictly outrank the target (equal cannot
* act on equal), and the owner is unremovable.
* - [citationFor] / [citationSatisfied] — the `vac` authority citation (CORD-04 §5)
* an actor writes, and whether an edition's citation resolves against this roster.
*
* Grants are validated by a fixpoint that only ever empowers members reachable
* from the owner: a Grant is honored when its signer already outranks every
* assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never
* touch the owner can never bootstrap themselves.
* from the owner: a Grant is honored when it sits at its member's own coordinate,
* its signer already outranks every assigned Role, holds
* [ConcordPermissions.MANAGE_ROLES], and cites the Grant it acts under. Cycles that
* never touch the owner can never bootstrap themselves.
*/
@ConsistentCopyVisibility
data class AuthorityResolver private constructor(
private val communityIdHex: String,
private val ownerLower: String,
private val roles: Map<String, RoleEntity>,
private val memberRoles: Map<String, Set<String>>,
private val banned: Set<String>,
/** Each role-holder's honored Grant head, keyed by member — what a `vac` must pin. */
private val grantHeads: Map<String, GrantHead>,
) {
// Derived from the constructor values, so deliberately outside the data class's equality.
private val communityId: ByteArray by lazy { communityIdHex.hexToByteArray() }
private val banlistEidHex: String by lazy { banlistCoordinateHex(communityId) }
/**
* A member's honored Grant head: the edition every reader folded their roles from, and so
* the one their authority actions cite (CORD-04 §5).
*/
data class GrantHead(
val version: Long,
val hashHex: String,
)
/** The resolved role definitions (authority-gated), keyed by role id. Safe for display. */
fun roles(): Map<String, RoleEntity> = roles
@@ -73,7 +96,7 @@ data class AuthorityResolver private constructor(
*/
fun roleHolders(): Set<String> = memberRoles.keys
/** The healed banlist union (lowercase hex). */
/** The folded Banlist (lowercase hex). */
fun bannedMembers(): Set<String> = banned
/** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */
@@ -84,6 +107,12 @@ data class AuthorityResolver private constructor(
return held.mapNotNull { roles[it]?.position }.minOrNull()
}
/**
* [author]'s standing for an equal-version tie-break (CORD-04 §1, "authority first"):
* the owner first, then by Role position, a roleless author last.
*/
fun tieBreakRank(author: String): Long = rank(author) ?: Long.MAX_VALUE
/** The union of a member's roles' permission bits (owner holds every bit). */
fun effectivePermissions(pubKey: String): ConcordPermissions {
val m = pubKey.lowercase()
@@ -132,6 +161,68 @@ data class AuthorityResolver private constructor(
return actorRank < targetRank
}
/** [member]'s honored Grant head, or null when they hold none (the owner never does). */
fun grantHead(member: String): GrantHead? = grantHeads[member.lowercase()]
/**
* The `vac` citation (CORD-04 §1/§5) [actor] must attach to a Control-authority action:
* their own Grant coordinate, pinned at the version and hash of the head this roster
* folded. Null for the owner (who cites nothing) and for an actor holding no honored
* Grant (whose actions no reader would honor anyway).
*/
fun citationFor(actor: String): AuthorityCitation? {
val m = actor.lowercase()
if (m == ownerLower) return null
val head = grantHeads[m] ?: return null
val coordinate = grantCoordinateOrNull(communityId, m) ?: return null
return AuthorityCitation(coordinate.hexToByteArray(), head.version, head.hashHex.hexToByteArray())
}
/**
* Whether [citation] satisfies CORD-04 §5 for an action by [actor] against this roster:
* the owner needs none; anyone else must cite their **own** Grant coordinate, and this
* roster must hold that Grant at the cited version with the cited hash, or past it. A
* citation ahead of what we hold (not yet synced), at a forked hash, or naming someone
* else's Grant parks the action — here, drops it until a later fold. It is a sync floor,
* never the verdict: the caller still judges rank against the current roster.
*/
fun citationSatisfied(
actor: String,
citation: AuthorityCitation?,
): Boolean {
val m = actor.lowercase()
if (m == ownerLower) return true
val coordinate = grantCoordinateOrNull(communityId, m) ?: return false
return citationMatches(citation, coordinate, grantHeads[m])
}
/** [citationSatisfied] for [edition]'s author and `vac`. */
fun citationSatisfied(edition: ControlEdition): Boolean = citationSatisfied(edition.author, edition.authorityCitation)
/**
* Whether an edition's content may stand at its coordinate at all, independent of who
* signed it: the entity coordinates CORD-04 §1 derives from the `community_id` (Metadata
* at the `community_id`, a Grant at its member's `grant_locator`, the Banlist at
* `banlist_locator`, an Invite Registry at its author's locator) and the content caps
* (CORD-04 §2, CORD-02 §6). A sub-kind we do not model is judged by its signer alone.
*/
fun isWellFormed(edition: ControlEdition): Boolean = wellFormed(edition, communityId, communityIdHex, banlistEidHex)
/**
* Whether a reader honors [edition] as an action gated by [bit] (CORD-04 §5): it is
* [isWellFormed], its author is the owner or holds [bit] (and is not banned), and its
* `vac` resolves ([citationSatisfied]). A null [bit] is owner-only.
*/
fun admits(
edition: ControlEdition,
bit: Int?,
): Boolean {
if (!isWellFormed(edition)) return false
if (isOwner(edition.author)) return true
if (bit == null || !hasPermission(edition.author, bit)) return false
return citationSatisfied(edition)
}
companion object {
private const val TAG = "ConcordAuthorityResolver"
@@ -146,11 +237,74 @@ data class AuthorityResolver private constructor(
*/
private const val MAX_BAN_RESOLUTION_PASSES = 4
/** `grant_locator(community_id, member)` as hex, or null when [member] is not a 32-byte hex key. */
internal fun grantCoordinateOrNull(
communityId: ByteArray,
member: String,
): String? {
val xOnly = member.hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null
return ConcordKeyDerivation.grantCoordinate(communityId, xOnly).toHexKey()
}
private fun banlistCoordinateHex(communityId: ByteArray): String = ConcordKeyDerivation.banlistCoordinate(communityId).toHexKey()
/**
* The CORD-04 §5 citation test against the Grant head [head] a verifier holds at the
* actor's coordinate [expectedGrantIdHex] — Armada's `citationSatisfied`, case for case:
* the citation must name that coordinate, and the head must be past the cited version,
* or at it with the same hash. Behind it (unsynced) or at a forked hash, it parks.
*/
internal fun citationMatches(
citation: AuthorityCitation?,
expectedGrantIdHex: String,
head: GrantHead?,
): Boolean {
if (citation == null || head == null) return false
if (citation.grantId.toHexKey() != expectedGrantIdHex) return false
if (head.version > citation.grantVersion) return true
if (head.version == citation.grantVersion) return head.hashHex == citation.grantHash.toHexKey()
return false
}
/** The Grant content at [edition], or null when it does not sit at its member's own coordinate (S5). */
private fun grantAt(
edition: ControlEdition,
communityId: ByteArray,
): GrantEntity? {
val g = ConcordJson.decodeOrNull<GrantEntity>(edition.content) ?: return null
val coordinate = grantCoordinateOrNull(communityId, g.member.lowercase()) ?: return null
return g.takeIf { coordinate == edition.entityIdHex }
}
/** See [isWellFormed]. */
private fun wellFormed(
edition: ControlEdition,
communityId: ByteArray,
communityIdHex: String,
banlistEidHex: String,
): Boolean =
when (edition.entityKind) {
ControlEntityKind.METADATA ->
edition.entityIdHex == communityIdHex &&
ConcordJson.decodeOrNull<MetadataEntity>(edition.content)?.let(ConcordLimits::metadataFits) == true
ControlEntityKind.ROLE -> ConcordJson.decodeOrNull<RoleEntity>(edition.content)?.isWellFormedAt(edition.entityIdHex) == true
ControlEntityKind.GRANT -> grantAt(edition, communityId) != null
ControlEntityKind.BANLIST -> edition.entityIdHex == banlistEidHex && ConcordJson.decodeBanlist(edition.content) != null
ControlEntityKind.INVITE_REGISTRY ->
edition.entityIdHex == ConcordKeyDerivation.inviteLinksCoordinate(communityId, edition.author.hexToByteArray()).toHexKey()
else -> true
}
/**
* The owner-rooted authority state of a community, with the banlist honored **against the
* Control Plane itself** (CORD-04 §4: a reader "drops every event from a banned npub —
* message, reaction, edit, or authority action").
*
* [communityId] pins every derived coordinate: a Grant is honored only at its member's own
* `grant_locator(community_id, member)` and the Banlist only at `banlist_locator(community_id)`
* (CORD-02 A.6), so a second chain minted at any other coordinate cannot override the
* canonical one.
*
* This is a bounded two-pass, because the rule is circular as stated: you cannot know who is
* banned until you fold the Banlist, and you cannot decide who may write the Banlist without
* knowing who is banned. `docs/concord-banlist-rank-conformance.md` §4 row 3 flagged that to
@@ -178,9 +332,10 @@ data class AuthorityResolver private constructor(
*/
fun resolve(
editions: Collection<ControlEdition>,
communityId: ByteArray,
ownerPubKey: String,
): AuthorityResolver {
val passA = resolveOnce(editions, ownerPubKey, bannedAuthors = emptySet())
val passA = resolveOnce(editions, communityId, ownerPubKey, bannedAuthors = emptySet())
// A further pass costs a whole fold, so skip it unless it could change something. Nobody
// banned, or nobody banned who ever wrote to the Control Plane — the overwhelmingly common
// shape, since most bans land on plain members who hold no role and author no editions —
@@ -204,7 +359,7 @@ data class AuthorityResolver private constructor(
var mask = passA.banned
var result = passA
repeat(MAX_BAN_RESOLUTION_PASSES) {
result = resolveOnce(editions, ownerPubKey, bannedAuthors = mask)
result = resolveOnce(editions, communityId, ownerPubKey, bannedAuthors = mask)
if (result.banned == mask) return result
mask = result.banned
}
@@ -226,10 +381,17 @@ data class AuthorityResolver private constructor(
*/
private fun resolveOnce(
editions: Collection<ControlEdition>,
communityId: ByteArray,
ownerPubKey: String,
bannedAuthors: Set<String>,
): AuthorityResolver {
val ownerLower = ownerPubKey.lowercase()
val communityIdHex = communityId.toHexKey()
// grant_locator(community_id, member) for every author the gates ask about, derived once.
val grantCoordinates = HashMap<String, String?>()
fun grantCoordinateOf(member: String): String? = grantCoordinates.getOrPut(member) { grantCoordinateOrNull(communityId, member) }
// Chains grouped by entity: one role chain per role id, one grant chain per member
// coordinate. We fold each chain through AUTHORIZED editions only, so a rogue cannot
@@ -240,6 +402,7 @@ data class AuthorityResolver private constructor(
var roles: Map<String, RoleEntity> = emptyMap()
var memberRoles: Map<String, Set<String>> = emptyMap()
var grantHeads: Map<String, GrantHead> = emptyMap()
// Authority helpers read the CURRENT (previous-pass) roster, so within a pass a granter's
// rank is judged by the chain already settled behind it — the owner-rooted resolution the
@@ -250,6 +413,9 @@ data class AuthorityResolver private constructor(
return held.mapNotNull { roles[it]?.position }.minOrNull()
}
// Authority first at an equal-version tie (CORD-04 §1), judged by the same settled roster.
fun tieRank(author: String): Long = rankOf(author.lowercase()) ?: Long.MAX_VALUE
// The bits a member currently holds, evaluated against the chain settled so far — the same
// owner-rooted basis as rankOf. Needed inside the fixpoint; effectivePermissionsOf below is
// the post-settlement view.
@@ -267,6 +433,17 @@ data class AuthorityResolver private constructor(
return held.any { roles[it]?.permissionBits()?.has(ConcordPermissions.MANAGE_ROLES) == true }
}
// CORD-04 §5: a non-owner edition must cite the exact Grant it acts under — its author's
// own coordinate — and we must hold that Grant at or past the cited version, hash
// matching at equality. Judged against the Grant heads settled so far, so the owner's
// grants settle first and delegation bootstraps outward (Armada `citedOk`).
fun cited(e: ControlEdition): Boolean {
val author = e.author.lowercase()
if (author == ownerLower) return true
val coordinate = grantCoordinateOf(author) ?: return false
return citationMatches(e.authorityCitation, coordinate, grantHeads[author])
}
// Owner-rooted fixpoint: each pass only ever empowers members reachable from the owner, so
// the roster grows monotonically and settles. Bounded by the edition count as a backstop.
val maxPasses = editions.size + 1
@@ -279,12 +456,16 @@ data class AuthorityResolver private constructor(
entity: String,
e: ControlEdition,
): Boolean {
// Well-formed first, for every author: a role_id naming another coordinate, an
// over-long name, or a live role at the owner's position 0 is no role at all.
val r = ConcordJson.decodeOrNull<RoleEntity>(e.content) ?: return false
if (!r.isWellFormedAt(entity)) return false
val author = e.author.lowercase()
if (author == ownerLower) return true
if (author in bannedAuthors) return false
if (!holdsManageRoles(author)) return false
if (!cited(e)) return false
val authorRank = rankOf(author) ?: return false
val r = ConcordJson.decodeOrNull<RoleEntity>(e.content) ?: return false
// MANAGE_ROLES alone was the whole test, which let any holder rewrite the
// role they hold — position 1 with every bit — and then demote the real
// admins beneath them. Grants are gated on rank (a granter must outrank
@@ -302,7 +483,7 @@ data class AuthorityResolver private constructor(
val newRoles = HashMap<String, RoleEntity>()
for ((entity, chain) in roleChains) {
val head = EditionFold.foldEntityGated(chain) { roleGate(entity, it) } ?: continue
val head = EditionFold.foldEntityGated(chain, rank = ::tieRank) { roleGate(entity, it) } ?: continue
val r = ConcordJson.decodeOrNull<RoleEntity>(head.content) ?: continue
if (r.deleted || r.position < 1) continue // no role may claim the owner's position 0
newRoles[entity] = r
@@ -312,14 +493,18 @@ data class AuthorityResolver private constructor(
// AND strictly outranks every role it hands out. Same candidate-then-gate shape, so a
// rogue grant is dropped without orphaning the honest grants chained above it.
fun grantGate(e: ControlEdition): Boolean {
// The coordinate must be the member's own grant_locator (CORD-04 §1, S5): a chain
// minted anywhere else is not that member's Grant, whoever signed it.
val g = grantAt(e, communityId) ?: return false
val granter = e.author.lowercase()
if (granter == ownerLower) return true
if (granter in bannedAuthors) return false
if (!holdsManageRoles(granter)) return false
if (!cited(e)) return false
val granterRank = rankOf(granter) ?: return false
val g = ConcordJson.decodeOrNull<GrantEntity>(e.content) ?: return false
// Must strictly outrank each assigned role that actually exists...
if (!g.roleIds.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true }) return false
val assigned = g.roleIds.take(ConcordLimits.MAX_ROLES_PER_MEMBER)
if (!assigned.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true }) return false
// ...and outrank the member being edited. A grant is an action ON that
// member, and a REVOKE carries no role ids at all — `all {}` over an
// empty list is vacuously true, so without this any MANAGE_ROLES holder
@@ -330,15 +515,36 @@ data class AuthorityResolver private constructor(
}
val newMemberRoles = HashMap<String, Set<String>>()
val newGrantHeads = HashMap<String, GrantHead>()
for ((_, chain) in grantChains) {
val head = EditionFold.foldEntityGated(chain, gate = ::grantGate) ?: continue
val g = ConcordJson.decodeOrNull<GrantEntity>(head.content) ?: continue
newMemberRoles[g.member.lowercase()] = g.roleIds.filter { newRoles.containsKey(it) }.toSet()
val head = EditionFold.foldEntityGated(chain, rank = ::tieRank, gate = ::grantGate) ?: continue
val g = grantAt(head, communityId) ?: continue
val member = g.member.lowercase()
// A member holds at most 64 Roles (CORD-04 §2): the rest of the list is ignored.
newMemberRoles[member] =
g.roleIds
.take(ConcordLimits.MAX_ROLES_PER_MEMBER)
.filter { newRoles.containsKey(it) }
.toSet()
newGrantHeads[member] = GrantHead(head.version, head.hashHex)
}
if (newRoles == roles && newMemberRoles == memberRoles) break
if (newRoles == roles && newMemberRoles == memberRoles && newGrantHeads == grantHeads) break
roles = newRoles
memberRoles = newMemberRoles
grantHeads = newGrantHeads
}
// A Community carries at most 100 Roles (CORD-04 §2): fold the 100 lowest role_ids and
// ignore the rest, after authorization, exactly where Armada trims them.
if (roles.size > ConcordLimits.MAX_ROLES_PER_COMMUNITY) {
val kept =
roles.keys
.sorted()
.take(ConcordLimits.MAX_ROLES_PER_COMMUNITY)
.toSet()
roles = roles.filterKeys { it in kept }
memberRoles = memberRoles.mapValues { (_, held) -> held.filterTo(HashSet()) { it in kept } }
}
// The union of a member's roles' permission bits (owner holds every bit).
@@ -350,22 +556,22 @@ data class AuthorityResolver private constructor(
return acc
}
// Banlist: honored only from a signer holding BAN (or the owner). The banlist is a single
// replaced doc, so fold its chain to the head first — that honors a legitimate unban, which
// is a *chained* edition replacing the previous set (e.g. ban→unban). Then heal concurrent
// forks: two moderators who ban different abusers at the same chain version fork the doc, and
// folding to one head would silently drop the other's ban. Union in every authorized edition
// that is NOT an ancestor of the head — those are the parallel bans the chain never absorbed.
// Ancestors (superseded by the chain, including an unban's now-cleared target) are already
// reflected by the head and must not be resurrected. This is CORD-06's "down-only healing":
// a concurrent ban is never lost, while an on-chain unban still takes effect.
val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST }
// Banlist: honored only from a signer holding BAN (or the owner), at the one coordinate
// CORD-02 A.6 derives for it. It is a single replaced document folded to ONE head like any
// entity (CORD-04 §4): two admins banning different members at the same version collide,
// the fold keeps one edition (authority first, then the lower rumor id), and the loser's
// addition drops until its writer re-heals it on top of the winner. Unioning every fork
// instead made a ban on a losing fork impossible to lift — no later edition supersedes a
// fork — and diverged from every other client's fold.
val banlistEid = banlistCoordinateHex(communityId)
val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST && it.entityIdHex == banlistEid }
fun banGate(e: ControlEdition): Boolean {
if (ConcordJson.decodeBanlist(e.content) == null) return false
val author = e.author.lowercase()
return author == ownerLower || (author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN))
if (author == ownerLower) return true
return author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN) && cited(e)
}
val authorizedBanlist = allBanlist.filter(::banGate)
// CORD-04 §3's rank rule binds "every action", and it names banning as its example ("an
// admin cannot ban a peer admin"); §5 step 3 restates it. Only §4, which defines the
@@ -428,46 +634,13 @@ data class AuthorityResolver private constructor(
return result
}
val banned = HashSet<String>()
// Candidate-then-gate, like roles and grants: an unauthorized banlist edition in the
// middle of the chain must not orphan the authorized ones chained above it (which, on
// a banlist, would silently resurrect every ban a later unban had cleared).
val banHead = EditionFold.foldEntityGated(allBanlist, gate = ::banGate)
if (banHead != null) {
banned.addAll(effectiveList(banHead, HashSet()))
// Ancestry is a STRUCTURAL fact, so it is walked over the full pool: an unauthorized
// edition on the head's back-chain still supersedes what is beneath it, and walking
// only the authorized subset would stop there and mis-read those genuine ancestors as
// concurrent forks — un-doing the unban the chain already recorded.
val ancestry = banlistAncestry(banHead, allBanlist)
for (edition in authorizedBanlist) {
if (edition.hashHex !in ancestry) {
banned.addAll(effectiveList(edition, HashSet()))
}
}
}
val banHead = EditionFold.foldEntityGated(allBanlist, rank = ::tieRank, gate = ::banGate)
val banned = banHead?.let { effectiveList(it, HashSet()) } ?: emptySet()
return AuthorityResolver(ownerLower, roles, memberRoles.toMap(), banned)
}
/**
* The set of edition hashes on [head]'s back-chain (head itself plus every edition it chains
* from via `prevHash`), among [pool]. Used to tell a superseded ancestor (already reflected by
* the head) from a concurrent fork (a parallel ban to heal). The `add`-guarded walk also
* terminates on any cycle.
*/
private fun banlistAncestry(
head: ControlEdition,
pool: List<ControlEdition>,
): Set<String> {
val byHash = pool.associateBy { it.hashHex }
val acc = HashSet<String>()
var cur: ControlEdition? = head
while (cur != null && acc.add(cur.hashHex)) {
val prev = cur.prevHash?.toHexKey()
cur = if (prev != null) byHash[prev] else null
}
return acc
return AuthorityResolver(communityIdHex, ownerLower, roles, memberRoles.toMap(), banned, grantHeads)
}
}
}
@@ -0,0 +1,51 @@
/*
* 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.cord04Roles
/**
* The protocol's size caps on Control Plane entities (CORD-04 §2, CORD-02 §6), counted as
* UTF-8 bytes. They are **read-side rules as well as write-side ones**: a writer refuses to
* mint an edition past them, and the fold drops (or, for the role counts, trims) whatever
* another client minted past them, exactly as the reference client (Armada `roles.ts` /
* `control.ts`) does, so both converge on the same state.
*/
object ConcordLimits {
/** The protocol-wide name cap: Roles, Channels and the Community name (CORD-02 §6). */
const val NAME_MAX_BYTES = 64
/** The Community description cap (CORD-02 §6). */
const val DESCRIPTION_MAX_BYTES = 10_000
/** A member holds at most this many Roles; a Grant's extra `role_ids` are ignored (CORD-04 §2). */
const val MAX_ROLES_PER_MEMBER = 64
/** A Community folds at most this many Roles: the lowest `role_id`s win (CORD-04 §2). */
const val MAX_ROLES_PER_COMMUNITY = 100
fun utf8Size(s: String): Int = s.encodeToByteArray().size
fun nameFits(name: String): Boolean = utf8Size(name) <= NAME_MAX_BYTES
fun descriptionFits(description: String?): Boolean = description == null || utf8Size(description) <= DESCRIPTION_MAX_BYTES
/** Whether [metadata] is within the Community metadata caps (CORD-02 §6). */
fun metadataFits(metadata: MetadataEntity): Boolean = nameFits(metadata.name) && descriptionFits(metadata.description)
}
@@ -23,10 +23,15 @@ package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
import com.vitorpamplona.quartz.concord.cord04Roles.control.eid
import com.vitorpamplona.quartz.concord.cord04Roles.control.ev
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.CanonicalDecimal
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EpTag
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EvTag
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag
import com.vitorpamplona.quartz.concord.crypto.EditionHash
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -51,9 +56,15 @@ class AuthorityCitation(
* verbatim entity [content]. Its identity is [hash] — a domain-separated hash of
* exactly those fields (see [EditionHash]) — which the next edition cites in `ep`,
* forming an unforgeable chain.
*
* [entityKind] is null for a sub-kind this client does not model (Pins, vsk 11;
* Signals, vsk 12; anything newer): nothing here folds such an edition into state, but
* it is kept — [vsk] carries its raw sub-kind — so the anti-rollback floor and a
* Refounding's compaction carry its head forward verbatim instead of silently dropping
* another client's state (CORD-04 §7, CORD-06 §3).
*/
class ControlEdition(
val entityKind: ControlEntityKind,
val entityKind: ControlEntityKind?,
val entityId: ByteArray,
val version: Long,
val prevHash: ByteArray?,
@@ -64,6 +75,8 @@ class ControlEdition(
/** The rumor's event id — the deterministic tie-break key at equal version. */
val rumorId: String,
val createdAt: Long,
/** The raw `vsk` sub-kind on the wire; [entityKind]'s wire value when it is modeled. */
val vsk: String = entityKind?.wire ?: "",
) {
/** Domain-separated edition identity; the next edition's `ep` cites this. */
val hash: ByteArray by lazy { EditionHash.hash(entityId, version, prevHash, content) }
@@ -71,20 +84,59 @@ class ControlEdition(
val entityIdHex: String get() = entityId.toHexKey()
val hashHex: String get() = hash.toHexKey()
/** This edition carrying [citation] as its `vac`. The hash does not cover it, so the chain is unchanged. */
fun withCitation(citation: AuthorityCitation?): ControlEdition = ControlEdition(entityKind, entityId, version, prevHash, citation, content, author, rumorId, createdAt, vsk)
companion object {
/** The machinery tags an edition may carry at most once each (Armada `parseEdition`). */
private val SINGLE_VALUED_TAGS = arrayOf(VskTag.TAG_NAME, EidTag.TAG_NAME, EvTag.TAG_NAME, EpTag.TAG_NAME, VacTag.TAG_NAME)
/**
* Sub-kinds that are never Control Plane editions (CORD-02 Appendix B): 6 and 9 are
* claimed by the addressable kind-33301 invite marker, 7 is retired (the v1 owner
* attestation), and 10 is the dissolution tombstone, which lives at its own address and
* must never be read off this plane (see `ConcordDissolution`).
*/
private val NOT_CONTROL_EDITIONS =
setOf(ControlEntityKind.INVITE_LIVE.wire, "7", ControlEntityKind.INVITE_REVOKED.wire, ControlEntityKind.DISSOLVED.wire)
/**
* Parses an opened Control Plane wrap into a [ControlEdition], or null when it is not
* one. On top of [fromRumor] it enforces the plane's seal kind: a Control edition
* **MUST** ride a plaintext kind-20014 seal (CORD-02 §5, Appendix B), since only a
* plaintext seal survives a compaction re-wrap with its signature intact. An edition
* under an encrypted 20013 seal is refused, as the reference client refuses it.
*/
fun fromOpened(opened: OpenedStreamEvent): ControlEdition? {
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT) return null
return fromRumor(opened.rumor, opened.author)
}
/**
* Parses a decrypted, verified kind-3308 [rumor] (its [author] is the
* rumor's pubkey) into a [ControlEdition], or returns null if it is not a
* well-formed control edition (unknown/absent `vsk`, missing `eid`/`ev`,
* malformed hex, …) so the caller drops it rather than folding garbage.
* Reads the typed tags of [ControlEditionEvent].
* well-formed control edition, so the caller drops it rather than folding
* garbage: an absent or non-canonical `vsk`, a sub-kind that is not a Control
* edition (6, 7, 9, 10), a missing or malformed `eid`/`ev`, a malformed `ep`/`vac`,
* or any of those machinery tags appearing more than once (an ambiguous edition
* two readers could parse differently). A canonical `vsk` this client does not
* model parses with a null [ControlEdition.entityKind] (see the class doc).
*
* Prefer [fromOpened] for anything read off a plane: this does not see the seal.
*/
fun fromRumor(
rumor: Event,
author: String = rumor.pubKey,
): ControlEdition? {
if (rumor.kind != ControlEditionEvent.KIND) return null
val entityKind = rumor.tags.vsk() ?: return null
for (name in SINGLE_VALUED_TAGS) {
if (rumor.tags.count { it.isNotEmpty() && it[0] == name } > 1) return null
}
val vskWire = rumor.tags.firstOrNull { it.size >= 2 && it[0] == VskTag.TAG_NAME }?.get(1) ?: return null
if (!CanonicalDecimal.isCanonical(vskWire) || vskWire in NOT_CONTROL_EDITIONS) return null
val entityKind = ControlEntityKind.of(vskWire)
val entityId = rumor.tags.eid() ?: return null
val version = rumor.tags.ev() ?: return null
@@ -107,6 +159,7 @@ class ControlEdition(
author = author,
rumorId = rumor.id,
createdAt = rumor.createdAt,
vsk = vskWire,
)
}
}
@@ -21,11 +21,20 @@
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.elementNames
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonObject
import kotlin.math.floor
/**
* JSON facility for Concord Control Plane entity content. Unknown keys are
@@ -47,6 +56,37 @@ object ConcordJson {
null
}
/**
* Encodes [value] as the next edition's content **without losing what the previous edition
* carried and we don't model** (CORD-02 §6: "an editor MUST round-trip fields it doesn't
* understand"). Every key [serializer] declares is ours to set — including to absent, so a form
* can clear an optional field — and every other key of [previousContent] (another client's
* `custom`, a newer protocol field like `av_brokers`) rides through verbatim.
*
* [previousContent] is the entity's current authorized head, or null for a genesis edition. A
* head that is not a JSON object contributes nothing.
*/
@OptIn(ExperimentalSerializationApi::class)
fun <T> encodePreserving(
serializer: KSerializer<T>,
value: T,
previousContent: String?,
): String {
val next = instance.encodeToJsonElement(serializer, value).jsonObject
val previous =
previousContent?.let {
try {
instance.parseToJsonElement(it) as? JsonObject
} catch (_: Exception) {
null
}
} ?: return instance.encodeToString(JsonObject.serializer(), next)
val managed = serializer.descriptor.elementNames.toSet()
val kept = previous.filterKeys { it !in managed }
if (kept.isEmpty()) return instance.encodeToString(JsonObject.serializer(), next)
return instance.encodeToString(JsonObject.serializer(), JsonObject(next + kept))
}
/** Parses a Banlist edition's content (a bare JSON array of hex pubkeys). */
fun decodeBanlist(content: String): List<String>? =
try {
@@ -71,11 +111,17 @@ data class RoleScope(
/**
* A Role's content (CORD-04): a named bundle of permissions at a [position].
* The role's id is the edition's entity id, not a content field. Lower [position]
* ranks higher; no role may claim position 0 (reserved for the owner).
* Lower [position] ranks higher; no role may claim position 0 (reserved for the owner).
*
* [roleId] is the role's own id, which the spec puts in the content (CORD-04 §2) and which
* must equal the edition's `eid`. The reference client drops a role without it, so every
* role we write carries it; roles minted before this client wrote it are still read (the
* `eid` is then the id), but a role whose [roleId] names a *different* coordinate is refused
* ([isWellFormedAt]).
*/
@Serializable
data class RoleEntity(
@SerialName("role_id") val roleId: String? = null,
val name: String = "",
val position: Long = 0,
/** u64 permission bitfield as a decimal string. */
@@ -86,6 +132,18 @@ data class RoleEntity(
val deleted: Boolean = false,
) {
fun permissionBits(): ConcordPermissions = ConcordPermissions.fromWireOrNull(permissions) ?: ConcordPermissions.NONE
/**
* Whether this content may stand as the role at coordinate [entityIdHex] (CORD-04 §2/§3):
* its [roleId], when present, names that coordinate; its name fits the 64-byte cap; and a
* live role claims a position below the owner's 0. Mirrors Armada's `roleFromJSON`, except
* that a legacy role with no [roleId] is still accepted.
*/
fun isWellFormedAt(entityIdHex: String): Boolean {
if (roleId != null && !roleId.equals(entityIdHex, ignoreCase = true)) return false
if (!ConcordLimits.nameFits(name)) return false
return deleted || position >= 1
}
}
/**
@@ -110,16 +168,35 @@ data class GrantEntity(
/**
* A Channel's content (CORD-03). The channel id is the edition entity id.
* [private] selects derived-key visibility; [voice] flags an audio channel.
* A [deleted] channel is terminal — its id is never reused.
* [private] selects derived-key visibility. A [deleted] channel is terminal — its id is never
* reused.
*
* There is no voice flag: every Channel is callable (CORD-07). A `voice` key an older client
* wrote is not ours to interpret; it rides through edits untouched like any unknown field
* ([ConcordJson.encodePreserving]), as does the optional `custom` object (CORD-02 §6).
*/
@Serializable
data class ChannelEntity(
val name: String = "",
val private: Boolean = false,
val voice: Boolean = false,
val deleted: Boolean = false,
)
) {
/** True when [name] is within the protocol's name rule ([isValidName]). */
fun hasValidName(): Boolean = isValidName(name)
companion object {
/** The protocol-wide name cap, in UTF-8 bytes (CORD-03 §2, CORD-04). */
const val NAME_MAX_BYTES = 64
/**
* A Channel name must be non-empty and at most [NAME_MAX_BYTES] UTF-8 bytes. Enforced when
* building an edition and again when folding one: an edition naming an empty or over-cap
* Channel is unauthorized, and the fold falls back to the previous candidate (the reference
* client's channel gate).
*/
fun isValidName(name: String): Boolean = name.isNotEmpty() && name.encodeToByteArray().size <= NAME_MAX_BYTES
}
}
/**
* A community's Metadata content (CORD-02): display [name], optional [description], the community's
@@ -137,4 +214,28 @@ data class MetadataEntity(
val banner: ImagePointer? = null,
val description: String? = null,
val relays: List<String> = emptyList(),
)
/**
* The disappearing-messages timer (CORD-08 §1) exactly as the edition carried it. Kept raw so
* a malformed value is carried through an edit untouched; read it through [messageExpirationSecs].
*/
@SerialName("message_expiration") val messageExpiration: JsonElement? = null,
) {
/**
* The disappearing-messages timer in whole seconds, or null when it is off (CORD-08 §1).
* Absent, `0`, negative or malformed (a string, an object, a non-finite number) all read as
* off — a reader MUST NOT guess a default from garbage. A fractional value floors, as the
* reference client does.
*/
fun messageExpirationSecs(): Long? {
val primitive = messageExpiration as? JsonPrimitive ?: return null
if (primitive.isString) return null
val value = primitive.doubleOrNull ?: return null
if (!value.isFinite()) return null
val secs = floor(value)
if (secs < 1 || secs > Long.MAX_VALUE.toDouble()) return null
return secs.toLong()
}
/** This metadata with the timer set to [secs], or turned off when [secs] is null or below 1. */
fun withMessageExpiration(secs: Long?): MetadataEntity = copy(messageExpiration = secs?.takeIf { it >= 1 }?.let { JsonPrimitive(it) })
}
@@ -56,6 +56,9 @@ enum class ControlEntityKind(
/** The dissolution tombstone (terminal). */
DISSOLVED("10"),
/** A Channel's Pin List (CORD-04 §7), gated by PIN_MESSAGES. */
PIN_LIST("11"),
;
companion object {
@@ -60,6 +60,12 @@ fun ControlEdition.asFloor(): EntityFloor = EntityFloor(version, hashHex, this)
*/
typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion: Long) -> Unit
/**
* An author's standing for the equal-version tie-break ([EditionFold.pickHead]): lower is
* higher authority — the owner lowest, then Role position, a roleless author last.
*/
typealias AuthorRank = (author: String) -> Long
/**
* Folds Control Plane editions into the current head of each entity (CORD-04
* §Edition Hashing & Chain Integrity).
@@ -88,12 +94,14 @@ typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion
* - **Intact chain / no downgrades** — the head advances to `version + 1` only
* when that edition's `ep` cites the current head's [ControlEdition.hash].
* Lower or non-chaining versions are ignored.
* - **Deterministic convergence** — at equal version, ties break on the lower
* rumor id, so every honest client folds to the same head.
* - **Deterministic convergence** — at equal version the spec's tie-break is
* "authority first, then the lower rumor id, never the author-settable
* timestamp" (CORD-04 §1). The bare structural fold only knows the rumor id;
* [foldEntityGated] / [foldGated] with a rank apply authority first both in the
* walk (which sibling anchors and extends the chain) and in the head pick
* ([pickHead]), so every honest client lands on the same head.
*
* Authority-weighted tie-break ("authority first, then the lower rumor id") and
* the owner-rooted `vac` verification are applied by the resolver layer on top of
* this structural fold; this class is purely the chain walk.
* The owner-rooted `vac` verification rides the callers' gates.
*/
object EditionFold {
private const val TAG = "ConcordEditionFold"
@@ -148,10 +156,17 @@ object EditionFold {
private fun bootstrapHead(
editions: List<ControlEdition>,
floorVersion: Long,
tie: Comparator<ControlEdition>,
): ControlEdition? =
editions
.filter { it.version >= floorVersion && it.version - floorVersion <= MAX_COMPACTION_VERSION_JUMP }
.minWithOrNull(compareByDescending<ControlEdition> { it.version }.thenBy { it.rumorId })
.minWithOrNull(compareByDescending<ControlEdition> { it.version }.then(tie))
/**
* The structural tie-break among equal-version siblings when nothing better is known: the
* lower rumor id. [foldEntityGated] replaces it with authority first (CORD-04 §1).
*/
val BY_RUMOR_ID: Comparator<ControlEdition> = compareBy { it.rumorId }
/**
* How far above the floor the compaction arm will follow an edition in one step.
@@ -183,30 +198,46 @@ object EditionFold {
private fun chainHead(
editions: List<ControlEdition>,
floor: EntityFloor,
tie: Comparator<ControlEdition>,
): ControlEdition? {
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e)
val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull() ?: return null
val winner = byVersion[lowest]?.minByOrNull { it.rumorId } ?: return null
var head =
when (lowest) {
floor.version -> winner.takeIf { it.hashHex == floor.hashHex }
floor.version + 1 -> winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }
else -> null
} ?: return null
while (true) {
val next =
byVersion[head.version + 1]
?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex }
?.minByOrNull { it.rumorId }
?: break
head = next
}
var head = floorAnchor(byVersion[lowest] ?: return null, lowest, floor, tie) ?: return null
while (true) head = nextLink(byVersion, head, tie) ?: break
return head
}
/**
* The edition at the lowest offered version [lowest] (at or above the floor) that connects
* to [floor], or null. Only two shapes connect: the floor edition itself (same version AND
* hash — a same-version sibling is a fork, not our chain, but its presence does not hide
* the edition we hold), or the floor's immediate successor citing the floor's hash, the
* [tie] winner among several.
*/
private fun floorAnchor(
siblings: List<ControlEdition>,
lowest: Long,
floor: EntityFloor,
tie: Comparator<ControlEdition>,
): ControlEdition? =
when (lowest) {
floor.version -> siblings.firstOrNull { it.hashHex == floor.hashHex }
floor.version + 1 -> siblings.filter { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }.minWithOrNull(tie)
else -> null
}
/** The [tie] winner among the `version + 1` editions citing [head], or null when the chain ends there. */
private fun nextLink(
byVersion: Map<Long, List<ControlEdition>>,
head: ControlEdition,
tie: Comparator<ControlEdition>,
): ControlEdition? =
byVersion[head.version + 1]
?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex }
?.minWithOrNull(tie)
/**
* Groups mixed [editions] by entity id and folds each to its head, honoring the
* per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]).
@@ -225,7 +256,7 @@ object EditionFold {
val byEntity = editions.groupBy { it.entityIdHex }
val out = HashMap<String, ControlEdition>(byEntity.size)
for ((entity, list) in byEntity) {
foldEntity(list, floors[entity], snapshot, onGap)?.let { out[entity] = it }
foldEntity(list, floors[entity], snapshot, onGap = onGap)?.let { out[entity] = it }
}
return out
}
@@ -246,11 +277,18 @@ object EditionFold {
* the version-anchored compaction arm instead of the chain walk — see the arm
* itself for why. Null (the default) keeps the pure chain walk, which is right for
* a single-epoch fold and for every caller that has no epoch to speak of.
*
* [tie] picks among equal-version siblings wherever the walk has a choice (the genesis
* anchor, each next link, the compaction head): the lower rumor id by default, authority
* first when [foldEntityGated] supplies one. Without authority in the walk, a lower-ranked
* member's fork with a grindable low rumor id would anchor the chain, and an honest
* successor chained onto the owner's sibling would be unreachable.
*/
fun foldEntity(
editions: List<ControlEdition>,
floor: EntityFloor? = null,
snapshot: Set<String>? = null,
tie: Comparator<ControlEdition> = BY_RUMOR_ID,
onGap: GapReporter = LOG_GAP,
): ControlEdition? {
if (editions.isEmpty()) return floor?.known
@@ -269,8 +307,8 @@ object EditionFold {
// strictly better evidence than "highest number wins", and preferring it denies a stray
// high-version edition its free win in every ordinary fold. The bootstrap keeps the
// cross-epoch case working, now bounded by MAX_COMPACTION_VERSION_JUMP.
chainHead(editions, floor)?.let { return it }
return bootstrapHead(editions, floor.version)
chainHead(editions, floor, tie)?.let { return it }
return bootstrapHead(editions, floor.version, tie)
?: run {
// Nothing admissible at or above the floor was served: the head we already
// accepted vanished from the offered set — withheld, so fail closed.
@@ -279,8 +317,7 @@ object EditionFold {
}
}
// Index editions by version, keeping the tie-break winner where several
// share a version (lower rumor id wins).
// Index editions by version; where several share one, [tie] picks among them.
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e)
@@ -295,15 +332,7 @@ object EditionFold {
// refuse; walking up from the anchor also makes a head below the floor version
// structurally impossible.
val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull()
val winner = lowest?.let { v -> byVersion[v]?.minByOrNull { it.rumorId } }
val anchor =
when {
winner == null -> null
lowest == floor.version -> winner.takeIf { it.hashHex == floor.hashHex }
lowest == floor.version + 1 ->
winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }
else -> null
}
val anchor = lowest?.let { v -> floorAnchor(byVersion[v] ?: emptyList(), v, floor, tie) }
anchor
?: run {
onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version })
@@ -315,22 +344,16 @@ object EditionFold {
// Refounded community carries a prev citing the prior epoch — a fresh joiner
// anchors at the lowest-version edition it does hold and accepts it as the
// baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here.
val byVersionThenTie = compareBy<ControlEdition> { it.version }.then(tie)
editions
.filter { it.prevHash == null }
.minWithOrNull(compareBy({ it.version }, { it.rumorId }))
?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId }))
.minWithOrNull(byVersionThenTie)
?: editions.minWithOrNull(byVersionThenTie)
?: return null
}
// Walk the chain upward while the next version chains from the current head.
while (true) {
val next =
byVersion[head.version + 1]
?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex }
?.minByOrNull { it.rumorId }
?: break
head = next
}
while (true) head = nextLink(byVersion, head, tie) ?: break
return head
}
@@ -370,13 +393,14 @@ object EditionFold {
floor: EntityFloor? = null,
snapshot: Set<String>? = null,
onGap: GapReporter = LOG_GAP,
tie: Comparator<ControlEdition> = BY_RUMOR_ID,
): List<ControlEdition> {
// Ask the fold whether it gapped rather than re-deriving the condition here: with the
// compaction arm and the successor anchor there are three ways to connect, and a second
// copy of that test is a bug waiting to drift out of sync with the first.
var gapped = false
val head =
foldEntity(editions, floor, snapshot) { e, f, o ->
foldEntity(editions, floor, snapshot, tie) { e, f, o ->
gapped = true
onGap(e, f, o)
} ?: return emptyList()
@@ -389,7 +413,7 @@ object EditionFold {
out.add(head)
editions
.filterTo(ArrayList()) { it.rumorId != head.rumorId && (floor == null || it.version >= floor.version) }
.sortedWith(compareByDescending<ControlEdition> { it.version }.thenBy { it.rumorId })
.sortedWith(compareByDescending<ControlEdition> { it.version }.then(tie))
.let(out::addAll)
return out
}
@@ -397,31 +421,83 @@ object EditionFold {
/**
* The head of one entity: the highest-priority [candidates] entry that passes
* [gate], or null when none does. See [candidates] for why the gate is applied
* *after* the chain walk rather than before it.
* *after* the chain walk rather than before it, and [pickHead] for how [rank]
* settles an equal-version tie.
*/
fun foldEntityGated(
editions: List<ControlEdition>,
floor: EntityFloor? = null,
snapshot: Set<String>? = null,
onGap: GapReporter = LOG_GAP,
rank: AuthorRank? = null,
gate: (ControlEdition) -> Boolean,
): ControlEdition? = candidates(editions, floor, snapshot, onGap).firstOrNull(gate)
): ControlEdition? {
if (rank == null) return pickHead(candidates(editions, floor, snapshot, onGap), null, gate)
// Authority first everywhere the walk has a choice, judged only over editions the gate
// admits (a rejected sibling ranks last, so it can never anchor the chain on authority it
// does not hold), then the lower rumor id. Memoized: a gate decodes the content.
val score = HashMap<String, Long>()
val tie =
compareBy<ControlEdition> { e -> score.getOrPut(e.rumorId) { if (gate(e)) rank(e.author) else Long.MAX_VALUE } }
.then(BY_RUMOR_ID)
return pickHead(candidates(editions, floor, snapshot, onGap, tie), rank, gate)
}
/**
* The first of the ordered [candidates] passing [gate], with an equal-version tie broken
* **authority first** (CORD-04 §1: "authority first, then the lower rumor id, never the
* author-settable timestamp"): among the gate-passing candidates at the winner's version,
* the one whose author [rank]s highest (lowest number) takes it, and only a rank tie falls
* back to the candidate order — the chain-verified head, then the lower rumor id.
*
* A rumor id is author-grindable, so without this a lower-ranked bit holder could mint
* editions until one sorted below the owner's at the same version and win the entity.
* This is Armada's `pickHead` (control.ts), which both clients must agree on. With no
* [rank] the first passing candidate wins, the old rumor-id-only rule.
*/
fun pickHead(
candidates: List<ControlEdition>,
rank: AuthorRank?,
gate: (ControlEdition) -> Boolean,
): ControlEdition? {
var head: ControlEdition? = null
var headRank = 0L
for (c in candidates) {
if (!gate(c)) continue
if (head == null) {
if (rank == null) return c
head = c
headRank = rank(c.author)
continue
}
// Candidates are version-descending after the head: nothing at a lower version can win.
if (c.version != head.version) break
val r = rank!!(c.author)
if (r < headRank) {
head = c
headRank = r
}
}
return head
}
/**
* Groups mixed [editions] by entity id and folds each to the highest-priority
* head passing [gate] — the gated counterpart of [fold]. See [candidates].
* head passing [gate] — the gated counterpart of [fold]. See [candidates] and
* [pickHead].
*/
fun foldGated(
editions: Collection<ControlEdition>,
floors: Map<String, EntityFloor> = emptyMap(),
snapshot: Set<String>? = null,
onGap: GapReporter = LOG_GAP,
rank: AuthorRank? = null,
gate: (ControlEdition) -> Boolean,
): Map<String, ControlEdition> {
val byEntity = editions.groupBy { it.entityIdHex }
val out = HashMap<String, ControlEdition>(byEntity.size)
for ((entity, list) in byEntity) {
foldEntityGated(list, floors[entity], snapshot, onGap, gate)?.let { out[entity] = it }
foldEntityGated(list, floors[entity], snapshot, onGap, rank, gate)?.let { out[entity] = it }
}
return out
}
@@ -0,0 +1,42 @@
/*
* 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.cord04Roles.control.tags
/**
* The strict tag-number form CORD-01 §5 fixes for the Control Plane's machinery tags
* (`vsk`, `ev`, a `vac` version): decimal with no sign and no leading zeros — `0`, `4`,
* `12`, never `04`, `+4`, `0x4` or `1e2`.
*
* `String.toLongOrNull()` accepts `+4` and `04`, so two clients reading the same edition
* could disagree about which version it is. The reference client (Armada `isTagDecimal`)
* refuses anything else, and so do we.
*/
object CanonicalDecimal {
fun isCanonical(s: String): Boolean {
if (s.isEmpty()) return false
if (s.length > 1 && s[0] == '0') return false
for (c in s) if (c !in '0'..'9') return false
return true
}
/** [s] as a non-negative Long when it is canonical and fits, else null. */
fun parse(s: String): Long? = if (isCanonical(s)) s.toLongOrNull() else null
}
@@ -33,7 +33,8 @@ class EvTag {
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toLongOrNull()?.takeIf { it >= 0 }
// Canonical decimal only: "04" or "+4" is not a version (CORD-01 §5).
return CanonicalDecimal.parse(tag[1])
}
fun assemble(version: Long) = arrayOf(TAG_NAME, version.toString())
@@ -42,7 +42,7 @@ class VacTag {
ensure(tag.has(3)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val grantId = tag[1].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null
val grantVersion = tag[2].toLongOrNull() ?: return null
val grantVersion = CanonicalDecimal.parse(tag[2]) ?: return null
val grantHash = tag[3].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null
return AuthorityCitation(grantId, grantVersion, grantHash)
}
@@ -0,0 +1,65 @@
/*
* 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.cord04Roles.pins
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
/**
* Folds the Control Plane's Pin Lists (CORD-04 §7): one vsk-11 entity per Channel, at
* `pins_locator(community_id, channel_id)`, gated like any edition by `PIN_MESSAGES` (the owner
* always passes). An edition at any other coordinate — a list minted for another Community or a
* Channel that isn't folded here — binds to nothing and is ignored.
*/
object ConcordPinLists {
/** The Pin List coordinate (hex) of [channelIdHex] in [communityIdHex]. */
fun coordinate(
communityIdHex: HexKey,
channelIdHex: HexKey,
): HexKey = ConcordKeyDerivation.pinsCoordinate(communityIdHex.hexToByteArray(), channelIdHex.hexToByteArray()).toHexKey()
/** Per channel id, the authorized head edition of its Pin List. Channels with no list are absent. */
fun heads(
editions: Collection<ControlEdition>,
authority: AuthorityResolver,
communityIdHex: HexKey,
channelIds: Collection<HexKey>,
floors: Map<String, EntityFloor> = emptyMap(),
): Map<HexKey, ControlEdition> {
if (channelIds.isEmpty()) return emptyMap()
val channelByCoordinate = channelIds.associateBy { coordinate(communityIdHex, it) }
val lists = editions.filter { it.entityKind == ControlEntityKind.PIN_LIST && it.entityIdHex in channelByCoordinate }
if (lists.isEmpty()) return emptyMap()
return EditionFold
// Same gate every other entity folds under: well-formed, owner or a PIN_MESSAGES holder, vac satisfied.
.foldGated(lists, floors, rank = authority::tieBreakRank) { authority.admits(it, ConcordPermissions.PIN_MESSAGES) }
.mapNotNull { (coordinate, head) -> channelByCoordinate[coordinate]?.let { it to head } }
.toMap()
}
}
@@ -0,0 +1,355 @@
/*
* 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.cord04Roles.pins
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Concord Pins (CORD-04 §7): a pin does not quote a message, it **proves** one.
*
* One Pin List per Channel lives on the Control Plane (vsk 11, coordinate
* `pins_locator(community_id, channel_id)`), replaced entire per edit. Each entry carries the
* message's original kind-20013 seal, verbatim, plus the 76-byte NIP-44 key disclosure for that one
* message ([PinKeyDisclosure]) — so any reader of the Control Plane, with no Chat-plane history and
* no old keys, can verify author, words, Channel and time. Compaction (CORD-06) re-wraps the list
* across rotations, which is how a pin reaches members who joined long after the message.
*
* An entry's wire shape is `{ "seal": {…}, "keys": "<152 hex>", "wrap"?: "<hex id>", "edit"?:
* {"seal", "keys"} }`. Entries are kept as the raw JSON they arrived as: the seal's fields must be
* carried exactly (its signature covers them), and fields we don't model must survive a republish.
*/
object ConcordPins {
/** At most this many entries — judged by whoever can open the form. */
const val MAX_ENTRIES = 25
/** At most this many bytes of edition `content`, judged on the carried bytes, both forms. */
const val MAX_CONTENT_BYTES = 32_768
const val KIND_MESSAGE = 9
const val KIND_COMMENT = 1111
const val KIND_EDIT = 3302
private val json = Json { prettyPrint = false }
private val HEX64 = Regex("^[0-9a-f]{64}$")
private val DECIMAL = Regex("^(0|[1-9][0-9]*)$")
/** A pin entry that passed every §7 check — safe to render. */
class VerifiedPin(
/** Recomputed from the decrypted bytes; never an embedded id. The entry's identity. */
val rumorId: HexKey,
/** The seal's signer, equal to the rumor's author. */
val author: HexKey,
val kind: Int,
/** The newest proven words: the attached Edit's when one verified, else the original's. */
val content: String,
val tags: Array<Array<String>>,
/** The message's own `epoch` tag, for jump-to-context. */
val epoch: String?,
/** Ordering basis: `created_at * 1000 + ms`. */
val orderMs: Long,
val createdAt: Long,
/** The unverifiable locator hint the entry carried, if any. */
val wrapHint: HexKey?,
/** True when a proven Edit supplied [content]. */
val edited: Boolean,
/** The proven Edit's rumor id, when one verified. */
val editRumorId: HexKey?,
/** The wire entry, verbatim, for republishing. */
val entry: JsonObject,
)
/** How a Pin List's content read (§7 Limits). */
class PinListRead(
val entries: List<JsonObject>,
/**
* True when the list is the sealed form under an epoch key this reader doesn't hold. Such a
* list is *unavailable*, not empty — a writer MUST NOT build an edition from it.
*/
val sealedUnavailable: Boolean,
/** True when the content broke a cap or the format, so every reader treats it as empty. */
val violating: Boolean,
) {
companion object {
val EMPTY = PinListRead(emptyList(), sealedUnavailable = false, violating = false)
val VIOLATING = PinListRead(emptyList(), sealedUnavailable = false, violating = true)
}
}
// ---- reading ---------------------------------------------------------------------------
private fun parse(s: String): JsonElement? =
try {
json.parseToJsonElement(s)
} catch (_: Exception) {
null
}
/**
* Reads a Pin List edition's [content]. A cap-violating or malformed edition reads as an empty
* list (it still folds and chains — refusing it would fork the version chain between
* implementations). The sealed form opens with [unsealKey], the Channel's conversation key at
* the named epoch, or reads as [PinListRead.sealedUnavailable] when this reader lacks it.
*/
fun read(
content: String,
unsealKey: (epoch: Long) -> ByteArray?,
): PinListRead {
if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) return PinListRead.VIOLATING
val root = parse(content) as? JsonObject ?: return PinListRead.VIOLATING
val entries = root["entries"]
if (entries != null) return entriesOf(entries)
val epoch = (root["epoch"] as? JsonPrimitive)?.takeIf { it.isString }?.content
val sealed = (root["sealed"] as? JsonPrimitive)?.takeIf { it.isString }?.content
if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return PinListRead.VIOLATING
val epochValue = epoch.toLongOrNull() ?: return PinListRead.VIOLATING
val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false)
val inner =
try {
parse(Nip44.v2.decrypt(sealed, key)) as? JsonObject
} catch (_: Exception) {
null
} ?: return PinListRead.VIOLATING
return entriesOf(inner["entries"] ?: return PinListRead.VIOLATING)
}
private fun entriesOf(element: JsonElement): PinListRead {
val array = element as? JsonArray ?: return PinListRead.VIOLATING
if (array.size > MAX_ENTRIES) return PinListRead.VIOLATING
// A non-object entry is just an invalid entry, dropped alone by the verifier.
return PinListRead(array.mapNotNull { it as? JsonObject }, sealedUnavailable = false, violating = false)
}
// ---- verification ----------------------------------------------------------------------
private class OpenedRumor(
val pubKey: HexKey,
val kind: Int,
val createdAt: Long,
val tags: Array<Array<String>>,
val content: String,
) {
val id: HexKey by lazy { EventHasher.hashId(pubKey, createdAt, kind, tags, content) }
fun tag(name: String): String? = tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1)
fun orderMs(): Long {
val raw = tag("ms")
val ms = raw?.takeIf { DECIMAL.matches(it) }?.toLongOrNull()?.takeIf { it <= 999 } ?: 0L
return createdAt * 1000 + ms
}
}
/** The seal object as an [Event], only if it is a correctly signed kind-20013 seal. */
private fun sealOf(element: JsonElement?): Event? {
val obj = element as? JsonObject ?: return null
val seal =
try {
Event.fromJson(json.encodeToString(JsonObject.serializer(), obj))
} catch (_: Exception) {
return null
}
if (seal.kind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null
val valid =
try {
seal.verify()
} catch (_: Exception) {
false
}
return seal.takeIf { valid }
}
/** Steps 2–4 up to the rumor: MAC, decrypt, unpad, parse, author equality. */
private fun openRumor(
seal: Event,
keysHex: String?,
): OpenedRumor? {
val keys = PinKeyDisclosure.decode(keysHex ?: return null) ?: return null
val plaintext = PinKeyDisclosure.decryptWith(seal.content, keys) ?: return null
val obj = parse(plaintext) as? JsonObject ?: return null
val pubKey = (obj["pubkey"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
val kind = (obj["kind"] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull ?: return null
val createdAt = (obj["created_at"] as? JsonPrimitive)?.takeIf { !it.isString }?.longOrNull ?: return null
val content = (obj["content"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
val tags =
(obj["tags"] as? JsonArray)?.map { tag ->
(tag as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content ?: return null }?.toTypedArray() ?: return null
} ?: return null
// NIP-59's impersonation check: renderers display rumor fields.
if (pubKey != seal.pubKey) return null
return OpenedRumor(pubKey, kind, createdAt, tags.toTypedArray(), content)
}
/**
* The five §7 verification steps, holding nothing but [entry] and its list's [channelIdHex]:
* a signed kind-20013 seal; the MAC under the disclosed HMAC key; decrypt, unpad and parse; the
* rumor's author equals the seal's, its kind is 9 or 1111, and it carries
* `["channel", channelIdHex]`; and its id recomputed from the bytes. Null on any failure — the
* entry is then dropped alone. A failing `edit` bundle costs only the revision, never the pin.
*/
fun verify(
entry: JsonObject,
channelIdHex: HexKey,
): VerifiedPin? {
if (!HEX64.matches(channelIdHex)) return null
val seal = sealOf(entry["seal"]) ?: return null
val rumor = openRumor(seal, (entry["keys"] as? JsonPrimitive)?.takeIf { it.isString }?.content) ?: return null
if (rumor.kind != KIND_MESSAGE && rumor.kind != KIND_COMMENT) return null
// The binding stops a private Channel's keyholder from pinning its messages into a public list.
if (rumor.tag("channel") != channelIdHex) return null
val rumorId = rumor.id
val edit = (entry["edit"] as? JsonObject)?.let { verifyEdit(it, seal.pubKey, rumorId, channelIdHex) }
return VerifiedPin(
rumorId = rumorId,
author = seal.pubKey,
kind = rumor.kind,
content = edit?.content ?: rumor.content,
tags = rumor.tags,
epoch = rumor.tag("epoch"),
orderMs = rumor.orderMs(),
createdAt = rumor.createdAt,
wrapHint = (entry["wrap"] as? JsonPrimitive)?.contentOrNull?.takeIf { HEX64.matches(it) },
edited = edit != null,
editRumorId = edit?.id,
entry = entry,
)
}
/** An Edit bundle: the same steps with kind 3302, plus the same author and an `e` naming the original. */
private fun verifyEdit(
bundle: JsonObject,
originalAuthor: HexKey,
originalRumorId: HexKey,
channelIdHex: HexKey,
): OpenedRumor? {
val seal = sealOf(bundle["seal"]) ?: return null
if (seal.pubKey != originalAuthor) return null
val rumor = openRumor(seal, (bundle["keys"] as? JsonPrimitive)?.takeIf { it.isString }?.content) ?: return null
if (rumor.kind != KIND_EDIT) return null
if (rumor.tag("channel") != channelIdHex) return null
if (rumor.tag("e") != originalRumorId) return null
return rumor
}
// ---- writing ---------------------------------------------------------------------------
private fun sealJson(seal: Event): JsonObject = parse(seal.toJson()) as JsonObject
/**
* Builds the entry for a message this client opened: its verbatim seal, the disclosure derived
* with the Channel's [conversationKey] at the message's epoch, and the [wrapId] hint. Null when
* the result would not verify (an unencrypted seal, a wrong epoch key — the MAC catches it).
*/
fun buildEntry(
opened: OpenedStreamEvent,
conversationKey: ByteArray,
channelIdHex: HexKey,
wrapId: HexKey?,
): JsonObject? {
val seal = opened.seal
if (seal.kind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null
val keys = PinKeyDisclosure.discloseFor(seal.content, conversationKey) ?: return null
val fields = LinkedHashMap<String, JsonElement>()
fields["seal"] = sealJson(seal)
fields["keys"] = JsonPrimitive(PinKeyDisclosure.encode(keys))
if (wrapId != null) fields["wrap"] = JsonPrimitive(wrapId)
val entry = JsonObject(fields)
return entry.takeIf { verify(it, channelIdHex) != null }
}
/**
* [entry] with the proof of [edit] attached (§7 Edits), replacing any earlier one — an entry
* carries at most the newest provable Edit. Returns [entry] unchanged when the bundle would not
* verify, so a refresh never downgrades it.
*/
fun withEdit(
entry: JsonObject,
edit: OpenedStreamEvent,
conversationKey: ByteArray,
channelIdHex: HexKey,
): JsonObject {
val seal = edit.seal
if (seal.kind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return entry
val keys = PinKeyDisclosure.discloseFor(seal.content, conversationKey) ?: return entry
val bundle = JsonObject(mapOf("seal" to sealJson(seal), "keys" to JsonPrimitive(PinKeyDisclosure.encode(keys))))
val candidate = JsonObject(entry + ("edit" to bundle))
return if (verify(candidate, channelIdHex)?.edited == true) candidate else entry
}
private fun listJson(entries: List<JsonObject>) = json.encodeToString(JsonObject.serializer(), JsonObject(mapOf("entries" to JsonArray(entries))))
/** Thrown instead of publishing an edition every reader would read as empty. */
class PinListTooLargeException(
message: String,
) : IllegalArgumentException(message)
private fun checkCaps(
count: Int,
content: String,
): String {
if (count > MAX_ENTRIES) throw PinListTooLargeException("pin list exceeds $MAX_ENTRIES entries")
val bytes = content.encodeToByteArray().size
if (bytes > MAX_CONTENT_BYTES) throw PinListTooLargeException("pin list content is $bytes bytes (cap $MAX_CONTENT_BYTES)")
return content
}
/** A public Channel's list: plaintext, since the Control Plane's wrap is the gate. */
fun serializePublic(entries: List<JsonObject>): String = checkCaps(entries.size, listJson(entries))
/** A private Channel's list, sealed under its [conversationKey] at [epoch]; caps judged on the final bytes. */
fun serializeSealed(
entries: List<JsonObject>,
conversationKey: ByteArray,
epoch: Long,
): String {
if (entries.size > MAX_ENTRIES) throw PinListTooLargeException("pin list exceeds $MAX_ENTRIES entries")
val sealed = Nip44.v2.encrypt(listJson(entries), conversationKey).encodePayload()
val content = json.encodeToString(JsonObject.serializer(), JsonObject(mapOf("epoch" to JsonPrimitive(epoch.toString()), "sealed" to JsonPrimitive(sealed))))
return checkCaps(entries.size, content)
}
// ---- deletion --------------------------------------------------------------------------
/**
* True when a kind-5 delete by [deleteAuthor] with [deleteTags] kills [pin]: self-erasure
* outranks curation, so only the pin's proven author can, by naming its recomputed rumor id.
*/
fun killedBy(
pin: VerifiedPin,
deleteAuthor: HexKey,
deleteTags: Array<Array<String>>,
): Boolean = deleteAuthor == pin.author && deleteTags.any { it.size >= 2 && it[0] == "e" && it[1] == pin.rumorId }
}
@@ -0,0 +1,99 @@
/*
* 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.cord04Roles.pins
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20
import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf
import com.vitorpamplona.quartz.utils.equalsConstantTime
/**
* NIP-44 v2 per-message key disclosure — the primitive a Concord Pin is built on (CORD-04 §7).
*
* NIP-44 derives each message's keys as `hkdf-expand(conversation_key, nonce, 76)` =
* `chacha_key[32] ‖ chacha_nonce[12] ‖ hmac_key[32]`. The expansion is one-way, so disclosing it
* opens exactly that one message: not the conversation key, not the epoch, not the author's other
* traffic. Wire form: 76 bytes as 152 lowercase hex characters.
*/
object PinKeyDisclosure {
const val MESSAGE_KEYS_BYTES = 76
private val HEX = Regex("^[0-9a-f]{152}$")
private val chaCha = ChaCha20()
/** The 76-byte lowercase-hex wire form of [keys]. */
fun encode(keys: Hkdf.MessageKey): String = (keys.chachaKey + keys.chachaNonce + keys.hmacKey).toHexKey()
/** Parses a disclosure; null unless it is exactly 152 lowercase hex characters. */
fun decode(hex: String): Hkdf.MessageKey? {
if (!HEX.matches(hex)) return null
val bytes = hex.hexToByteArrayOrNull() ?: return null
return Hkdf.MessageKey(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 44), bytes.copyOfRange(44, 76))
}
private fun decodePayload(payload: String): Nip44v2.EncryptedInfo? =
try {
Nip44v2.EncryptedInfo.decodePayload(payload)
} catch (_: Exception) {
null
}
/** The disclosure for one NIP-44 [payload], derived with the stream's [conversationKey]; null if the payload is malformed. */
fun discloseFor(
payload: String,
conversationKey: ByteArray,
): Hkdf.MessageKey? {
val decoded = decodePayload(payload) ?: return null
return Nip44.v2.getMessageKeys(conversationKey, decoded.nonce)
}
/**
* Opens a NIP-44 v2 [payload] with disclosed [keys] (a pin's proof): MAC over nonce‖ciphertext
* with the disclosed HMAC key, then ChaCha20, then a strict unpad. Null on any failure. Only the
* standard u16-prefixed form is accepted — a Concord payload never exceeds 65,535 bytes.
*/
fun decryptWith(
payload: String,
keys: Hkdf.MessageKey,
): String? {
val decoded = decodePayload(payload) ?: return null
val mac =
try {
Nip44.v2.hmacAad(keys.hmacKey, decoded.ciphertext, decoded.nonce)
} catch (_: Exception) {
return null
}
if (!mac.equalsConstantTime(decoded.mac)) return null
val padded = chaCha.decrypt(decoded.ciphertext, keys.chachaNonce, keys.chachaKey)
if (padded.size < 2) return null
val len = (padded[0].toInt() and 0xFF shl 8) or (padded[1].toInt() and 0xFF)
if (len < 1) return null
if (padded.size.toLong() != 2 + Nip44.v2.calcPaddedLen(len)) return null
return try {
padded.decodeToString(2, 2 + len, throwOnInvalidSequence = true)
} catch (_: Exception) {
null
}
}
}
@@ -83,6 +83,7 @@ object ConcordDirectInvite {
if (seal !is SealEvent) return null
val rumor = seal.unsealOrNull(recipientSigner) ?: return null
if (rumor.kind != KIND) return null
return ConcordJson.decodeOrNull<CommunityInvite>(rumor.content)
// Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply").
return ConcordJson.decodeOrNull<CommunityInvite>(rumor.content)?.let { ConcordInviteBundle.bound(it) }
}
}
@@ -26,9 +26,11 @@ import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -94,6 +96,52 @@ class MintedInviteLink(
object ConcordInviteBundle {
const val KIND = ConcordInviteBundleEvent.KIND
/**
* A bundle naming more Channels than this is refused before anything is allocated for it
* (CORD-05 §1: "reject a bundle carrying more than a sane channel count (Vector's ceiling is
* 256)"). A bundle is attacker-crafted input reached by following a link.
*/
const val MAX_BUNDLE_CHANNELS = 256
/**
* A bundle's `relays` are truncated to the Community's relay cap before anything connects to
* them (CORD-05 §1, CORD-02 §6's "up to 5"): a hostile link must not be a connect storm.
*/
const val MAX_COMMUNITY_RELAYS = 5
/**
* Bounds an attacker-crafted [invite] (CORD-05 §1 MUST): null when it names more than
* [MAX_BUNDLE_CHANNELS] Channels, otherwise the invite with `relays` de-duplicated and
* truncated to [MAX_COMMUNITY_RELAYS]. Every redeem path — link bundle, Direct Invite —
* goes through [validate], which applies this.
*/
fun bound(invite: CommunityInvite): CommunityInvite? {
if (invite.channels.size > MAX_BUNDLE_CHANNELS) return null
val relays =
invite.relays
.filter { it.isNotBlank() }
.distinct()
.take(MAX_COMMUNITY_RELAYS)
return if (relays == invite.relays) invite else invite.copy(relays = relays)
}
/**
* True when [event] is really the bundle coordinate `(33301, linkSigner, d="")` (CORD-05 §2):
* the right kind, authored by [linkSignerPubKey], an empty `d`, and a valid signature. A relay
* filter is a hint, not a proof — a relay (or anyone who can write to one) could otherwise
* serve a forged newer `vsk 9` and revoke a link it never owned.
*/
fun isAtCoordinate(
event: Event,
linkSignerPubKey: HexKey,
): Boolean {
if (event.kind != KIND) return false
if (!event.pubKey.equals(linkSignerPubKey, ignoreCase = true)) return false
val d = event.tags.firstOrNull { it.isNotEmpty() && it[0] == "d" }?.getOrNull(1) ?: ""
if (d != "") return false
return event.verify()
}
private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite)
/** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */
@@ -125,7 +173,10 @@ object ConcordInviteBundle {
createdAt: Long,
): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt))
/** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */
/**
* Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle.
* The result is already [bound]ed (CORD-05 §1), so an over-long relay list never reaches a caller.
*/
fun parse(
event: Event,
token: ByteArray,
@@ -133,7 +184,7 @@ object ConcordInviteBundle {
if (event.kind != KIND) return null
return try {
val bundleKey = ConcordKeyDerivation.inviteBundleKey(token)
ConcordJson.decodeOrNull<CommunityInvite>(Nip44.v2.decrypt(event.content, bundleKey))
ConcordJson.decodeOrNull<CommunityInvite>(Nip44.v2.decrypt(event.content, bundleKey))?.let { bound(it) }
} catch (_: Exception) {
null
}
@@ -152,11 +203,25 @@ object ConcordInviteBundle {
* milliseconds) resolves to [InviteBundleStatus.Expired] rather than
* [InviteBundleStatus.Live], so the expiry is actually enforced at the one place
* every redeeming client already funnels through.
*
* Only events really at the coordinate count ([isAtCoordinate]: kind, author ==
* [linkSignerPubKey], `d == ""`, valid signature) — the relay filter is not trusted, so a
* forged newer revocation cannot kill a link, nor a forged bundle hijack one.
*/
fun classify(
wraps: List<Event>,
linkSignerPubKey: HexKey,
token: ByteArray,
nowMs: Long = TimeUtils.nowMillis(),
): InviteBundleStatus {
val genuine = wraps.filter { isAtCoordinate(it, linkSignerPubKey) }
return classifyGenuine(genuine, token, nowMs)
}
private fun classifyGenuine(
wraps: List<Event>,
token: ByteArray,
nowMs: Long,
): InviteBundleStatus {
val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent
if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked
@@ -197,6 +262,7 @@ object ConcordInviteBundle {
* member. Raise with the Concord/Armada authors before diverging.
*/
fun validate(invite: CommunityInvite): Boolean {
if (invite.channels.size > MAX_BUNDLE_CHANNELS) return false
val owner = invite.owner.hexToByteArrayOrNull() ?: return false
val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false
return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId
@@ -62,6 +62,13 @@ object ConcordInviteLink {
const val VERSION = 4
const val FLAG_STOCK_RELAYS = 0x01
/**
* The fragment carries at most this many bootstrap relays (CORD-05 §3): it only has to *find*
* the bundle, which then carries the Community's authoritative relay set. The reference
* client's decoder refuses a longer list, so an encoder must never emit one.
*/
const val MAX_BOOTSTRAP_RELAYS = 3
private const val MARKER_WSS_HOST = 0
private const val MARKER_FULL_URL = 255
private const val WSS_PREFIX = "wss://"
@@ -69,13 +76,10 @@ object ConcordInviteLink {
/**
* Encodes the fragment for [token] and optional [relays]. Passing null or the
* exact stock set uses flag `0x01` and emits no relay bytes; otherwise every
* relay is encoded (dictionary id, `wss://` host, or full URL).
*
* The only ceiling is the format's own: the relay count is a single byte, so at
* most 255 relays fit. Each carries its own byte cost, so a long list makes a long
* link — pick relays that can actually serve the bundle rather than pasting a
* whole relay list in.
* exact stock set uses flag `0x01` and emits no relay bytes (the stock set is
* flag-selected, so exempt from the cap); otherwise the first
* [MAX_BOOTSTRAP_RELAYS] relays are encoded (dictionary id, `wss://` host, or full
* URL) and the rest dropped (CORD-05 §3) — the bundle carries the full set.
*/
@OptIn(ExperimentalEncodingApi::class)
fun encodeFragment(
@@ -90,10 +94,10 @@ object ConcordInviteLink {
if (useStock) {
out.add(FLAG_STOCK_RELAYS.toByte())
} else {
require(relays.size <= 255) { "relay count must fit in one byte, was ${relays.size}" }
val bounded = relays.distinct().take(MAX_BOOTSTRAP_RELAYS)
out.add(0)
out.add(relays.size.toByte())
for (r in relays) {
out.add(bounded.size.toByte())
for (r in bounded) {
val id = InviteRelayDictionary.idOf(r)
when {
id != null -> out.add(id.toByte())
@@ -119,8 +123,9 @@ object ConcordInviteLink {
}
/**
* Decodes an invite [fragment]. Throws for a malformed fragment or a version
* other than [VERSION] (lower = legacy, higher = newer than this client).
* Decodes an invite [fragment]. Throws for a malformed fragment, a version
* other than [VERSION] (lower = legacy, higher = newer than this client), or more
* than [MAX_BOOTSTRAP_RELAYS] relays (CORD-05 §3, as the reference client does).
* Unknown dictionary ids are skipped rather than aborting the parse.
*/
@OptIn(ExperimentalEncodingApi::class)
@@ -138,7 +143,9 @@ object ConcordInviteLink {
relays.addAll(InviteRelayDictionary.STOCK)
usedStock = true
} else {
require(pos < bytes.size) { "fragment truncated" }
val count = bytes[pos++].toInt() and 0xFF
require(count <= MAX_BOOTSTRAP_RELAYS) { "too many bootstrap relays ($count, cap $MAX_BOOTSTRAP_RELAYS)" }
repeat(count) {
val marker = bytes[pos++].toInt() and 0xFF
when (marker) {
@@ -162,8 +169,9 @@ object ConcordInviteLink {
}
/**
* Builds a full shareable invite URL under [base], carrying every relay in [relays]
* as the bootstrap set (or the stock flag when null / exactly the stock set).
* Builds a full shareable invite URL under [base], carrying the first
* [MAX_BOOTSTRAP_RELAYS] of [relays] as the bootstrap set (or the stock flag when
* null / exactly the stock set).
*/
fun buildUrl(
base: String,
@@ -33,6 +33,7 @@ import kotlinx.serialization.descriptors.elementNames
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonTransformingSerializer
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
@@ -86,12 +87,16 @@ class ConcordInviteListTombstone(
* a newer schema. They are carried verbatim rather than dropped (re-encoding without them would
* delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every
* future mint and revoke for this account until someone else repaired the list).
*
* [opaqueTombstones] is the same for tombstones: one that does not type-check is residue we carry
* verbatim, never drop — dropping a retirement is how a stale device resurrects a revoked link.
*/
class ConcordInviteListDocument(
val entries: List<ConcordInviteListEntry> = emptyList(),
val tombstones: List<ConcordInviteListTombstone> = emptyList(),
val residue: JsonObject = NoExtras,
val opaqueEntries: List<JsonObject> = emptyList(),
val opaqueTombstones: List<JsonElement> = emptyList(),
) {
companion object {
val EMPTY = ConcordInviteListDocument()
@@ -201,14 +206,16 @@ object ConcordInviteList {
}
}
val opaqueTombstones = mutableListOf<JsonElement>()
val tombstones =
(root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element ->
try {
val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject)
ConcordInviteListTombstone(it.token, it.communityId, it.extras)
} catch (_: Exception) {
// A tombstone we cannot read must not silently un-retire its link, but we
// have no token to key it by, so it can only ride along as document residue.
// A tombstone we cannot type must not silently un-retire its link: carry it
// verbatim as residue (and still honor its token in the merge, if it has one).
opaqueTombstones.add(element)
null
}
}
@@ -218,6 +225,7 @@ object ConcordInviteList {
tombstones = tombstones,
residue = JsonObject(root - "entries" - "tombstones"),
opaqueEntries = opaque,
opaqueTombstones = opaqueTombstones,
)
} catch (_: Exception) {
null
@@ -238,30 +246,43 @@ object ConcordInviteList {
),
).jsonObject
// Entries we could not type ride back out untouched. Dropping them here is the data loss
// this whole class exists to prevent — they are somebody's link signer too.
if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries)
return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries)))
// Entries and tombstones we could not type ride back out untouched. Dropping them here is
// the data loss this whole class exists to prevent — a link signer, or a link's retirement.
if (doc.opaqueEntries.isEmpty() && doc.opaqueTombstones.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
var out = wire
if (doc.opaqueEntries.isNotEmpty()) {
out = JsonObject(out + ("entries" to JsonArray((out["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries)))
}
if (doc.opaqueTombstones.isNotEmpty()) {
out = JsonObject(out + ("tombstones" to JsonArray((out["tombstones"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueTombstones)))
}
return ConcordJson.instance.encodeToString(JsonObject.serializer(), out)
}
/** The `token` an untyped tombstone still names, if it names one as a string. */
private fun opaqueTombstoneToken(element: JsonElement): String? = ((element as? JsonObject)?.get("token") as? JsonPrimitive)?.takeIf { it.isString }?.content
/**
* Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in
* either side's tombstones is dropped from the result and kept tombstoned, so a retired link
* cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a
* token both sides carry, which is what makes "read remote, apply my change, publish" converge.
* Merges [patch] onto [base], keyed by `token` — the spec's own merge key (CORD-05 §4). An entry
* is **immutable once minted**, so the first copy of a token wins ([base], the published list,
* before [patch]) and a later copy can never rewrite its `signer_sk` or url; tombstones union
* (first wins likewise), and a tombstone always beats an entry — terminally, so a retired link
* cannot be resurrected by a device that still has it cached. Mirrors the reference client's
* `mergeInviteLists`. A tombstone we could not type still retires the token it names.
*/
fun merge(
base: ConcordInviteListDocument,
patch: ConcordInviteListDocument,
): ConcordInviteListDocument {
val tombstones = LinkedHashMap<String, ConcordInviteListTombstone>()
for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t
for (t in base.tombstones + patch.tombstones) tombstones.getOrPut(t.token) { t }
val opaqueTombstones = (base.opaqueTombstones + patch.opaqueTombstones).distinct()
val retired = tombstones.keys + opaqueTombstones.mapNotNull { opaqueTombstoneToken(it) }
val entries = LinkedHashMap<String, ConcordInviteListEntry>()
for (e in base.entries + patch.entries) {
if (e.token in tombstones) continue
entries[e.token] = e
if (e.token in retired) continue
entries.getOrPut(e.token) { e }
}
return ConcordInviteListDocument(
entries = entries.values.toList(),
@@ -270,6 +291,7 @@ object ConcordInviteList {
// Untyped entries survive the merge for the same reason they survive a decode: we cannot
// read them, so we are in no position to decide they are disposable.
opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(),
opaqueTombstones = opaqueTombstones,
)
}
}
@@ -24,38 +24,37 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
/**
* Stranded recovery (CORD-05/06).
* Stranded detection (CORD-05/06).
*
* A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no
* recipient list** — so a member who is simply left out of the rekey recipient
* set receives nothing and is silently stranded on the dead epoch forever, while
* everyone else moves on. This is true of any member, the owner included, and
* cannot be prevented on the receive side.
* set receives nothing and is silently stranded on the dead epoch, while everyone
* else moves on. The invite link the membership was joined through
* ([ConcordCommunityListEntry.inviteRef]) is re-minted at the current epoch by its
* creator, so re-resolving it and finding a **higher** epoch tells a member they
* were left behind.
*
* The way out is the invite link the membership was joined through
* ([ConcordCommunityListEntry.inviteRef]): the community keeps publishing its
* bundle at that same addressable coordinate, re-minted at the current epoch. So
* a member who re-resolves their own join link and finds a **higher** epoch than
* the one they hold knows they were left behind, and can merge forward.
*
* This object holds only the pure decision + merge; fetching and unlocking the
* bundle at the link is the caller's job.
* What a bundle may NOT do is move the base (CORD-06 §2, and the reference client's
* `isCatchUpBundle`: "It may never move the base"). Nothing binds `community_root`
* to `community_id`, so a bundle is not proof of continuity: a link creator — or
* anyone who ever held a link's signer — could serve a higher-epoch bundle carrying
* a root of their own and silently relocate every member who joined through that
* link onto streams they read. The base advances only by a CORD-06 §2 rekey blob
* whose `prevcommit` proves it extends the key we hold, from a rotator our roster
* authorizes. So this object only **detects** ([isStranded]); the background sweep
* never adopts anything, and the one way forward from a bundle is the user
* explicitly accepting the link again ([rejoinForward]) — the same trust decision
* as the first join, never taken on their behalf.
*/
object ConcordStrandedRecovery {
/**
* True when [bundle], resolved at [entry]'s stored invite link, proves we were
* True when [bundle], resolved at [entry]'s stored invite link, says we were
* left behind: it must describe the same community and sit at a strictly higher
* epoch. Same or lower is a no-op (we are current, or the bundle is stale).
*
* [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold
* it right now, have me on its banlist?" — and a `true` refuses the recovery
* outright. It is a required argument rather than a caller-side `if` because
* getting it wrong turns this mechanism inside out: recovery exists so a member
* *wrongly* omitted from a rotation can catch up, but the test it performs (a
* higher epoch at a link whose unlock token an ex-member keeps forever) cannot
* tell that member apart from one the community deliberately removed. Without
* this, a Refounding — the only hard removal Concord has — is undone by our own
* background sweep a few minutes later.
* it right now, have me on its banlist?" — and a `true` answers false outright:
* a removed member is not stranded, they are removed.
*/
fun isStranded(
entry: ConcordCommunityListEntry,
@@ -68,21 +67,18 @@ object ConcordStrandedRecovery {
bundle.rootEpoch > entry.rootEpoch
/**
* Merges [entry] forward onto the higher-epoch [bundle], or returns null when
* there is nothing to do ([isStranded] is false) — so the caller can treat null
* as "stay put" without a second check.
* The entry that results from the user **explicitly** re-accepting the invite
* link [bundle] was resolved from, while stranded on [entry] — or null when
* [isStranded] is false. Never call this from a background sweep: adopting a
* bundle's root is a join decision (see the class note), and only the user can
* make it.
*
* The merge is epoch-monotonic (it never moves backwards, by construction of
* [isStranded]) and preserves two things the naive "adopt the bundle" would
* destroy:
*
* - the [ConcordCommunityListEntry.inviteRef] anchor, so the next Refounding we
* are left out of is recoverable too; and
* - the existing [ConcordCommunityListEntry.heldRoots], plus the root we are
* leaving, so prior-epoch history the member legitimately holds stays
* derivable instead of going dark on catch-up.
* The merge is epoch-monotonic and preserves what a fresh join would lose: the
* [ConcordCommunityListEntry.inviteRef] anchor, and the existing
* [ConcordCommunityListEntry.heldRoots] plus the root we are leaving, so
* prior-epoch history stays derivable.
*/
fun mergeForward(
fun rejoinForward(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
bannedAtCurrentEpoch: Boolean,
@@ -92,7 +88,7 @@ object ConcordStrandedRecovery {
// Bank the epoch we are leaving with its control_pk, so its Control Plane
// stays re-subscribable for the anti-rollback floor (a split epoch's address
// is held, never derivable — CORD-02 §2).
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() }
return ConcordCommunityListEntry(
id = entry.id,
@@ -109,10 +105,8 @@ object ConcordStrandedRecovery {
name = entry.name.ifEmpty { bundle.name },
addedAt = entry.addedAt,
inviteRef = entry.inviteRef,
// We were excluded from the epoch we were sitting on when we found the gap.
excludedAtEpoch = entry.rootEpoch,
// Unknown keys another client wrote are data we hold in trust: carry them forward,
// or this recovery write silently deletes them.
// or this write silently deletes them.
residue = entry.residue,
)
}
@@ -21,7 +21,10 @@
package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current
@@ -74,11 +78,51 @@ class ReceivedRefounding(
val rotator: HexKey,
val newControlPk: ByteArray? = null,
val newControlRoot: ByteArray? = null,
/**
* The Grant the rotator claims to act under (`vac`, CORD-06 §3 "Authority"), null when absent
* (the owner cites nothing). The caller verifies it against its fold before adopting — see
* [ConcordRotationAuthority.citationSatisfied].
*/
val authority: AuthorityCitation? = null,
) {
/** True when this was a legacy pre-split rotation (72-byte base blob). */
val legacy: Boolean get() = newControlPk == null
}
/**
* A Refounding the rotator has already minted keys for (CORD-06 §3 "Failure and races"): the
* fresh [newRoot] + [newControlRoot] reserved for the rotation from ([rootEpoch], [prevCommit]).
* A retry of the same rotation MUST reuse them — rotations correlate by (rotator, newepoch,
* prevcommit), so a retry with a fresh root would merge into the first attempt's set and split
* the members across two roots at one epoch. Keep it until the rotation is adopted, then drop it.
*/
class PendingRefounding(
val communityId: HexKey,
val rootEpoch: Long,
val prevCommit: HexKey,
val newRoot: ByteArray,
val newControlRoot: ByteArray,
) {
/** True when this reservation is for the rotation that leaves [priorRoot] at [rootEpoch]. */
fun matches(
communityId: HexKey,
rootEpoch: Long,
priorRoot: ByteArray,
): Boolean =
this.communityId.equals(communityId, ignoreCase = true) &&
this.rootEpoch == rootEpoch &&
prevCommit == ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
}
/**
* Thrown when a Refounding cannot carry the whole Control Plane into the new epoch (CORD-06 §3:
* "If the Refounder cannot reliably fold all Control events, the Refounding must be aborted").
* [missing] names the entities (`eid` hex) whose honored head is not in the compaction.
*/
class IncompleteControlPlaneException(
val missing: List<String>,
) : IllegalStateException("Refounding aborted: the Control Plane could not be folded in full (${missing.size} entity head(s) missing)")
/**
* Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a
* removed member absolutely. Public Channels and the Control/Guestbook planes all
@@ -111,6 +155,11 @@ object ConcordRefounding {
* @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key
* @param staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing
* permission holders, CORD-04 §3) and receives the 136-byte blob
* @param authority the rotator's `vac` citation (CORD-06 §3 "Authority"), stamped on every
* rekey chunk; null when the owner rotates
* @param mustCarry the entity heads (`eid` hex -> version) the rotator currently honors; the
* compaction must carry each at or above that version, or the Refounding
* aborts with [IncompleteControlPlaneException] (CORD-06 §3)
*/
suspend fun build(
rotatorSigner: NostrSigner,
@@ -125,11 +174,15 @@ object ConcordRefounding {
staffXOnly: Set<HexKey>,
createdAt: Long,
ownerPubKey: HexKey,
authority: AuthorityCitation? = null,
mustCarry: Map<String, Long> = emptyMap(),
): RefoundingBuild {
val newEpoch = rootEpoch + 1
val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot)
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, ownerPubKey)
// Acquired in full BEFORE anything is published (CORD-06 §3): throws when the plane cannot be
// carried whole, so a failed fold never leaves a half rotation as the only copy.
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, communityId, ownerPubKey, mustCarry)
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch)
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
@@ -146,6 +199,7 @@ object ConcordRefounding {
prevEpoch = rootEpoch,
prevCommit = prevCommit,
createdAt = createdAt,
authority = authority,
)
return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps)
@@ -163,19 +217,28 @@ object ConcordRefounding {
* must hold the new signer. A Rotator MUST NOT mirror editions to the new
* epoch's legacy-derived address to appease stale readers — the mirror
* re-opens exactly the member-writable surface the split closes.
*
* Only plaintext-sealed editions are carried ([ControlEdition.fromOpened]): an
* encrypted-seal edition is not a Control edition (CORD-02 §5), and re-wrapping one
* would republish it under a signature over ciphertext no reader can keep. Heads of
* sub-kinds this client does not model (Pins, Signals, anything newer) ride through
* verbatim like every other head, so our Refounding never erases another client's state
* (CORD-06 §3: the compaction re-wraps each entity's current head).
*/
fun compactControlPlane(
priorWraps: List<Event>,
priorControlKeys: ControlPlaneKeys,
newControlKeys: ControlPlaneKeys,
communityId: ByteArray,
ownerPubKey: HexKey,
mustCarry: Map<String, Long> = emptyMap(),
): List<Event> {
// entity coordinate -> every edition we can open, paired with its verified seal.
val byCoordinate = HashMap<String, MutableList<Pair<ControlEdition, Event>>>()
for (wrap in priorWraps) {
val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKeys) ?: continue
val edition = ControlEdition.fromRumor(opened.rumor) ?: continue
val coord = edition.entityKind.wire + ":" + edition.entityIdHex
val edition = ControlEdition.fromOpened(opened) ?: continue
val coord = edition.vsk + ":" + edition.entityIdHex
byCoordinate.getOrPut(coord) { ArrayList() }.add(edition to opened.seal)
}
@@ -195,21 +258,48 @@ object ConcordRefounding {
// unprivileged author, and it is exactly what ConcordCommunityState.fold would seat, so the
// compacted epoch starts where the previous one left off.
val editions = byCoordinate.values.flatten()
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, ownerPubKey)
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, communityId, ownerPubKey)
val out = ArrayList<Event>(honored.size)
for ((_, floor) in honored) {
val head = floor.known ?: continue
val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue
val missing = ArrayList<String>()
for ((entity, floor) in honored) {
val head = floor.known
val seal = head?.let { h -> editions.firstOrNull { it.first.rumorId == h.rumorId }?.second }
if (seal == null) {
// A head we honor whose signed seal we cannot re-wrap would silently drop the entity
// from the new epoch: abort instead (fold-all-or-abort, CORD-06 §3).
missing.add(entity)
continue
}
out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt))
}
// Every head the caller already folds must survive at or above the version it honors: a
// shorter compaction means the fetch we compacted from was partial, and publishing it would
// roll the community back for every member who follows the new epoch.
for ((entity, version) in mustCarry) {
val carried = honored[entity]?.known?.version
if (carried == null || carried < version) missing.add(entity)
}
if (missing.isNotEmpty()) throw IncompleteControlPlaneException(missing.distinct())
return out
}
/**
* The version of every entity head [editions] honor (`eid` hex -> version) — what a
* Refounder passes as `mustCarry`, so the compaction aborts rather than drop a head the
* Refounder itself folds (CORD-06 §3).
*/
fun headVersions(
editions: Collection<ControlEdition>,
communityId: ByteArray,
ownerPubKey: HexKey,
): Map<String, Long> = ConcordCommunityState.authorizedHeads(editions, communityId, ownerPubKey).mapValues { it.value.version }
/**
* Mints the base-rotation rekey blobs delivering [newRoot] + [newControlPk] to
* [recipientsXOnly] — the [staffXOnly] subset also receiving [newControlRoot]
* in the 136-byte staff form (CORD-06 §1) — chunked at
* [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal,
* in the 136-byte staff form (CORD-06 §1) — chunked by count and bytes
* ([ConcordRekey.chunkBlobs], 1-based `chunk` tags, [authority] cited on every
* chunk) and wrapped (encrypted seal,
* rotator-signed) on the [baseRekeyKey] address so every current member — who
* precomputes that address from the prior root — receives it live.
*/
@@ -225,6 +315,7 @@ object ConcordRefounding {
prevEpoch: Long,
prevCommit: HexKey,
createdAt: Long,
authority: AuthorityCitation? = null,
): List<Event> {
if (recipientsXOnly.isEmpty()) return emptyList()
val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() }
@@ -240,10 +331,19 @@ object ConcordRefounding {
newControlRoot = if (recipient.lowercase() in staffLower) newControlRoot else null,
)
}
val chunks = blobs.chunked(ConcordRekey.MAX_BLOBS_PER_CHUNK)
// The envelope is measured once at the widest `chunk` tag this rotation could carry.
val widest = blobs.size.coerceAtLeast(1)
val envelopeTags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, widest, widest, authority)
val envelope =
RumorAssembler
.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, envelopeTags, "")
.toJson()
.encodeToByteArray()
.size
val chunks = ConcordRekey.chunkBlobs(blobs, envelope)
val total = chunks.size
return chunks.mapIndexed { index, chunk ->
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index, total)
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index + 1, total, authority)
val rumor = RumorAssembler.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk))
ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt)
}
@@ -252,14 +352,25 @@ object ConcordRefounding {
/**
* Receives a base rotation for the member behind [recipientSigner]: opens the
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
* verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit`
* continues the [priorRoot] the member holds, and returns the delivered new
* root and Control Plane keys (with the rotator's real pubkey, so the caller
* can authorize it against the folded roster). A staff blob's delivered secret
* must derive to exactly the delivered `control_pk` (CORD-02 §5) — a
* mismatched pair is refused rather than adopting a plane split from its
* readers. Null if no chunk carries this member's blob — which only means
* "removed" once the caller confirms it holds every chunk of the rotation.
* verifies each is a well-formed root rotation to `rootEpoch + 1` whose
* `prevepoch`/`prevcommit` continue the [priorRoot] the member holds, and returns
* the delivered new root and Control Plane keys with the rotator's real pubkey and
* `vac` citation, so the caller can authorize it against the folded roster.
*
* A rekey rumor must ride an **encrypted** (20013) seal (CORD-02 §5, Appendix B); a
* malformed `chunk` tag (0-based, `i > n`, non-decimal) or `vac` tag drops the
* chunk, and a rotator whose chunks cite different Grants is distrusted whole. A
* staff blob's delivered secret must derive to exactly the delivered `control_pk`
* (CORD-02 §5) — a mismatched pair is refused rather than adopting a plane split
* from its readers.
*
* Race convergence (CORD-06 §3): among the candidates [accept] admits (the
* caller's authority check — authorize BEFORE converging, or an unauthorized
* lower root would win), the lexicographically lowest new base key wins; the
* control pair rides the winner's blob and is never compared.
*
* Null if no chunk carries this member's blob — which only means "removed" once
* the caller confirms it holds every chunk of the rotation.
*/
suspend fun findNewRoot(
wraps: List<Event>,
@@ -268,31 +379,138 @@ object ConcordRefounding {
communityId: ByteArray,
priorRoot: ByteArray,
rootEpoch: Long,
): ReceivedRefounding? {
accept: (ReceivedRefounding) -> Boolean = { true },
): ReceivedRefounding? = converge(findNewRoots(wraps, baseRekeyKey, recipientSigner, communityId, priorRoot, rootEpoch).filter(accept))
/**
* Every candidate rotation delivering this member a new root from [priorRoot] at
* [rootEpoch] (one per rotator; see [findNewRoot] for the checks), unauthorized and
* unconverged. Callers normally want [findNewRoot].
*/
suspend fun findNewRoots(
wraps: List<Event>,
baseRekeyKey: GroupKey,
recipientSigner: NostrSigner,
communityId: ByteArray,
priorRoot: ByteArray,
rootEpoch: Long,
): List<ReceivedRefounding> {
val newEpoch = rootEpoch + 1
val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey()
val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
// Pass 1: the well-formed chunks of this continuity point, grouped by rotator.
val byRotator = LinkedHashMap<HexKey, MutableList<RekeyChunk>>()
for (wrap in wraps) {
val opened = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey) ?: continue
// Rekey seals are encrypted (CORD-02 §5): a plaintext seal would expose the rotator.
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) continue
val rumor = opened.rumor
if (rumor.kind != ConcordRekey.KIND) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE) != expectedScope) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE)?.lowercase() != expectedScope) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT) != expectedCommit) continue
val blobs = ConcordRekey.decodeContent(rumor.content)
val rotatorXOnly = opened.author.hexToByteArray()
val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue
val controlRoot = payload.newControlRoot
val controlPk = payload.newControlPk
if (controlRoot != null && controlPk != null) {
// The staff derive-check (CORD-06 §1): refuse a pair whose secret does not
// derive to the pk the other members were handed — fails closed.
val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey
if (!derived.contentEquals(controlPk)) continue
}
return ReceivedRefounding(payload.newKey, newEpoch, opened.author, controlPk, controlRoot)
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)?.toLongOrNull() != rootEpoch) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() != expectedCommit) continue
if (ConcordRekey.chunkOf(rumor.tags) == null) continue
// A present-but-malformed citation is a corrupt chunk; absent means the owner acts.
val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME }
val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue
byRotator.getOrPut(opened.author.lowercase()) { ArrayList() }.add(RekeyChunk(opened.author, rumor.content, citation))
}
return null
// Pass 2: per rotator, find this member's blob.
val out = ArrayList<ReceivedRefounding>()
for ((_, chunks) in byRotator) {
// Every chunk of one rotation must cite the same Grant; a disagreeing set is distrusted.
val citations = chunks.map { c -> c.citation?.let { VacTag.assemble(it).joinToString(",") } }.distinct()
if (citations.size > 1) continue
val rotator = chunks.first().rotator
val rotatorXOnly = rotator.hexToByteArray()
for (chunk in chunks) {
val blobs = ConcordRekey.decodeContent(chunk.content)
val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue
val controlRoot = payload.newControlRoot
val controlPk = payload.newControlPk
if (controlRoot != null && controlPk != null) {
// The staff derive-check (CORD-06 §1): refuse a pair whose secret does not
// derive to the pk the other members were handed — fails closed.
val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey
if (!derived.contentEquals(controlPk)) continue
}
out.add(ReceivedRefounding(payload.newKey, newEpoch, rotator, controlPk, controlRoot, chunk.citation))
break
}
}
return out
}
private class RekeyChunk(
val rotator: HexKey,
val content: String,
val citation: AuthorityCitation?,
)
/**
* The winner among authorized candidates racing to one epoch (CORD-06 §3): the
* lexicographically lowest new base key. Every client computes the same winner, so
* concurrent Refoundings converge; null on no candidates.
*/
fun converge(candidates: List<ReceivedRefounding>): ReceivedRefounding? = candidates.minWithOrNull { a, b -> compareKeys(a.newRoot, b.newRoot) }
/** Unsigned lexicographic order of two keys — the order the convergence rule compares in. */
fun compareKeys(
a: ByteArray,
b: ByteArray,
): Int {
for (i in 0 until minOf(a.size, b.size)) {
val x = a[i].toInt() and 0xFF
val y = b[i].toInt() and 0xFF
if (x != y) return x - y
}
return a.size - b.size
}
/**
* The down-only heal (CORD-06 §3): true when [candidate] should replace the [held] root
* of the same epoch — only a **strictly lower** sibling does, so a flaky fetch that
* returns only the higher sibling can never re-fork a settled epoch.
*/
fun healsTo(
held: ByteArray,
candidate: ByteArray,
): Boolean = compareKeys(candidate, held) < 0
/**
* The held roots a client folds Control from: per epoch, the lowest key — the one the
* convergence rule settled on. A higher sibling at the same epoch is a losing fork's root,
* kept only so the messages sent into that fork stay readable (CORD-06 §3: "Both forks'
* keys are retained"); its Control Plane is not the community's.
*/
fun canonicalHeldRoots(heldRoots: List<HeldRoot>): List<HeldRoot> =
heldRoots
.groupBy { it.epoch }
.values
.mapNotNull { sameEpoch -> sameEpoch.minWithOrNull { a, b -> a.key.lowercase().compareTo(b.key.lowercase()) } }
/**
* The keys for the Refounding that leaves [priorRoot] at [rootEpoch]: [pending] when it
* was reserved for exactly this rotation (a retry — CORD-06 §3 requires every step to be
* idempotent, so a retry must re-deliver the SAME root), a fresh pair otherwise. The
* caller persists the result before publishing anything and drops it once adopted.
*/
fun reserveKeys(
pending: PendingRefounding?,
communityId: HexKey,
rootEpoch: Long,
priorRoot: ByteArray,
): PendingRefounding {
if (pending != null && pending.matches(communityId, rootEpoch, priorRoot)) return pending
return PendingRefounding(
communityId = communityId.lowercase(),
rootEpoch = rootEpoch,
prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey(),
newRoot = RandomInstance.bytes(32),
newControlRoot = RandomInstance.bytes(32),
)
}
}
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -85,7 +87,13 @@ object ConcordRekey {
return RekeyBlob(locator, wrapped)
}
/** The kind-3303 rumor tags for a rekey chunk. */
/**
* The kind-3303 rumor tags for a rekey chunk. [chunkIndex] is **1-based** (CORD-06 §2's
* "chunk i of n"; the reference client refuses an index below 1, so a 0-based chunk makes the
* whole rotation unreadable to it). [authority] is the rotator's `vac` citation (CORD-06 §3
* "Authority", CORD-04 §5), carried on EVERY chunk so a partial holder can judge authority;
* null when the owner rotates.
*/
fun tags(
scopeId: ByteArray,
newEpoch: Long,
@@ -93,14 +101,78 @@ object ConcordRekey {
prevCommit: HexKey,
chunkIndex: Int,
chunkTotal: Int,
): Array<Array<String>> =
arrayOf(
arrayOf(TAG_SCOPE, scopeId.toHexKey()),
arrayOf(TAG_NEWEPOCH, newEpoch.toString()),
arrayOf(TAG_PREVEPOCH, prevEpoch.toString()),
arrayOf(TAG_PREVCOMMIT, prevCommit),
arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()),
)
authority: AuthorityCitation? = null,
): Array<Array<String>> {
require(chunkTotal >= 1 && chunkIndex in 1..chunkTotal) { "chunk must be 1..n, was $chunkIndex of $chunkTotal" }
val base =
arrayOf(
arrayOf(TAG_SCOPE, scopeId.toHexKey()),
arrayOf(TAG_NEWEPOCH, newEpoch.toString()),
arrayOf(TAG_PREVEPOCH, prevEpoch.toString()),
arrayOf(TAG_PREVCOMMIT, prevCommit),
arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()),
)
return if (authority == null) base else base + arrayOf(VacTag.assemble(authority))
}
/** Strict decimal (`0|[1-9][0-9]*`): digits only, no sign, exponent, radix prefix, padding or leading zero. */
private fun strictDecimal(value: String?): Int? {
if (value.isNullOrEmpty() || value.length > 9 || !value.all { it in '0'..'9' }) return null
if (value.length > 1 && value[0] == '0') return null
return value.toInt()
}
/**
* The `["chunk", i, n]` position of a kind-3303 rumor as a 1-based `(i, n)` pair, or null when
* the tag is malformed: non-decimal, `i < 1`, `n < 1` or `i > n` (a 0-based chunk included).
* An absent tag reads as the single chunk `(1, 1)`, as the reference client does.
*/
fun chunkOf(tags: Array<Array<String>>): Pair<Int, Int>? {
val tag = tags.firstOrNull { it.isNotEmpty() && it[0] == TAG_CHUNK } ?: return 1 to 1
val index = strictDecimal(tag.getOrNull(1)) ?: return null
val count = strictDecimal(tag.getOrNull(2)) ?: return null
if (index < 1 || count < 1 || index > count) return null
return index to count
}
/**
* Splits [blobs] into chunks that each fit one kind-3303 rumor: at most
* [MAX_BLOBS_PER_CHUNK] blobs AND a rumor JSON of at most [REKEY_RUMOR_MAX_BYTES], given the
* [envelopeBytes] the rumor costs with an empty content (measure it at the widest `chunk` tag
* the rotation can carry — over-reserving only makes chunks smaller). Mirrors the reference
* client's budget byte for byte: the content's own `[]`, one comma between blobs, and each
* blob at its JSON-escaped length inside the content string. A lone over-budget blob is kept
* (blobs are indivisible). Always yields at least one (possibly empty) chunk.
*/
fun chunkBlobs(
blobs: List<RekeyBlob>,
envelopeBytes: Int,
): List<List<RekeyBlob>> {
val budget = REKEY_RUMOR_MAX_BYTES - envelopeBytes
val chunks = ArrayList<List<RekeyBlob>>()
var current = ArrayList<RekeyBlob>()
var used = 2 // the content's own "[]"
for (blob in blobs) {
val cost = escapedJsonLength(blob)
if (current.isNotEmpty() && (current.size >= MAX_BLOBS_PER_CHUNK || used + 1 + cost > budget)) {
chunks.add(current)
current = ArrayList()
used = 2
}
used += cost + (if (current.isNotEmpty()) 1 else 0)
current.add(blob)
}
if (current.isNotEmpty() || chunks.isEmpty()) chunks.add(current)
return chunks
}
/** A blob's byte length inside the rumor's JSON-escaped `content` string (without outer quotes). */
private fun escapedJsonLength(blob: RekeyBlob): Int {
val raw = encodeContent(listOf(blob)).let { it.substring(1, it.length - 1) }
var extra = 0
for (c in raw) if (c == '"' || c == '\\') extra++
return raw.encodeToByteArray().size + extra
}
/** Serializes a chunk's blobs into the kind-3303 rumor content. */
fun encodeContent(blobs: List<RekeyBlob>): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs)
@@ -118,6 +190,16 @@ object ConcordRekey {
/** CORD-06 §1: a single kind-3303 event carries at most this many per-recipient blobs. */
const val MAX_BLOBS_PER_CHUNK = 120
/**
* Byte ceiling on one kind-3303 rumor's JSON, beside the 120-blob count cap. Base blobs are
* wider than channel ones, and 120 of them pushed the wrap's NIP-44 plaintext (the seal, which
* carries the rumor's own NIP-44 ciphertext as base64) past the 65,535-byte cap. 40,960 is the
* top of the NIP-44 padding bucket that still wraps — the reference client's
* `REKEY_RUMOR_MAX_BYTES`. Capacity: 120 blobs at 72 bytes (the count cap binds), 99 at 104,
* 90 at 136. Finer chunking is always wire-legal.
*/
const val REKEY_RUMOR_MAX_BYTES = 40_960
/**
* Builds a rekey blob for one recipient using [rotatorSigner] instead of a raw
* private key, so a NIP-46 bunker rotator can mint blobs without exposing its
@@ -0,0 +1,95 @@
/*
* 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.cord06Rekey
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
/**
* The `vac` authority citation on a rotation (CORD-06 §3 "Authority", CORD-04 §5): a rotation
* cites the Grant its rotator acts under like any authority action, so a just-demoted admin's
* rotation is never honored by a lagging client. The owner cites nothing — the `community_id`
* proves them.
*
* The citation is a *sync floor*, not the verdict: a verifier refuses the rotation until it
* holds at least the cited Grant edition, then resolves the rotator's rank against its current
* roster (the caller's `hasPermission(BAN)` check). Mirrors the reference client's
* `citationSatisfied`: a newer Grant head than the cited one satisfies, the same version must
* match the edition hash (a fork is refused), an older head parks the rotation (fail closed).
*
* [heads] is the authority-gated head of every Control entity keyed by `eid` hex — exactly
* [ConcordCommunityState.authorizedHeads] over the current epoch's editions ([headsOf]).
*/
object ConcordRotationAuthority {
/** The authority-gated entity heads a citation is minted from and checked against. */
fun headsOf(
editions: Collection<ControlEdition>,
communityIdHex: HexKey,
ownerPubKey: HexKey,
): Map<String, EntityFloor> = ConcordCommunityState.authorizedHeads(editions, communityIdHex.hexToByteArray(), ownerPubKey)
/**
* The citation [actor] puts on a rotation: their own Grant's current head, or null for the
* owner (who cites nothing) and for an actor with no Grant (whose rotation nobody honors).
*/
fun citationFor(
communityIdHex: HexKey,
actor: HexKey,
ownerPubKey: HexKey,
heads: Map<String, EntityFloor>,
): AuthorityCitation? {
if (actor.equals(ownerPubKey, ignoreCase = true)) return null
val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray())
val head = heads[eid.toHexKey()] ?: return null
val hash = head.hashHex.hexToByteArrayOrNull() ?: return null
return AuthorityCitation(eid, head.version, hash)
}
/** Whether [citation] authorizes [actor]'s rotation under the verifier's [heads]. */
fun citationSatisfied(
communityIdHex: HexKey,
actor: HexKey,
ownerPubKey: HexKey,
citation: AuthorityCitation?,
heads: Map<String, EntityFloor>,
): Boolean {
if (actor.equals(ownerPubKey, ignoreCase = true)) return true
if (citation == null) return false
// Must name the actor's OWN Grant coordinate.
val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray()).toHexKey()
if (citation.grantId.toHexKey() != eid) return false
val head = heads[eid] ?: return false
return when {
head.version > citation.grantVersion -> true
// At exactly it: the hash must match our fold's winner, not a fork.
head.version == citation.grantVersion -> head.hashHex.equals(citation.grantHash.toHexKey(), ignoreCase = true)
// Behind it: park until the Grant arrives.
else -> false
}
}
}
@@ -226,6 +226,25 @@ object ConcordKeyDerivation {
memberXOnly: ByteArray,
): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.GRANT, memberXOnly))
/**
* The dissolution tombstone address for a community (CORD-02 §9, A.6):
* `group_key("concord/dissolved", community_id, 0…0)`, no epoch.
*
* Derived from the public `community_id` alone, so every member past or present finds
* the same grave whatever epoch they hold — and so can anyone else, which is why a
* tombstone read here is honored only when owner-signed and bound to this id.
*/
fun dissolvedPlaneKey(communityId: ByteArray): GroupKey = groupKey(ConcordLabels.DISSOLVED, communityId, ByteArray(32))
/**
* A Channel's Pin List entity id (CORD-04 §7, A.6): `hkdf32(communityId, "concord/pins" ‖ 0x00 ‖
* channel_id)`. Derived from the community id, so a list binds to its Community by construction.
*/
fun pinsCoordinate(
communityId: ByteArray,
channelId: ByteArray,
): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.PINS, channelId))
/** The community-wide Banlist entity id: `hkdf32(communityId, "concord/banlist" ‖ 0x00 ‖ ZERO32)`. */
fun banlistCoordinate(communityId: ByteArray): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.BANLIST, ByteArray(32)))
@@ -57,6 +57,9 @@ object ConcordLabels {
/** Banlist coordinate derivation (CORD-04). */
const val BANLIST = "concord/banlist"
/** A Channel's Pin List coordinate (CORD-04 §7). */
const val PINS = "concord/pins"
/** Invite-link coordinate derivation (CORD-05). */
const val INVITE_LINKS = "concord/invite-links"
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verifyId
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -79,7 +80,7 @@ object ConcordStreamEnvelope {
): Event {
val content =
if (encrypted) {
Nip44.v2.encrypt(rumor.toJson(), stream.conversationKey).encodePayload()
encryptChecked(rumor.toJson(), stream.conversationKey)
} else {
rumor.toJson()
}
@@ -115,7 +116,7 @@ object ConcordStreamEnvelope {
createdAt: Long = TimeUtils.now(),
): Event {
val streamSigner = NostrSignerSync(KeyPair(privKey = signerKey.secretKey))
val content = Nip44.v2.encrypt(seal.toJson(), readConversationKey).encodePayload()
val content = encryptChecked(seal.toJson(), readConversationKey)
val ephemeralP = KeyPair().pubKey.toHexKey()
val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP
return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content)
@@ -200,7 +201,7 @@ object ConcordStreamEnvelope {
}
require(wrap.verify()) { "Wrap signature/id is invalid" }
val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, readConversationKey))
val seal = Event.fromJson(decryptChecked(wrap.content, readConversationKey))
require(seal.kind == KIND_SEAL_ENCRYPTED || seal.kind == KIND_SEAL_PLAINTEXT) {
"Not a Concord seal: kind ${seal.kind}"
}
@@ -208,7 +209,7 @@ object ConcordStreamEnvelope {
val rumorJson =
if (seal.kind == KIND_SEAL_ENCRYPTED) {
Nip44.v2.decrypt(seal.content, readConversationKey)
decryptChecked(seal.content, readConversationKey)
} else {
seal.content
}
@@ -257,6 +258,47 @@ object ConcordStreamEnvelope {
): OpenedStreamEvent? = openOrNull(wrap, keys.address, keys.readKey.conversationKey)
private val EMPTY_TAGS = emptyArray<Array<String>>()
/**
* NIP-44's hard plaintext cap (CORD-02 Appendix B). Every layer of a Concord event is a NIP-44
* plaintext, and the spec makes enforcing the cap each implementation's job: quartz's NIP-44
* silently switches to its extended (u32-prefixed) format past it, which strict readers —
* the reference client among them — cannot decrypt.
*/
const val NIP44_MAX_PLAINTEXT = 65_535
/** The largest standard-format NIP-44 v2 ciphertext: the u16 prefix plus the 64 KiB pad bucket. */
private const val MAX_STANDARD_CIPHERTEXT = 2 + 65_536
/** base64 of version (1) + nonce (32) + [MAX_STANDARD_CIPHERTEXT] + mac (32): anything longer is not standard NIP-44. */
private const val MAX_STANDARD_PAYLOAD = 87_472
/**
* NIP-44 v2 encrypt that refuses a plaintext over [NIP44_MAX_PLAINTEXT] UTF-8 bytes instead of
* minting an extended-format payload (the reference client's `encryptChecked`).
*/
private fun encryptChecked(
plaintext: String,
conversationKey: ByteArray,
): String {
val size = plaintext.encodeToByteArray().size
require(size <= NIP44_MAX_PLAINTEXT) { "Concord plaintext is $size bytes, over the NIP-44 cap of $NIP44_MAX_PLAINTEXT (CORD-02 Appendix B)" }
return Nip44.v2.encrypt(plaintext, conversationKey).encodePayload()
}
/**
* NIP-44 v2 decrypt that only accepts the standard format: a payload or ciphertext too large
* for the u16 length prefix is the extended format, which no strict Concord client can read
* and none of ours ever writes, so it is refused before any decryption work.
*/
private fun decryptChecked(
payload: String,
conversationKey: ByteArray,
): String {
val info = Nip44v2.EncryptedInfo.decodePayload(payload, MAX_STANDARD_PAYLOAD)
require(info.ciphertext.size <= MAX_STANDARD_CIPHERTEXT) { "Extended-format NIP-44 payload refused (CORD-02 Appendix B)" }
return Nip44.v2.decrypt(info, conversationKey)
}
}
/**
@@ -99,6 +99,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
@@ -873,6 +874,7 @@ class EventFactory {
RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig)
ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig)
ConcordCommunityListFragmentEvent.KIND -> ConcordCommunityListFragmentEvent(id, pubKey, createdAt, tags, content, sig)
ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig)
ConcordInviteListEvent.KIND -> ConcordInviteListEvent(id, pubKey, createdAt, tags, content, sig)
ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,113 @@
/*
* 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.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* CORD-03 §2 channel fold rules: deletion is terminal across the whole accepted chain, and a
* Channel name is 1..64 UTF-8 bytes — an edition breaking the name rule is unauthorized and the
* fold falls back to the previous candidate (the reference client's channel gate).
*/
class ChannelFoldConformanceTest {
private val owner = "0f".repeat(32)
private val chan = "c1".repeat(32)
private fun chained(
version: Long,
prev: ControlEdition?,
content: String,
author: String = owner,
) = ControlEdition(ControlEntityKind.CHANNEL, chan.hexToByteArray(), version, prev?.hash, null, content, author, "r-$version", version)
@Test
fun aDeletedChannelCannotBeResurrectedByALaterEdition() {
val c0 = chained(0, null, """{"name":"general"}""")
val c1 = chained(1, c0, """{"name":"general","deleted":true}""")
val c2 = chained(2, c1, """{"name":"general","deleted":false}""")
val state = ControlFixtures.fold(listOf(c0, c1, c2), owner)
assertNull(state.channels[chan], "a deletion anywhere in the accepted chain is terminal")
}
@Test
fun anUnauthorizedDeleteDoesNotRetireTheChannel() {
val troll = "77".repeat(32)
val c0 = chained(0, null, """{"name":"general"}""")
val forged = chained(1, c0, """{"name":"general","deleted":true}""", author = troll)
val state = ControlFixtures.fold(listOf(c0, forged), owner)
assertEquals("general", state.channels[chan]?.definition?.name)
}
@Test
fun anOverCapOrEmptyNameFallsBackToThePreviousEdition() {
val c0 = chained(0, null, """{"name":"general"}""")
val tooLong = chained(1, c0, """{"name":"${"x".repeat(65)}"}""")
assertEquals(
"general",
ControlFixtures
.fold(listOf(c0, tooLong), owner)
.channels[chan]
?.definition
?.name,
)
val empty = chained(1, c0, """{"name":""}""")
assertEquals(
"general",
ControlFixtures
.fold(listOf(c0, empty), owner)
.channels[chan]
?.definition
?.name,
)
// Exactly 64 bytes is fine — counted in UTF-8, so 16 four-byte emoji hit the cap too.
val atCap = chained(1, c0, """{"name":"${"x".repeat(64)}"}""")
assertEquals(
"x".repeat(64),
ControlFixtures
.fold(listOf(c0, atCap), owner)
.channels[chan]
?.definition
?.name,
)
}
@Test
fun theNameRuleCountsUtf8Bytes() {
val emoji = "😀" // 4 bytes in UTF-8
assertTrue(ChannelEntity.isValidName(emoji.repeat(16)))
assertFalse(ChannelEntity.isValidName(emoji.repeat(16) + "a"))
assertFalse(ChannelEntity.isValidName(""))
assertTrue(ChannelEntity(name = "general").hasValidName())
}
}
@@ -30,6 +30,7 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
@@ -87,7 +88,13 @@ class ConcordCommunityFactoryTest {
val community =
ConcordCommunityFactory.create(owner, name = "Gamers", createdAt = 1L, relays = listOf("wss://r.example"))
val state = ConcordCommunityState.fold(community.genesisEditions, community.ownerPubKey)
val state = ConcordCommunityState.fold(community.genesisEditions, community.communityId, community.ownerPubKey)
// CORD-04 §1: versions start at 1.
community.genesisEditions.forEach {
assertEquals(1L, it.version)
assertEquals(null, it.prevHash)
}
assertEquals("Gamers", state.metadata?.name)
assertEquals(listOf("wss://r.example"), state.metadata?.relays)
@@ -103,6 +110,16 @@ class ConcordCommunityFactoryTest {
assertFalse(state.dissolved)
}
@Test
fun refusesAGenesisPastTheMetadataCaps() =
runTest {
// CORD-02 §6: every reader drops metadata past 64 bytes of name / 10,000 of description,
// so a genesis past them would found a community with no metadata at all.
assertFailsWith<IllegalArgumentException> { ConcordCommunityFactory.create(owner, "n".repeat(65), 1L) }
assertFailsWith<IllegalArgumentException> { ConcordCommunityFactory.create(owner, "ok", 1L, description = "d".repeat(10_001)) }
assertEquals("n".repeat(64), ConcordCommunityFactory.create(owner, "n".repeat(64), 1L).let { ConcordCommunityState.fold(it.genesisEditions, it.communityId, it.ownerPubKey).metadata?.name })
}
@Test
fun differentCommunitiesFromSameOwnerHaveDistinctIds() =
runTest {
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -46,23 +47,23 @@ class ConcordCommunityStateTest {
fun foldsMetadataChannelsRolesAndAuthority() {
val editions =
listOf(
edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"My Server","description":"hi"}"""),
edition(ControlEntityKind.METADATA, ControlFixtures.COMMUNITY_ID_HEX, """{"name":"My Server","description":"hi"}"""),
edition(ControlEntityKind.CHANNEL, "c1".repeat(32), """{"name":"general","private":false}"""),
edition(ControlEntityKind.CHANNEL, "c2".repeat(32), """{"name":"voice-lounge","private":false,"voice":true}"""),
edition(ControlEntityKind.CHANNEL, "c3".repeat(32), """{"name":"old","deleted":true}"""),
edition(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""),
edition(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$alice","role_ids":["$adminRole"]}"""),
edition(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), """{"member":"$alice","role_ids":["$adminRole"]}"""),
)
val state = ConcordCommunityState.fold(editions, owner)
val state = ControlFixtures.fold(editions, owner)
assertEquals("My Server", state.metadata?.name)
assertEquals("hi", state.metadata?.description)
// deleted channel excluded; general + voice channel kept
// deleted channel excluded; the other two kept (a legacy `voice` key is just an unknown field)
assertEquals(2, state.channels.size)
assertEquals("general", state.channels["c1".repeat(32)]?.definition?.name)
assertTrue(state.channels["c2".repeat(32)]?.definition?.voice == true)
assertEquals("voice-lounge", state.channels["c2".repeat(32)]?.definition?.name)
assertNull(state.channels["c3".repeat(32)])
assertNotNull(state.roles[adminRole])
@@ -79,7 +80,7 @@ class ConcordCommunityStateTest {
@Test
fun anUnauthorizedChannelOrMetadataEditionMidChainDoesNotOrphanTheEditionsAboveIt() {
val chan = "c1".repeat(32)
val meta = "00".repeat(32)
val meta = ControlFixtures.COMMUNITY_ID_HEX
val troll = "77".repeat(32) // holds no roles at all
fun chained(
@@ -99,20 +100,86 @@ class ConcordCommunityStateTest {
val m1 = chained(ControlEntityKind.METADATA, meta, 1, m0, """{"name":"HACKED"}""", troll)
val m2 = chained(ControlEntityKind.METADATA, meta, 2, m1, """{"name":"Renamed Server"}""", owner)
val state = ConcordCommunityState.fold(listOf(c0, c1, c2, m0, m1, m2), owner)
val state = ControlFixtures.fold(listOf(c0, c1, c2, m0, m1, m2), owner)
assertEquals("renamed", state.channels[chan]?.definition?.name, "the owner's v2 rename must apply")
assertEquals("Renamed Server", state.metadata?.name, "the owner's v2 metadata edit must apply")
}
private fun chainedMeta(
eid: String,
version: Long,
prev: ControlEdition?,
content: String,
author: String = owner,
rumorId: String = "m-$eid-$version-$author",
) = ControlEdition(ControlEntityKind.METADATA, eid.hexToByteArray(), version, prev?.hash, null, content, author, rumorId, version)
@Test
fun dissolutionTombstoneMarksCommunityDissolved() {
fun metadataAtAnyCoordinateButTheCommunityIdIsIgnored() {
// CORD-04 §1: the metadata entity's eid IS the community_id. A fresh chain minted at any
// other coordinate — even owner-signed, even at a far higher version — is not this
// community's metadata, so it can neither shadow the real chain nor bypass it (S8).
val real = chainedMeta(ControlFixtures.COMMUNITY_ID_HEX, 1, null, """{"name":"Real"}""")
val decoy = chainedMeta("99".repeat(32), 50, null, """{"name":"Decoy"}""")
assertEquals("Real", ControlFixtures.fold(listOf(real, decoy), owner).metadata?.name)
assertNull(ControlFixtures.fold(listOf(decoy), owner).metadata, "a decoy alone is no metadata at all")
}
@Test
fun metadataPastTheNameOrDescriptionCapFallsBackToThePreviousEdition() {
// CORD-02 §6 caps are fold gates, as in the reference client: an edition past them is
// dropped and the entity keeps the edition below it.
val cid = ControlFixtures.COMMUNITY_ID_HEX
val v1 = chainedMeta(cid, 1, null, """{"name":"Fine"}""")
val longName = chainedMeta(cid, 2, v1, """{"name":"${"n".repeat(65)}"}""")
assertEquals("Fine", ControlFixtures.fold(listOf(v1, longName), owner).metadata?.name)
// 64 bytes of UTF-8 is the cap, not 64 characters: 33 two-byte characters is 66 bytes.
val wideName = chainedMeta(cid, 2, v1, """{"name":"${"é".repeat(33)}"}""")
assertEquals("Fine", ControlFixtures.fold(listOf(v1, wideName), owner).metadata?.name)
val longDescription = chainedMeta(cid, 2, v1, """{"name":"Fine2","description":"${"d".repeat(10_001)}"}""")
assertEquals("Fine", ControlFixtures.fold(listOf(v1, longDescription), owner).metadata?.name)
val atTheCaps = chainedMeta(cid, 2, v1, """{"name":"${"n".repeat(64)}","description":"${"d".repeat(10_000)}"}""")
assertEquals("n".repeat(64), ControlFixtures.fold(listOf(v1, atTheCaps), owner).metadata?.name)
}
@Test
fun anEqualVersionMetadataForkGoesToTheHigherAuthorityNotTheLowerRumorId() {
// CORD-04 §1: "authority first, then the lower rumor id". Alice holds MANAGE_METADATA and
// grinds a rumor id below the owner's at the same version; the owner still wins.
val cid = ControlFixtures.COMMUNITY_ID_HEX
val v1 = chainedMeta(cid, 1, null, """{"name":"Genesis"}""")
val ownerV2 = chainedMeta(cid, 2, v1, """{"name":"Owner's"}""", rumorId = "ffff")
val aliceV2 = chainedMeta(cid, 2, v1, """{"name":"Alice's"}""", author = alice, rumorId = "0000")
val editions =
listOf(
edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"Doomed"}"""),
edition(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"4"}"""), // MANAGE_METADATA
edition(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), """{"member":"$alice","role_ids":["$adminRole"]}"""),
v1,
aliceV2,
ownerV2,
)
assertEquals("Owner's", ControlFixtures.fold(editions, owner).metadata?.name)
// And an edition chained onto the owner's sibling extends the chain from there.
val v3 = chainedMeta(cid, 3, ownerV2, """{"name":"Next"}""", author = alice)
assertEquals("Next", ControlFixtures.fold(editions + v3, owner).metadata?.name)
}
@Test
fun aControlPlaneVsk10EditionDoesNotDissolve() {
// CORD-02 §9: the tombstone lives at `dissolved_pk` and must name its community. A vsk-10
// edition on the Control Plane skips that binding, so it must never seal the community.
val editions =
listOf(
edition(ControlEntityKind.METADATA, ControlFixtures.COMMUNITY_ID_HEX, """{"name":"Doomed"}"""),
edition(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""),
)
val state = ConcordCommunityState.fold(editions, owner)
assertTrue(state.dissolved)
val state = ControlFixtures.fold(editions, owner)
assertFalse(state.dissolved)
assertTrue(state.withDissolved(true).dissolved)
}
}
@@ -0,0 +1,109 @@
/*
* 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.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
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.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ConcordDissolutionTest {
private val owner = NostrSignerInternal(KeyPair())
private val stranger = NostrSignerInternal(KeyPair())
private val communityX = ConcordKeyDerivation.communityId(owner.pubKey.hexToByteArray(), ByteArray(32) { 1 }).toHexKey()
private val communityY = ConcordKeyDerivation.communityId(owner.pubKey.hexToByteArray(), ByteArray(32) { 2 }).toHexKey()
@Test
fun tombstoneWireShapeIsChainlessAndBound() {
val rumor = ConcordDissolution.rumor(owner.pubKey, communityX, createdAt = 1725000000)
assertEquals(ControlEditionEvent.KIND, rumor.kind)
assertEquals("", rumor.content)
assertEquals(listOf(listOf("vsk", "10"), listOf("eid", communityX)), rumor.tags.map { it.toList() })
}
@Test
fun addressDerivesFromTheCommunityIdAlone() {
// A.6: `concord/dissolved`, ikm = community_id, id = 0…0, no epoch.
val expected = ConcordKeyDerivation.groupKey("concord/dissolved", communityX.hexToByteArray(), ByteArray(32))
assertEquals(expected.publicKeyHex, ConcordDissolution.planeKey(communityX).publicKeyHex)
}
@Test
fun ownerTombstoneDissolvesItsOwnCommunity() =
runTest {
val wrap = ConcordDissolution.build(owner, communityX)
assertEquals(ConcordDissolution.planeKey(communityX).publicKeyHex, wrap.pubKey)
assertTrue(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey))
}
@Test
fun nonOwnerTombstoneIsNoise() =
runTest {
val wrap = ConcordDissolution.build(stranger, communityX)
assertFalse(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey))
}
@Test
fun aTombstoneForXReWrappedAtYDoesNotKillY() =
runTest {
// The replay CORD-02 §9 closes: lift X's genuine owner seal and re-wrap it verbatim at Y's
// (public) dissolved address. The seal still verifies, but its eid names X.
val planeX = ConcordDissolution.planeKey(communityX)
val sealForX = ConcordStreamEnvelope.seal(ConcordDissolution.rumor(owner.pubKey, communityX), planeX, owner, encrypted = false)
val reWrappedAtY = ConcordStreamEnvelope.wrapSeal(sealForX, ConcordDissolution.planeKey(communityY))
assertFalse(ConcordDissolution.isDissolved(listOf(reWrappedAtY), communityY, owner.pubKey))
}
@Test
fun theLegacyAllZeroEidIsRefused() =
runTest {
val plane = ConcordDissolution.planeKey(communityX)
val legacy =
RumorAssembler.assembleRumor(
owner.pubKey,
eventTemplate<ControlEditionEvent>(ControlEditionEvent.KIND, "") {
add(arrayOf("vsk", ControlEntityKind.DISSOLVED.wire))
add(arrayOf("eid", "00".repeat(32)))
},
)
val wrap = ConcordStreamEnvelope.wrap(legacy, plane, owner, encrypted = false)
assertFalse(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey))
}
@Test
fun anEncryptedSealIsRefused() =
runTest {
val plane = ConcordDissolution.planeKey(communityX)
val wrap = ConcordStreamEnvelope.wrap(ConcordDissolution.rumor(owner.pubKey, communityX), plane, owner, encrypted = true)
assertFalse(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey))
}
}
@@ -0,0 +1,498 @@
/*
* 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.cord02Community
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* CORD-02 §8 wire conformance, pinned to bytes produced by the reference client's own serializer
* (Armada `src/concord/lib/listFrag.ts` @ 7588884, run under Node against the same fixtures).
* Byte identity is the contract: identical state must fragment and serialize identically across
* implementations, or the canonical-bytes tie-break flaps between two clients' republishes.
*/
class ConcordListFragmentsTest {
private val json = Json
private fun parse(s: String) = json.parseToJsonElement(s).jsonObject
/** Every §8 rule once: seed omitted after a rename, kept across a refounding with current's cosmetics, a tombstoned entry dropped, a re-join kept, unknown keys sorted and untouched. */
private val smallInput =
"""{"vendor/flag":{"z":1,"a":[2,{"y":1,"b":2}]},"entries":[{"community_id":"1a960f3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","seed":{"co""" +
"""mmunity_id":"1a960f3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","owner":"f2047532a0e8f1ca30e2391636330b1f05768679a7d9e78191d66ef6919662""" +
"""3e","owner_salt":"ba76992a6079074311285334177ddda598e8d8f9bae4df70875886d5229c26ac","community_root":"82e42e2dc2fc1a7957f3b625f5ec2cfbafe7c2546d6c92db""" +
"""dc239953c0333bec","root_epoch":0,"control_pk":"840aa4c586fff4d34e8ffb6bcf73acf4d5e033273934bfb980e899f51aa2fd4a","channels":[],"relays":["wss://relay.""" +
"""example.com","wss://r0.example"],"name":"Gone"},"current":{"community_id":"1a960f3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","owner":"""" +
"""f2047532a0e8f1ca30e2391636330b1f05768679a7d9e78191d66ef69196623e","owner_salt":"ba76992a6079074311285334177ddda598e8d8f9bae4df70875886d5229c26ac","com""" +
"""munity_root":"82e42e2dc2fc1a7957f3b625f5ec2cfbafe7c2546d6c92dbdc239953c0333bec","root_epoch":0,"control_pk":"840aa4c586fff4d34e8ffb6bcf73acf4d5e033273""" +
"""934bfb980e899f51aa2fd4a","channels":[],"relays":["wss://relay.example.com","wss://r0.example"],"name":"Gone"},"added_at":1719800000002},{"community_id""" +
"""":"5ec89d515d1ab8e34fcadabc030883587429fbb00092d573b0d7f8c3679dc705","seed":{"community_id":"5ec89d515d1ab8e34fcadabc030883587429fbb00092d573b0d7f8c36""" +
"""79dc705","owner":"00155506969316836e9c4649c39eb76e244c720731a5ea708709ff3bcb3212a8","owner_salt":"51e0ed5c000f3205a88bca9498b39922f08def28b2da77f063cf""" +
"""88b9708607a0","community_root":"aea3ef4992ed46d0190f47392d69a647b28e1b57260698c3462afa76f53fd428","root_epoch":0,"control_pk":"ee8bb460e7251e4194e7b5c""" +
"""8a56a7eaeb9e3f7e7aabc834bf2f53a081868fd28","channels":[],"relays":["wss://relay.example.com","wss://r1.example"],"name":"Back"},"current":{"community_""" +
"""id":"5ec89d515d1ab8e34fcadabc030883587429fbb00092d573b0d7f8c3679dc705","owner":"00155506969316836e9c4649c39eb76e244c720731a5ea708709ff3bcb3212a8","own""" +
"""er_salt":"51e0ed5c000f3205a88bca9498b39922f08def28b2da77f063cf88b9708607a0","community_root":"aea3ef4992ed46d0190f47392d69a647b28e1b57260698c3462afa76""" +
"""f53fd428","root_epoch":0,"control_pk":"ee8bb460e7251e4194e7b5c8a56a7eaeb9e3f7e7aabc834bf2f53a081868fd28","channels":[],"relays":["wss://relay.example.""" +
"""com","wss://r1.example"],"name":"Back"},"added_at":1722500000000},{"community_id":"5ef38335d3bbd0a5d0241fdcdd02d600170507cf47b2249d7de9e74b119623f4","""" +
"""seed":{"community_id":"5ef38335d3bbd0a5d0241fdcdd02d600170507cf47b2249d7de9e74b119623f4","owner":"713a34a3c313be426d15b5c62ca9c115a1fa6a3a1b51a8f4c6e3""" +
"""74b001a45310","owner_salt":"dbc4579ae2b3ab293213f42bb852706ea995c3b5c3987f8aa9faae5004acb3cf","community_root":"6f432a684ce828f64eee716b22121f3c6eccda""" +
"""6752dd1c2e5413c9272d12d714","root_epoch":1,"control_pk":"bb30490c32fa152d1d7ed3135f7106626a0cf6b8c78d0664f762a25287a04f8c","channels":[{"id":"e83a3324""" +
"""ddbbbcecac7111372041ea2744331b22301a97b834c8db26f9493c71","key":"f9ee91030abe276e839e1e790bbcc68777610df7f4200f75dd5d3a01a4ab2f89","epoch":1,"name":"s""" +
"""taff-Stale"}],"relays":["wss://relay.example.com","wss://r2.example"],"name":"Stale"},"current":{"community_id":"5ef38335d3bbd0a5d0241fdcdd02d60017050""" +
"""7cf47b2249d7de9e74b119623f4","owner":"713a34a3c313be426d15b5c62ca9c115a1fa6a3a1b51a8f4c6e374b001a45310","owner_salt":"dbc4579ae2b3ab293213f42bb852706e""" +
"""a995c3b5c3987f8aa9faae5004acb3cf","community_root":"4e62eea03e8bc82ee7871fc927b8a0768e39116a2b9ad8cc9ab0c1e2c40c15a4","root_epoch":3,"control_pk":"b22""" +
"""cb88bb1cfbb8c4f8697fb982e79c22065e4c5e8afc9336b8da44c550f03a6","channels":[{"id":"e83a3324ddbbbcecac7111372041ea2744331b22301a97b834c8db26f9493c71","k""" +
"""ey":"e21fda697ca9afaf39201db8e25ae77f4bd32c69b4a7db8e5214a349f3e404df","epoch":3,"name":"staff-Fresh"}],"relays":["wss://relay.example.com","wss://r2.""" +
"""example"],"name":"Fresh","control_root":"170ef9cce8cce1cacb0fa729f43db38756632e3b5e134408f85bb7d3163e41fd"},"added_at":1719800000001,"held_roots":[{"e""" +
"""poch":1,"key":"82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828"}]},{"community_id":"a8e2754881acda66e47dc5a810d0f16a384742ff5b0022825""" +
"""b5cd915382bdf65","seed":{"community_id":"a8e2754881acda66e47dc5a810d0f16a384742ff5b0022825b5cd915382bdf65","owner":"1557f949eb074ee1de813d3598b394ea65""" +
"""4e9066ba7dd5ffe290848c31a8c9c8","owner_salt":"dc90cf07de907ccc64636ceddb38e552a1a0d984743b1f36a447b73877012c39","community_root":"e6e642c51a2df47d476e""" +
"""8961b0a9a9db2bef4116761960ac74c19cb7c46fb397","root_epoch":0,"control_pk":"e412e33f694277b150dbdd801f378cc1886769de7a398f2b06e3713616c5133e","channels""" +
"""":[{"id":"797e5887b3e390bc65acb5a81c47d1fe418c9bc160a4adb71fb31fa18981bc18","key":"b76205818bb9254a3af1e9900cdb486617b60265c4e21e600d6bbcd979ac3389","""" +
"""epoch":0,"name":"staff-Old Name"}],"relays":["wss://relay.example.com","wss://r1.example"],"name":"Old Name"},"current":{"community_id":"a8e2754881acd""" +
"""a66e47dc5a810d0f16a384742ff5b0022825b5cd915382bdf65","owner":"1557f949eb074ee1de813d3598b394ea654e9066ba7dd5ffe290848c31a8c9c8","owner_salt":"dc90cf07""" +
"""de907ccc64636ceddb38e552a1a0d984743b1f36a447b73877012c39","community_root":"e6e642c51a2df47d476e8961b0a9a9db2bef4116761960ac74c19cb7c46fb397","root_ep""" +
"""och":0,"control_pk":"e412e33f694277b150dbdd801f378cc1886769de7a398f2b06e3713616c5133e","channels":[{"id":"797e5887b3e390bc65acb5a81c47d1fe418c9bc160a4""" +
"""adb71fb31fa18981bc18","key":"b76205818bb9254a3af1e9900cdb486617b60265c4e21e600d6bbcd979ac3389","epoch":0,"name":"staff-New Name"}],"relays":["wss://re""" +
"""lay.example.com","wss://r1.example"],"name":"New Name"},"added_at":1719800000000,"invite_ref":"naddr1xyz#frag"}],"tombstones":[{"community_id":"1a960f""" +
"""3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","removed_at":1722400000000,"vendor/why":"left"},{"community_id":"5ec89d515d1ab8e34fcadabc0""" +
"""30883587429fbb00092d573b0d7f8c3679dc705","removed_at":1722400000000}]}"""
private val smallExpected =
"""{"frags":1,"entries":[{"community_id":"XsidUV0auONPytq8AwiDWHQp-7AAktVzsNf4w2edxwU","current":{"owner":"ABVVBpaTFoNunEZJw563biRMcgcxpepwhwn_O8syEqg","""" +
"""owner_salt":"UeDtXAAPMgWoi8qUmLOZIvCN7yiy2nfwY8-IuXCGB6A","community_root":"rqPvSZLtRtAZD0c5LWmmR7KOG1cmBpjDRir6dvU_1Cg","root_epoch":0,"control_pk":"""" +
"""7ou0YOclHkGU57XIpWp-rrnj9-eqvINL8vU6CBho_Sg","relays":["wss://relay.example.com","wss://r1.example"],"name":"Back"},"added_at":1722500000000},{"commun""" +
"""ity_id":"XvODNdO70KXQJB_c3QLWABcFB89HsiSdfennSxGWI_Q","seed":{"owner":"cTo0o8MTvkJtFbXGLKnBFaH6ajobUaj0xuN0sAGkUxA","owner_salt":"28RXmuKzqykyE_QruFJw""" +
"""bqmVw7XDmH-KqfquUASss88","community_root":"b0MqaEzoKPZO7nFrIhIfPG7M2mdS3RwuVBPJJy0S1xQ","root_epoch":1,"control_pk":"uzBJDDL6FS0dftMTX3EGYmoM9rjHjQZk9""" +
"""2KiUoegT4w","channels":[{"id":"6DozJN27vOyscRE3IEHqJ0QzGyIwGpe4NMjbJvlJPHE","key":"-e6RAwq-J26Dnh55C7zGh3dhDff0IA913V06AaSrL4k","epoch":1,"name":"staf""" +
"""f-Fresh"}],"relays":["wss://relay.example.com","wss://r2.example"],"name":"Fresh"},"current":{"owner":"cTo0o8MTvkJtFbXGLKnBFaH6ajobUaj0xuN0sAGkUxA","o""" +
"""wner_salt":"28RXmuKzqykyE_QruFJwbqmVw7XDmH-KqfquUASss88","community_root":"TmLuoD6LyC7nhx_JJ7igdo45EWormtjMmrDB4sQMFaQ","root_epoch":3,"control_pk":"s""" +
"""iy4i7HPu4xPhpf7mC55wiBl5MXor8kza42kTFUPA6Y","control_root":"Fw75zOjM4crLD6cp9D2zh1ZjLjteE0QI-Fu30xY-Qf0","channels":[{"id":"6DozJN27vOyscRE3IEHqJ0QzGy""" +
"""IwGpe4NMjbJvlJPHE","key":"4h_aaXypr685IB244lrnf0vTLGm0p9uOUhSjSfPkBN8","epoch":3,"name":"staff-Fresh"}],"relays":["wss://relay.example.com","wss://r2.""" +
"""example"],"name":"Fresh"},"added_at":1719800000001,"held_roots":[{"epoch":1,"key":"82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828"}]""" +
"""},{"community_id":"qOJ1SIGs2mbkfcWoENDxajhHQv9bACKCW1zZFTgr32U","current":{"owner":"FVf5SesHTuHegT01mLOU6mVOkGa6fdX_4pCEjDGoycg","owner_salt":"3JDPB96""" +
"""QfMxkY2zt2zjlUqGg2YR0Ox82pEe3OHcBLDk","community_root":"5uZCxRot9H1HbolhsKmp2yvvQRZ2GWCsdMGct8Rvs5c","root_epoch":0,"control_pk":"5BLjP2lCd7FQ292AHzeM""" +
"""wYhnad56OY8rBuNxNhbFEz4","channels":[{"id":"eX5Yh7PjkLxlrLWoHEfR_kGMm8FgpK23H7MfoYmBvBg","key":"t2IFgYu5JUo68emQDNtIZhe2AmXE4h5gDWu82XmsM4k","epoch":0""" +
""","name":"staff-New Name"}],"relays":["wss://relay.example.com","wss://r1.example"],"name":"New Name"},"added_at":1719800000000,"invite_ref":"naddr1xyz""" +
"""#frag"}],"tombstones":[{"community_id":"GpYPP4TP5Vj5RIm4RMs4My_0ZokbLnelQ_PpbGJjRlc","removed_at":1722400000000,"vendor/why":"left"},{"community_id":"""" +
"""XsidUV0auONPytq8AwiDWHQp-7AAktVzsNf4w2edxwU","removed_at":1722400000000}],"vendor/flag":{"a":[2,{"b":2,"y":1}],"z":1}}"""
@Test
fun smallFixtureMatchesTheReferenceBytes() {
assertEquals(listOf(smallExpected), ConcordListFragments.pack(parse(smallInput)))
}
@Test
fun decodingTheReferenceBytesAndRepackingIsStable() {
val decoded = ConcordListFragments.decodeFragment(smallExpected)
assertEquals(1, decoded.frags)
assertEquals(listOf(smallExpected), ConcordListFragments.pack(decoded.doc))
}
@Test
fun decodeRestoresHexAndInheritsTheCommunityId() {
val doc = ConcordListFragments.decodeFragment(smallExpected).doc
val first = doc["entries"]!!.jsonArray[0].jsonObject
val cid = first["community_id"]!!.jsonPrimitive.content
assertEquals(64, cid.length)
val current = first["current"]!!.jsonObject
assertEquals(cid, current["community_id"]!!.jsonPrimitive.content)
assertEquals(64, current["owner"]!!.jsonPrimitive.content.length)
// An absent seed reads as equal to current.
assertEquals(current, first["seed"])
}
@Test
fun unknownFieldsKeepTheirAuthorsSpelling() {
// held_roots is not a field §8 names, so its hex key stays hex on the wire.
assertTrue(smallExpected.contains("\"held_roots\":[{\"epoch\":1,\"key\":\"82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828\"}]"))
val out = ConcordListFragments.pack(ConcordListFragments.decodeFragment(smallExpected).doc).single()
assertTrue(out.contains("82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828"))
}
@Test
fun base64urlIsUnpaddedAndCaseSensitive() {
val hex = "a8e2754881acda66e47dc5a810d0f16a384742ff5b0022825b5cd915382bdf65"
val b64 = ConcordListFragments.hexToB64(hex)
assertEquals(43, b64.length)
assertFalse(b64.contains('='))
assertEquals(hex, ConcordListFragments.b64ToHex(b64))
// Not 32 bytes either way: passes through untouched.
assertEquals("wss://relay", ConcordListFragments.hexToB64("wss://relay"))
assertEquals("short", ConcordListFragments.b64ToHex("short"))
}
private fun h(s: String) = sha256(s.encodeToByteArray()).toHexKey()
private fun mat(
cid: String,
i: Int,
epoch: Int,
name: String,
withChan: Boolean,
): JsonObject =
buildJsonObject {
put("community_id", cid)
put("owner", h("owner$i"))
put("owner_salt", h("salt$i"))
put("community_root", h("root$i:$epoch"))
put("root_epoch", epoch)
put("control_pk", h("cpk$i:$epoch"))
put(
"channels",
buildJsonArray {
if (withChan) {
add(
buildJsonObject {
put("id", h("chan$i"))
put("key", h("key$i:$epoch"))
put("epoch", epoch)
put("name", "staff-$name")
},
)
}
},
)
put(
"relays",
buildJsonArray {
add(JsonPrimitive("wss://relay.example.com"))
add(JsonPrimitive("wss://r${i % 3}.example"))
},
)
put("name", name)
}
private fun bigList(): JsonObject {
val entries =
(0 until 300).map { i ->
val cid = h("big$i")
buildJsonObject {
put("community_id", cid)
put("seed", mat(cid, i, 0, "Community $i", i % 4 == 0))
put("current", mat(cid, i, i % 3, "Community $i", i % 4 == 0))
put("added_at", 1719800000000L + i)
}
}
val tombs =
(0 until 40).map { i ->
buildJsonObject {
put("community_id", h("gone$i"))
put("removed_at", 1722400000000L + i)
}
}
return buildJsonObject {
put("entries", JsonArray(entries))
put("tombstones", JsonArray(tombs))
}
}
@Test
fun largeListFragmentsExactlyLikeTheReference() {
val frags = ConcordListFragments.pack(bigList())
assertEquals(
listOf(
"f25fb53425472f4249e5801192d05a4fa589d58e8fa420a2ea0489c99c0f3acc",
"9470fb19cf3fa850b737280bd11be99e599ea9670932a5cf5fd6bb305f9738c4",
"f73480bbce3a92ee651a04373197bf70116f6cf1db6d99686ba3596b1030f942",
"c3f6318681a9a1fbcd8e50129b7678b0df5e7cf59ef6527d5bd6b485435d865e",
"d66d7973f215a3dd2b44b7c6760cd21955baaa6687e02accaa05f708660e0698",
"35d3b110eb57dd2aa116d0faee8d3858a4608d7a1d5d681775a077671eae13ac",
),
frags.map { sha256(it.encodeToByteArray()).toHexKey() },
)
for (f in frags) {
assertTrue(ConcordListFragments.projectedEventBytes(f.encodeToByteArray().size) <= ConcordListFragments.PACK_TARGET_BYTES)
assertEquals(6, ConcordListFragments.decodeFragment(f).frags)
}
}
// ---- merges ----------------------------------------------------------------------------
private fun entry(
cid: String,
seed: JsonObject,
current: JsonObject,
addedAt: Long,
) = buildJsonObject {
put("community_id", cid)
put("seed", seed)
put("current", current)
put("added_at", addedAt)
}
private fun doc(
entries: List<JsonObject> = emptyList(),
tombstones: List<JsonObject> = emptyList(),
) = JsonObject(mapOf("entries" to JsonArray(entries), "tombstones" to JsonArray(tombstones)))
private fun tomb(
cid: String,
at: Long,
) = buildJsonObject {
put("community_id", cid)
put("removed_at", at)
}
@Test
fun seedMovesBackwardAndCurrentForward() {
val cid = h("m")
val a = doc(listOf(entry(cid, mat(cid, 1, 1, "A", false), mat(cid, 1, 2, "A", false), 10)))
val b = doc(listOf(entry(cid, mat(cid, 1, 0, "B", false), mat(cid, 1, 3, "B", false), 20)))
for (merged in listOf(ConcordListFragments.mergeDocs(a, b), ConcordListFragments.mergeDocs(b, a))) {
val e = merged["entries"]!!.jsonArray.single().jsonObject
assertEquals("0", e["seed"]!!.jsonObject["root_epoch"]!!.jsonPrimitive.content)
assertEquals("3", e["current"]!!.jsonObject["root_epoch"]!!.jsonPrimitive.content)
assertEquals("20", e["added_at"]!!.jsonPrimitive.content)
}
}
@Test
fun oneTombstonePerCommunityTheLaterWins() {
val cid = h("t")
val merged = ConcordListFragments.mergeDocs(doc(tombstones = listOf(tomb(cid, 5))), doc(tombstones = listOf(tomb(cid, 9))))
assertEquals(
"9",
merged["tombstones"]!!
.jsonArray
.single()
.jsonObject["removed_at"]!!
.jsonPrimitive.content,
)
}
@Test
fun aStaleFragmentCannotResurrectALeave() {
val cid = h("x")
val joined = doc(listOf(entry(cid, mat(cid, 1, 0, "X", false), mat(cid, 1, 0, "X", false), 100)))
val left = doc(tombstones = listOf(tomb(cid, 200)))
val merged = ConcordListFragments.mergeDocs(left, joined)
assertEquals(emptyList(), ConcordCommunityList.decodeDocument(merged).entries)
// And the packed List carries the tombstone alone.
val packed = ConcordListFragments.decodeFragment(ConcordListFragments.pack(merged).single()).doc
assertEquals(0, packed["entries"]!!.jsonArray.size)
assertEquals(1, packed["tombstones"]!!.jsonArray.size)
}
// ---- fragment sets ---------------------------------------------------------------------
private fun frag(
frags: Int,
entries: List<JsonObject> = emptyList(),
tombstones: List<JsonObject> = emptyList(),
) = ConcordListFragments.serializeFragment(
frags,
entries.map { ConcordListFragments.entryToWire(it) },
tombstones.mapNotNull { ConcordListFragments.tombstoneToWire(it) },
)
private fun membership(i: Int): JsonObject {
val cid = h("member$i")
return entry(cid, mat(cid, i, 0, "M$i", false), mat(cid, i, 0, "M$i", false), 1000L + i)
}
@Test
fun theNewestFragmentDeclaresTheCountAndATieGoesLarger() {
val set =
ConcordListFragmentSet.of(
listOf(
ConcordListFragmentSet.Copy(0, 100, "a", frag(2, listOf(membership(0)))),
ConcordListFragmentSet.Copy(1, 100, "b", frag(3, listOf(membership(1)))),
),
)
assertEquals(3, set.declared)
assertFalse(set.complete, "index 2 is unseen")
assertEquals(2, ConcordCommunityList.decodeDocument(set.doc).entries.size)
}
@Test
fun anUnreadableHeadIsAMissingIndexNotAnOlderCopy() {
val set =
ConcordListFragmentSet.of(
listOf(
ConcordListFragmentSet.Copy(0, 100, "old", frag(1, listOf(membership(0)))),
ConcordListFragmentSet.Copy(0, 200, "new", null),
),
)
assertTrue(0 in set.unreadable)
assertTrue(set.held.isEmpty())
}
@Test
fun anUnreadableOnlyFragmentIsNeverOverwritten() {
// The one fragment on the wire didn't decrypt (a timed-out signer): not "no List".
val set = ConcordListFragmentSet.of(listOf(ConcordListFragmentSet.Copy(0, 100, "a", null)))
assertFalse(set.complete)
assertFalse(set.isEmpty)
assertFailsWith<ConcordListIncompleteException> { set.planWrites(doc(listOf(membership(0))), now = 1000) }
}
@Test
fun anUnreadableNewerFragmentBlocksARepackThatWouldShrinkTheCount() {
// Fragment 1 may declare more fragments than readable fragment 0 does; a repack to 1
// fragment would push its memberships out of range.
val set =
ConcordListFragmentSet.of(
listOf(
ConcordListFragmentSet.Copy(0, 100, "a", frag(1, listOf(membership(0)))),
ConcordListFragmentSet.Copy(1, 200, "b", null),
),
)
assertFalse(set.complete)
val writes = set.planWrites(ConcordListFragments.mergeDocs(set.doc, doc(listOf(membership(5)))), now = 1000)
assertEquals(listOf(0), writes.map { it.index }, "only a scoped write into the readable fragment")
assertEquals(1, ConcordListFragments.decodeFragment(writes.single().plaintext).frags)
}
@Test
fun anEntryWithUnencodableMaterialIsSkippedNotFatal() {
val broken =
buildJsonObject {
put("community_id", h("broken"))
put("current", buildJsonObject { put("name", "no keys") })
put("added_at", 1)
}
val packed = ConcordListFragments.pack(doc(listOf(membership(0), broken))).single()
assertEquals(
1,
ConcordListFragments
.decodeFragment(packed)
.doc["entries"]!!
.jsonArray.size,
)
}
@Test
fun fragmentsPastTheCountStayDormant() {
val set =
ConcordListFragmentSet.of(
listOf(
ConcordListFragmentSet.Copy(0, 300, "a", frag(1, listOf(membership(0)))),
ConcordListFragmentSet.Copy(1, 100, "b", frag(2, listOf(membership(1)))),
),
)
assertEquals(1, set.declared)
assertTrue(set.complete)
assertEquals(listOf(h("member0")), ConcordCommunityList.decodeDocument(set.doc).entries.map { it.id })
}
@Test
fun aRepackThatShrinksEmptiesTheDroppedIndices() {
val set =
ConcordListFragmentSet.of(
listOf(
ConcordListFragmentSet.Copy(0, 100, "a", frag(2, listOf(membership(0)))),
ConcordListFragmentSet.Copy(1, 100, "b", frag(2, listOf(membership(1)))),
),
)
assertTrue(set.complete)
val writes = set.planWrites(set.doc, now = 50)
// Both memberships fit one fragment: 0 is rewritten with frags=1, 1 is emptied — never abandoned.
assertEquals(listOf(0, 1), writes.map { it.index })
assertEquals(
2,
ConcordListFragments
.decodeFragment(writes[0].plaintext)
.doc["entries"]!!
.jsonArray.size,
)
assertEquals(ConcordListFragments.emptyFragment(1), writes[1].plaintext)
// created_at always climbs past the index's previous copy, even with a clock behind it.
assertTrue(writes.all { it.createdAt == 101L })
}
@Test
fun anIncompleteListOnlyRewritesTheFragmentHoldingTheChange() {
val held0 = frag(3, listOf(membership(0)))
val set =
ConcordListFragmentSet.of(
listOf(
ConcordListFragmentSet.Copy(0, 100, "a", held0),
ConcordListFragmentSet.Copy(2, 100, "c", frag(3, listOf(membership(2)))),
),
)
assertFalse(set.complete)
// Leave membership 2: a tombstone lands in fragment 2 only; fragment 1 (unseen) is untouched.
val next = ConcordListFragments.mergeDocs(set.doc, doc(tombstones = listOf(tomb(h("member2"), 5000))))
val writes = set.planWrites(next, now = 1000)
assertEquals(listOf(2), writes.map { it.index })
val written = ConcordListFragments.decodeFragment(writes.single().plaintext)
assertEquals(3, written.frags, "a scoped write never changes the count")
assertEquals(0, written.doc["entries"]!!.jsonArray.size)
assertEquals(1, written.doc["tombstones"]!!.jsonArray.size)
}
@Test
fun anIncompleteListPutsANewMembershipInTheLowestHeldFragment() {
val set =
ConcordListFragmentSet.of(
listOf(ConcordListFragmentSet.Copy(1, 100, "b", frag(2, listOf(membership(1))))),
)
val next = ConcordListFragments.mergeDocs(set.doc, doc(listOf(membership(9))))
val writes = set.planWrites(next, now = 1000)
assertEquals(listOf(1), writes.map { it.index })
assertEquals(
2,
ConcordListFragments
.decodeFragment(writes.single().plaintext)
.doc["entries"]!!
.jsonArray.size,
)
}
@Test
fun anUnchangedListWritesNothing() {
val set = ConcordListFragmentSet.of(listOf(ConcordListFragmentSet.Copy(0, 100, "a", ConcordListFragments.pack(doc(listOf(membership(0)))).single())))
assertEquals(emptyList(), set.planWrites(set.doc, now = 1000).map { it.index })
}
@Test
fun theTypedLayerTombstonesOnLeave() {
val entry = ConcordCommunityList.decodeDocument(doc(listOf(membership(0)))).entries.single()
val residue = ConcordListResidue.EMPTY.withTombstone(entry.id, 5000)
val internal = ConcordCommunityList.encodeInternal(emptyList(), residue)
val back = ConcordCommunityList.decodeDocument(internal)
assertEquals(emptyList(), back.entries)
assertEquals(
"5000",
back.residue.tombstones
.single()["removed_at"]!!
.jsonPrimitive.content,
)
// An earlier removal never lowers the tombstone.
assertEquals(residue, residue.withTombstone(entry.id, 10))
}
}
@@ -0,0 +1,243 @@
/*
* 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.cord03Channels
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
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.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* CORD-01/02/03 Chat Plane conformance: the strict binding (I13), the `ms` tag (I15), the
* four-element inline quote (I16), the in-stream delete (S3), the Chat ingest gate (S9) and the
* private-channel keying (S2), each pinned against the spec text and examples.md §2.
*/
class ChannelChatConformanceTest {
private val communityRoot = ByteArray(32) { 0x5A }
private val channelId = ByteArray(32) { 0x42 }
private val channelIdHex = channelId.toHexKey()
private val author = KeyPair().pubKey.toHexKey()
private fun rumorWithTags(vararg tags: Array<String>): Event = RumorAssembler.assembleRumor<Event>(author, 1_700_000_000L, 9, arrayOf(*tags), "x")
// ---- I13 strict binding -------------------------------------------------------------------
@Test
fun bindingRequiresExactlyOneChannelAndOneCanonicalEpoch() {
val ok = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", "4"))
assertTrue(ok.tags.isConcordBoundTo(channelIdHex, 4))
// Non-canonical spellings of 4 are a different binding (CORD-01 Encoding: no leading zeros).
for (spelling in listOf("04", "+4", " 4", "4 ", "4.0", "0x4")) {
val bad = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", spelling))
assertFalse(bad.tags.isConcordBoundTo(channelIdHex, 4), "epoch \"$spelling\" must not bind to 4")
assertNull(bad.tags.concordEpoch(), "epoch \"$spelling\" must not parse")
}
assertNull(EpochTag.parse(arrayOf("epoch", "-1")))
assertEquals(0L, EpochTag.parse(arrayOf("epoch", "0")))
// A duplicated tag is ambiguous, even when both copies agree.
val dupChannel = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("channel", channelIdHex), arrayOf("epoch", "4"))
assertFalse(dupChannel.tags.isConcordBoundTo(channelIdHex, 4))
assertNull(dupChannel.tags.concordChannel())
val dupEpoch = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", "4"), arrayOf("epoch", "4"))
assertFalse(dupEpoch.tags.isConcordBoundTo(channelIdHex, 4))
assertNull(dupEpoch.tags.concordEpoch())
// A valueless duplicate still counts as a second binding tag (Armada's uniqueTag).
val shortDup = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("channel"), arrayOf("epoch", "4"))
assertFalse(shortDup.tags.isConcordBoundTo(channelIdHex, 4))
}
@Test
fun extraTagsCannotSmuggleASecondBinding() {
val rumor =
ChannelChat.message(
author,
channelIdHex,
0,
"hi",
createdAt = 1L,
extraTags = arrayOf(arrayOf("channel", "00".repeat(32)), arrayOf("epoch", "9"), arrayOf("ms", "5"), arrayOf("emoji", "a", "u1"), arrayOf("emoji", "b", "u2")),
)
assertTrue(ChannelChat.isBoundTo(rumor, channelIdHex, 0))
assertEquals(1, rumor.tags.count { it[0] == "ms" })
// Every extra (non-binding) tag survives, including repeated names.
assertEquals(2, rumor.tags.count { it[0] == "emoji" })
}
// ---- I15 ms tag ---------------------------------------------------------------------------
@Test
fun everyChatBuilderStampsAWellFormedMsTag() {
val parent = ChannelChat.message(author, channelIdHex, 0, "root", createdAt = 1L, ms = 417)
val built =
listOf(
parent,
ChannelChat.inlineReply(author, channelIdHex, 0, "q", parent.id, parent.pubKey, 2L),
ChannelChat.reply(author, channelIdHex, 0, "t", parent, 3L),
ChannelChat.reaction(author, channelIdHex, 0, parent.id, parent.pubKey, 9, "+", 4L),
ChannelChat.edit(author, channelIdHex, 0, parent.id, "e", 5L),
ChannelChat.delete(author, channelIdHex, 0, listOf(parent), 6L),
ChannelChat.typing(author, channelIdHex, 0, 7L),
ChannelChat.imageMessage(author, channelIdHex, 0, "i", emptyList(), 8L),
)
for (rumor in built) {
val ms = rumor.tags.filter { it[0] == "ms" }
assertEquals(1, ms.size, "kind ${rumor.kind} must carry exactly one ms tag")
assertNotNull(MsTag.parse(ms.single()), "kind ${rumor.kind} ms tag must be well formed")
}
// The examples' order: channel, epoch, ms first.
assertContentEquals(arrayOf("channel", channelIdHex), parent.tags[0])
assertContentEquals(arrayOf("epoch", "0"), parent.tags[1])
assertContentEquals(arrayOf("ms", "417"), parent.tags[2])
assertEquals(1_417L, ChannelChat.orderingMs(parent))
}
@Test
fun msParsingIsStrictAndOrderingUsesTheMillisecondBasis() {
assertEquals(0, MsTag.parse(arrayOf("ms", "0")))
assertEquals(999, MsTag.parse(arrayOf("ms", "999")))
for (bad in listOf("1000", "-1", "007", "+5", " 5", "1e2", "0x1f", "")) {
assertNull(MsTag.parse(arrayOf("ms", bad)), "ms \"$bad\" is malformed")
}
assertFailsWith<IllegalArgumentException> { MsTag.assemble(1000) }
assertEquals(5_000L, MsTag.orderingMs(5, emptyArray())) // absent = 0
assertEquals(5_123L, MsTag.orderingMs(5, arrayOf(arrayOf("ms", "123"))))
assertNull(MsTag.orderingMs(5, arrayOf(arrayOf("ms", "1000"))))
assertNull(MsTag.orderingMs(5, arrayOf(arrayOf("ms", "1"), arrayOf("ms", "2"))))
// Same second, the ms remainder decides the order.
val early = ChannelChat.message(author, channelIdHex, 0, "a", createdAt = 10L, ms = 900)
val late = ChannelChat.message(author, channelIdHex, 0, "b", createdAt = 11L, ms = 5)
val mid = ChannelChat.message(author, channelIdHex, 0, "c", createdAt = 10L, ms = 950)
assertEquals(listOf("a", "c", "b"), listOf(late, mid, early).sortedBy { ChannelChat.orderingMs(it) }.map { it.content })
}
@Test
fun msRemainderTracksTheCurrentSecondOnly() {
assertEquals(0, MsTag.remainderFor(1L))
val r = MsTag.remainderFor(TimeUtils.now())
assertTrue(r in 0..999)
}
// ---- I16 inline quote ---------------------------------------------------------------------
@Test
fun inlineQuoteUsesTheFourElementQTag() {
val parentAuthor = KeyPair().pubKey.toHexKey()
val quote = ChannelChat.inlineReply(author, channelIdHex, 0, "Welcome!", "ab".repeat(32), parentAuthor, 2L)
val q = quote.tags.single { it[0] == "q" }
assertContentEquals(arrayOf("q", "ab".repeat(32), "", parentAuthor), q)
assertEquals(9, quote.kind)
}
// ---- S3 delete ----------------------------------------------------------------------------
@Test
fun deleteIsAChannelBoundKind5WithETagsThenKTags() =
runTest {
val alice = NostrSignerInternal(KeyPair())
val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0)
val message = ChannelChat.message(alice.pubKey, channelIdHex, 0, "oops", createdAt = 1L)
val reply = ChannelChat.reply(alice.pubKey, channelIdHex, 0, "also oops", message, 2L)
val reaction = ChannelChat.reaction(alice.pubKey, channelIdHex, 0, message.id, message.pubKey, 9, "+", 3L)
val delete = ChannelChat.delete(alice.pubKey, channelIdHex, 0, listOf(message, reply, reaction), 4L, ms = 533)
assertEquals(5, delete.kind)
assertEquals("", delete.content)
assertContentEquals(arrayOf("channel", channelIdHex), delete.tags[0])
assertContentEquals(arrayOf("epoch", "0"), delete.tags[1])
assertContentEquals(arrayOf("ms", "533"), delete.tags[2])
assertEquals(listOf(message.id, reply.id, reaction.id), delete.tags.filter { it[0] == "e" }.map { it[1] })
assertEquals(listOf("9", "1111", "7"), delete.tags.filter { it[0] == "k" }.map { it[1] })
// It rides the channel plane in an encrypted seal like any other Chat rumor.
val wrap = ConcordStreamEnvelope.wrap(delete, channel, alice, encrypted = true)
val opened = ConcordStreamEnvelope.open(wrap, channel)
assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind)
assertNotNull(ChannelChat.acceptOpened(opened, channelIdHex, 0))
assertFailsWith<IllegalArgumentException> { ChannelChat.delete(alice.pubKey, channelIdHex, 0, emptyList(), 4L) }
}
// ---- S9 chat ingest gate ------------------------------------------------------------------
@Test
fun chatIngestAcceptsOnlyEncryptedSealsAndChatKinds() =
runTest {
val alice = NostrSignerInternal(KeyPair())
val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0)
suspend fun open(
rumor: Event,
encrypted: Boolean = true,
) = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, channel, alice, encrypted = encrypted), channel)
val message = ChannelChat.message(alice.pubKey, channelIdHex, 0, "hi", createdAt = 1L)
assertNotNull(ChannelChat.acceptOpened(open(message), channelIdHex, 0))
// A plaintext seal is Control-only (CORD-02 §5).
assertNull(ChannelChat.acceptOpened(open(message, encrypted = false), channelIdHex, 0))
// Another plane's kind, correctly bound, is still refused (Armada PLANE_KINDS).
for (kind in listOf(3308, 3306, 3309, 3312, 3303, 3313, 1)) {
val foreign = RumorAssembler.assembleRumor<Event>(alice.pubKey, 1L, kind, arrayOf(arrayOf("channel", channelIdHex), arrayOf("epoch", "0")), "{}")
assertNull(ChannelChat.acceptOpened(open(foreign), channelIdHex, 0), "kind $kind must not enter a Chat Plane")
}
for (kind in listOf(9, 1111, 7, 5, 3302, 23311, 1740)) {
assertTrue(ChannelChat.isChatKind(kind), "kind $kind is a Chat kind")
}
// A malformed ms drops the rumor instead of being interpreted.
val badMs = RumorAssembler.assembleRumor<Event>(alice.pubKey, 1L, 9, arrayOf(arrayOf("channel", channelIdHex), arrayOf("epoch", "0"), arrayOf("ms", "1500")), "hi")
assertNull(ChannelChat.acceptOpened(open(badMs), channelIdHex, 0))
// And a binding mismatch is dropped as before.
assertNull(ChannelChat.acceptOpened(open(message), channelIdHex, 1))
}
// ---- S2 private channel keying ------------------------------------------------------------
@Test
fun aPrivateChannelLivesOnItsOwnKeyNotTheRootPlane() {
val channelKey = ByteArray(32) { 0x33 }
val public = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0)
val private = ConcordChannelKeys.privateChannel(channelKey, channelId, 1)
assertNotEquals(public.publicKeyHex, private.publicKeyHex)
// The channel epoch is part of the derivation: a stale key generation is a different plane.
assertNotEquals(private.publicKeyHex, ConcordChannelKeys.privateChannel(channelKey, channelId, 2).publicKeyHex)
}
}
@@ -0,0 +1,72 @@
/*
* 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.cord03Channels
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcordDisappearingTest {
private val author = KeyPair().pubKey.toHexKey()
private val channel = "cc".repeat(32)
@Test
fun onlyDurableChatKindsExpire() {
assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(9, 1_000, 86_400))
assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(1111, 1_000, 86_400))
assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(7, 1_000, 86_400))
assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(3302, 1_000, 86_400))
// Deletes and notices never expire; ephemeral kinds carry nothing; an unset timer tags nothing.
assertNull(ConcordDisappearing.expirationFor(5, 1_000, 86_400))
assertNull(ConcordDisappearing.expirationFor(1740, 1_000, 86_400))
assertNull(ConcordDisappearing.expirationFor(23311, 1_000, 86_400))
assertNull(ConcordDisappearing.expirationFor(9, 1_000, null))
assertNull(ConcordDisappearing.expirationFor(9, 1_000, 0))
}
@Test
fun theRumorsOwnTagDecidesExpiry() {
val tags = ConcordDisappearing.withExpiration(arrayOf(arrayOf("channel", channel)), 2_000)
val rumor = ChannelChat.message(author, channel, 0, "gone soon", 1_000, extraTags = tags)
assertEquals(2_000L, ConcordDisappearing.expirationOf(rumor))
assertFalse(ConcordDisappearing.isExpired(rumor, now = 1_999))
assertTrue(ConcordDisappearing.isExpired(rumor, now = 2_000), "NIP-40: exp <= now")
assertFalse(ConcordDisappearing.isExpired(ChannelChat.message(author, channel, 0, "forever", 1_000), now = Long.MAX_VALUE))
}
@Test
fun timerNoticeRoundTripsAndRejectsGarbage() {
val notice = ConcordDisappearing.timerNotice(author, channel, 4, 2_592_000, 10)
assertEquals(1740, notice.kind)
assertEquals("", notice.content)
assertEquals(channel, ChannelChat.channelOf(notice))
assertEquals(4L, ChannelChat.epochOf(notice))
assertEquals(2_592_000L, ConcordDisappearing.noticeTimerSecs(notice))
assertEquals(0L, ConcordDisappearing.noticeTimerSecs(ConcordDisappearing.timerNotice(author, channel, 4, 0, 10)))
val bad = ChannelChat.message(author, channel, 4, "", 10, extraTags = arrayOf(arrayOf("timer", "04")))
assertNull(ConcordDisappearing.noticeTimerSecs(bad), "not a 1740")
}
}
@@ -50,6 +50,7 @@ class AuthorityResolverTest {
json: String,
) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0)
/** A genesis Grant of [member] at their own coordinate; [grantId] only names the rumor. */
private fun grant(
grantId: String,
member: String,
@@ -57,7 +58,7 @@ class AuthorityResolverTest {
granter: String,
) = ControlEdition(
ControlEntityKind.GRANT,
grantId.hexToByteArray(),
ControlFixtures.grantEid(member).hexToByteArray(),
0,
null,
null,
@@ -75,7 +76,7 @@ class AuthorityResolverTest {
vararg banned: String,
) = ControlEdition(
ControlEntityKind.BANLIST,
"44".repeat(32).hexToByteArray(),
ControlFixtures.banlistEid().hexToByteArray(),
0,
null,
null,
@@ -106,7 +107,7 @@ class AuthorityResolverTest {
)
val r =
AuthorityResolver.resolve(
ControlFixtures.resolve(
listOf(
role(adminRole, adminJson), // position 1, owner-authored
modV0, // position 5, owner-authored
@@ -130,7 +131,7 @@ class AuthorityResolverTest {
// strip anyone, the owner's admins included. Armada gates this the same way: a grant is an
// action ON the member, so demotion must be at least as hard as promotion.
val modWithManageRoles = """{"name":"Mod","position":5,"permissions":"9"}""" // KICK|MANAGE_ROLES
val adminGrantId = "31".repeat(32)
val adminGrantId = ControlFixtures.grantEid(alice)
val adminGrant = grant(adminGrantId, alice, listOf(adminRole), granter = owner)
val revokeByMod =
ControlEdition(
@@ -146,7 +147,7 @@ class AuthorityResolverTest {
)
val r =
AuthorityResolver.resolve(
ControlFixtures.resolve(
listOf(
role(adminRole, adminJson), // position 1
role(modRole, modWithManageRoles), // position 5
@@ -163,7 +164,7 @@ class AuthorityResolverTest {
@Test
fun anAdminCanStillRevokeAMemberBeneathIt() {
// The gate must not block legitimate moderation: an admin may revoke a moderator's roles.
val modGrantId = "32".repeat(32)
val modGrantId = ControlFixtures.grantEid(bob)
val modGrant = grant(modGrantId, bob, listOf(modRole), granter = owner)
val revokeByAdmin =
ControlEdition(
@@ -179,7 +180,7 @@ class AuthorityResolverTest {
)
val r =
AuthorityResolver.resolve(
ControlFixtures.resolve(
listOf(
role(adminRole, adminJson),
role(modRole, modJson),
@@ -211,7 +212,7 @@ class AuthorityResolverTest {
)
val r =
AuthorityResolver.resolve(
ControlFixtures.resolve(
listOf(
role(adminRole, adminJson),
modV0,
@@ -235,7 +236,7 @@ class AuthorityResolverTest {
grant("ba".repeat(32), bob, listOf(modRole), granter = alice),
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
)
val r = AuthorityResolver.resolve(heads, owner)
val r = ControlFixtures.resolve(heads, owner)
assertTrue(r.isOwner(owner))
assertEquals(0L, r.rank(owner))
@@ -263,7 +264,7 @@ class AuthorityResolverTest {
// carol has no authority, so her grant to dave is dropped
grant("cd".repeat(32), dave, listOf(adminRole), granter = carol),
)
val r = AuthorityResolver.resolve(heads, owner)
val r = ControlFixtures.resolve(heads, owner)
assertNull(r.rank(dave))
assertEquals(ConcordPermissions.NONE.bits, r.effectivePermissions(dave).bits)
}
@@ -277,7 +278,7 @@ class AuthorityResolverTest {
grant("ab".repeat(32), alice, listOf(modRole), granter = owner), // alice is a mod (no MANAGE_ROLES)
grant("ae".repeat(32), dave, listOf(adminRole), granter = alice), // mod cannot grant admin
)
val r = AuthorityResolver.resolve(heads, owner)
val r = ControlFixtures.resolve(heads, owner)
assertEquals(5L, r.rank(alice))
assertNull(r.rank(dave)) // rejected: alice lacks MANAGE_ROLES and doesn't outrank admin
}
@@ -290,7 +291,7 @@ class AuthorityResolverTest {
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
banlist(alice),
)
val r = AuthorityResolver.resolve(heads, owner)
val r = ControlFixtures.resolve(heads, owner)
assertTrue(r.isBanned(alice))
// A banned actor can take no action even though the role bit is present.
assertFalse(r.hasPermission(alice, BAN))
@@ -298,11 +299,11 @@ class AuthorityResolverTest {
}
@Test
fun concurrentBansHealIntoAUnionAndAreNeverDropped() {
// Two authorized moderators ban different abusers at the same banlist version — a
// fork of the single banlist doc. Folding to one chain tip would silently drop the
// loser's ban and let that abuser back in; the union keeps both (M1 / CORD-06
// down-only healing).
fun concurrentBansForkAndTheFoldKeepsOneEdition() {
// Two authorized members ban different abusers at the same banlist version — a fork of
// the single replaced document. CORD-04 §4: "the fold keeps one edition and the other's
// addition drops until re-applied". Authority breaks the tie (§1), so the owner's edition
// wins even though alice's rumor id sorts first; a union of forks is NOT the fold.
val heads =
listOf(
role(adminRole, adminJson),
@@ -310,9 +311,71 @@ class AuthorityResolverTest {
banlistBy(owner, "ban-owner", bob), // owner bans bob
banlistBy(alice, "ban-alice", carol), // alice concurrently bans carol
)
val r = AuthorityResolver.resolve(heads, owner)
val r = ControlFixtures.resolve(heads, owner)
assertTrue(r.isBanned(bob), "the owner's edition wins the fork")
assertFalse(r.isBanned(carol), "the losing fork's addition drops until re-applied")
}
@Test
fun theLosingBanIsReHealedByReApplyingItAtopTheWinner() {
// §4 re-heal: after publishing, re-fold, and if your addition isn't in the head, re-apply
// it on top of the winner. That converges on the union without the fold ever unioning.
val ownerFork = banlistBy(owner, "ban-owner", bob)
val base =
listOf(
role(adminRole, adminJson),
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
ownerFork,
banlistBy(alice, "ban-alice", carol),
)
val reHeal =
ControlEdition(
ControlEntityKind.BANLIST,
ControlFixtures.banlistEid().hexToByteArray(),
1,
ownerFork.hash, // atop the winner
null,
"[\"$bob\",\"$carol\"]",
alice,
"ban-alice-reheal",
1,
)
val r = ControlFixtures.resolve(base + reHeal, owner)
assertTrue(r.isBanned(bob))
assertTrue(r.isBanned(carol))
assertTrue(r.isBanned(carol), "re-applied atop the winner, alice's ban lands")
}
@Test
fun aBanOnALosingForkNeverSticks() {
// The union's worst failure: a ban that lost the fork could never be lifted, because no
// later edition supersedes a fork. Folding to one head, the losing fork is simply inert, and
// an unban chained onto the head sticks.
val ownerV0 = banlistBy(owner, "ban-owner", bob)
val unban =
ControlEdition(ControlEntityKind.BANLIST, ControlFixtures.banlistEid().hexToByteArray(), 1, ownerV0.hash, null, "[]", owner, "unban", 1)
val r =
ControlFixtures.resolve(
listOf(
role(adminRole, adminJson),
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
ownerV0,
banlistBy(alice, "ban-alice", dave), // a v0 fork that loses to the owner
unban,
),
owner,
)
assertFalse(r.isBanned(bob), "the owner's unban applies")
assertFalse(r.isBanned(dave), "and the losing fork's ban never took effect")
}
@Test
fun aBanlistAtAnyOtherCoordinateIsIgnored() {
// The Banlist lives at banlist_locator(community_id) (CORD-02 A.6). A list at any other eid
// is not this community's Banlist, even owner-signed.
val stray =
ControlEdition(ControlEntityKind.BANLIST, "44".repeat(32).hexToByteArray(), 0, null, null, "[\"$bob\"]", owner, "stray", 0)
val r = ControlFixtures.resolve(listOf(role(adminRole, adminJson), stray), owner)
assertFalse(r.isBanned(bob))
}
@Test
@@ -323,7 +386,7 @@ class AuthorityResolverTest {
role(adminRole, adminJson),
banlistBy(carol, "ban-carol", dave),
)
val r = AuthorityResolver.resolve(heads, owner)
val r = ControlFixtures.resolve(heads, owner)
assertFalse(r.isBanned(dave))
}
@@ -335,7 +398,7 @@ class AuthorityResolverTest {
role(modRole, """{"name":"Peer","position":0,"permissions":"25"}"""), // illegal position 0
grant("ab".repeat(32), alice, listOf(adminRole, modRole), granter = owner),
)
val r = AuthorityResolver.resolve(heads, owner)
val r = ControlFixtures.resolve(heads, owner)
assertNull(r.rank(alice)) // both assigned roles are invalid
}
@@ -352,7 +415,7 @@ class AuthorityResolverTest {
role(adminRole, """{"name":"Admin","position":1,"permissions":"25","scope":{"kind":"server"},"color":0}"""),
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
)
val r = AuthorityResolver.resolve(heads, owner)
val r = ControlFixtures.resolve(heads, owner)
assertEquals(1L, r.rank(alice))
assertTrue(r.effectivePermissions(alice).has(BAN))
}
@@ -365,7 +428,7 @@ class AuthorityResolverTest {
*/
@Test
fun rogueHigherVersionGrantCannotSupersedeALegitGrant() {
val grantId = "ab".repeat(32)
val grantId = ControlFixtures.grantEid(alice)
val ownerGrant = grant(grantId, alice, listOf(adminRole), granter = owner) // v0, prev null
val rogueV1 =
ControlEdition(
@@ -379,7 +442,7 @@ class AuthorityResolverTest {
"grant-$grantId-rogue",
1,
)
val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), ownerGrant, rogueV1), owner)
val r = ControlFixtures.resolve(listOf(role(adminRole, adminJson), ownerGrant, rogueV1), owner)
assertEquals(1L, r.rank(alice)) // rogue v1 dropped; the owner's v0 grant stands
}
@@ -411,7 +474,7 @@ class AuthorityResolverTest {
*/
@Test
fun anUnauthorizedEditionMidChainDoesNotOrphanTheHonestEditionsAboveIt() {
val grantId = "ab".repeat(32)
val grantId = ControlFixtures.grantEid(alice)
val v0 = edition(ControlEntityKind.GRANT, grantId, 0, null, grantJson(listOf(modRole)), owner, "g0")
val v1 = edition(ControlEntityKind.GRANT, grantId, 1, v0, grantJson(listOf(adminRole)), owner, "g1")
// carol holds ZERO roles — correctly rejected, at any position in the chain.
@@ -421,7 +484,7 @@ class AuthorityResolverTest {
val v5 = edition(ControlEntityKind.GRANT, grantId, 5, v4, grantJson(listOf(modRole)), owner, "g5")
val r =
AuthorityResolver.resolve(
ControlFixtures.resolve(
listOf(role(adminRole, adminJson), role(modRole, modJson), v0, v1, v2, v3, v4, v5),
owner,
)
@@ -446,7 +509,7 @@ class AuthorityResolverTest {
val v4 = edition(ControlEntityKind.ROLE, modRole, 4, v3, mod(7, "8"), owner, "r4")
val r =
AuthorityResolver.resolve(
ControlFixtures.resolve(
listOf(role(adminRole, adminJson), v0, v1, v2, v3, v4, grant("32".repeat(32), bob, listOf(modRole), granter = owner)),
owner,
)
@@ -463,7 +526,7 @@ class AuthorityResolverTest {
*/
@Test
fun anUnauthorizedBanlistEditionMidChainDoesNotOrphanOrResurrectBans() {
val banId = "44".repeat(32)
val banId = ControlFixtures.banlistEid()
fun list(vararg keys: String) = "[${keys.joinToString(",") { "\"$it\"" }}]"
@@ -472,7 +535,7 @@ class AuthorityResolverTest {
val v2 = edition(ControlEntityKind.BANLIST, banId, 2, v1, list(), dave, "b2") // dave holds no BAN
val v3 = edition(ControlEntityKind.BANLIST, banId, 3, v2, list(carol), owner, "b3") // owner unbans bob
val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), v0, v1, v2, v3), owner)
val r = ControlFixtures.resolve(listOf(role(adminRole, adminJson), v0, v1, v2, v3), owner)
assertTrue(r.isBanned(carol), "carol's ban survives to the head")
assertFalse(r.isBanned(bob), "the owner's v3 unban must apply — v2 may not orphan it")
@@ -481,7 +544,7 @@ class AuthorityResolverTest {
/** A forged edition takes effect at NO position: not at the tip, and not mid-chain. */
@Test
fun aForgedEditionNeverTakesEffectAtAnyPosition() {
val grantId = "ab".repeat(32)
val grantId = ControlFixtures.grantEid(alice)
val v0 = edition(ControlEntityKind.GRANT, grantId, 0, null, grantJson(listOf(adminRole)), owner, "g0")
// carol holds nothing; her revoke is the forgery, and it must apply at NO position.
val forgedV1 = edition(ControlEntityKind.GRANT, grantId, 1, v0, grantJson(emptyList()), carol, "g1")
@@ -490,18 +553,18 @@ class AuthorityResolverTest {
// Mid-chain: the honest v2 above it still resolves (this arm needs the fix), and the
// forged revoke never empties alice's roles.
val mid = AuthorityResolver.resolve(base + listOf(v0, forgedV1, v2), owner)
val mid = ControlFixtures.resolve(base + listOf(v0, forgedV1, v2), owner)
assertEquals(setOf(modRole), mid.rolesOf(alice))
assertEquals(5L, mid.rank(alice))
// At the tip: the chain-verified head fails the gate, so the fold falls back to v0.
val tip = AuthorityResolver.resolve(base + listOf(v0, forgedV1), owner)
val tip = ControlFixtures.resolve(base + listOf(v0, forgedV1), owner)
assertEquals(setOf(adminRole), tip.rolesOf(alice), "the forged revoke must not strip alice")
assertEquals(1L, tip.rank(alice))
// Above the tip, dangling: a higher version is never a shortcut past the gate.
val danglingV9 = edition(ControlEntityKind.GRANT, grantId, 9, null, grantJson(emptyList()), carol, "g9")
val above = AuthorityResolver.resolve(base + listOf(v0, danglingV9), owner)
val above = ControlFixtures.resolve(base + listOf(v0, danglingV9), owner)
assertEquals(setOf(adminRole), above.rolesOf(alice))
assertEquals(1L, above.rank(alice))
}
@@ -522,7 +585,7 @@ class AuthorityResolverTest {
private val modWithBanJson = """{"name":"Mod","position":5,"permissions":"24"}""" // KICK|BAN
private fun rankedBanScenario(vararg extra: ControlEdition) =
AuthorityResolver.resolve(
ControlFixtures.resolve(
listOf(
role(adminRole, adminJson), // position 1
role(modRole, modWithBanJson), // position 5, holds BAN
@@ -592,7 +655,7 @@ class AuthorityResolverTest {
// PIN_MESSAGES alone (bit 11 = 2048) writes Control editions, so it is a staff bit.
val pinJson = """{"name":"Curator","position":6,"permissions":"2048"}"""
val r =
AuthorityResolver.resolve(
ControlFixtures.resolve(
listOf(
role(adminRole, adminJson), // MANAGE_ROLES|KICK|BAN → staff via MANAGE_ROLES/BAN
role(modRole, modJson), // KICK only → Guestbook writer, NOT staff
@@ -46,8 +46,8 @@ import kotlin.test.assertTrue
* [AuthorityResolver.resolve]. Note that a chain-local rule ("the author must not be banned by the
* state their edition chains from") would NOT have been enough:
* [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan] forks at genesis so no
* parent ever mentions the ban, and CORD-04 §4's re-heal union would carry it in anyway. The rule
* had to bind the union too, which is why it is expressed as a whole-pass mask.
* parent ever mentions the ban. The rule had to bind every fork too, which is why it is expressed as
* a whole-pass mask.
*
* Three tests pin behaviour the fix had to *preserve* rather than change:
* [selfUnbanIsStillRefused], [aJuniorPuppetCannotLiftASeniorsBan], and
@@ -65,11 +65,11 @@ class BannedStaffEscalationTest {
private val modRole = "22".repeat(32)
private val puppetRole = "33".repeat(32)
private val banlistEntity = "44".repeat(32)
private val banlistEntity = ControlFixtures.banlistEid()
private val channelEntity = "55".repeat(32)
private val metadataEntity = "66".repeat(32)
private val bobGrantEntity = "32".repeat(32)
private val puppetGrantEntity = "35".repeat(32)
private val metadataEntity = ControlFixtures.COMMUNITY_ID_HEX
private val bobGrantEntity = ControlFixtures.grantEid(bob)
private val puppetGrantEntity = ControlFixtures.grantEid(puppet)
// MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|KICK|BAN|CREATE_INVITE = 1+2+4+8+16+64
private val adminJson = """{"name":"Admin","position":1,"permissions":"95"}"""
@@ -102,7 +102,8 @@ class BannedStaffEscalationTest {
prev: ByteArray? = null,
) = edition(
ControlEntityKind.GRANT,
coordinate,
// A Grant lives at its member's own coordinate (CORD-04 §1); [coordinate] only names the rumor.
ControlFixtures.grantEid(member),
version,
prev,
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
@@ -167,7 +168,7 @@ class BannedStaffEscalationTest {
@Test
fun aBanStripsTheAuthorityCheckedByFoldButNotTheOneCheckedByTheResolver() {
val r = AuthorityResolver.resolve(community() + ownerBansAlice, owner)
val r = ControlFixtures.resolve(community() + ownerBansAlice, owner)
assertTrue(r.isBanned(alice), "the owner's ban lands")
assertFalse(r.hasPermission(alice, ConcordPermissions.MANAGE_ROLES), "the ban-aware check refuses her")
@@ -182,7 +183,7 @@ class BannedStaffEscalationTest {
@Test
fun aBannedAdminPromotesAFreshSockpuppetToAdmin() {
val r = AuthorityResolver.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner)
val r = ControlFixtures.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner)
assertEquals(null, r.rank(puppet), "the banned admin's role and grant editions are both dropped")
assertFalse(r.isBanned(puppet), "the puppet itself is a clean npub — it is never banned, just powerless")
@@ -200,7 +201,7 @@ class BannedStaffEscalationTest {
channel("""{"name":"general","deleted":true}""", puppet, 1, channelV0.hash) +
metadata("""{"name":"Owned by the guy you banned"}""", puppet, 1, metadataV0.hash)
val state = ConcordCommunityState.fold(editions, owner)
val state = ControlFixtures.fold(editions, owner)
assertEquals(1, state.channels.size, "the puppet holds nothing, so its tombstone is inert")
assertEquals("My Community", state.metadata?.name, "and the community keeps its identity")
@@ -210,7 +211,7 @@ class BannedStaffEscalationTest {
fun theSockpuppetBansEveryMemberBeneathIt() {
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash, alice, bob, carol)
val r = AuthorityResolver.resolve(editions, owner)
val r = ControlFixtures.resolve(editions, owner)
assertFalse(r.isBanned(bob), "the puppet's banlist edition is unauthorized, so the moderator stands")
assertFalse(r.isBanned(carol), "and so do the plain members")
@@ -220,7 +221,7 @@ class BannedStaffEscalationTest {
fun aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll() {
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash, alice, bob, carol)
val r = AuthorityResolver.resolve(editions, owner)
val r = ControlFixtures.resolve(editions, owner)
assertTrue(r.isBanned(alice), "her own ban stands — it was the owner's")
assertFalse(r.isBanned(bob), "banGate now drops a banned author's edition outright")
@@ -231,14 +232,14 @@ class BannedStaffEscalationTest {
fun aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan() {
// The same attack as above, except her edition does NOT chain onto the edition that banned
// her — it forks at genesis. So a rule that only asks "was the author banned by this
// edition's parent?" never sees her ban, and CORD-04 §4's re-heal union carries her bans in
// regardless. Any fix has to bind the union, not just the chain.
// edition's parent?" never sees her ban. The equal-version fork now resolves authority
// first (CORD-04 §1): the owner's edition takes it whatever the rumor ids say.
val editions = community() + ownerBansAlice + banlist(alice, 0, null, bob, carol)
val r = AuthorityResolver.resolve(editions, owner)
val r = ControlFixtures.resolve(editions, owner)
assertTrue(r.isBanned(alice), "the owner's ban survives the fork — the union is down-only")
assertFalse(r.isBanned(bob), "the fix binds the UNION too: her fork is dropped before it can be healed in")
assertTrue(r.isBanned(alice), "the owner's ban wins the fork")
assertFalse(r.isBanned(bob), "her fork loses, and her authorship is masked besides")
assertFalse(r.isBanned(carol), "same")
}
@@ -246,7 +247,7 @@ class BannedStaffEscalationTest {
fun aBannedAdminRevokesTheSurvivingModerators() {
val editions = community() + ownerBansAlice + grant(bobGrantEntity, bob, emptyList(), author = alice, version = 1, prev = bobGrantV0.hash)
val r = AuthorityResolver.resolve(editions, owner)
val r = ControlFixtures.resolve(editions, owner)
assertEquals(5, r.rank(bob), "a banned admin's revoke is dropped, so the moderator keeps their role")
assertTrue(r.hasPermission(bob, ConcordPermissions.BAN), "and keeps the authority that comes with it")
@@ -256,7 +257,7 @@ class BannedStaffEscalationTest {
fun aBannedAdminDeletesEveryRoleBeneathThem() {
val tombstone = role(modRole, """{"name":"Mod","position":5,"permissions":"24","deleted":true}""", author = alice, version = 1, prev = modRoleV0.hash)
val r = AuthorityResolver.resolve(community() + ownerBansAlice + tombstone, owner)
val r = ControlFixtures.resolve(community() + ownerBansAlice + tombstone, owner)
assertEquals(5, r.roles()[modRole]?.position, "a banned admin's tombstone is dropped, so the role survives")
assertEquals(5, r.rank(bob), "and its holders keep their standing")
@@ -268,7 +269,7 @@ class BannedStaffEscalationTest {
// gates removals too, and strict outranking means nobody outranks themselves.
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban")
assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban")
}
@Test
@@ -278,7 +279,7 @@ class BannedStaffEscalationTest {
// can outrank her, and so nothing she mints can unban her.
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator")
assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator")
}
@Test
@@ -289,22 +290,21 @@ class BannedStaffEscalationTest {
// to win the head fold. The head's own effective list then never carried his ban, so there is
// nothing to remove and the rank rule never fires.
//
// §4's re-heal is what closes it: the owner's edition is authorized and is NOT on the forked
// head's back-chain, so it is unioned back in as a concurrent ban.
// A dangling high version never wins the fold: the chain anchors at the genesis (the lowest
// version without a `prev`), and the chain-verified head outranks every edition off it.
val editions = community() + ownerBansAlice + banlist(alice, 99, null, carol)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the re-heal union must put the owner's ban back")
assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "the owner's chain keeps the head")
}
@Test
fun aPrivateBanlistChainOfHisOwnCannotLaunderTheBanAway() {
// The same idea two editions deep, so the winning head has a clean ancestry entirely of his
// own making. Ancestry is walked over the full pool, so the owner's ban is still recognised
// as a concurrent fork rather than a superseded ancestor.
// own making. It is still a chain off the side of the genesis the owner's edition anchors.
val mine = banlist(alice, 50, null)
val editions = community() + ownerBansAlice + mine + banlist(alice, 51, mine.hash)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away")
assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away")
}
@Test
@@ -318,15 +318,15 @@ class BannedStaffEscalationTest {
// whole defense, so this splits the community in two: clients that already folded the ban
// refuse the rollback, while fresh joiners have no floor to refuse with and see no ban at all.
val editions = community() + ownerBansAlice
val floors = ConcordCommunityState.authorizedHeads(editions, owner)
val floors = ControlFixtures.authorizedHeads(editions, owner)
val compacted = editions.filter { it.entityKind != ControlEntityKind.BANLIST }
assertFalse(
ConcordCommunityState.fold(compacted, owner).authority.isBanned(alice),
ControlFixtures.fold(compacted, owner).authority.isBanned(alice),
"ESCALATION: a fresh joiner holds no floor, so the omitted ban simply never existed",
)
assertTrue(
ConcordCommunityState.fold(compacted, owner, floors).authority.isBanned(alice),
ControlFixtures.fold(compacted, owner, floors).authority.isBanned(alice),
"a client that already folded the ban must refuse the rollback",
)
}
@@ -340,10 +340,10 @@ class BannedStaffEscalationTest {
// to re-issue. See B2 in docs/concord-soft-ban-audit.md.
val promoted = grant("36".repeat(32), carol, listOf(modRole), author = alice)
val before = AuthorityResolver.resolve(community() + promoted, owner)
val before = ControlFixtures.resolve(community() + promoted, owner)
assertEquals(5, before.rank(carol), "while alice is in good standing, her grant stands")
val after = AuthorityResolver.resolve(community() + promoted + ownerBansAlice, owner)
val after = ControlFixtures.resolve(community() + promoted + ownerBansAlice, owner)
assertEquals(null, after.rank(carol), "banning alice retroactively drops the grant she authored")
}
@@ -356,7 +356,7 @@ class BannedStaffEscalationTest {
// only honored when authored by someone who outranks it — the owner does, bob does not.
val carolByOwner = grant("38".repeat(32), carol, listOf(modRole), author = owner)
val r = AuthorityResolver.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner)
val r = ControlFixtures.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner)
assertTrue(r.isBanned(alice), "alice is the only one banned")
assertEquals(5, r.rank(bob), "bob is untouched")
@@ -387,7 +387,7 @@ class BannedStaffEscalationTest {
// ...while the owner concurrently bans the rogue, never naming bob
ownerBansAlice
val r = AuthorityResolver.resolve(editions, owner)
val r = ControlFixtures.resolve(editions, owner)
assertTrue(r.isBanned(alice), "the owner's ban of the rogue stands")
assertFalse(r.isBanned(bob), "and the rogue's ban of the moderator falls with them")
@@ -21,11 +21,15 @@
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.EditionHash
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
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.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
@@ -109,6 +113,77 @@ class ControlEditionTest {
)
}
private fun parse(vararg tags: Array<String>) = ControlEdition.fromRumor(RumorAssembler.assembleRumor<Event>(author, 1L, ControlEditionEvent.KIND, arrayOf(*tags), "{}"))
private val cite = arrayOf("vac", ByteArray(32) { 0x02 }.toHexKey(), "2", ByteArray(32) { 0x03 }.toHexKey())
@Test
fun versionTagsMustBeCanonicalDecimals() {
// CORD-01 §5: no sign, no leading zeros. toLongOrNull() would read all of these as 4.
assertNotNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4")))
for (bad in listOf("04", "+4", "-4", "4.0", "0x4", "1e2", "", " 4")) {
assertNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", bad)), "ev '$bad'")
}
assertNotNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")), "a legacy v0 chain still reads")
// The vac version too, and a malformed vac rejects the edition rather than reading as "no citation".
assertEquals(2L, parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"), cite)?.authorityCitation?.grantVersion)
for (bad in listOf("02", "+2")) {
val vac = arrayOf("vac", cite[1], bad, cite[3])
assertNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"), vac), "vac version '$bad'")
}
// And the sub-kind: "03" is not the Grant sub-kind.
assertNull(parse(arrayOf("vsk", "03"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4")))
}
@Test
fun aDuplicatedMachineryTagMakesTheEditionInvalid() {
// Two readers could each take a different copy, so the edition is ambiguous (Armada `parseEdition`).
val base = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"))
assertNotNull(parse(*base))
assertNull(parse(*base, arrayOf("vsk", "2")), "duplicate vsk")
assertNull(parse(*base, arrayOf("eid", ByteArray(32).toHexKey())), "duplicate eid")
assertNull(parse(*base, arrayOf("ev", "5")), "duplicate ev")
assertNull(parse(*base, arrayOf("ep", ByteArray(32).toHexKey()), arrayOf("ep", ByteArray(32) { 1 }.toHexKey())), "duplicate ep")
assertNull(parse(*base, cite, cite), "duplicate vac")
// Even a duplicate that fails to parse on its own: "04" beside "4" is still two ev tags.
assertNull(parse(*base, arrayOf("ev", "04")), "a second, non-canonical ev")
}
@Test
fun subKindsThatAreNotControlEditionsAreRefusedAndUnknownOnesKept() {
// 6/9 belong to the kind-33301 invite marker, 7 is retired, 10 is the dissolution tombstone.
for (vsk in listOf("6", "7", "9", "10")) {
assertNull(parse(arrayOf("vsk", vsk), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1")), "vsk $vsk")
}
// A sub-kind this client does not model (Signals 12, anything newer) is kept, raw.
val signals = parse(arrayOf("vsk", "12"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1"))
assertNotNull(signals)
assertNull(signals.entityKind)
assertEquals("12", signals.vsk)
// Pins (11) are modeled.
assertEquals(ControlEntityKind.PIN_LIST, parse(arrayOf("vsk", "11"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1"))?.entityKind)
assertEquals(ControlEntityKind.CHANNEL.wire, parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1"))?.vsk)
}
@Test
fun anEditionUnderAnEncryptedSealIsNotAControlEdition() =
runTest {
// CORD-02 §5: Control Plane seals MUST be plaintext (20014) — only those survive a
// compaction re-wrap with the author's signature intact.
val signer = NostrSignerInternal(KeyPair())
val plane = ConcordKeyDerivation.controlPlaneKey(ByteArray(32) { 1 }, ByteArray(32) { 2 }, 0)
val rumor = ControlEditionBuilder.rumor(signer.pubKey, ControlEntityKind.CHANNEL, eid, 1, null, """{"name":"general"}""", 1L)
val plaintext = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, plane, signer, encrypted = false, createdAt = 1L), plane)
assertNotNull(ControlEdition.fromOpened(plaintext))
val encrypted = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, plane, signer, encrypted = true, createdAt = 1L), plane)
assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, encrypted.sealKind)
assertNull(ControlEdition.fromOpened(encrypted))
}
@Test
fun genesisHasNullPrevWhenEpAbsent() {
val tags = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0"))
@@ -0,0 +1,93 @@
/*
* 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.cord04Roles
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
class ControlEntityRoundTripTest {
private fun parse(json: String): JsonObject = ConcordJson.instance.parseToJsonElement(json).jsonObject
@Test
fun renameCarriesUnknownMetadataFieldsThrough() {
// CORD-02 §6: another client's `custom`, a newer protocol field, and the CORD-08 timer must
// all survive a rename by a client that models none of them.
val head = """{"name":"Old","message_expiration":2592000,"custom":{"rules":"be nice"},"av_brokers":["https://b.example"]}"""
val renamed = ConcordJson.decodeOrNull<MetadataEntity>(head)!!.copy(name = "New")
val out = parse(ConcordJson.encodePreserving(MetadataEntity.serializer(), renamed, head))
assertEquals("New", (out["name"] as JsonPrimitive).content)
assertEquals(JsonPrimitive(2592000), out["message_expiration"])
assertEquals(parse("""{"rules":"be nice"}"""), out["custom"])
assertEquals(parse(head)["av_brokers"], out["av_brokers"])
}
@Test
fun aModeledFieldCanStillBeCleared() {
val head = """{"name":"X","description":"gone soon","custom":{"k":1}}"""
val cleared = ConcordJson.decodeOrNull<MetadataEntity>(head)!!.copy(description = null)
val out = parse(ConcordJson.encodePreserving(MetadataEntity.serializer(), cleared, head))
assertFalse("description" in out)
assertEquals(parse("""{"k":1}"""), out["custom"])
}
@Test
fun channelEditKeepsLegacyVoiceFlagAndCustom() {
val head = """{"name":"lounge","private":false,"voice":true,"custom":{"topic":"x"}}"""
val renamed = ConcordJson.decodeOrNull<ChannelEntity>(head)!!.copy(name = "hangout")
val out = parse(ConcordJson.encodePreserving(ChannelEntity.serializer(), renamed, head))
assertEquals(JsonPrimitive(true), out["voice"])
assertEquals(parse("""{"topic":"x"}"""), out["custom"])
assertEquals(JsonPrimitive("hangout"), out["name"])
}
@Test
fun genesisHasNothingToPreserve() {
val out = parse(ConcordJson.encodePreserving(ChannelEntity.serializer(), ChannelEntity(name = "general"), null))
assertEquals(JsonPrimitive("general"), out["name"])
}
@Test
fun messageExpirationParsesPerCord08() {
fun secs(json: String) = ConcordJson.decodeOrNull<MetadataEntity>(json)!!.messageExpirationSecs()
assertEquals(2592000L, secs("""{"name":"a","message_expiration":2592000}"""))
assertEquals(86400L, secs("""{"name":"a","message_expiration":86400.9}"""))
assertNull(secs("""{"name":"a"}"""))
assertNull(secs("""{"name":"a","message_expiration":0}"""))
assertNull(secs("""{"name":"a","message_expiration":-5}"""))
assertNull(secs("""{"name":"a","message_expiration":"2592000"}"""))
assertNull(secs("""{"name":"a","message_expiration":{"v":1}}"""))
assertNull(secs("""{"name":"a","message_expiration":null}"""))
}
@Test
fun settingAndClearingTheTimer() {
val on = MetadataEntity(name = "a").withMessageExpiration(604800)
assertEquals(604800L, on.messageExpirationSecs())
val off = on.withMessageExpiration(null)
assertNull(off.messageExpirationSecs())
assertFalse("message_expiration" in parse(ConcordJson.encodePreserving(MetadataEntity.serializer(), off, null)))
}
}
@@ -0,0 +1,102 @@
/*
* 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.cord04Roles
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
/**
* Shared scaffolding for tests that hand-build Control Plane editions.
*
* The fold pins every derived coordinate to the `community_id` (a Grant at its member's
* `grant_locator`, the Banlist at `banlist_locator`, Metadata at the id itself) and honors a
* non-owner edition only when it cites its author's Grant (`vac`, CORD-04 §5). Tests that are
* about something else build editions at [grantEid] / [banlistEid] / [COMMUNITY_ID_HEX] and run
* them through [cited], which stamps every uncited non-owner edition with the citation an honest
* client would have written — its author's Grant head as the fold sees it.
*/
object ControlFixtures {
const val COMMUNITY_ID_HEX = "c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf"
val communityId: ByteArray = COMMUNITY_ID_HEX.hexToByteArray()
/** `grant_locator(community_id, member)` as hex. */
fun grantEid(
member: String,
cid: ByteArray = communityId,
): String = ConcordKeyDerivation.grantCoordinate(cid, member.hexToByteArray()).toHexKey()
/** `banlist_locator(community_id)` as hex. */
fun banlistEid(cid: ByteArray = communityId): String = ConcordKeyDerivation.banlistCoordinate(cid).toHexKey()
/**
* [editions] with every non-owner edition that carries no `vac` given the citation its author
* would have written: their Grant head in the resolved roster. Iterated because a delegated
* granter's own Grant only resolves once the editions above it are cited. Editions that already
* carry a citation (a test's deliberate forgery, say) are left exactly as they are.
*/
fun cited(
editions: List<ControlEdition>,
owner: String,
cid: ByteArray = communityId,
): List<ControlEdition> {
var current = editions
repeat(editions.size + 1) {
val authority = AuthorityResolver.resolve(current, cid, owner)
var changed = false
val next =
current.map { e ->
if (e.authorityCitation != null || e.author.equals(owner, ignoreCase = true)) {
e
} else {
authority.citationFor(e.author)?.let {
changed = true
e.withCitation(it)
} ?: e
}
}
if (!changed) return current
current = next
}
return current
}
fun resolve(
editions: List<ControlEdition>,
owner: String,
cid: ByteArray = communityId,
): AuthorityResolver = AuthorityResolver.resolve(cited(editions, owner, cid), cid, owner)
fun fold(
editions: List<ControlEdition>,
owner: String,
floors: Map<String, EntityFloor> = emptyMap(),
cid: ByteArray = communityId,
): ConcordCommunityState = ConcordCommunityState.fold(cited(editions, owner, cid), cid, owner, floors)
fun authorizedHeads(
editions: List<ControlEdition>,
owner: String,
floors: Map<String, EntityFloor> = emptyMap(),
cid: ByteArray = communityId,
): Map<String, EntityFloor> = ConcordCommunityState.authorizedHeads(cited(editions, owner, cid), cid, owner, floors)
}
@@ -0,0 +1,293 @@
/*
* 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.cord04Roles
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* CORD-04 §1/§2/§5 fold rules the reference client (Armada `control.ts`) enforces and this client
* did not: Grant coordinates bound to their member (S5), authority-first equal-version ties (S7),
* the `vac` authority citation (I4), `role_id` in Role content (I5) and the Role caps (I17).
*/
class ControlPlaneConformanceTest {
private val owner = "0f".repeat(32)
private val alice = "a1".repeat(32)
private val bob = "b2".repeat(32)
private val carol = "c3".repeat(32)
private val adminRole = "11".repeat(32)
private val modRole = "22".repeat(32)
private val cid = ControlFixtures.communityId
private fun ed(
kind: ControlEntityKind,
eid: String,
version: Long,
prev: ControlEdition?,
content: String,
author: String,
rumorId: String,
vac: AuthorityCitation? = null,
) = ControlEdition(kind, eid.hexToByteArray(), version, prev?.hash, vac, content, author, rumorId, version)
private fun roleJson(
name: String,
position: Int,
permissions: String,
roleId: String? = null,
) = if (roleId == null) {
"""{"name":"$name","position":$position,"permissions":"$permissions"}"""
} else {
"""{"role_id":"$roleId","name":"$name","position":$position,"permissions":"$permissions"}"""
}
private fun grantJson(
member: String,
roleIds: List<String>,
) = """{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}"""
// Admin: position 1, MANAGE_ROLES|KICK|BAN = 25. Mod: position 5, KICK|BAN = 24.
private val adminDef = ed(ControlEntityKind.ROLE, adminRole, 1, null, roleJson("Admin", 1, "25"), owner, "role-admin")
private val modDef = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24"), owner, "role-mod")
private val aliceIsAdmin = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(adminRole)), owner, "grant-alice")
private fun banlist(
version: Long,
prev: ControlEdition?,
author: String,
rumorId: String,
vararg banned: String,
vac: AuthorityCitation? = null,
) = ed(ControlEntityKind.BANLIST, ControlFixtures.banlistEid(), version, prev, "[${banned.joinToString(",") { "\"$it\"" }}]", author, rumorId, vac)
private fun citation(
grant: ControlEdition,
version: Long = grant.version,
hash: ByteArray = grant.hash,
) = AuthorityCitation(grant.entityId, version, hash)
// ---- S5: a Grant only counts at its member's own coordinate --------------------------------
@Test
fun aGrantChainAtAForeignCoordinateCannotOverrideTheMembersOwn() {
// grant_locator(community_id, member) is THE Grant coordinate (CORD-04 §1). A second chain for
// the same member anywhere else used to be grouped as its own entity, and whichever chain the
// map iterated last set the member's roles — so a MANAGE_ROLES holder could override the
// owner's revoke, or the owner's grant, by arrival order.
val foreignRevoke = ed(ControlEntityKind.GRANT, "99".repeat(32), 7, null, grantJson(alice, emptyList()), owner, "foreign")
for (order in listOf(listOf(adminDef, aliceIsAdmin, foreignRevoke), listOf(foreignRevoke, adminDef, aliceIsAdmin))) {
val r = AuthorityResolver.resolve(order, cid, owner)
assertEquals(setOf(adminRole), r.rolesOf(alice), "a Grant at a foreign coordinate is not alice's Grant")
}
// And a foreign-coordinate grant alone confers nothing, however well signed.
val foreignGrant = ed(ControlEntityKind.GRANT, "98".repeat(32), 1, null, grantJson(bob, listOf(adminRole)), owner, "foreign-bob")
assertNull(AuthorityResolver.resolve(listOf(adminDef, foreignGrant), cid, owner).rank(bob))
}
// ---- S7: equal-version ties go to authority first ------------------------------------------
@Test
fun anEqualVersionRoleForkGoesToTheOwnerOverALowerRumorId() {
// Alice (Admin, MANAGE_ROLES at position 1) and the owner both edit the Mod role at v2. Alice's
// rumor id sorts first — rumor ids are grindable — but authority decides (CORD-04 §1).
val aliceV2 = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("Alice's", 5, "8"), alice, "0000")
val ownerV2 = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("Owner's", 5, "8"), owner, "ffff")
val r = ControlFixtures.resolve(listOf(adminDef, modDef, aliceIsAdmin, aliceV2, ownerV2), owner)
assertEquals("Owner's", r.roles()[modRole]?.name)
// Among peers of equal rank the lower rumor id still settles it.
val ownerV2b = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("Owner's other", 5, "8"), owner, "0001")
assertEquals("Owner's other", ControlFixtures.resolve(listOf(adminDef, modDef, aliceIsAdmin, ownerV2, ownerV2b), owner).roles()[modRole]?.name)
}
@Test
fun anEqualVersionGrantForkGoesToTheHigherRankedGranter() {
// Bob's Grant at v2: alice (rank 1) makes him Mod, the owner (rank 0) revokes him, same version.
val bobV1 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, emptyList()), owner, "bob-1")
val aliceV2 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 2, bobV1, grantJson(bob, listOf(modRole)), alice, "0000")
val ownerV2 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 2, bobV1, grantJson(bob, emptyList()), owner, "ffff")
val r = ControlFixtures.resolve(listOf(adminDef, modDef, aliceIsAdmin, bobV1, aliceV2, ownerV2), owner)
assertNull(r.rank(bob), "the owner's revoke wins the tie")
}
@Test
fun pickHeadBreaksAnEqualVersionTieOnRankBeforeRumorId() {
val low = ed(ControlEntityKind.CHANNEL, "cc".repeat(32), 3, null, "{}", alice, "0000")
val high = ed(ControlEntityKind.CHANNEL, "cc".repeat(32), 3, null, "{}", owner, "ffff")
val older = ed(ControlEntityKind.CHANNEL, "cc".repeat(32), 2, null, "{}", owner, "0001")
val rank: AuthorRank = { if (it == owner) 0L else 1L }
assertEquals(high, EditionFold.pickHead(listOf(low, high, older), rank) { true })
assertEquals(low, EditionFold.pickHead(listOf(low, high, older), null) { true }, "without a rank: first passing candidate")
assertEquals(low, EditionFold.pickHead(listOf(low, high, older), rank) { it !== high }, "a gated-out sibling never wins")
assertEquals(older, EditionFold.pickHead(listOf(low, high, older), rank) { it === older }, "a lower version only when nothing above passes")
}
// ---- I4: the vac authority citation --------------------------------------------------------
@Test
fun aNonOwnerEditionWithoutACitationIsDropped() {
// Alice holds BAN, but her banlist edition does not cite the Grant she acts under.
val uncited = banlist(1, null, alice, "b1", carol)
assertFalse(AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, uncited), cid, owner).isBanned(carol))
// The identical edition citing her Grant head is honored.
val cited = banlist(1, null, alice, "b1", carol, vac = citation(aliceIsAdmin))
assertTrue(AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, cited), cid, owner).isBanned(carol))
// The owner cites nothing.
assertTrue(AuthorityResolver.resolve(listOf(banlist(1, null, owner, "b1", carol)), cid, owner).isBanned(carol))
}
@Test
fun aChannelEditionWithoutACitationIsDroppedToo() {
// Channels fold under their own gate (the name rule), which must still require the vac.
val channelsRole = "33".repeat(32)
val channelsDef = ed(ControlEntityKind.ROLE, channelsRole, 1, null, roleJson("Chans", 3, ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire()), owner, "role-chans")
val aliceChans = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(channelsRole)), owner, "grant-alice-chans")
val channelId = "ce".repeat(32)
fun channel(vac: AuthorityCitation?) = ed(ControlEntityKind.CHANNEL, channelId, 1, null, """{"name":"news"}""", alice, "chan-1", vac)
val uncited = ConcordCommunityState.fold(listOf(channelsDef, aliceChans, channel(null)), cid, owner)
assertTrue(uncited.channels.isEmpty(), "an uncited channel edition is not honored")
val cited = ConcordCommunityState.fold(listOf(channelsDef, aliceChans, channel(citation(aliceChans))), cid, owner)
assertEquals("news", cited.channels[channelId]?.definition?.name)
}
@Test
fun aCitationMustNameTheActorsOwnGrantAtAVersionAndHashTheVerifierHolds() {
val bobIsMod = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, listOf(modRole)), owner, "grant-bob")
val base = listOf(adminDef, modDef, aliceIsAdmin, bobIsMod)
fun bansCarolCiting(vac: AuthorityCitation) = AuthorityResolver.resolve(base + banlist(1, null, alice, "b1", carol, vac = vac), cid, owner).isBanned(carol)
assertTrue(bansCarolCiting(citation(aliceIsAdmin)))
assertFalse(bansCarolCiting(citation(bobIsMod)), "someone else's Grant is not the actor's authority")
assertFalse(bansCarolCiting(citation(aliceIsAdmin, version = 2)), "a version the verifier has not synced parks the action")
assertFalse(bansCarolCiting(citation(aliceIsAdmin, hash = ByteArray(32) { 7 })), "a forked or forged hash parks it too")
}
@Test
fun aCitationOfASupersededGrantPassesTheSyncFloorButRankStillDecides() {
// The head moved on (compaction discards superseded versions), so an older citation is
// satisfied — the verdict is the CURRENT roster's. Promoted: honored. Demoted: dropped.
val aliceV2 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 2, aliceIsAdmin, grantJson(alice, listOf(adminRole)) + " ", owner, "grant-alice-2")
val ban = banlist(1, null, alice, "b1", carol, vac = citation(aliceIsAdmin))
assertTrue(AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, aliceV2, ban), cid, owner).isBanned(carol))
val demoted = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 2, aliceIsAdmin, grantJson(alice, emptyList()), owner, "grant-alice-revoke")
assertFalse(
AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, demoted, ban), cid, owner).isBanned(carol),
"citing the old valid Grant grandfathers nothing",
)
}
@Test
fun delegatedGrantsNeedTheGranterToCiteTheirOwnGrant() {
// Alice (Admin) makes bob a Mod. Without her citation the grant does not stand.
val bobByAlice = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, listOf(modRole)), alice, "grant-bob")
assertNull(AuthorityResolver.resolve(listOf(adminDef, modDef, aliceIsAdmin, bobByAlice), cid, owner).rank(bob))
val cited = bobByAlice.withCitation(citation(aliceIsAdmin))
assertEquals(5L, AuthorityResolver.resolve(listOf(adminDef, modDef, aliceIsAdmin, cited), cid, owner).rank(bob))
}
@Test
fun authorityCitationsCiteTheActorsFoldedGrantHead() {
val r = AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin), cid, owner)
assertNull(AuthorityCitations.forActor(r, owner), "the owner cites nothing")
assertNull(AuthorityCitations.forActor(r, carol), "nor does someone holding no Grant")
val vac = AuthorityCitations.forActor(r, alice)
assertNotNull(vac)
assertEquals(ControlFixtures.grantEid(alice), vac.grantId.toHexKey())
assertEquals(aliceIsAdmin.version, vac.grantVersion)
assertContentEquals(aliceIsAdmin.hash, vac.grantHash)
assertTrue(AuthorityCitations.isSatisfied(r, alice, vac))
// The editions overload folds the same roster.
assertContentEquals(vac.grantHash, AuthorityCitations.forActor(listOf(adminDef, aliceIsAdmin), cid, owner, alice)?.grantHash)
}
// ---- I5: role_id ---------------------------------------------------------------------------
@Test
fun aRoleWhoseRoleIdNamesAnotherCoordinateIsRefused() {
val grant = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(modRole)), owner, "g")
val matching = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24", roleId = modRole), owner, "r")
assertEquals(5L, AuthorityResolver.resolve(listOf(matching, grant), cid, owner).rank(alice))
val legacy = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24"), owner, "r")
assertEquals(5L, AuthorityResolver.resolve(listOf(legacy, grant), cid, owner).rank(alice), "a legacy role without role_id still reads")
val mismatched = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24", roleId = adminRole), owner, "r")
assertNull(AuthorityResolver.resolve(listOf(mismatched, grant), cid, owner).rank(alice), "role_id must equal the eid")
}
// ---- I17: caps -----------------------------------------------------------------------------
@Test
fun aRoleNamePastSixtyFourBytesFallsBackToThePreviousEdition() {
val renamed = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("m".repeat(65), 5, "24"), owner, "r2")
assertEquals("Mod", ControlFixtures.resolve(listOf(modDef, renamed), owner).roles()[modRole]?.name)
val atCap = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("m".repeat(64), 5, "24"), owner, "r2")
assertEquals("m".repeat(64), ControlFixtures.resolve(listOf(modDef, atCap), owner).roles()[modRole]?.name)
}
private fun roleIdOf(i: Int) = i.toString(16).padStart(64, '0')
@Test
fun aMemberHoldsAtMostSixtyFourRoles() {
val roles = (1..70).map { ed(ControlEntityKind.ROLE, roleIdOf(it), 1, null, roleJson("R$it", 10 + it, "8"), owner, "r$it") }
val everything = (1..70).map { roleIdOf(it) }
val grant = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, everything), owner, "g")
val held = AuthorityResolver.resolve(roles + grant, cid, owner).rolesOf(alice)
assertEquals(everything.take(64).toSet(), held, "the first 64 role_ids, the rest ignored")
}
@Test
fun aCommunityFoldsAtMostOneHundredRolesTheLowestRoleIds() {
val roles = (1..101).map { ed(ControlEntityKind.ROLE, roleIdOf(it), 1, null, roleJson("R$it", 10, "16"), owner, "r$it") }
val lowest = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(roleIdOf(1))), owner, "ga")
val highest = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, listOf(roleIdOf(101))), owner, "gb")
val r = AuthorityResolver.resolve(roles.reversed() + lowest + highest, cid, owner)
assertEquals(100, r.roles().size)
assertTrue(roleIdOf(101) !in r.roles(), "the highest role_id is the one ignored")
assertTrue(r.hasPermission(alice, BAN))
assertFalse(r.hasPermission(bob, BAN), "a grant of an ignored role confers nothing")
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -60,7 +59,7 @@ import kotlin.test.assertTrue
* it now picks the chain head instead.
*
* The banlist was the one entity that survived, and by accident: `AuthorityResolver` folds it with
* its own floor-less chain walk and then re-heals the union across authorized editions, so an
* its own floor-less chain walk, where the chain-verified head outranks a dangling version, so an
* honest ban landed even when the head was poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan]
* keeps pinning that, because it was the only thing standing between this bug and a permanently
* unmoderatable community.
@@ -70,9 +69,9 @@ class ControlPlaneVersionExhaustionTest {
private val bob = "b2".repeat(32)
private val modRole = "22".repeat(32)
private val metadataEntity = "66".repeat(32)
private val metadataEntity = ControlFixtures.COMMUNITY_ID_HEX
private val channelEntity = "55".repeat(32)
private val banlistEntity = "44".repeat(32)
private val banlistEntity = ControlFixtures.banlistEid()
private fun edition(
kind: ControlEntityKind,
@@ -90,7 +89,7 @@ class ControlPlaneVersionExhaustionTest {
vararg rest: ControlEdition,
) = listOf(
edition(ControlEntityKind.ROLE, modRole, 0, null, """{"name":"Mod","position":5,"permissions":"$permissions"}""", owner, "role-mod"),
edition(ControlEntityKind.GRANT, "32".repeat(32), 0, null, """{"member":"$bob","role_ids":["$modRole"]}""", owner, "grant-bob"),
edition(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 0, null, """{"member":"$bob","role_ids":["$modRole"]}""", owner, "grant-bob"),
) + rest
@Test
@@ -99,11 +98,11 @@ class ControlPlaneVersionExhaustionTest {
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0)
// A client that has folded this community once holds a floor for the metadata entity.
val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner)
val floorsBefore = ControlFixtures.authorizedHeads(community, owner)
assertEquals(0, floorsBefore[metadataEntity]?.version, "an ordinary floor at the genesis edition")
val poison = edition(ControlEntityKind.METADATA, metadataEntity, Long.MAX_VALUE, metadataV0.hash, """{"name":"PWNED"}""", bob, "meta-poison")
val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore)
val floorsAfter = ControlFixtures.authorizedHeads(community + poison, owner, floorsBefore)
assertEquals(0, floorsAfter[metadataEntity]?.version, "the floor must not follow a stray to the top of the version space")
// The owner tries to repair it, chaining honestly onto their own genesis.
@@ -112,39 +111,66 @@ class ControlPlaneVersionExhaustionTest {
assertEquals(
"My Community",
ConcordCommunityState.fold(pool, owner).metadata?.name,
ControlFixtures.fold(pool, owner).metadata?.name,
"a fresh joiner walks the chain and is unaffected",
)
assertEquals(
"My Community",
ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name,
ControlFixtures.fold(pool, owner, floorsAfter).metadata?.name,
"a client holding a floor follows the honest chain, not the stray",
)
assertEquals(
"My Community",
ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name,
ControlFixtures.fold(community + repair, owner, floorsAfter).metadata?.name,
"and a Refounding that drops the poison stays repaired",
)
}
/**
* The poison here renames rather than deletes: CORD-03 §2 makes a Channel deletion by any
* authorized holder terminal across the whole accepted edition set (the reference client's
* `everDeleted`), whatever its version, so a max-version *delete* from bob now retires the
* Channel by design. What must still hold is that a max-version edition cannot pin the Channel
* to bob's content.
*/
@Test
fun oneEditionAtMaxVersionNoLongerDeletesAChannel() {
fun oneEditionAtMaxVersionNoLongerPinsAChannel() {
val channelV0 = edition(ControlEntityKind.CHANNEL, channelEntity, 0, null, """{"name":"general"}""", owner, "chan-0")
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire(), channelV0)
val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner)
val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"general","deleted":true}""", bob, "chan-poison")
val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore)
val floorsBefore = ControlFixtures.authorizedHeads(community, owner)
val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"PWNED"}""", bob, "chan-poison")
val floorsAfter = ControlFixtures.authorizedHeads(community + poison, owner, floorsBefore)
val repair = edition(ControlEntityKind.CHANNEL, channelEntity, 1, channelV0.hash, """{"name":"general"}""", owner, "chan-1")
val pool = community + poison + repair
assertEquals(1, ConcordCommunityState.fold(pool, owner).channels.size, "a fresh joiner still sees the channel")
assertEquals(1, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor")
assertEquals(
1,
ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size,
"the channel survives a Refounding too",
"general",
ControlFixtures
.fold(pool, owner)
.channels[channelEntity]
?.definition
?.name,
"a fresh joiner follows the honest chain",
)
assertEquals(
"general",
ControlFixtures
.fold(pool, owner, floorsAfter)
.channels[channelEntity]
?.definition
?.name,
"and so does a client holding a floor",
)
assertEquals(
"general",
ControlFixtures
.fold(community + repair, owner, floorsAfter)
.channels[channelEntity]
?.definition
?.name,
"the channel stays repaired across a Refounding too",
)
}
@@ -152,25 +178,25 @@ class ControlPlaneVersionExhaustionTest {
fun aPoisonedBanlistStillAcceptsTheOwnersBan() {
// This was the saving grace before the fix — the reason the bug was "community with a broken
// name" rather than "community nobody can moderate". AuthorityResolver folds the banlist on
// its own floor-less chain walk and re-heals the union across every authorized edition, so
// the owner's ban landed even while the banlist's floor sat at Long.MAX_VALUE. The floor can
// no longer be poisoned, but keep this: do not "unify" the banlist onto the floored fold
// without replacing the protection.
// its own floor-less chain walk, where the chain-verified head (the owner's v1) outranks any
// dangling higher version, so the owner's ban lands even while the banlist's floor sat at
// Long.MAX_VALUE. (It used to union every authorized fork too; CORD-04 §4 folds to one head.)
// The floor can no longer be poisoned, but keep this.
val banlistV0 = edition(ControlEntityKind.BANLIST, banlistEntity, 0, null, "[]", owner, "ban-0")
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.BAN).toWire(), banlistV0)
val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner)
val floorsBefore = ControlFixtures.authorizedHeads(community, owner)
val poison = edition(ControlEntityKind.BANLIST, banlistEntity, Long.MAX_VALUE, banlistV0.hash, "[]", bob, "ban-poison")
val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore)
val floorsAfter = ControlFixtures.authorizedHeads(community + poison, owner, floorsBefore)
assertEquals(0, floorsAfter[banlistEntity]?.version, "the banlist floor is no longer poisonable either")
val ownerBansBob = edition(ControlEntityKind.BANLIST, banlistEntity, 1, banlistV0.hash, """["$bob"]""", owner, "ban-1")
val pool = community + poison + ownerBansBob
assertTrue(ConcordCommunityState.fold(pool, owner).authority.isBanned(bob), "a fresh joiner honors the ban")
assertTrue(ControlFixtures.fold(pool, owner).authority.isBanned(bob), "a fresh joiner honors the ban")
assertTrue(
ConcordCommunityState.fold(pool, owner, floorsAfter).authority.isBanned(bob),
"the re-heal union must keep the banlist working even with a poisoned floor",
ControlFixtures.fold(pool, owner, floorsAfter).authority.isBanned(bob),
"the chain-verified head keeps the banlist working even with a poisoned floor",
)
}
@@ -182,14 +208,14 @@ class ControlPlaneVersionExhaustionTest {
// so it connects to nothing, and its version is legitimately several ahead of our floor.
val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0")
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0)
val floors = ConcordCommunityState.authorizedHeads(community, owner)
val floors = ControlFixtures.authorizedHeads(community, owner)
val danglingPrev = ByteArray(32) { 0x7f }
val compacted = edition(ControlEntityKind.METADATA, metadataEntity, 4, danglingPrev, """{"name":"Renamed While We Were Away"}""", owner, "meta-compacted")
assertEquals(
"Renamed While We Were Away",
ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + compacted, owner, floors).metadata?.name,
ControlFixtures.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + compacted, owner, floors).metadata?.name,
"a compacted head whose prev dangles by design must still be adopted",
)
}
@@ -200,7 +226,7 @@ class ControlPlaneVersionExhaustionTest {
// missed, so the fold reports a gap and keeps what it already had rather than following it.
val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0")
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0)
val floors = ConcordCommunityState.authorizedHeads(community, owner)
val floors = ControlFixtures.authorizedHeads(community, owner)
val danglingPrev = ByteArray(32) { 0x7f }
val tooFar =
@@ -216,7 +242,7 @@ class ControlPlaneVersionExhaustionTest {
assertEquals(
"My Community",
ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name,
ControlFixtures.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name,
"a jump past the bound is a gap, not a head",
)
}
@@ -0,0 +1,190 @@
/*
* 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.cord04Roles.pins
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcordPinsTest {
private val alice = NostrSignerInternal(KeyPair())
private val mallory = NostrSignerInternal(KeyPair())
private val root = ByteArray(32) { 7 }
private val channelA = "aa".repeat(32)
private val channelB = "bb".repeat(32)
private val plane = ConcordChannelKeys.publicChannel(root, channelA.hexToByteArray(), 3)
private suspend fun sendAndOpen(
rumor: Event,
signer: NostrSignerInternal = alice,
encrypted: Boolean = true,
) = ConcordStreamEnvelope.wrap(rumor, plane, signer, encrypted = encrypted).let { wrap -> wrap.id to ConcordStreamEnvelope.open(wrap, plane) }
@Test
fun theCoordinateMatchesTheSpecDerivation() {
// A.6: hkdf(ikm = community_id, salt = empty, info = "concord/pins" ‖ 0x00 ‖ channel_id), 32 bytes.
// Expected value computed independently with Node's crypto.hkdfSync.
assertEquals(
"13ff5d549aa39c9f11993e2066c9122700a622553fddf22e68c107d3ea00d242",
ConcordPinLists.coordinate("11".repeat(32), "22".repeat(32)),
)
}
@Test
fun aPinnedMessageVerifiesFromTheEntryAlone() =
runTest {
val rumor = ChannelChat.message(alice.pubKey, channelA, 3, "ship it", 1_700_000_000)
val (wrapId, opened) = sendAndOpen(rumor)
val entry = assertNotNull(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, wrapId))
// A reader holding nothing but the entry and the list's channel.
val pin = assertNotNull(ConcordPins.verify(entry, channelA))
assertEquals(rumor.id, pin.rumorId)
assertEquals(alice.pubKey, pin.author)
assertEquals("ship it", pin.content)
assertEquals("3", pin.epoch)
assertEquals(wrapId, pin.wrapHint)
assertFalse(pin.edited)
}
@Test
fun theDisclosureIsOneMessagesKeysNotTheConversationKey() =
runTest {
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "hi", 1))
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
val keys = (entry["keys"] as JsonPrimitive).content
assertEquals(152, keys.length)
assertFalse(keys.contains(plane.conversationKey.toHexKey()))
// The same keys do not open a second message on the same plane.
val (_, other) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "other", 2))
assertNull(PinKeyDisclosure.decryptWith(other.seal.content, PinKeyDisclosure.decode(keys)!!))
}
@Test
fun aPinReplayedIntoAnotherChannelsListFails() =
runTest {
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "private words", 1))
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
assertNull(ConcordPins.verify(entry, channelB), "the channel binding is step 4")
}
@Test
fun tamperedKeysOrSealAreDroppedAlone() =
runTest {
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "x", 1))
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
val badKeys = JsonObject(entry + ("keys" to JsonPrimitive("00".repeat(76))))
assertNull(ConcordPins.verify(badKeys, channelA))
val seal = entry["seal"] as JsonObject
val forgedSeal = JsonObject(seal + ("pubkey" to JsonPrimitive(mallory.pubKey)))
assertNull(ConcordPins.verify(JsonObject(entry + ("seal" to forgedSeal)), channelA))
assertNull(ConcordPins.verify(JsonObject(entry - "keys"), channelA))
}
@Test
fun onlyMessagesAndRepliesArePinnable() =
runTest {
val reaction = ChannelChat.reaction(alice.pubKey, channelA, 3, "cc".repeat(32), alice.pubKey, 9, "+", 1)
val (_, opened) = sendAndOpen(reaction)
assertNull(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null))
}
@Test
fun aPlaintextSealCannotBePinned() =
runTest {
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "x", 1), encrypted = false)
assertNull(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null))
}
@Test
fun anEditByTheAuthorRevisesThePinAndAForeignEditIsIgnored() =
runTest {
val original = ChannelChat.message(alice.pubKey, channelA, 3, "teh plan", 1)
val (_, opened) = sendAndOpen(original)
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
val (_, edit) = sendAndOpen(ChannelChat.edit(alice.pubKey, channelA, 3, original.id, "the plan", 2))
val edited = ConcordPins.withEdit(entry, edit, plane.conversationKey, channelA)
val pin = ConcordPins.verify(edited, channelA)!!
assertTrue(pin.edited)
assertEquals("the plan", pin.content)
assertEquals(original.id, pin.rumorId, "the identity stays the original's")
val (_, foreign) = sendAndOpen(ChannelChat.edit(mallory.pubKey, channelA, 3, original.id, "pwned", 3), signer = mallory)
assertEquals(edited, ConcordPins.withEdit(edited, foreign, plane.conversationKey, channelA), "an unprovable edit never downgrades the entry")
}
@Test
fun publicAndSealedListsRoundTrip() =
runTest {
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "pinned", 1))
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
val public = ConcordPins.read(ConcordPins.serializePublic(listOf(entry))) { null }
assertEquals(listOf(entry), public.entries)
val sealed = ConcordPins.serializeSealed(listOf(entry), plane.conversationKey, 3)
val opened3 = ConcordPins.read(sealed) { epoch -> plane.conversationKey.takeIf { epoch == 3L } }
assertEquals(listOf(entry), opened3.entries)
val noKey = ConcordPins.read(sealed) { null }
assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it")
assertTrue(noKey.entries.isEmpty())
}
@Test
fun capsReadAsEmptyAndRefuseToWrite() =
runTest {
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "p", 1))
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
val tooMany = List(ConcordPins.MAX_ENTRIES + 1) { entry }
assertFailsWith<ConcordPins.PinListTooLargeException> { ConcordPins.serializePublic(tooMany) }
val handMade = """{"entries":[${tooMany.joinToString(",") { it.toString() }}]}"""
assertTrue(ConcordPins.read(handMade) { null }.violating)
assertTrue(ConcordPins.read("x".repeat(ConcordPins.MAX_CONTENT_BYTES + 1)) { null }.violating)
assertTrue(ConcordPins.read("not json") { null }.violating)
}
@Test
fun onlyTheAuthorsDeleteKillsAPin() =
runTest {
val rumor = ChannelChat.message(alice.pubKey, channelA, 3, "oops", 1)
val (_, opened) = sendAndOpen(rumor)
val pin = ConcordPins.verify(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!, channelA)!!
val tags = arrayOf(arrayOf("e", rumor.id), arrayOf("k", "9"))
assertTrue(ConcordPins.killedBy(pin, alice.pubKey, tags))
assertFalse(ConcordPins.killedBy(pin, mallory.pubKey, tags))
}
}
@@ -31,9 +31,12 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
@@ -55,21 +58,14 @@ class ConcordInviteClassifyTest {
name = "Nostrichs",
)
/** A raw kind-33301 event at the link-signer coordinate carrying an arbitrary [vsk] wire value. */
/** A kind-33301 event at the coordinate of [linkSigner], signed by it, carrying an arbitrary [vsk] wire value. */
private fun coordinateEvent(
linkSignerPubKey: String,
linkSigner: ByteArray,
vsk: String,
createdAt: Long,
content: String = "",
) = Event(
id = "00".repeat(32),
pubKey = linkSignerPubKey,
createdAt = createdAt,
kind = ConcordInviteBundleEvent.KIND,
tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }),
content = content,
sig = "00".repeat(64),
)
dTag: String = "",
): Event = NostrSignerSync(KeyPair(privKey = linkSigner)).sign(createdAt, ConcordInviteBundleEvent.KIND, arrayOf(arrayOf("d", dTag), arrayOf(VskTag.TAG_NAME, vsk)), content)
@Test
fun liveBundleOpens() =
@@ -77,7 +73,7 @@ class ConcordInviteClassifyTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token)
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)
assertTrue(status is InviteBundleStatus.Live)
assertEquals(community.communityIdHex, status.invite.communityId)
}
@@ -89,11 +85,11 @@ class ConcordInviteClassifyTest {
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
// A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle.
val tombstone = coordinateEvent(minted.linkSignerPubKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
val tombstone = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.token))
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.linkSignerPubKey, minted.token))
// Order of the fetched list must not matter — newest createdAt wins regardless.
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token))
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.linkSignerPubKey, minted.token))
}
@Test
@@ -111,7 +107,7 @@ class ConcordInviteClassifyTest {
// Both fetch orders must resolve to the re-mint — `fetchAll` gives no ordering guarantee.
listOf(listOf(minted.bundleEvent, remint), listOf(remint, minted.bundleEvent)).forEach { wraps ->
val status = ConcordInviteBundle.classify(wraps, minted.token)
val status = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token)
assertTrue(status is InviteBundleStatus.Live)
assertEquals("bb".repeat(32), status.invite.communityRoot)
assertEquals(2L, status.invite.rootEpoch)
@@ -123,8 +119,9 @@ class ConcordInviteClassifyTest {
runTest {
// A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the
// relayop.xyz link that hung — is present but not a vsk=6 bundle we can open.
val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16)))
val signer = KeyPair()
val registry = coordinateEvent(signer.privKey!!, ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), signer.pubKey.toHexKey(), ByteArray(16)))
}
/**
@@ -152,11 +149,11 @@ class ConcordInviteClassifyTest {
val wraps = listOf(minted.bundleEvent)
// Before the expiry the very same bundle still opens…
val live = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs - 1)
val live = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs - 1)
assertTrue(live is InviteBundleStatus.Live)
// …and after it, the join path must refuse it (not Live) while the preview data survives.
val expired = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs + 1)
val expired = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs + 1)
assertTrue(expired is InviteBundleStatus.Expired)
assertEquals(community.communityIdHex, expired.invite.communityId)
}
@@ -167,12 +164,12 @@ class ConcordInviteClassifyTest {
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live)
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live)
}
@Test
fun emptyFetchIsAbsent() {
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16)))
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), "aa".repeat(32), ByteArray(16)))
}
@Test
@@ -201,12 +198,12 @@ class ConcordInviteClassifyTest {
// End to end: what the creator publishes is what every redeemer then resolves.
val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L)
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.token))
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.linkSignerPubKey, minted.token))
// And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the
// refresh path must skip a coordinate it did not resolve Live first.
val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L)
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.token) is InviteBundleStatus.Live)
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.linkSignerPubKey, minted.token) is InviteBundleStatus.Live)
}
@Test
@@ -218,6 +215,86 @@ class ConcordInviteClassifyTest {
"""{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}"""
val event = Event.fromJson(json)
val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray()
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), token))
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), event.pubKey, token))
}
// ---- S14: the relay filter is a hint, not a proof (CORD-05 §2) ----------------------
@Test
fun aForgedNewerRevocationByAnotherKeyDoesNotRevoke() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
// A relay serves a newer vsk=9 "at the coordinate" — but signed by someone else.
val forger = KeyPair()
val forged = coordinateEvent(forger.privKey!!, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token))
}
@Test
fun aNewerRevocationClaimingTheSignerButBadlySignedDoesNotRevoke() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
// Right pubkey, garbage signature: exactly what a relay could fabricate without the secret.
val forged =
Event(
id = "00".repeat(32),
pubKey = minted.linkSignerPubKey,
createdAt = 2L,
kind = ConcordInviteBundleEvent.KIND,
tags = arrayOf(arrayOf("d", ""), arrayOf(VskTag.TAG_NAME, ControlEntityKind.INVITE_REVOKED.wire)),
content = "",
sig = "00".repeat(64),
)
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token))
}
@Test
fun aRevocationAtAnotherDTagIsNotThisCoordinate() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
// Genuinely signed by the link signer, but at a different `d` — a different coordinate.
val elsewhere = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L, dTag = "x")
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, elsewhere), minted.linkSignerPubKey, minted.token))
}
@Test
fun aBundleFromTheWrongAuthorIsAbsent() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(listOf(minted.bundleEvent), "aa".repeat(32), minted.token))
}
// ---- S11: bundle bounds (CORD-05 §1) --------------------------------------------------
@Test
fun aBundleNamingTooManyChannelsIsRefused() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val huge = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS + 1) { InviteChannel(id = it.toString(), epoch = 0) })
val minted = ConcordInviteBundle.mintLink("https://vector.chat", huge, createdAt = 1L)
assertNull(ConcordInviteBundle.bound(huge))
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token))
val atCap = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS) { InviteChannel(id = it.toString(), epoch = 0) })
val ok = ConcordInviteBundle.mintLink("https://vector.chat", atCap, createdAt = 1L)
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(ok.bundleEvent), ok.linkSignerPubKey, ok.token))
}
@Test
fun aBundlesRelaysAreTruncatedToTheCommunityCap() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val relays = List(40) { "wss://r$it.example" }
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community).copy(relays = relays + relays), createdAt = 1L)
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)
assertIs<InviteBundleStatus.Live>(status)
assertEquals(relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), status.invite.relays)
}
}
@@ -89,7 +89,7 @@ class ConcordInviteJoinFlowTest {
)
assertFalse(controlPlane.canWrite, "an invite must never hand a joiner the write key")
val editions = community.genesisWraps.mapNotNull { ControlEdition.fromRumor(ConcordStreamEnvelope.open(it, controlPlane).rumor) }
val state = ConcordCommunityState.fold(editions, invite.owner)
val state = ConcordCommunityState.fold(editions, invite.communityId.hexToByteArray(), invite.owner)
assertEquals("Nostrichs", state.metadata?.name)
assertTrue(state.channels.isNotEmpty()) // #general is visible to the new member
}

Some files were not shown because too many files have changed in this diff Show More