fix(concord): private channels on their own keys, in-channel deletes

S2 — a `private: true` channel was keyed from community_root at the root
epoch, so posts under the Lock icon landed on a plane every member
decrypts. Now (CORD-03 §1):

- ConcordActions.currentChannelPlane / historicalChannelPlanes derive a
  private channel only from the held key in the Community List entry's
  `privateChannels`, bound to the channel epoch; with no key there is no
  plane at all — never the root-derived one.
- The session, subscription planner, plane registry, the app's verbs
  (send, image, reply, react, edit, typing) and amy all resolve planes
  through it. A key delivered later is adopted in place
  (ConcordCommunitySession.adoptPrivateChannels), and keys an invite
  carries are stored on join.
- A keyless private channel is locked: ConcordChannel.keyHeld /
  canPost() false, the composer is replaced by a notice, and
  `amy concord send` fails with `no_channel_key` (`channels` reports
  `readable`). Creating private channels (key delivery) stays open (F7).

S3 — deleting your own Concord message went out as a NIP-17 kind 5 to
the p-tagged users, leaking the rumor id outside the community, and
never reached the channel. Account.delete / deletePrivately now route
Concord notes (messages, replies, reactions) to
AccountConcordActions.deleteConcordRumors: the in-stream kind 5, sealed
20013 on the plane of each target's bound epoch. Tapping your own
Concord reaction again retracts it the same way.

S9 — chat ingest (session, typing, ConcordActions.channelRumors) goes
through ChannelChat.acceptOpened, and EventCache.consumeConcordRumor
refuses non-chat kinds as defense in depth. channelMessages orders by
created_at*1000+ms.

CORD-03 §3 — your own kind-1111 thread replies can be edited like
kind-9 messages.

