mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+4
-3
@@ -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
|
||||
|
||||
+12
-2
@@ -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
@@ -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. |
|
||||
|
||||
+30
-5
@@ -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`. */
|
||||
|
||||
+133
-13
@@ -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 --------------------------------------------------------------
|
||||
|
||||
|
||||
+18
-14
@@ -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
|
||||
|
||||
|
||||
+96
-18
@@ -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
|
||||
|
||||
+6
@@ -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).
|
||||
|
||||
+17
-2
@@ -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
|
||||
|
||||
+97
-41
@@ -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
|
||||
}
|
||||
|
||||
+8
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+307
@@ -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>
|
||||
|
||||
+3
-3
@@ -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 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user