Review statuses updated for S2, S3, S9 (chat half), S10, I13-I16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
Claude
2026-09-29 15:21:33 +00:00
parent e94bb2f637
commit 4714144bf0
20 changed files with 789 additions and 125 deletions
@@ -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
@@ -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),
+3 -3
View File
@@ -676,9 +676,9 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
| `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 Community List — the kind:33302 fragments plus the retired kind:13302 (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 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. |
| `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. |
@@ -57,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, "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),
)
},
),
)
@@ -80,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))
@@ -127,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))
@@ -27,6 +27,7 @@ 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
@@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
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.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
@@ -248,6 +250,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(
@@ -477,6 +480,7 @@ 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) ?: "",
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
),
)
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays))
@@ -546,6 +550,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 },
@@ -564,6 +569,7 @@ 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) },
)
/**
@@ -483,12 +483,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
@@ -53,6 +53,18 @@ 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(),
)
/** 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 past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
@@ -29,6 +29,7 @@ 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.ControlEdition
@@ -68,15 +69,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 +147,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*
@@ -344,6 +435,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 +485,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 +495,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 +514,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 --------------------------------------------------------------
@@ -122,24 +122,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
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
@@ -66,6 +67,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCon
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
@@ -560,6 +562,9 @@ class AccountConcordActions(
// Read access to the Control Plane, never write (CORD-05 §1). Absent = the
// community is still pre-split, so we fold it at the legacy address.
controlPk = bundle.controlPk,
// Private Channel keys the invite delivered (CORD-03 §1 / CORD-05 §1), so those
// channels are read and written on their own planes from the first fold.
privateChannels = ConcordActions.privateChannelKeysOf(bundle),
relays = bundle.relays,
name = bundle.name,
addedAt = TimeUtils.now() * 1000,
@@ -591,7 +596,10 @@ class AccountConcordActions(
if (!account.isWriteable()) return false
val session = account.concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
// The channel's own plane: root-derived for a Public Channel, its held key for a Private one,
// and no plane at all (refuse) for a Private Channel whose key we do not hold (CORD-03 §1).
val plane = session.currentChannelPlane(channelIdHex) ?: return false
val channelKey = plane.key
// NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the
// custom image everywhere (the kind-9 rumor carries them; recipients render via the tags).
@@ -608,13 +616,13 @@ class AccountConcordActions(
// the user attached media); an inline reply is a kind-9 message quoting the parent; a
// fresh post is a plain kind-9 message.
parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() ->
ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags)
ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, imetas, TimeUtils.now(), emojiTags)
parent != null && replyMode == ReplyMode.MINICHAT ->
ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags)
parent != null ->
ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags)
else ->
ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags)
ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, TimeUtils.now(), emojiTags)
}
sendConcordChannelWrap(entry, channelKey, wrap)
return true
@@ -636,14 +644,15 @@ class AccountConcordActions(
if (!account.isWriteable()) return false
val session = account.concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val plane = session.currentChannelPlane(channelIdHex) ?: return false
val channelKey = plane.key
// Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message.
val emojiTags =
account.emoji
.findEmojiTags(text)
.map { it.toTagArray() }
.toTypedArray()
val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags)
val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, imetas, TimeUtils.now(), emojiTags)
sendConcordChannelWrap(entry, channelKey, wrap)
return true
}
@@ -665,9 +674,11 @@ class AccountConcordActions(
val target = note.event ?: return false
val communityId = channel.channelId.communityId
val channelIdHex = channel.channelId.channelId
val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false
val session = account.concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val plane = session.currentChannelPlane(channelIdHex) ?: return false
val channelKey = plane.key
// A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to
// resolve to an image on the other side; a plain unicode/`+` reaction yields no tags.
val emojiTags =
@@ -675,7 +686,7 @@ class AccountConcordActions(
.findEmojiTags(reaction)
.map { it.toTagArray() }
.toTypedArray()
val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags)
val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, plane.epoch, target, reaction, TimeUtils.now(), emojiTags)
publishConcordWrap(entry, wrap)
return true
}
@@ -686,7 +697,8 @@ class AccountConcordActions(
* message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays
* inside the encrypted channel (a public edit would e-tag the private rumor id onto
* public relays). The receiving side overlays the newest edit onto the target message;
* only the *original author's* edits are applied, so we gate to my own kind-9 messages.
* only the *original author's* edits are applied, so we gate to my own messages — a kind-9
* message or a kind-1111 thread reply (CORD-03 §3: edits target either by rumor id).
* Returns false if [note] isn't an editable Concord message I authored.
*/
suspend fun editConcordChannelMessage(
@@ -696,25 +708,87 @@ class AccountConcordActions(
if (!account.isWriteable()) return false
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false
val target = note.event ?: return false
// Edits only apply to plain kind-9 messages, and only the author may edit their own.
if (target !is ChatEvent || target.pubKey != account.signer.pubKey) return false
// Edits apply to messages and thread replies, and only the author may edit their own.
if (!isConcordEditable(target)) return false
val communityId = channel.channelId.communityId
val channelIdHex = channel.channelId.channelId
val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false
val session = account.concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val plane = session.currentChannelPlane(channelIdHex) ?: return false
val channelKey = plane.key
// Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message.
val emojiTags =
account.emoji
.findEmojiTags(newText)
.map { it.toTagArray() }
.toTypedArray()
val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags)
val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags)
publishConcordWrap(entry, wrap)
return true
}
/** True when [target] is a Concord message this account may edit: my own kind-9 message or kind-1111 reply. */
fun isConcordEditable(target: Event): Boolean = (target is ChatEvent || target is CommentEvent) && target.pubKey == account.signer.pubKey
/**
* The Concord channel [note] belongs to: its own gatherer for a message or thread reply, else
* (a reaction, a delete) the channel of the note it points at. Null when it is not Concord.
*/
fun concordChannelOf(note: Note): ConcordChannel? =
note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }
?: note.replyTo?.firstNotNullOfOrNull { target -> target.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } }
/**
* Delete my own Concord rumors [notes] (messages, thread replies, reactions) in [channel] the
* way CORD-01 prescribes: a kind-5 rumor with `e` + `k` tags, sealed (20013) and wrapped on the
* channel's own plane, so only the community sees it. Never a signed kind 5 or a NIP-17 delete,
* both of which would carry the rumor ids to people and relays outside the community.
*
* Each target is retracted on the plane that carried it (its bound epoch), falling back to the
* channel's current plane when this account no longer holds that one. A member's delete of
* their own message stays honored after Dissolution (CORD-02 §9), so this is not gated on it.
* Returns false when nothing could be sent (not writeable, no session, no plane, no own notes).
*/
suspend fun deleteConcordRumors(
channel: ConcordChannel,
notes: List<Note>,
): Boolean {
if (!account.isWriteable()) return false
val session = account.concordSessions.sessionFor(channel.channelId.communityId) ?: return false
val channelIdHex = channel.channelId.channelId
val mine = notes.mapNotNull { it.event }.filter { it.pubKey == account.signer.pubKey }.distinctBy { it.id }
if (mine.isEmpty()) return false
val current = session.currentChannelPlane(channelIdHex)
val byPlane =
mine.groupBy { target ->
target.tags.concordEpoch()?.let { session.channelPlaneFor(channelIdHex, it) } ?: current
}
var sent = false
for ((plane, targets) in byPlane) {
if (plane == null) continue
val wrap = ConcordActions.buildChannelDelete(account.signer, plane.key, channelIdHex, plane.epoch, targets, TimeUtils.now())
publishConcordWrap(session.entry, wrap)
sent = true
}
return sent
}
/**
* Toggle my [reaction] on Concord message [note]: retract my existing reactions of that content
* with an in-channel delete, else add it ([reactToConcordMessage]). Returns false when nothing
* was sent.
*/
suspend fun toggleConcordReaction(
note: Note,
reaction: String,
): Boolean {
val channel = concordChannelOf(note) ?: return false
val mine = note.allReactionsOfContentByAuthor(account.userProfile(), reaction)
return if (mine.isNotEmpty()) deleteConcordRumors(channel, mine) else reactToConcordMessage(note, reaction)
}
/**
* Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at
* most every few seconds while composing. Not folded locally (we never show our own typing);
@@ -736,8 +810,8 @@ class AccountConcordActions(
return
}
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now())
val plane = session.currentChannelPlane(channelIdHex) ?: return
val wrap = ConcordActions.buildChannelTyping(account.signer, plane.key, channelIdHex, plane.epoch, TimeUtils.now())
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isNotEmpty()) account.client.publish(wrap, relays)
}
@@ -1478,6 +1552,8 @@ class AccountConcordActions(
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
val channelId = RandomInstance.bytes(32)
val channel = ChannelEntity(name = name.trim())
// Readers drop an empty or over-64-byte name (CORD-03 §2); never mint one.
if (!channel.hasValidName()) return false
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
@@ -1501,6 +1577,7 @@ class AccountConcordActions(
?.get(channelIdHex)
?.definition
val channel = (standing ?: ChannelEntity()).copy(name = name.trim())
if (!channel.hasValidName()) return false
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
@@ -1523,6 +1600,7 @@ class AccountConcordActions(
?.get(channelIdHex)
?.definition
val channel = (standing ?: ChannelEntity()).copy(name = name.trim(), deleted = true)
if (!channel.hasValidName()) return false
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
@@ -138,6 +138,7 @@ 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
@@ -971,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).
@@ -55,6 +55,14 @@ class ConcordChannel(
var isPrivate: Boolean = false
private set
/**
* 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. */
var communityName: String? = null
private set
@@ -107,6 +115,7 @@ 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.
@@ -117,6 +126,7 @@ class ConcordChannel(
val newCommunityBanner = state.metadata?.banner
val newMembership = ConcordMembership.of(state.authority, myPubKey)
val newDissolved = state.dissolved
val newKeyHeld = !newPrivate || keyHeld
val changed =
channelName != newChannelName ||
@@ -125,7 +135,8 @@ class ConcordChannel(
communityIcon != newCommunityIcon ||
communityBanner != newCommunityBanner ||
membership != newMembership ||
dissolved != newDissolved
dissolved != newDissolved ||
this.keyHeld != newKeyHeld
channelName = newChannelName
isPrivate = newPrivate
@@ -135,6 +146,7 @@ class ConcordChannel(
communityRelays = relays
membership = newMembership
dissolved = newDissolved
this.keyHeld = newKeyHeld
return changed
}
@@ -148,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,6 +20,7 @@
*/
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
@@ -239,14 +240,20 @@ class ConcordCommunitySession(
private val guestbookWraps = LinkedHashMap<HexKey, Event>()
private val baseRekeyWraps = 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
@@ -333,7 +340,26 @@ class ConcordCommunitySession(
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
@@ -344,8 +370,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)
}
@@ -380,9 +406,9 @@ 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 }
}
/**
@@ -435,6 +461,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
@@ -503,15 +556,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)
}
}
}
@@ -551,8 +602,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 —
@@ -588,31 +641,32 @@ class ConcordCommunitySession(
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.withDissolved(dissolved)
folded.channels.keys.filterNot { it in prevChannels }
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)
}
@@ -677,9 +731,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)
}
/**
@@ -707,6 +761,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
@@ -0,0 +1,307 @@
/*
* 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,
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, secretId, ChannelEntity(name = "x".repeat(65)), community.genesisEditions, 2L, owner = community.ownerPubKey)
}
assertFailsWith<IllegalArgumentException> {
ConcordModeration.defineChannel(owner, community.controlPlane, secretId, ChannelEntity(name = ""), community.genesisEditions, 2L, owner = community.ownerPubKey)
}
}
}
@@ -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
}
@@ -41,15 +41,15 @@ Ranked security > interop > feature inside each group.
| # | 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 | open → chat-plane batch |
| 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 | open → chat-plane batch |
| 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 | open → rekey/invite batch |
| 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 | open → control-plane batch |
| 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 | open → control-plane batch |
| 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 | open → control-plane batch |
| S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | open → control-plane batch |
| 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` | open → control-plane + chat-plane batches |
| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | open → chat-plane batch |
| 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` | chat half **fixed** — `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. Control half → control-plane batch |
| 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 | open → rekey/invite batch |
| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | open → rekey/invite batch |
| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | open → rekey/invite batch |
@@ -71,10 +71,10 @@ Ranked security > interop > feature inside each group.
| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | open → rekey/invite batch |
| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | open → rekey/invite batch |
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | open → rekey/invite batch |
| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | open → chat-plane batch |
| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | open → chat-plane batch |
| I15 | 02 §4 | No `ms` tag on chat rumors | open → chat-plane batch |
| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | open → chat-plane batch |
| 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 | open → control-plane batch |
| 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 | open → rekey/invite batch |