Merge remote-tracking branch 'origin/main' into claude/vigilant-ptolemy-ggxtwn

This commit is contained in:
Claude
2026-09-30 02:09:37 +00:00
67 changed files with 6409 additions and 398 deletions
@@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -94,6 +95,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.NoteActionHandlers
import com.vitorpamplona.amethyst.commons.ui.note.elements.ShareOptionsBottomSheet
import com.vitorpamplona.amethyst.commons.ui.note.elements.noteActionSections
import com.vitorpamplona.amethyst.commons.ui.note.elements.observeBookmarksFollowsAndAccount
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordExpiringPinDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.navigateToReloadMint
@@ -110,12 +112,15 @@ import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid
import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability
import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
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
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.uuid.ExperimentalUuidApi
// null amount = open the on-chain dialog with no prefill.
@@ -393,17 +398,38 @@ fun ChatMessageActionSheet(
// Concord (CORD-04 §7): pin/unpin into the channel's Pin List. Only offered to a
// PIN_MESSAGES holder who can write the Control Plane (null otherwise).
val concordPinned = remember(note) { accountViewModel.account.concord.concordPinState(note) }
// Read off the main thread: it verifies the channel's whole Pin List.
val concordPinState by produceState<Boolean?>(null, note) {
value = withContext(Dispatchers.Default) { accountViewModel.account.concord.concordPinState(note) }
}
val concordPinned = concordPinState
if (concordPinned != null && !note.isDraft()) {
var confirmExpiringPin by remember(note) { mutableStateOf(false) }
SectionDivider()
TileRow {
val label = if (concordPinned) Res.string.relay_group_unpin_message else Res.string.relay_group_pin_message
ActionTile(MaterialSymbols.PushPin, stringRes(label)) {
// Pinning a disappearing message (CORD-08) keeps its words past the timer: ask first.
val expires = note.event?.let { ConcordDisappearing.expirationOf(it) } != null
if (!concordPinned && expires) {
confirmExpiringPin = true
} else {
accountViewModel.toggleConcordPin(note)
onDismiss()
}
}
}
if (confirmExpiringPin) {
ConcordExpiringPinDialog(
onConfirm = {
confirmExpiringPin = false
accountViewModel.toggleConcordPin(note)
onDismiss()
},
onDismiss = { confirmExpiringPin = false },
)
}
}
}
}
@@ -88,7 +88,6 @@ import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.concordTimerText
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinDuties
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedButton
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedMessagesSheet
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
@@ -186,10 +185,9 @@ fun ConcordChannelScreen(
newMessageModel.init(accountViewModel)
newMessageModel.load(communityId, channelId)
// CORD-04 §7 Pins: the header's entry point, the sheet it opens, the jump it requests, and the
// delayed duty writes (deletion omission / Edit refresh) a PIN_MESSAGES holder owes.
// CORD-04 §7 Pins: the header's entry point, the sheet it opens and the jump it requests. The
// delayed duty writes a PIN_MESSAGES holder owes run from the account (scheduleConcordPinDuties).
val pins by rememberConcordChannelPins(communityId, channelId, accountViewModel)
ConcordPinDuties(communityId, channelId, pins, accountViewModel)
var showPins by remember { mutableStateOf(false) }
val jumpToNoteId = remember { mutableStateOf<String?>(null) }
pins?.let { current ->
@@ -199,6 +197,7 @@ fun ConcordChannelScreen(
channelId = channelId,
pins = current,
accountViewModel = accountViewModel,
nav = nav,
onJumpToMessage = { jumpToNoteId.value = it },
onDismiss = { showPins = false },
)
@@ -208,7 +207,7 @@ fun ConcordChannelScreen(
Scaffold(
topBar = {
TopAppBar(
actions = { ConcordPinnedButton(pins) { showPins = true } },
actions = { ConcordPinnedButton(communityId, pins, accountViewModel) { showPins = true } },
title = {
Column {
Text(channel.toBestDisplayName(), maxLines = 1)
@@ -332,7 +331,11 @@ private fun ConcordTimerIndicator(
communityId: String,
accountViewModel: AccountViewModel,
) {
val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } ?: return
// Re-resolved on every session-set change: the session may not exist yet at first composition, and
// a Refounding replaces it (a captured one would keep reading the dead epoch's fold).
val sessions = accountViewModel.account.concordSessions
val revision by sessions.revision.collectAsStateWithLifecycle()
val session = remember(communityId, revision) { sessions.sessionFor(communityId) } ?: return
val state by session.state.collectAsStateWithLifecycle()
val secs = state?.metadata?.messageExpirationSecs() ?: return
Text(
+10 -5
View File
@@ -677,6 +677,10 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
| `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; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). |
| `amy concord channel create COMMUNITY NAME [--private [--role NAME]]` | Create a channel (MANAGE_CHANNELS). `--private` gives it its own independent key at channel epoch 0 (stored before anything publishes) plus a bit-less access Role scoped to it (CORD-04 §2, default name = the channel's); nobody holds that Role yet — `concord grant` it to let members read. |
| `amy concord channel privatize COMMUNITY CHANNEL [--role NAME]` | Convert a Public channel to Private (CORD-03 §2): a fresh key at the next channel epoch — floored at the highest channel rotation found on the wire, refused (`inconclusive`) past 32 — plus an access Role, then the flag. Protects the future only. |
| `amy concord channel publicize COMMUNITY CHANNEL` | Convert a Private channel back to Public (flag only); the held key stays so the private era keeps reading. |
| `amy concord channel rekey COMMUNITY CHANNEL` | Rotate a Private channel's key (CORD-06 §1-2) to exactly the members its Roles entitle today plus us: 72-byte scope-bound blobs at the channel-rekey address, `vac` on every chunk. Needs MANAGE_CHANNELS and outranking every cut role holder; the key is reserved in `concord.json` so a re-run re-delivers the same one. |
| `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). Banned members' messages are left out and counted in `hidden_banned`. |
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator), then publish this account's Invite Registry (`vsk 8`, CORD-05 §5) listing its live link signers — expired links pruned. Output adds `registry_published`, `public` and `live_invite_links`. |
@@ -686,15 +690,16 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
| `amy concord decline WRAP-ID` | Discard a Direct Invite; its wrap id is remembered in `concord-invites.json` so it never resurfaces. |
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, records it in your Invite List, then republishes your Invite Registry without it. When it was the community's last live link the output carries `privatized: true` / `refound_required: true`: the community is Private now, and `concord refound COMMUNITY --privatize` rotates its keys (CORD-05 §2). |
| `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). |
| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). |
| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). Then follows every held private channel's own rotations (`channel_rekeys`): a complete, honored rotation off the key we hold is adopted; one from a rotator who outranks us that leaves us out drops the key and records the cut, so no older key comes back. |
| `amy concord recover [COMMUNITY] [--rejoin]` | Report whether a Refounding left us behind (our joined-through link resolves to a higher epoch). A bundle never moves the base on its own (CORD-06 §2); `--rejoin` explicitly re-accepts the link. Ban-gated, fails closed. |
| `amy concord refound COMMUNITY --remove U[,U…]` / `--privatize` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). |
| `amy concord refound COMMUNITY --remove U[,U…]` / `--privatize` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after, then rotates every held private channel to its entitled kept set, sealed under the prior root (`channels_rotated`); reserves its keys so a re-run resumes with the same root; refused for a dissolved community. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). |
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04), plus the community's mode from the folded Invite Registries (CORD-05 §5): `public` (true while any live invite link exists), `live_invite_links`, and `invite_registries` (links per creator). |
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). |
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. |
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. Private-channel keys follow the Grant (CORD-03/06): every channel it opens to a member is vended to them by Direct Invite carrying only those channels (`channel_keys_vended`); every channel it closes is rotated (`channels_rotated`, or `channels_not_rotated` with the reason). Only keys this account holds can move (`channels_not_held`). |
| `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. A ban reports `public` and `refound_required`: a Public ban is the Banlist alone, while a ban from a Private community owes a Refounding (`concord refound COMMUNITY --remove USER`, CORD-06 §3). |
| `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). |
| `amy concord pin COMMUNITY CHANNEL RUMOR_ID` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. |
| `amy concord kick COMMUNITY USER` | Cooperative Kick (CORD-04 §6): strips the member's roles first (when you may — MANAGE_ROLES + outrank), then publishes the Guestbook KICK (kind 3309) citing your Grant. Needs KICK and a strict outrank. Reports `roles_stripped`; changes no key — the member can rejoin, and a compliant client leaves on its own. |
| `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). Expired (CORD-08) pinned messages are hidden like deleted ones. |
| `amy concord pin COMMUNITY CHANNEL RUMOR_ID [--force]` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). A disappearing message (one carrying a CORD-08 `expiration`) is refused with `expiring_message` unless `--force`: the pin would keep its words past the timer. Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. |
| `amy concord dissolve COMMUNITY --yes` | Owner only, irreversible: publish the `eid`-bound dissolution tombstone that seals the community read-only (CORD-02 §9). |
| `amy concord timer COMMUNITY [off\|SECONDS\|1d\|1w\|30d\|90d\|1y]` | CORD-08 disappearing messages. No value: print the folded timer (`0` = off). With one: publish the metadata edition (MANAGE_METADATA) and a kind-1740 notice into every channel whose key we hold. While a timer is set, `send` signs a NIP-40 `expiration` into the rumor and repeats it on the wrap; `read` drops expired messages. |
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
@@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
@@ -79,6 +81,15 @@ object ConcordCommands {
| concord import fetch + decrypt this account's kind:33302
| community list (carries heldRoots, CORD-06)
| concord channels COMMUNITY list a community's channels
| concord channel create COMMUNITY NAME create a channel (MANAGE_CHANNELS); --private
| [--private [--role NAME]] gives it its own key at channel epoch 0 plus a
| bit-less access Role (default: the channel name);
| grant that Role to let members read it
| concord channel privatize COMMUNITY CHANNEL convert a Public channel to Private: a fresh key
| [--role NAME] at the next channel epoch + an access Role
| concord channel publicize COMMUNITY CHANNEL convert a Private channel back to Public (flag only)
| concord channel rekey COMMUNITY CHANNEL rotate a Private channel's key to exactly the
| members its Roles entitle today (CORD-06)
| concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id)
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50);
| [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane
@@ -96,7 +107,9 @@ object ConcordCommands {
| it in your invite list so it stays retired
| concord join URL redeem an invite link and save the community
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for:
| open our blob and adopt the new epoch
| open our blob and adopt the new epoch; then
| follow each held private channel's rotations
| (adopt the new key, or drop it when cut)
| concord recover [COMMUNITY] [--rejoin] re-resolve the joined-through invite link and
| report whether a Refounding left us behind;
| --rejoin re-accepts that link (a bundle never
@@ -106,8 +119,12 @@ object ConcordCommands {
| and public: true/false + live invite links
| from the folded registries (CORD-05 §5)
| concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK)
| concord grant COMMUNITY USER ROLE-ID grant a role to a member
| concord grant COMMUNITY USER ROLE-ID grant a role to a member; the private channels it
| opens are vended by Direct Invite, the ones it
| closes are rotated (CORD-03/06)
| concord ban COMMUNITY USER ban a member
| concord kick COMMUNITY USER cooperative kick (CORD-04 §6): strip the member's
| roles, then the Guestbook KICK; re-joinable
| concord pins COMMUNITY CHANNEL the channel's verified Pin List (CORD-04 §7)
| concord pin COMMUNITY CHANNEL RUMOR_ID pin a message (PIN_MESSAGES); proves it with
| its original seal, capped at 25 / 32 KiB
@@ -133,7 +150,7 @@ object ConcordCommands {
route(
"concord",
tail,
"concord <create|list|import|channels|send|read|invite|invites|accept|decline|revoke|join|recover|rekey|roles|role|grant|ban|unban|pins|pin|unpin|refound|dissolve|timer>",
"concord <create|list|import|channels|channel|send|read|invite|invites|accept|decline|revoke|join|recover|rekey|roles|role|grant|ban|kick|unban|pins|pin|unpin|refound|dissolve|timer>",
help = USAGE,
routes =
mapOf(
@@ -141,6 +158,7 @@ object ConcordCommands {
"list" to { rest -> list(dataDir, rest) },
"import" to { rest -> import(dataDir, rest) },
"channels" to { rest -> ConcordChannelCommands.channels(dataDir, rest) },
"channel" to { rest -> ConcordPrivateChannelCommands.channel(dataDir, rest) },
"send" to { rest -> ConcordChannelCommands.send(dataDir, rest) },
"read" to { rest -> ConcordChannelCommands.read(dataDir, rest) },
"invite" to { rest -> invite(dataDir, rest) },
@@ -155,6 +173,7 @@ object ConcordCommands {
"role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) },
"grant" to { rest -> ConcordModCommands.grant(dataDir, rest) },
"ban" to { rest -> ConcordModCommands.ban(dataDir, rest) },
"kick" to { rest -> ConcordModCommands.kick(dataDir, rest) },
"unban" to { rest -> ConcordModCommands.unban(dataDir, rest) },
"pins" to { rest -> ConcordPinCommands.pins(dataDir, rest) },
"pin" to { rest -> ConcordPinCommands.pin(dataDir, rest) },
@@ -729,9 +748,12 @@ object ConcordCommands {
0
}
is DirectInviteAcceptPlan.CatchUp -> {
val held = heldSc!!
store.upsert(storedFrom(held, plan.entry))
val added = plan.entry.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } }
// Re-applied to the record as stored NOW (the fold above took a while), never the
// snapshot the plan was computed from.
val held = store.load().firstOrNull { it.communityId == heldSc!!.communityId } ?: heldSc!!
val adopted = ConcordInviteVend.adoptCatchUp(entryFor(held), opened.invite, plan.channelIds) ?: plan.entry
store.upsert(storedFrom(held, adopted))
val added = adopted.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } }
Output.emit(done(mapOf("joined" to true, "catch_up" to true, "channels" to added.map { mapOf("id" to it.channelId, "name" to it.name, "epoch" to it.epoch) })))
0
}
@@ -840,7 +862,9 @@ object ConcordCommands {
}
/** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */
fun entryFor(sc: StoredCommunity) =
fun entryFor(sc: StoredCommunity) = sc.channelCuts.entries.fold(entryShape(sc)) { entry, (id, epoch) -> ConcordChannelKeyring.withCut(entry, id, epoch) }
private fun entryShape(sc: StoredCommunity) =
ConcordCommunityListEntry(
id = sc.communityId,
owner = sc.owner,
@@ -870,6 +894,7 @@ object ConcordCommands {
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) },
channelCuts = ConcordChannelKeyring.cutsOf(entry),
)
/**
@@ -1048,7 +1073,15 @@ object ConcordCommands {
store.upsert(storedFrom(sc, adopted).copy(pendingRefounding = null))
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator)
}
Output.emit(mapOf("communities" to results))
// Then every held Private Channel's own rotations (CORD-06 §2), off the records as stored
// now — a base adoption above may have moved the root they are sealed under.
val channelResults = mutableListOf<Map<String, Any?>>()
for (id in targets.map { it.communityId }) {
val fresh = store.load().firstOrNull { it.communityId == id } ?: continue
if (fresh.privateChannels.isEmpty() || isDissolved(ctx, fresh)) continue
channelResults += ConcordPrivateChannelCommands.drainChannelRekeys(ctx, store, fresh)
}
Output.emit(mapOf("communities" to results, "channel_rekeys" to channelResults))
return 0
}
}
@@ -29,10 +29,13 @@ import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.cli.stores.StoredPendingRefounding
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
@@ -41,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
@@ -204,7 +208,11 @@ object ConcordModCommands {
)
val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc))
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + RawEventSupport.ackFields(ack))
// Role-gated channel keys follow the Grant (CORD-03/06): vend what it opened, rotate what it closed.
val before = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
val after = editions + ConcordActions.controlEditions(listOf(wrap), cp)
val access = ConcordPrivateChannelCommands.reconcileAccess(ctx, ConcordStore(dataDir.concordFile), loaded.community, before, after)
Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + access + RawEventSupport.ackFields(ack))
return 0
}
}
@@ -318,6 +326,61 @@ object ConcordModCommands {
}
}
/**
* Kicks a member: `kick <community> <user>` (CORD-04 §6, the Cooperative Kick). Role Removal
* first — an empty Grant when the target holds roles and we may strip them (MANAGE_ROLES +
* outrank, and the control_root to publish it; best-effort) — then the Guestbook directive
* (kind 3309) citing our Grant. Needs KICK and a strict outrank of the target.
*/
suspend fun kick(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val userRef = args.positional(1, "user")
args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile)
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val member = ctx.requireUserHex(userRef)
val loaded = load(ctx, sc, dataDir)
val (cp, editions) = loaded
val cid = sc.communityId.hexToByteArray()
val me = ctx.signer.pubKey
val authority = AuthorityResolver.resolve(editions, cid, sc.owner)
if (!authority.canActOn(me, member, ConcordPermissions.KICK)) {
return Output.error("forbidden", "kicking $member takes KICK and a strict outrank in '$handle' (CORD-04 §6)")
}
val relays = ConcordCommands.relaysFor(ctx, sc)
// Strip first, so the target's rank is gone before the departure lands.
var chain = editions
var access: Map<String, Any?> = emptyMap()
val strip = authority.rolesOf(member).isNotEmpty() && cp.canWrite && authority.canActOn(me, member, ConcordPermissions.MANAGE_ROLES)
if (strip) {
val stripWrap = ConcordModeration.grant(ctx.signer, cp, cid, member, emptyList(), editions, TimeUtils.now(), owner = sc.owner)
if (ctx.publish(stripWrap, relays).values.any { it.accepted }) {
chain = editions + ConcordActions.controlEditions(listOf(stripWrap), cp)
access = ConcordPrivateChannelCommands.reconcileAccess(ctx, store, loaded.community, authority, chain)
} else {
System.err.println("[concord] the role strip for $member was not accepted by any relay; kicking anyway")
}
}
val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), cid, sc.rootEpoch)
ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey))
val citation = AuthorityResolver.resolve(chain, cid, sc.owner).citationFor(me)
val wrap = ConcordActions.buildGuestbookKick(ctx.signer, gb, member, citation, TimeUtils.now())
val ack = ctx.publish(wrap, relays)
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
Output.emit(mapOf("member" to member, "kicked" to true, "roles_stripped" to (chain !== editions)) + access + RawEventSupport.ackFields(ack))
return 0
}
}
/** Unbans a member: `unban <community> <user>`. */
suspend fun unban(
dataDir: DataDir,
@@ -544,11 +607,33 @@ object ConcordModCommands {
}
val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } }
// 4b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among
// the kept members, sealed under the PRIOR root so a base-fork loser can still open
// it. One that no relay takes keeps its key and is reported: resumable, not atomic.
val afterBans = ConcordCommunityState.fold(chain, sc.communityId.hexToByteArray(), sc.owner)
val kept = recipients.mapTo(HashSet()) { it.lowercase() }
var withChannels = ConcordCommands.entryFor(loaded.community)
val channelsRotated = mutableListOf<String>()
val channelsNotRotated = mutableListOf<String>()
for (held in withChannels.privateChannels) {
val id = held.channelId.lowercase()
if (id !in afterBans.privateChannelIds || ConcordChannelKeyring.heldKey(withChannels, id) == null) continue
val keep = ConcordPrivateChannels.keepSet(afterBans.authority, id, me).filterTo(HashSet()) { it in kept || it == me.lowercase() }
val newKey = ConcordChannelRekey.mintKey()
val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation)
if (wraps.all { wrap -> ctx.publish(wrap, relays).values.any { it.accepted } }) {
withChannels = ConcordChannelKeyring.withRotatedKey(withChannels, id, newKey.toHexKey(), held.epoch + 1) ?: withChannels
channelsRotated += id
} else {
channelsNotRotated += id
}
}
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
// epoch we are leaving for the anti-rollback floor — and drop the reservation.
val adopted =
ConcordReceive.withAdoptedRoot(
ConcordCommands.entryFor(loaded.community),
withChannels,
keys.newRoot,
build.newEpoch,
build.newControlKeys.address.hexToByteArray(),
@@ -606,6 +691,8 @@ object ConcordModCommands {
"rekey_wraps" to build.rekeyWraps.size,
"compaction_failures" to compactionFailures,
"invites_refreshed" to refreshed,
"channels_rotated" to channelsRotated,
"channels_not_rotated" to channelsNotRotated,
),
)
return 0
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome
import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -91,7 +93,8 @@ object ConcordPinCommands {
?.key
?.conversationKey
},
isKilled = view.evidence::isKilled,
// CORD-08 §3: an expired message never shows, pinned or not (the proof stays valid, the rumor says it is gone).
isKilled = { view.evidence.isKilled(it) || it.tags.isExpirationBefore(TimeUtils.now()) },
newestEdit = view.evidence::newestEdit,
)
}
@@ -168,6 +171,7 @@ object ConcordPinCommands {
val handle = args.positional(0, "community")
val channelRef = args.positional(1, "channel")
val rumorId = args.positional(2, "rumor_id").lowercase()
val force = pin && args.bool("force")
args.rejectUnknown()
if (!HEX64.matches(rumorId)) return Output.error("bad_args", "RUMOR_ID must be a 64-char hex rumor id")
val stored = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
@@ -202,9 +206,14 @@ object ConcordPinCommands {
if (pin) {
val refused = ConcordPinning.refusal(pinCtx)
val source = if (refused == null) ConcordPinning.sourceFrom(view.wraps, view.planes, rumorId) else null
val expiresAt = source?.let { ConcordDisappearing.expirationOf(it.opened.rumor) }
when {
refused != null -> ConcordPinWrite(refused)
source == null -> ConcordPinWrite(ConcordPinOutcome.MESSAGE_UNAVAILABLE)
// A pin carries the message's words in its proof: pinning a disappearing message
// makes it outlive its timer (CORD-08), so that takes an explicit --force.
expiresAt != null && !force ->
return Output.error("expiring_message", "that message disappears at $expiresAt (CORD-08) and a pin would keep its words past the timer; pass --force to pin it anyway")
else -> ConcordPinning.pin(pinCtx, source, TimeUtils.now())
}
} else {
@@ -0,0 +1,409 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* `amy concord channel create|privatize|publicize|rekey` — Private Channels (CORD-03 §1-2,
* CORD-06 §1-2). Thin assembly over [ConcordPrivateChannels] (commons); the decisions — the key
* epoch, the access Role, who a rotation keeps, whether a received rotation is honored — are all
* shared with Amethyst. Like every amy verb that holds secrets, keys live in the local store only
* (amy does not republish the Community List).
*/
object ConcordPrivateChannelCommands {
/** How many channel epochs `privatize` probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH). */
private const val MAX_PROBED_CHANNEL_EPOCH = 32L
suspend fun channel(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"concord channel",
tail,
"concord channel <create|privatize|publicize|rekey>",
routes =
mapOf(
"create" to { rest -> create(dataDir, rest) },
"privatize" to { rest -> privatize(dataDir, rest) },
"publicize" to { rest -> publicize(dataDir, rest) },
"rekey" to { rest -> rekey(dataDir, rest) },
),
)
/** Publishes [wraps] in order to [sc]'s relays; the first one no relay takes stops the run. */
private suspend fun publishAll(
ctx: Context,
sc: StoredCommunity,
wraps: List<Event>,
): Int? {
val relays = ConcordCommands.relaysFor(ctx, sc)
for (wrap in wraps) {
val ack = ctx.publish(wrap, relays)
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
}
return null
}
private fun canManageChannels(
authority: AuthorityResolver,
me: HexKey,
): Boolean = authority.isOwner(me) || authority.hasPermission(me, ConcordPermissions.MANAGE_CHANNELS)
private fun forbidden(): Int = Output.error("forbidden", "this needs the Manage-channels permission (CORD-03 §2); readers would drop the edition")
/** Stores [sc] with the Private Channel [key] (refused when it would not move the channel forward). */
private fun storeKey(
store: ConcordStore,
sc: StoredCommunity,
key: PrivateChannelKey,
): Boolean {
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
val next = ConcordChannelKeyring.withChannelKey(ConcordCommands.entryFor(fresh), key) ?: return false
store.upsert(ConcordCommands.storedFrom(fresh, next))
return true
}
/** `concord channel create COMMUNITY NAME [--private [--role NAME]]`. */
private suspend fun create(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val name = args.positional(1, "name")
val private = args.bool("private")
val roleName = args.flag("role")
args.rejectUnknown()
if (!ChannelEntity(name = name.trim()).hasValidName()) return Output.error("bad_args", "a channel name must be 1..${ChannelEntity.NAME_MAX_BYTES} UTF-8 bytes").let { 2 }
if (roleName != null && !private) return Output.error("bad_args", "--role names a Private channel's access Role; add --private").let { 2 }
val store = ConcordStore(dataDir.concordFile)
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val loaded = ConcordModCommands.load(ctx, sc, dataDir)
val (cp, editions) = loaded
ConcordModCommands.writeGuard(cp)?.let { return it }
val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
if (!canManageChannels(authority, ctx.signer.pubKey)) return forbidden()
if (!private) {
val channelId = RandomInstance.bytes(32)
val wrap = ConcordModeration.defineChannel(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, ChannelEntity(name = name.trim()), editions, TimeUtils.now(), owner = sc.owner)
publishAll(ctx, sc, listOf(wrap))?.let { return it }
Output.emit(mapOf("channel_id" to channelId.toHexKey(), "name" to name.trim(), "private" to false))
return 0
}
val build =
ConcordPrivateChannels.create(ctx.signer, cp, sc.communityId.hexToByteArray(), name, roleName, editions, authority, sc.owner, TimeUtils.now())
?: return Output.error("forbidden", "no rank to mint this channel's access Role from")
// The key goes into the store BEFORE the editions publish: otherwise a crash orphans the only copy.
if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key")
publishAll(ctx, sc, build.wraps)?.let { return it }
Output.emit(
mapOf(
"channel_id" to build.channelIdHex,
"name" to name.trim(),
"private" to true,
"channel_epoch" to build.key.epoch,
"access_role_id" to build.roleIdHex,
),
)
return 0
}
}
/** `concord channel privatize COMMUNITY CHANNEL [--role NAME]`. */
private suspend fun privatize(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val channelRef = args.positional(1, "channel")
val roleName = args.flag("role")
args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile)
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val loaded = ConcordModCommands.load(ctx, sc, dataDir)
val (cp, editions) = loaded
ConcordModCommands.writeGuard(cp)?.let { return it }
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden()
val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane")
if (standing.private) return Output.error("already_private", "channel '$channelRef' is already private")
// The next channel epoch must climb past every generation ever used — including ones this
// account never held — so probe the rekey addresses the roots derive (CORD-03 §2).
val entry = ConcordCommands.entryFor(loaded.community)
val window = HashMap<HexKey, Long>()
for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) {
for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId.hexToByteArray(), epoch).publicKeyHex] = epoch
}
val relays = ConcordCommands.relaysFor(ctx, sc)
val seen = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }).map { it.second }
val floor = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0
if (floor >= MAX_PROBED_CHANNEL_EPOCH) return Output.error("inconclusive", "this channel has rotated at least $MAX_PROBED_CHANNEL_EPOCH times; its next epoch can't be established safely")
val build =
ConcordPrivateChannels.privatize(ctx.signer, cp, entry, channelId, standing, roleName, editions, state.authority, TimeUtils.now(), floor)
?: return Output.error("forbidden", "no rank to mint this channel's access Role from")
if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key")
publishAll(ctx, sc, build.wraps)?.let { return it }
Output.emit(mapOf("channel_id" to channelId, "private" to true, "channel_epoch" to build.key.epoch, "access_role_id" to build.roleIdHex))
return 0
}
}
/** `concord channel publicize COMMUNITY CHANNEL`. */
private suspend fun publicize(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val channelRef = args.positional(1, "channel")
args.rejectUnknown()
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val (cp, editions) = ConcordModCommands.load(ctx, sc, dataDir)
ConcordModCommands.writeGuard(cp)?.let { return it }
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden()
val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane")
val wrap =
ConcordPrivateChannels.publicize(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, standing, editions, sc.owner, TimeUtils.now())
?: return Output.error("already_public", "channel '$channelRef' is not private")
publishAll(ctx, sc, listOf(wrap))?.let { return it }
Output.emit(mapOf("channel_id" to channelId, "private" to false))
return 0
}
}
/** `concord channel rekey COMMUNITY CHANNEL` — rotate to exactly the members entitled today. */
private suspend fun rekey(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val channelRef = args.positional(1, "channel")
args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile)
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
if (ConcordCommands.isDissolved(ctx, sc)) return Output.error("dissolved", "community '$handle' has been dissolved (CORD-02 §9)")
val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val loaded = ConcordModCommands.load(ctx, sc, dataDir)
val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner)
// The known role holders a rotation to the entitled set leaves out; a roleless member ranks
// last, so any MANAGE_CHANNELS holder outranks them and they need no check.
val keep = ConcordPrivateChannels.keepSet(authority, channelId, ctx.signer.pubKey)
val cut = (authority.roleHolders() + authority.owner()).filterTo(HashSet()) { it !in keep }
return rotate(ctx, store, loaded.community, channelId, authority, loaded.editions, cut)
}
}
/** A rotation's result: [error] (code to message) when refused or unpublished, else what landed. */
internal class Rotation(
val error: Pair<String, String>? = null,
val newEpoch: Long = 0,
val kept: Int = 0,
val chunks: Int = 0,
)
/** [rotateSilently] with its outcome emitted as the command's JSON line. */
internal suspend fun rotate(
ctx: Context,
store: ConcordStore,
sc: StoredCommunity,
channelId: HexKey,
authority: AuthorityResolver,
editions: List<ControlEdition>,
cut: Set<HexKey>,
): Int {
val r = rotateSilently(ctx, store, sc, channelId, authority, editions, cut)
r.error?.let { (code, message) -> return Output.error(code, message) }
Output.emit(mapOf("channel_id" to channelId, "rekeyed" to true, "channel_epoch" to r.newEpoch, "kept" to r.kept, "chunks" to r.chunks))
return 0
}
/**
* Rotates [channelId] to its entitled set (CORD-06 §1-2), cutting [cut]: authority checked, the
* key reserved in the store before anything publishes (a retry re-delivers the same key), every
* chunk accepted by a relay before the new key is adopted locally. Prints nothing.
*/
internal suspend fun rotateSilently(
ctx: Context,
store: ConcordStore,
sc: StoredCommunity,
channelId: HexKey,
authority: AuthorityResolver,
editions: List<ControlEdition>,
cut: Set<HexKey>,
): Rotation {
val me = ctx.signer.pubKey
val entry = ConcordCommands.entryFor(sc)
val held = ConcordChannelKeyring.heldKey(entry, channelId) ?: return Rotation("no_channel_key" to "this account holds no key for channel $channelId, so it cannot rotate it")
if (!ConcordPrivateChannels.canRotate(authority, me, cut)) {
return Rotation("forbidden" to "rotating needs the Manage-channels permission and outranking every member it cuts (CORD-06 §3)")
}
val citation = ConcordReceive.rotationCitation(entry, editions, me)
if (citation == null && !authority.isOwner(me)) return Rotation("forbidden" to "no Grant of ours to cite; nobody would honor this rotation (CORD-06 §3)")
val newEpoch = held.epoch + 1
val reservation = "${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}"
val newKeyHex = sc.pendingChannelRotations[reservation] ?: ConcordChannelRekey.mintKey().toHexKey()
store.upsert(sc.copy(pendingChannelRotations = sc.pendingChannelRotations + (reservation to newKeyHex)))
val keep = ConcordPrivateChannels.keepSet(authority, channelId, me)
val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, sc.root.hexToByteArray(), held, newKeyHex.hexToByteArray(), keep, TimeUtils.now(), citation)
val relays = ConcordCommands.relaysFor(ctx, sc)
for (wrap in wraps) {
if (ctx.publish(wrap, relays).values.none { it.accepted }) {
return Rotation("rejected" to "no relay accepted chunk ${wrap.id} of the rotation; re-running re-delivers the same key")
}
}
// Adopt at once: the rotator must never keep writing under the severed key.
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
val next = ConcordChannelKeyring.withRotatedKey(ConcordCommands.entryFor(fresh), channelId, newKeyHex, newEpoch)
if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next).copy(pendingChannelRotations = fresh.pendingChannelRotations - reservation))
return Rotation(newEpoch = newEpoch, kept = keep.size, chunks = wraps.size)
}
/**
* Follows every held Private Channel's rotations for [sc] (CORD-06 §2): drains the watched
* channel-rekey addresses, adopts a key carried off the one we hold, or drops the channel (and
* records the cut) when a rotation from someone who outranks us left us out. Returns one result
* per channel acted on.
*/
internal suspend fun drainChannelRekeys(
ctx: Context,
store: ConcordStore,
sc: StoredCommunity,
): List<Map<String, Any?>> {
val entry = ConcordCommands.entryFor(sc)
val keys = ConcordPrivateChannels.watchKeys(entry)
if (keys.isEmpty()) return emptyList()
val relays = ConcordCommands.relaysFor(ctx, sc)
ctx.registerConcordStreamKeys(relays, keys.values.map { it.secretKey })
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(keys.keys.toList())) }, pendingOnAuthRequired = true).map { it.second }
if (wraps.isEmpty()) return emptyList()
val loaded = ConcordModCommands.load(ctx, sc)
val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner)
val outcomes = ConcordPrivateChannels.receive(entry, wraps, loaded.editions, authority, ctx.signer)
if (outcomes.isEmpty()) return emptyList()
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
val next = ConcordPrivateChannels.applyOutcome(ConcordCommands.entryFor(fresh), outcomes, entry.privateChannels.associate { it.channelId.lowercase() to it.epoch })
if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next))
return outcomes.map { (id, outcome) ->
when (outcome) {
is ChannelRekeyOutcome.Adopted -> mapOf("community_id" to sc.communityId, "channel_id" to id, "adopted" to true, "channel_epoch" to outcome.epoch)
is ChannelRekeyOutcome.Removed -> mapOf("community_id" to sc.communityId, "channel_id" to id, "removed" to true, "channel_epoch" to outcome.epoch)
ChannelRekeyOutcome.None -> mapOf("community_id" to sc.communityId, "channel_id" to id)
}
}
}
/**
* After a Grant: vends every Private Channel it opened to its member by Direct Invite (only those
* channels), and rotates every one it closed (CORD-03/06; Armada `handleToggleRole`). Returns what
* it did, for the grant command's output.
*/
internal suspend fun reconcileAccess(
ctx: Context,
store: ConcordStore,
sc: StoredCommunity,
before: AuthorityResolver,
afterEditions: List<ControlEdition>,
): Map<String, Any?> {
val state = ConcordCommunityState.fold(afterEditions, sc.communityId.hexToByteArray(), sc.owner)
val me = ctx.signer.pubKey.lowercase()
val entry = ConcordCommands.entryFor(sc)
val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds)
val vended = mutableListOf<Map<String, Any?>>()
val rotated = mutableListOf<String>()
val unrotated = mutableListOf<Map<String, String>>()
val unheld = mutableListOf<String>()
val byMember = HashMap<HexKey, MutableSet<HexKey>>()
for (change in changes) {
if (ConcordChannelKeyring.heldKey(entry, change.channelIdHex) == null) {
unheld += change.channelIdHex
continue
}
for (m in change.gained - me) byMember.getOrPut(m) { HashSet() }.add(change.channelIdHex)
val cut = change.lost - me
if (cut.isNotEmpty()) {
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
val r = rotateSilently(ctx, store, fresh, change.channelIdHex, state.authority, afterEditions, cut)
if (r.error == null) rotated += change.channelIdHex else unrotated += mapOf("channel_id" to change.channelIdHex, "reason" to r.error.second)
}
}
for ((member, channels) in byMember) {
val draft = ConcordActions.draftDirectInvite(entry, state, me, member, onlyChannelIds = channels) as? ConcordDirectInviteDraft.Ready ?: continue
val wrap = ConcordActions.buildDirectInvite(ctx.signer, member, draft.invite)
val lists = ctx.cachedRelayListsOf(member) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, member, ctx.bootstrapRelays())
val ack = ctx.publish(wrap, ConcordActions.directInviteDeliveryRelays(lists))
vended += mapOf("member" to member, "channels" to draft.invite.channels.map { it.id }, "delivered" to ack.values.any { it.accepted })
}
return mapOf("channel_keys_vended" to vended, "channels_rotated" to rotated, "channels_not_rotated" to unrotated, "channels_not_held" to unheld)
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.cli.stores
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.SecureFileIO
import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox
import java.io.File
/** amy's bookkeeping for Concord Direct Invites (CORD-05 §6): the wrap ids the user declined. */
@@ -50,6 +51,8 @@ class ConcordInviteInboxStore(
fun decline(wrapId: String) {
val current = load()
if (wrapId in current.declined) return
SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(current.copy(declined = current.declined + wrapId)))
// Bounded like the app's store: the newest declines are kept, a long-expired one is not worth a line.
val next = (current.declined + wrapId).takeLast(ConcordDirectInviteInbox.DECLINED_CAP)
SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(current.copy(declined = next)))
}
}
@@ -57,6 +57,13 @@ data class StoredCommunity(
// 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(),
// Per Private Channel, the channel epoch whose rotation cut this account out (CORD-06 §2; the
// reference client's `channel_cuts`): a key below it is never adopted again from a bundle.
val channelCuts: Map<String, Long> = emptyMap(),
// Channel keys reserved for a Private Channel rotation this account started but has not yet
// adopted, keyed "channelId:newEpoch:prevcommit" (CORD-06): a retried `channel rekey` must
// re-deliver the SAME key, never a sibling that splits the members at one epoch.
val pendingChannelRotations: Map<String, String> = emptyMap(),
// Keys reserved for a Refounding this account started but has not yet adopted (CORD-06 §3): a
// retried `refound` must re-deliver the SAME root, never mint a sibling that splits the members.
val pendingRefounding: StoredPendingRefounding? = null,
@@ -33,8 +33,8 @@ 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.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
@@ -212,24 +212,30 @@ object ConcordActions {
/**
* 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.
* - Public: its plane under every held prior root ([historicalChannelPlanes]), plus every
* private-era plane a channel key is held for (a channel that was Private before);
* - Private: the planes of the older channel keys the entry still carries (its `seed` and a
* peer's `priors`, [ConcordChannelKeyring.historicalKeys]) — history across a channel rekey.
* Never the root-derived plane: every member reads that one, so showing it would present
* public content as private (Armada `channelsView`).
*/
fun historicalChannelPlanes(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
isPrivate: Boolean,
): List<ChannelPlane> {
if (isPrivate) return emptyList()
val channelId = channelIdHex.hexToByteArray()
val olderKeys =
ConcordChannelKeyring.historicalKeys(entry, channelIdHex).map { old ->
ChannelPlane(channelIdHex, old.epoch, ConcordChannelKeys.privateChannel(old.key.hexToByteArray(), channelId, old.epoch))
}
if (isPrivate) return olderKeys
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))
ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch))
}
return rootEras + listOfNotNull(privateEra)
return rootEras + listOfNotNull(privateEra) + olderKeys
}
/**
@@ -500,6 +506,11 @@ object ConcordActions {
* Builds an encrypted-seal **edit** wrap (kind-3302 [ChannelChat.edit] of [target]) on the
* [channel] plane. [newText] replaces [target]'s content on receivers that apply the edit overlay;
* only the original author's edits take effect, so restrict callers to their own messages.
*
* The Edit carries [expiration] verbatim — by default [target]'s own NIP-40 deadline, and none
* when [target] has none — never `now + timer`: an Edit stamped with a fresh deadline would
* outlive (or cut short) the message it revises, so the revised words could survive the message
* the timer already erased (CORD-08 §2; the reference client keeps `expirationOf(original)`).
*/
suspend fun buildChannelEdit(
authorSigner: NostrSigner,
@@ -510,9 +521,10 @@ object ConcordActions {
newText: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
timerSecs: Long? = null,
expiration: Long? = ConcordDisappearing.expirationOf(target),
): Event {
val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, withTimer(extraTags, ConcordChatEditEvent.KIND, createdAt, timerSecs))
val tags = ConcordDisappearing.withExpiration(extraTags.filterNot { it.isNotEmpty() && it[0] == "expiration" }.toTypedArray(), expiration)
val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, tags)
return wrapChat(rumor, channel, authorSigner)
}
@@ -616,14 +628,16 @@ object ConcordActions {
/**
* [openChannelRumor] without the CORD-08 expiry refusal, for a caller that must tell an expired
* rumor apart from garbage — the session, which purges an expired rumor's wrap instead of merely
* skipping it. Such a caller owns the refusal.
* skipping it. Such a caller owns the refusal. [kinds] widens the gate to
* [ChannelChat.PLANE_KINDS] for a caller that routes the WebXDC signal apart from chat rows.
*/
fun openChannelRumorAnyExpiry(
wrap: Event,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch) }
kinds: Set<Int> = ChannelChat.CHAT_KINDS,
): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch, kinds) }
// ---- invites --------------------------------------------------------------
@@ -676,6 +690,7 @@ object ConcordActions {
expiresAtMs: Long? = null,
name: String = entry.name,
icon: ImagePointer? = null,
onlyChannelIds: Set<HexKey>? = null,
): CommunityInvite =
CommunityInvite(
communityId = entry.id,
@@ -684,7 +699,12 @@ object ConcordActions {
communityRoot = entry.root,
rootEpoch = entry.rootEpoch,
controlPk = entry.controlPk,
channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)),
channels =
ConcordInviteVend.toInviteChannels(
ConcordInviteVend
.vendableChannels(entry.privateChannels, authority, recipient)
.filter { onlyChannelIds == null || it.channelId.lowercase() in onlyChannelIds },
),
relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS),
name = name.ifBlank { entry.name },
icon = icon,
@@ -705,6 +725,7 @@ object ConcordActions {
sender: HexKey,
recipient: HexKey,
expiresAtMs: Long? = null,
onlyChannelIds: Set<HexKey>? = null,
): ConcordDirectInviteDraft {
val to = recipient.lowercase()
if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT)
@@ -719,6 +740,7 @@ object ConcordActions {
expiresAtMs = expiresAtMs,
name = state.metadata?.name ?: entry.name,
icon = state.metadata?.icon,
onlyChannelIds = onlyChannelIds?.mapTo(HashSet()) { it.lowercase() },
),
)
}
@@ -879,11 +901,31 @@ object ConcordActions {
return ConcordStreamEnvelope.wrap(rumor, guestbook, memberSigner, encrypted = true, createdAt = createdAt)
}
/** Opens the guestbook [wraps] into their live membership set (joins minus later leaves). */
/**
* Builds an authorized Guestbook KICK (kind 3309) wrap naming [target], citing [citation] — the
* actor's own Grant head (`vac`, CORD-04 §5), null only for the owner. A Kick is the *second*
* layer of a removal: the caller strips the target's roles first (CORD-04 §6).
*/
suspend fun buildGuestbookKick(
actorSigner: NostrSigner,
guestbook: GroupKey,
target: HexKey,
citation: AuthorityCitation?,
createdAt: Long,
): Event {
val rumor = Guestbook.kick(actorSigner.pubKey, target.lowercase(), createdAt, citation = citation)
return ConcordStreamEnvelope.wrap(rumor, guestbook, actorSigner, encrypted = true, createdAt = createdAt)
}
/**
* Opens the guestbook [wraps] into their live membership set: joins minus later leaves and later
* Kicks honored against [authority] (none is honored without it).
*/
fun guestbookMembers(
wraps: List<Event>,
guestbook: GroupKey,
): Set<HexKey> = projectGuestbook(wraps.mapNotNull { guestbookEntry(it, guestbook) })
authority: AuthorityResolver? = null,
): Set<HexKey> = projectGuestbook(wraps.mapNotNull { guestbookEntry(it, guestbook) }, authority)
/**
* Opens a single guestbook [wrap] into its entry, or null when it doesn't belong to
@@ -898,17 +940,26 @@ object ConcordActions {
fun guestbookEntry(
wrap: Event,
guestbook: GroupKey,
): GuestbookEntry? = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor?.let { Guestbook.parse(it) }
): GuestbookEntry? =
ConcordStreamEnvelope
.openOrNull(wrap, guestbook)
// The Guestbook's seals MUST be encrypted (CORD-02 §5); a plaintext one is Control-only.
?.takeIf { it.sealKind == ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED }
?.rumor
?.let { Guestbook.parse(it) }
/** Last-writer-wins projection of already-opened [entries] down to the JOINed member set. */
fun projectGuestbook(entries: Collection<GuestbookEntry>): Set<HexKey> {
val latest = HashMap<HexKey, GuestbookEntry>()
for (entry in entries) {
val prev = latest[entry.member.lowercase()]
if (prev == null || entry.createdAt > prev.createdAt) latest[entry.member.lowercase()] = entry
}
return latest.values.filter { it.action == GuestbookAction.JOIN }.mapTo(HashSet()) { it.member.lowercase() }
}
/**
* The CORD-02 §5 coalesce of already-opened [entries] (latest motion per npub, Kicks honored
* against [authority]) down to the JOINed member set.
*/
fun projectGuestbook(
entries: Collection<GuestbookEntry>,
authority: AuthorityResolver? = null,
nowMs: Long = TimeUtils.nowMillis(),
): Set<HexKey> = joinedMembers(Guestbook.coalesce(entries, nowMs, authority))
/** The npubs whose coalesced Guestbook state ([Guestbook.coalesce]) is a Join. */
fun joinedMembers(coalesced: Map<HexKey, GuestbookEntry>): Set<HexKey> = coalesced.filterValues { it.action == GuestbookAction.JOIN }.keys
// ---- refounding / rekey (CORD-06) ----------------------------------------
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
@@ -98,7 +99,8 @@ object ConcordModeration {
communityId: ByteArray,
entityId: ByteArray,
owner: HexKey,
): ControlEdition? = ConcordCommunityState.authorizedHeads(current, communityId, owner)[entityId.toHexKey()]?.known
floors: Map<String, EntityFloor> = emptyMap(),
): ControlEdition? = ConcordCommunityState.authorizedHeads(current, communityId, owner, floors)[entityId.toHexKey()]?.known
/** version/prevHash to chain onto the current head of [entityId], or a genesis at version 1 (CORD-04 §1). */
private fun versioning(head: ControlEdition?): Pair<Long, ByteArray?> = if (head != null) (head.version + 1) to head.hash else 1L to null
@@ -140,8 +142,9 @@ object ConcordModeration {
createdAt: Long,
citation: AuthorityCitation?,
owner: HexKey,
floors: Map<String, EntityFloor> = emptyMap(),
): Event {
val head = headOf(current, communityId, entityId, owner)
val head = headOf(current, communityId, entityId, owner, floors)
val content = ConcordJson.encodePreserving(serializer, value, head?.content)
return wrap(actor, controlPlane, communityId, kind, entityId, head, content, current, createdAt, citation, owner)
}
@@ -207,7 +210,8 @@ object ConcordModeration {
createdAt: Long,
citation: AuthorityCitation? = null,
owner: HexKey,
): Event = editMetadata(actor, controlPlane, communityId, standing.withMessageExpiration(secs), current, createdAt, citation, owner)
floors: Map<String, EntityFloor> = emptyMap(),
): Event = editMetadata(actor, controlPlane, communityId, standing.withMessageExpiration(secs), current, createdAt, citation, owner, floors)
/**
* Replaces the community metadata (name / icon / description / relays). The
@@ -224,10 +228,11 @@ object ConcordModeration {
createdAt: Long,
citation: AuthorityCitation? = null,
owner: HexKey,
floors: Map<String, EntityFloor> = emptyMap(),
): Event {
require(ConcordLimits.nameFits(metadata.name)) { "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" }
require(ConcordLimits.descriptionFits(metadata.description)) { "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes" }
return edit(actor, controlPlane, communityId, ControlEntityKind.METADATA, communityId, MetadataEntity.serializer(), metadata, current, createdAt, citation, owner)
return edit(actor, controlPlane, communityId, ControlEntityKind.METADATA, communityId, MetadataEntity.serializer(), metadata, current, createdAt, citation, owner, floors)
}
/**
@@ -390,9 +395,12 @@ object ConcordModeration {
createdAt: Long,
citation: AuthorityCitation? = null,
owner: HexKey,
floors: Map<String, EntityFloor> = emptyMap(),
): Event {
val entityId = ConcordInviteRegistry.coordinate(communityId, actor.pubKey)
val head = headOf(current, communityId, entityId, owner)
// Floor-aware: after a Refounding the honored head may be the prior epoch's (the anti-rollback
// floor), and chaining onto the current epoch's editions alone would fork below it.
val head = headOf(current, communityId, entityId, owner, floors)
return wrap(actor, controlPlane, communityId, ControlEntityKind.INVITE_REGISTRY, entityId, head, ConcordInviteRegistry.encode(linkSigners), current, createdAt, citation, owner)
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* This pin as the rumor the chat feed would render (CORD-04 §7): the recomputed id, author, kind and
* the original's tags — so its NIP-92 `imeta` attachments, encrypted ones included, come along — with
* the newest words this client can show ([ConcordPinnedMessage.content]). Unsigned: a rumor never is.
* A pin proves its message without this account ever having held it, so this is the only source of
* the attachment keys for such a pin.
*/
fun ConcordPinnedMessage.toRumor(): Event = Event(pin.rumorId, pin.author, pin.createdAt, pin.kind, pin.tags, content, "")
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.serialization.json.JsonObject
import kotlin.random.Random
@@ -92,6 +93,11 @@ class ConcordChannelPins(
val sealedForm: Boolean,
/** Entries that failed verification and were dropped alone. */
val invalidEntries: Int,
/**
* False while the Control Plane has not been swept whole yet: [head] is whatever the partial fold
* holds, so an empty list may only mean "not served yet". No edition may be built from it (§7).
*/
val complete: Boolean = true,
) {
val count: Int get() = pins.size
@@ -100,8 +106,20 @@ class ConcordChannelPins(
/** True when the head owes keyless readers a republish: an erased entry, or a newer Edit to attach. */
val owesRepublish: Boolean get() = killed.isNotEmpty() || pins.any { it.newerEdit != null }
/** Every rumor id the list carries, shown or erased — what a delete or an Edit must name to change this read. */
val rumorIds: Set<HexKey> by lazy { (alive + killed).mapTo(HashSet()) { it.rumorId } }
/**
* The soonest NIP-40 deadline (unix seconds) after [now] among the shown pins, or null: when this
* read goes stale, since an expired message leaves the list (CORD-08 §3).
*/
fun nextExpiry(now: Long): Long? = alive.mapNotNull { pin -> pin.tags.firstNotNullOfOrNull(ExpirationTag::parse) }.filter { it > now }.minOrNull()
companion object {
fun none(channelIdHex: HexKey) = ConcordChannelPins(channelIdHex, null, emptyList(), emptyList(), emptyList(), sealedUnavailable = false, violating = false, sealedForm = false, invalidEntries = 0)
fun none(
channelIdHex: HexKey,
complete: Boolean = true,
) = ConcordChannelPins(channelIdHex, null, emptyList(), emptyList(), emptyList(), sealedUnavailable = false, violating = false, sealedForm = false, invalidEntries = 0, complete = complete)
}
}
@@ -136,6 +154,38 @@ class ConcordPinVerifier(
}
return verdict
}
private val lists = LinkedHashMap<HexKey, ConcordPins.PinListRead>()
/** List parses (and sealed-form decrypts) actually performed (cache misses) — for tests. */
var listReads: Int = 0
private set
/**
* [head]'s content read as a Pin List, memoized by the head's rumor id: an edition's bytes never
* change, so re-reading the pins on every trigger (a fold, a delete landing, an expiry) parses and
* decrypts the list once. A read that found the list sealed under a key not held is not cached,
* so the key arriving later (a Private Channel key delivered on grant) opens it.
*/
fun readList(
head: ControlEdition,
unsealKey: (epoch: Long) -> ByteArray?,
): ConcordPins.PinListRead {
lock.withLock { lists[head.rumorId] }?.let { return it }
val read = ConcordPins.read(head.content, unsealKey)
lock.withLock {
listReads++
if (!read.sealedUnavailable) {
lists[head.rumorId] = read
while (lists.size > MAX_LISTS) lists.remove(lists.keys.first())
}
}
return read
}
companion object {
private const val MAX_LISTS = 64
}
}
/** The proof material for pinning one opened message: its original seal and the plane key of its epoch. */
@@ -214,6 +264,9 @@ enum class ConcordPinOutcome {
UNVERIFIABLE,
TOO_MANY_PINS,
TOO_LARGE,
/** The edition was built but no relay acknowledged it, so it may not have landed. */
NOT_CONFIRMED,
}
class ConcordPinWrite(
@@ -283,9 +336,10 @@ object ConcordPinning {
verifier: ConcordPinVerifier = ConcordPinVerifier(),
isKilled: (VerifiedPin) -> Boolean = { false },
newestEdit: (VerifiedPin) -> ConcordLocalEdit? = { null },
complete: Boolean = true,
): ConcordChannelPins {
if (head == null) return ConcordChannelPins.none(channelIdHex)
val read = ConcordPins.read(head.content, unsealKey)
if (head == null) return ConcordChannelPins.none(channelIdHex, complete)
val read = verifier.readList(head, unsealKey)
val alive = ArrayList<VerifiedPin>()
val killed = ArrayList<VerifiedPin>()
var invalid = 0
@@ -314,11 +368,23 @@ object ConcordPinning {
pins = shown,
sealedUnavailable = read.sealedUnavailable,
violating = read.violating,
sealedForm = ConcordPins.isSealedForm(head.content),
sealedForm = read.sealedForm,
invalidEntries = invalid,
complete = complete,
)
}
/**
* [pins]' shown entries a reader displays: an entry by a [isBanned] author (the community declines
* to show a banned member's posts, CORD-04 §4) or by someone the reader [isHidden]s (mutes or
* blocks) is left out. Display only — the list itself, and every write built from it, is untouched.
*/
fun visible(
pins: ConcordChannelPins,
isBanned: (HexKey) -> Boolean,
isHidden: (HexKey) -> Boolean,
): List<ConcordPinnedMessage> = pins.pins.filterNot { isBanned(it.author) || isHidden(it.author) }
/** True when [edit] is newer than whatever Edit [pin]'s proof already carries. */
private fun isNewer(
edit: ConcordLocalEdit,
@@ -363,6 +429,9 @@ object ConcordPinning {
when {
!ctx.authorized -> ConcordPinOutcome.NOT_AUTHORIZED
!ctx.controlPlane.canWrite -> ConcordPinOutcome.NO_WRITE_KEY
// §7: never write from a list the fold was not served — a partial fold's head (or its
// absence) is not the list a replace-entire edition would overwrite.
!ctx.pins.complete -> ConcordPinOutcome.NOT_FOLDED
// §7: a writer MUST NOT build an edition from a list it could not read.
ctx.pins.sealedUnavailable -> ConcordPinOutcome.LIST_UNAVAILABLE
ctx.channelIsPrivate && ctx.currentPlane == null -> ConcordPinOutcome.NO_CHANNEL_KEY
@@ -0,0 +1,387 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleScope
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRotation
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* A Private Channel just minted or privatised (CORD-03 §2): its [channelIdHex], the independent
* [key] to store in the Community List **before** [wraps] publish (a lost List write would orphan
* the only copy), the access Role minted beside it ([roleIdHex]), and the Control editions to
* publish in order — the Role first (an orphan Role is inert), then the channel edition.
*/
class PrivateChannelBuild(
val channelIdHex: HexKey,
val key: PrivateChannelKey,
val roleIdHex: HexKey,
val wraps: List<Event>,
)
/**
* Private Channels end to end (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) as pure
* builders and decisions, shared by the app and `amy`. The network and the Community List write
* stay with the caller.
*
* Who may read a Private Channel is its Roles: a Role scoped `{kind:"channel", channel_id}` IS its
* access list ([ConcordInviteVend.isEntitled]). Read access is enforced by key possession alone, so
* this decides who a key is delivered TO (a Direct Invite on grant) and who a rotation keeps (a
* channel rekey on revoke). Pinned to Armada's `channelAccess.ts`, `useCommunityActions`
* (`createChannel`, `privatiseChannel`, `publiciseChannel`) and `useRekey` (`useChannelRekey`,
* `useChannelRekeyWatch`).
*/
object ConcordPrivateChannels {
/**
* The position a new access Role takes (Armada `accessRolePosition`): the bottom of the roster,
* never above what [actor] may mint (the owner mints from 1, anyone else strictly below their
* own rank), so a grantee is never promoted by being let into a channel. Null when [actor] holds
* no rank to mint from.
*/
fun accessRolePosition(
authority: AuthorityResolver?,
actor: HexKey,
owner: HexKey,
): Long? {
val ceiling =
if (actor.equals(owner, ignoreCase = true)) {
1L
} else {
(authority?.rank(actor) ?: return null) + 1
}
val lowest =
authority
?.roles()
?.values
?.filterNot { it.deleted }
?.maxOfOrNull { it.position } ?: 0L
return maxOf(ceiling, lowest + 1)
}
/** [name] cut to the protocol's 64-byte cap on a character boundary (Armada slices the same way). */
private fun fitName(name: String): String {
var out = name
while (!ConcordLimits.nameFits(out)) out = out.dropLast(1)
return out
}
/** The bit-less access Role scoped to [channelIdHex] (CORD-04 §2): read access is the key, never a bit. */
fun accessRole(
name: String,
channelIdHex: HexKey,
position: Long,
): RoleEntity =
RoleEntity(
name = fitName(name),
position = position,
permissions = "0",
scope = RoleScope(kind = "channel", channelId = channelIdHex.lowercase()),
)
/**
* A new channel (CORD-03 §2): a random `channel_id`, and for a Private one an independent key
* at channel epoch 0 plus its access Role (Armada `createChannel`: a born-private channel is
* epoch 0; the first *privatisation* of a public channel is epoch 1). Returns null when the
* name is invalid or the actor has no rank to mint the Role from.
*/
suspend fun create(
actor: NostrSigner,
cp: ControlPlaneKeys,
communityId: ByteArray,
name: String,
accessRoleName: String?,
current: List<ControlEdition>,
authority: AuthorityResolver?,
owner: HexKey,
createdAt: Long,
): PrivateChannelBuild? {
val channel = ChannelEntity(name = name.trim(), private = true)
if (!channel.hasValidName()) return null
val position = accessRolePosition(authority, actor.pubKey, owner) ?: return null
val channelId = RandomInstance.bytes(32)
val channelIdHex = channelId.toHexKey()
val roleId = RandomInstance.bytes(32)
val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: channel.name, channelIdHex, position)
val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = owner)
val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelId, channel, current, createdAt, owner = owner)
return PrivateChannelBuild(
channelIdHex = channelIdHex,
key = PrivateChannelKey(channelIdHex, ConcordChannelRekey.mintKey().toHexKey(), 0, channel.name),
roleIdHex = roleId.toHexKey(),
wraps = listOf(roleWrap, channelWrap),
)
}
/**
* Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh independent key at
* the NEXT channel epoch ([ConcordChannelKeyring.nextChannelEpoch], floored at [observedFloor],
* the highest channel rotation seen on the wire), a new access Role, and the channel edition
* flipping `private` on — every other field of [standing] carried through. Protects the future
* only: the public era stays readable to every member. Null when the actor can't mint the Role.
*/
suspend fun privatize(
actor: NostrSigner,
cp: ControlPlaneKeys,
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
standing: ChannelEntity,
accessRoleName: String?,
current: List<ControlEdition>,
authority: AuthorityResolver?,
createdAt: Long,
observedFloor: Long = 0,
): PrivateChannelBuild? {
if (standing.private || standing.deleted) return null
val position = accessRolePosition(authority, actor.pubKey, entry.owner) ?: return null
val communityId = entry.id.hexToByteArray()
val roleId = RandomInstance.bytes(32)
val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: standing.name, channelIdHex, position)
val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = entry.owner)
val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = true), current, createdAt, owner = entry.owner)
val epoch = ConcordChannelKeyring.nextChannelEpoch(entry, channelIdHex, observedFloor)
return PrivateChannelBuild(
channelIdHex = channelIdHex.lowercase(),
key = PrivateChannelKey(channelIdHex.lowercase(), ConcordChannelRekey.mintKey().toHexKey(), epoch, standing.name),
roleIdHex = roleId.toHexKey(),
wraps = listOf(roleWrap, channelWrap),
)
}
/**
* Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The
* channel derives from the `community_root` from here on; the held key stays in the List so its
* holders keep reading the private era, which a later joiner never can. Null when it is not
* private.
*/
suspend fun publicize(
actor: NostrSigner,
cp: ControlPlaneKeys,
communityId: ByteArray,
channelIdHex: HexKey,
standing: ChannelEntity,
current: List<ControlEdition>,
owner: HexKey,
createdAt: Long,
): Event? {
if (!standing.private || standing.deleted) return null
return ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = false), current, createdAt, owner = owner)
}
// ---- channel rotations (CORD-06 §1-2) -------------------------------------
/**
* Whether [actor] may launch a single-channel Rekey cutting [removed] (CORD-06 §3 Authority):
* `MANAGE_CHANNELS` (or `BAN`, a Refounding's) and strictly outranking every removed target.
* The owner may always; the owner is never a valid target.
*/
fun canRotate(
authority: AuthorityResolver,
actor: HexKey,
removed: Collection<HexKey>,
bit: Int = ConcordPermissions.MANAGE_CHANNELS,
): Boolean {
if (authority.isOwner(actor)) return true
if (!authority.hasPermission(actor, bit)) return false
return removed.all { it.equals(actor, ignoreCase = true) || authority.canActOn(actor, it, bit) }
}
/**
* The members a rotation of [channelIdHex] keeps (Armada `handleRotateChannelKey`): exactly
* those entitled today ([ConcordInviteVend.entitledMembers]) plus the [rotator], who must keep
* every key or nobody could rotate the channel next time.
*/
fun keepSet(
authority: AuthorityResolver,
channelIdHex: HexKey,
rotator: HexKey,
): Set<HexKey> = ConcordInviteVend.entitledMembers(authority, channelIdHex) + rotator.lowercase()
/**
* The wraps of one rotation of [held] to [newKey] for [keep], sealed under [sealingRoot] (the
* current root for a single-channel Rekey, the PRIOR root inside a Refounding — CORD-06 §3), and
* citing [authority] on every chunk.
*/
suspend fun buildRotation(
rotator: NostrSigner,
sealingRoot: ByteArray,
held: PrivateChannelKey,
newKey: ByteArray,
keep: Collection<HexKey>,
createdAt: Long,
authority: AuthorityCitation?,
): List<Event> =
ConcordChannelRekey.build(
rotatorSigner = rotator,
sealingRoot = sealingRoot,
channelId = held.channelId.hexToByteArray(),
heldKey = held.key.hexToByteArray(),
heldEpoch = held.epoch,
newKey = newKey,
recipients = keep + rotator.pubKey,
createdAt = createdAt,
authority = authority,
)
/** The roots a member watches channel rekeys under: the current one and the canonical prior one (CORD-06 §3). */
fun watchRoots(entry: ConcordCommunityListEntry): List<ByteArray> {
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).filter { it.epoch == entry.rootEpoch - 1 }
return (listOf(entry.root) + prior.map { it.key }).distinct().map { it.hexToByteArray() }
}
/**
* Every channel-rekey address this entry should watch (CORD-06 §2): per held Private Channel,
* the next [ConcordChannelRekey.LOOKAHEAD] channel epochs past the held one, under each of
* [watchRoots]. Address hex → key.
*/
fun watchKeys(entry: ConcordCommunityListEntry): Map<HexKey, GroupKey> {
val out = LinkedHashMap<HexKey, GroupKey>()
val roots = watchRoots(entry)
for (held in entry.privateChannels) {
if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue
val channelId = held.channelId.hexToByteArray()
for (root in roots) {
for (ahead in 1..ConcordChannelRekey.LOOKAHEAD) {
val key = ConcordChannelRekey.address(root, channelId, held.epoch + ahead)
out[key.publicKeyHex] = key
}
}
}
return out
}
/**
* Whether a received channel rotation's Rotator may be honored (CORD-06 §3 Authority): the owner,
* or a non-banned holder of `MANAGE_CHANNELS` (a single-channel Rekey) or `BAN` (a Refounding's
* channel rekeys), whose `vac` cites a Grant our fold has synced. Key possession is never
* authority.
*/
fun isHonoredRotation(
entry: ConcordCommunityListEntry,
editions: Collection<ControlEdition>,
authority: AuthorityResolver,
rotation: ChannelRotation,
): Boolean {
val rotator = rotation.rotator
if (!authority.isOwner(rotator)) {
if (authority.isBanned(rotator)) return false
if (!authority.hasPermission(rotator, ConcordPermissions.MANAGE_CHANNELS) && !authority.hasPermission(rotator, ConcordPermissions.BAN)) return false
}
val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner)
return ConcordRotationAuthority.citationSatisfied(entry.id, rotator, entry.owner, rotation.authority, heads)
}
/** Whether [rotator] strictly outranks [me] — only such a Rotator's omission is a cut (CORD-06 §3). */
fun outranks(
authority: AuthorityResolver,
rotator: HexKey,
me: HexKey,
): Boolean {
if (authority.isOwner(me)) return false
val theirs = authority.rank(rotator) ?: return false
val mine = authority.rank(me) ?: Long.MAX_VALUE
return theirs < mine
}
/**
* What the buffered channel-rekey [wraps] mean for each Private Channel [entry] holds a key for
* (CORD-06 §2), per channel id: an adoption moves that channel's key forward, a cut drops it and
* records `channel_cuts`. Channels with nothing to do are omitted. The caller applies the result
* inside its List write ([applyOutcome]).
*/
suspend fun receive(
entry: ConcordCommunityListEntry,
wraps: Collection<Event>,
editions: Collection<ControlEdition>,
authority: AuthorityResolver,
recipient: NostrSigner,
): Map<HexKey, ChannelRekeyOutcome> {
if (wraps.isEmpty()) return emptyMap()
val keys = watchKeys(entry)
val me = recipient.pubKey
val joinedAtSecs = entry.addedAt / 1000
val out = LinkedHashMap<HexKey, ChannelRekeyOutcome>()
for (held in entry.privateChannels) {
if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue
val id = held.channelId.lowercase()
val rotations = ConcordChannelRekey.rotations(wraps, keys, id)
if (rotations.isEmpty()) continue
val outcome =
ConcordChannelRekey.walk(
rotations = rotations,
channelIdHex = id,
heldKey = held.key.hexToByteArray(),
heldEpoch = held.epoch,
recipientSigner = recipient,
joinedAtSecs = joinedAtSecs,
honored = { isHonoredRotation(entry, editions, authority, it) },
outranksMe = { outranks(authority, it, me) },
)
if (outcome !is ChannelRekeyOutcome.None) out[id] = outcome
}
return out
}
/**
* [current] with [outcomes] applied — only where the channel is still at the epoch the outcome
* was computed from ([fromEpochs]), so a write racing another adoption never rolls a key back —
* or null when nothing changed.
*/
fun applyOutcome(
current: ConcordCommunityListEntry,
outcomes: Map<HexKey, ChannelRekeyOutcome>,
fromEpochs: Map<HexKey, Long>,
): ConcordCommunityListEntry? {
var next = current
for ((id, outcome) in outcomes) {
val held = ConcordChannelKeyring.heldKey(next, id) ?: continue
if (held.epoch != fromEpochs[id]) continue
next =
when (outcome) {
is ChannelRekeyOutcome.Adopted -> ConcordChannelKeyring.withRotatedKey(next, id, outcome.key.toHexKey(), outcome.epoch) ?: next
is ChannelRekeyOutcome.Removed -> ConcordChannelKeyring.withoutChannel(next, id, outcome.epoch)
ChannelRekeyOutcome.None -> next
}
}
return if (next === current) null else next
}
}
@@ -96,7 +96,8 @@ object ConcordSubscriptionPlanner {
* The off-channel planes every joined community subscribes to upfront (known
* from the entry alone): the Guestbook Plane (membership motions) and the
* next-epoch base-rekey address (so an inbound Refounding is received live,
* CORD-06), and the dissolution tombstone address (CORD-02 §9). All are kind-1059
* CORD-06), the dissolution tombstone address (CORD-02 §9), and every held Private Channel's
* next channel-rekey addresses (CORD-06 §2). All are kind-1059
* wraps authored by their derived stream address.
*/
fun auxiliaryPlaneSubs(entries: List<ConcordCommunityListEntry>): List<ConcordPlaneSub> =
@@ -114,7 +115,10 @@ object ConcordSubscriptionPlanner {
ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays),
// The current epoch's own rekey address, so a racing sibling can heal us (CORD-06 §3).
sibling?.let { ConcordPlaneSub(channelId = null, pubKeyHex = it.publicKeyHex, relays = relays) },
)
) +
// Each held Private Channel's next channel-rekey addresses (CORD-06 §2), so a rotation
// that moves the key forward — or cuts us — is received live.
ConcordPrivateChannels.watchKeys(e).keys.map { ConcordPlaneSub(channelId = null, pubKeyHex = it, relays = relays) }
}
/**
@@ -189,6 +189,7 @@ import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo
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.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
@@ -359,6 +360,7 @@ import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -366,7 +368,10 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -377,6 +382,9 @@ import kotlin.coroutines.cancellation.CancellationException
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
/** How long past a disappearing message's deadline the sweep waits, to purge nearby deadlines in one pass (CORD-08). */
private const val CONCORD_EXPIRY_COALESCE_MS = 2_000L
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
@@ -759,6 +767,8 @@ class Account(
val expired = concordSessions.sweepExpired(now)
for (rumors in expired.values) {
for (gone in rumors) {
// Its attachments' decryption keys go with it: a cached key would keep the blob readable.
gone.attachmentUrls.forEach { encryptionKeyCache.remove(it) }
cache.getNoteIfExists(gone.rumorId)?.let { note ->
note.detachFromChildren()
cache.pruner.unlinkAndRemove(note)
@@ -799,7 +809,7 @@ class Account(
* decrypts the blob transparently on fetch (keyed by URL) — the same path NIP-17 encrypted media
* uses. Runs for both inbound wraps and our own local echo, so a sent image renders immediately.
*/
private fun registerConcordEncryptedImages(rumor: Event) {
internal fun registerConcordEncryptedImages(rumor: Event) {
val images = ChannelChat.encryptedImagesOf(rumor)
if (images.isEmpty()) return
images.forEach { img ->
@@ -4184,19 +4194,44 @@ class Account(
// A promotion to staff delivers the Control Plane write key inside the Grant
// itself (CORD-04 §3), so the fold that seats the role is also when it arrives.
runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) }
// A Private Channel rotation lands on its channel-rekey address (CORD-06 §2): adopt the new
// key, or drop the channel when it cut us.
runCatching { concord.drainConcordChannelRekeys() }.onFailure { Log.w("Concord", "channel rekey drain failed", it) }
// A Grant folding late turns a parked catch-up invite into one we adopt without a click.
runCatching { concord.drainConcordCatchUps() }.onFailure { Log.w("Concord", "catch-up drain failed", it) }
// An honored Kick naming us (CORD-04 §6): leave the community locally and say so.
runCatching { concord.drainConcordKicks() }.onFailure { Log.w("Concord", "kick drain failed", it) }
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
// found by re-resolving the invite link we joined through. Rate-limited internally.
runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
}
}
// CORD-04 §7: a PIN_MESSAGES holder owes keyless readers the deletion omission and the Edit
// refresh whether or not the channel is open, so the delayed pin duties run from the account —
// on every structural tick (a new Pin List head, a fold) and whenever a delete or an Edit lands.
// The scheduler itself waits 3–15 s, keeps one duty per channel and one attempt per debt.
scope.launch {
@OptIn(FlowPreview::class)
merge(
concordSessions.revision.map { },
cache.live.newEventBundles
.filter { notes -> notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent } }
.map { },
).sample(1000).collect {
runCatching { concord.scheduleConcordPinDuties(scope) }.onFailure { Log.w("Concord", "pin duty scheduling failed", it) }
}
}
// CORD-08 §3: purge disappearing Concord messages when they expire. Sleeps until the earliest
// deadline any joined community holds and never wakes while nothing carries one, so a
// community without a timer costs nothing. A new earlier deadline restarts the wait.
scope.launch(Dispatchers.IO) {
concordSessions.nextExpiry.collectLatest { at ->
if (at == null) return@collectLatest
val waitMs = (at - TimeUtils.now()) * 1000
// Coalesced: sleep a little past the deadline and sweep everything due by then, so a
// burst of messages sent seconds apart expires in one pass instead of one sweep each.
val waitMs = (at - TimeUtils.now()) * 1000 + CONCORD_EXPIRY_COALESCE_MS
if (waitMs > 0) delay(waitMs)
runCatching { sweepExpiredConcordMessages() }.onFailure { Log.w("Concord", "expired-message sweep failed", it) }
}
@@ -27,9 +27,12 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext
import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome
import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite
import com.vitorpamplona.amethyst.commons.actions.ConcordPinnedMessage
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.actions.toRumor
import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays
import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult
import com.vitorpamplona.amethyst.commons.model.Note
@@ -39,6 +42,8 @@ import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox
import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView
import com.vitorpamplona.amethyst.commons.model.concord.ConcordKickNotice
import com.vitorpamplona.amethyst.commons.model.concord.ConcordPinDutyScheduler
import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan
import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute
import com.vitorpamplona.amethyst.commons.util.ConcurrentSet
@@ -53,12 +58,17 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeExcep
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.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitations
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
@@ -68,8 +78,11 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
@@ -99,19 +112,26 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeoutOrNull
/** Name of the default Concord community Admin role minted by "Make admin". */
@@ -128,6 +148,9 @@ private const val SESSION_WAIT_MS = 10_000L
*/
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
/** How many times a Pin List write re-applies itself on top of a concurrent edition that won the fold. */
private const val PIN_REHEAL_RETRIES = 2
/**
* How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`.
*
@@ -137,6 +160,9 @@ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
*/
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
// How many channel epochs a privatisation probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH).
private const val MAX_PROBED_CHANNEL_EPOCH = 32L
/** A lowercase 32-byte hex key (the Guestbook `invite` tag's creator). */
private val HEX64 = Regex("^[0-9a-f]{64}$")
@@ -234,6 +260,21 @@ class AccountConcordActions(
/** Adds or replaces [entry] in the Community List; false when it could not be written. */
private suspend fun persistConcordEntry(entry: ConcordCommunityListEntry): Boolean = writeConcordList { it.follow(entry) }
/**
* Rewrites the held entry for [communityId] through [transform], applied to the entry **as the
* List holds it inside the write** ([ConcordChannelListState.update]) — never to a snapshot read
* before a suspension, which a concurrent rekey or import could have moved on. True only when
* [transform] produced a change and it was written.
*/
private suspend fun updateConcordEntry(
communityId: String,
transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?,
): Boolean {
var changed = false
val ok = writeConcordList { list -> list.update(communityId) { cur -> transform(cur)?.also { changed = true } } }
return ok && changed
}
/** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */
private suspend fun announceConcordGuestbookJoin(
entry: ConcordCommunityListEntry,
@@ -399,24 +440,30 @@ class AccountConcordActions(
retired: List<HexKey> = emptyList(),
): Boolean {
val session = account.concordSessions.sessionFor(entry.id) ?: return false
// Only from a drained fold: `published` is read off our honored head, and a partial fold
// would read "no registry" and chain a fresh v1 over the real one (dropped by every reader).
val state = session.foldForWrite() ?: return false
// A dissolved community has no future (CORD-02 §9): no registry edit can change anything.
if (state.dissolved) return false
if (!isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE)) return false
val cp = controlKeysForWrite(session) ?: return false
val me = account.signer.pubKey
val state = session.state.value
val published = state?.registryOf(me).orEmpty()
val published = state.registryOf(me)
val next = ConcordInviteRegistry.nextLinks(published, list, entry.id, TimeUtils.now(), minted, retired)
val hasHead = state?.inviteRegistries?.containsKey(me.lowercase()) == true
val hasHead = state.inviteRegistries.containsKey(me.lowercase())
if (next == published.sorted() && (hasHead || next.isEmpty())) return false
// The writer chains off the same authorized head the fold honors (ConcordModeration.headOf).
// The writer chains off the same authorized, floor-aware head the fold honors.
val wrap =
try {
ConcordModeration.setInviteRegistry(account.signer, cp, entry.id.hexToByteArray(), next, session.controlEditions(), TimeUtils.now(), owner = entry.owner)
ConcordModeration.setInviteRegistry(account.signer, cp, entry.id.hexToByteArray(), next, session.controlEditions(), TimeUtils.now(), owner = entry.owner, floors = session.controlFloors())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Concord", "invite registry build failed for ${entry.id}", e)
return false
}
publishConcordWrap(entry, wrap)
return true
// Confirmed: the registry is the community's Public/Private source of truth (CORD-05 §5).
return publishConcordWrapConfirmed(entry, wrap)
}
/**
@@ -844,15 +891,41 @@ class AccountConcordActions(
* The Direct Invite inbox: wraps from the dedicated sweep ([refreshConcordDirectInvites]) and
* from the NIP-17 giftwrap pipeline land here, parked until the user accepts or declines.
*/
val directInviteInbox = ConcordDirectInviteInbox(account.signer)
val directInviteInbox =
ConcordDirectInviteInbox(
account.signer,
// Muted/blocked senders never park; followed ones outrank strangers when the inbox is full.
isHidden = { account.isHidden(it) },
isFollowed = { it in account.followingKeySet() },
)
/**
* The parked Direct Invites a UI should show, newest first: invites for communities we don't
* hold, plus catch-ups for ones we do ([ConcordDirectInviteInbox.visible]).
* The parked Direct Invites a UI should show, followed senders first, then newest: invites for
* communities we don't hold (nor left after they were sent), plus catch-ups for ones we do
* ([ConcordDirectInviteInbox.visible]).
*/
val pendingConcordDirectInvites: StateFlow<List<ConcordDirectInviteView>> =
combine(directInviteInbox.pending, account.concordChannelList.liveCommunities) { pending, joined ->
ConcordDirectInviteInbox.visible(pending.values, joined)
combine(
directInviteInbox.pending,
account.concordChannelList.liveCommunities,
account.concordChannelList.removedAt,
account.hiddenUsers.flow,
// A fold landing can make a parked catch-up admissible or not (the sender's standing).
combine(account.kind3FollowList.flow, account.concordSessions.revision) { follows, _ -> follows },
) { pending, joined, removedAt, _, follows ->
ConcordDirectInviteInbox.visible(
pending.values,
joined,
removedAt = removedAt,
isFollowed = { it in follows.authors },
isHidden = { account.isHidden(it) },
heldStateOf = { id ->
account.concordSessions
.sessionFor(id)
?.state
?.value
},
)
}.stateIn(account.scope, SharingStarted.WhileSubscribed(5_000), emptyList())
/**
@@ -878,9 +951,42 @@ class AccountConcordActions(
val filter = ConcordActions.directInvitesFilter(account.signer.pubKey, directInviteInbox.since())
val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(filter) })
wraps.distinctBy { it.id }.forEach { directInviteInbox.offer(it) }
drainConcordCatchUps()
return (directInviteInbox.pending.value.keys - before).size
}
/**
* Adopts, without a click, every parked catch-up the held fold says is exactly the delivery a
* Grant prescribes (Armada `judgeCatchUp`, [ConcordInviteVend.judgeCatchUp]): a staff sender, a
* recipient who isn't banned, and only live Private Channels our Roles entitle us to. Consent
* came from the Grant. Anything else waits for a manual Accept. Runs after an inbox sweep and on
* the revision tick (a Grant folding late turns a waiting catch-up adoptable).
*/
internal suspend fun drainConcordCatchUps() {
if (!account.isWriteable()) return
val me = account.signer.pubKey
val now = TimeUtils.nowMillis()
for (opened in directInviteInbox.pending.value.values) {
if (opened.isExpired(now)) continue
val held =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id.equals(opened.invite.communityId, ignoreCase = true) } ?: continue
val state =
account.concordSessions
.sessionFor(held.id)
?.state
?.value ?: continue
if (state.dissolved) continue
val verdict = ConcordInviteVend.judgeCatchUp(state.authority, state.privateChannelIds, me, opened.sender, opened.invite, held)
if (verdict != ConcordInviteVend.CatchUpVerdict.ADOPT) continue
val ids = ConcordInviteVend.catchUpChannelIds(held, opened.invite)
if (updateConcordEntry(held.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, opened.invite, ids) }) {
Log.i("Concord") { "Adopted a granted Private Channel key for ${held.id} from ${opened.sender}" }
directInviteInbox.resolve(opened.wrapId)
}
}
}
/**
* The recipient's giftwrap inbox (CORD-05 §6): their kind-10050 DM relays, else NIP-65 read
* relays — from the cache when we have their lists, fetched otherwise — else the stock set.
@@ -916,6 +1022,7 @@ class AccountConcordActions(
communityId: String,
recipientPubKey: HexKey,
expiresAtMs: Long? = null,
onlyChannelIds: Set<HexKey>? = null,
): ConcordDirectInviteSendResult {
if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE
val recipient = recipientPubKey.lowercase()
@@ -929,7 +1036,7 @@ class AccountConcordActions(
?.state
?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED
val invite =
when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) {
when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs, onlyChannelIds)) {
is ConcordDirectInviteDraft.Refused -> return draft.reason
is ConcordDirectInviteDraft.Ready -> draft.invite
}
@@ -971,9 +1078,12 @@ class AccountConcordActions(
// No folded roster yet: whether it bans us is unknown, so the invite waits.
DirectInviteAcceptPlan.RosterNotLoaded -> ConcordInviteResult.NotReachable
DirectInviteAcceptPlan.NothingNew -> ConcordInviteResult.Joined(bundle.communityId)
// Keys only, on the held base: no second Guestbook Join.
is DirectInviteAcceptPlan.CatchUp ->
if (persistConcordEntry(plan.entry)) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable
// Keys only, on the held base: no second Guestbook Join. Re-applied to the entry the
// List holds at write time, so a root imported meanwhile is never written back over.
is DirectInviteAcceptPlan.CatchUp -> {
val ok = writeConcordList { list -> list.update(plan.entry.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, bundle, plan.channelIds) } }
if (ok) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable
}
DirectInviteAcceptPlan.Join ->
joinValidatedConcordInvite(
bundle = bundle,
@@ -1140,7 +1250,8 @@ class AccountConcordActions(
.findEmojiTags(newText)
.map { it.toTagArray() }
.toTypedArray()
val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags, session.messageExpirationSecs())
// CORD-08 §2: the Edit keeps the ORIGINAL message's deadline (none if it has none), never now + timer.
val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags, expiration = ConcordDisappearing.expirationOf(target))
publishConcordWrap(entry, wrap)
return true
}
@@ -1246,6 +1357,31 @@ class AccountConcordActions(
if (relays.isNotEmpty()) account.client.publish(wrap, relays)
}
/**
* [publishConcordWrap] for a Control Plane edition whose caller must know it landed: waits for a
* relay OK (`publish` only queues and never reports acceptance) and folds the wrap into the
* session only then, so a refused write never shows as done locally. False when no relay of the
* community accepted it.
*/
private suspend fun publishConcordWrapConfirmed(
entry: ConcordCommunityListEntry,
wrap: Event,
): Boolean {
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isEmpty()) return false
val landed =
try {
account.client.publishAndConfirm(wrap, relays)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Concord", "Control edition publish failed for ${entry.id}", e)
false
}
if (landed) account.concordSessions.ingest(wrap)
return landed
}
/**
* Echo an own Concord channel message into its feed and publish it, driving
* the bubble's send state as it goes.
@@ -1371,6 +1507,7 @@ class AccountConcordActions(
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
val before = session.state.value?.authority
// A Grant that first makes its member staff must deliver the control_root in the same
// edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the
// roles carry a Control-writing bit and we hold the secret to hand over.
@@ -1388,6 +1525,8 @@ class AccountConcordActions(
epoch = session.entry.rootEpoch,
)
publishConcordWrap(session.entry, wrap)
// Role-gated channel keys follow the Grant: vend what it opened, rotate what it closed.
reconcileConcordChannelAccess(communityId, before)
return true
}
@@ -1500,8 +1639,10 @@ class AccountConcordActions(
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
val before = session.state.value?.authority
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, grantWrap)
reconcileConcordChannelAccess(communityId, before)
return true
}
@@ -1558,14 +1699,20 @@ class AccountConcordActions(
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
val before = session.state.value?.authority
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
// Judged on the fold that now carries the ban (publishConcordWrap ingests it first).
val state = session.state.value
if (state != null && state.banRequiresRefounding(listOf(member))) {
// The Refounding rotates every held Private Channel to its entitled set (CORD-06 §3).
if (!refoundConcordCommunity(communityId, setOf(member))) {
Log.w("Concord") { "Banned $member from the Private community $communityId, but its Refounding did not complete" }
}
} else {
// A Public ban keeps the base, so the Private Channels the target could read are cut by
// their own rekeys (CORD-04 §6: "a Private-Channel rekey for a channel-scoped cut").
reconcileConcordChannelAccess(communityId, before)
}
return true
}
@@ -1583,6 +1730,80 @@ class AccountConcordActions(
return true
}
/**
* Kick [member] (CORD-04 §6, the Cooperative Kick): Role Removal first — an empty Grant when they
* hold roles and this account may strip them (MANAGE_ROLES + outrank; best-effort, as the
* reference client) — so their rank is gone before the departure lands, *then* the Guestbook
* directive (kind 3309) citing our Grant (`vac`). Needs KICK and a strict outrank of the target;
* the directive rides the Guestbook, so no Control write key is needed for it. A kicked member
* may re-join or be re-invited. Refused on a dissolved community (CORD-02 §9).
*/
suspend fun kickConcordMember(
communityId: String,
member: HexKey,
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
if (!isAuthorizedFor(session, ConcordPermissions.KICK, member)) return false
if (session.state.value?.dissolved == true) return false
val me = account.signer.pubKey
val authority = session.state.value?.authority
if (authority != null && authority.rolesOf(member).isNotEmpty() &&
(authority.isOwner(me) || authority.canActOn(me, member, ConcordPermissions.MANAGE_ROLES))
) {
if (!grantConcordRole(communityId, member, emptyList())) {
Log.w("Concord") { "Kick of $member in $communityId: could not strip their roles first; kicking anyway" }
}
}
val entry = session.entry
val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch)
val citation =
session.state.value
?.authority
?.let { AuthorityCitations.forActor(it, me) }
val wrap = ConcordActions.buildGuestbookKick(account.signer, guestbook, member, citation, TimeUtils.now())
publishConcordWrap(entry, wrap)
return true
}
private val _concordKicks = MutableSharedFlow<ConcordKickNotice>(extraBufferCapacity = 16)
/** One notice per community this account left because an honored Kick named it ([drainConcordKicks]). */
val concordKicks: SharedFlow<ConcordKickNotice> = _concordKicks.asSharedFlow()
// Rumor ids of the Kicks already complied with, so a revision tick racing the List write acts once.
private val handledConcordKicks = ConcurrentSet<HexKey>()
/**
* Compliance with a Kick against this account (CORD-04 §6): a compliant client tears the
* Community down locally. For each joined community whose coalesced Guestbook carries an honored
* Kick naming us that postdates this membership ([ConcordCommunitySession.kickedMe]), drop the
* List entry and tombstone it, exactly as a Leave does, and tell the user
* ([concordKicks]). Network-silent beyond the List write (no Guestbook Leave): the Kick already
* says we departed. The tombstone never blocks a later re-join — re-adding an entry bumps its
* `added_at` past the tombstone, and that newer `added_at` puts this Kick behind the new
* membership. Runs on the Concord revision tick; a Guestbook arrival and a control fold (which can
* newly honor a parked Kick) both bump it.
*/
internal suspend fun drainConcordKicks() {
if (!account.isWriteable()) return
for (session in account.concordSessions.sessions()) {
val kick = session.kickedMe() ?: continue
if (!handledConcordKicks.add(kick.rumorId)) continue
val communityId = session.entry.id
val name =
session.state.value
?.metadata
?.name
if (leaveConcordCommunity(communityId)) {
Log.i("Concord") { "Kicked from $communityId by ${kick.author}: left the community locally (CORD-04 §6)" }
_concordKicks.tryEmit(ConcordKickNotice(communityId, name, kick.author))
} else {
handledConcordKicks.remove(kick.rumorId)
}
}
}
// ── Concord pins (CORD-04 §7) ─────────────────────────────────────────────
// A Channel's Pin List rides the Control Plane as one replace-entire edition (vsk 11) of
// self-proving entries. The read side verifies every entry and applies what this client holds
@@ -1625,6 +1846,18 @@ class AccountConcordActions(
)
}
/**
* [pinned] as the rumor the chat feed would render — its newest proven words over the original's
* tags (the NIP-92 `imeta` attachments), with the encrypted attachments' keys registered so the
* shared media pipeline fetches and decrypts them exactly as for a feed message. A pin proves its
* message without this account ever having held it, so the keys come from the proof itself.
*/
fun concordPinnedRumor(pinned: ConcordPinnedMessage): Event {
val rumor = pinned.toRumor()
account.registerConcordEncryptedImages(rumor)
return rumor
}
/** The author's newest Concord Edit this account holds for [rumorId], or null. */
private fun heldConcordEdit(
rumorId: HexKey,
@@ -1655,6 +1888,11 @@ class AccountConcordActions(
/**
* Runs one pin write: re-reads the list inside the lock (the previous write was echoed into the
* session, so this chains onto it), resolves the context, and publishes the edition [op] builds.
*
* The publish waits for a relay OK ([ConcordPinOutcome.NOT_CONFIRMED] otherwise). Then, like a
* ban, the write re-heals: a concurrent curator's edition at the same version may win the fold
* (CORD-04 §1), silently dropping this change, so when the refolded head is not ours the same
* [op] runs again on top of the winner — at most [PIN_REHEAL_RETRIES] times.
*/
private suspend fun writeConcordPins(
communityId: String,
@@ -1663,6 +1901,7 @@ class AccountConcordActions(
): ConcordPinOutcome =
concordPinMutex.withLock {
if (!account.isWriteable()) return@withLock ConcordPinOutcome.NOT_WRITEABLE
repeat(1 + PIN_REHEAL_RETRIES) {
val session = account.concordSessions.sessionFor(communityId) ?: return@withLock ConcordPinOutcome.NOT_FOLDED
val definition =
session.state.value
@@ -1684,8 +1923,16 @@ class AccountConcordActions(
authorized = holdsConcordPinBit(session),
)
val write = op(session, ctx)
write.wrap?.let { publishConcordWrap(session.entry, it) }
write.outcome
// A retry that finds the winner already says what we meant (ALREADY_PINNED, NOT_PINNED,
// NOTHING_TO_DO) ends here too.
val wrap = write.wrap ?: return@withLock write.outcome
if (!publishConcordWrapConfirmed(session.entry, wrap)) return@withLock ConcordPinOutcome.NOT_CONFIRMED
val ours = ConcordStreamEnvelope.openOrNull(wrap, ctx.controlPlane)?.let { ControlEdition.fromOpened(it) }?.rumorId
if (ours == null || session.pinHeads.value[channelIdHex]?.rumorId == ours) return@withLock ConcordPinOutcome.PUBLISHED
Log.i("Concord") { "Pin List edit in $communityId lost the fold to a concurrent edition; re-applying on the winner" }
}
// Landed every time but kept losing the tie-break: it is on the relays, just not the head.
ConcordPinOutcome.PUBLISHED
}
/** Pin Concord message [note] into its channel's Pin List, proving it with its original seal. */
@@ -1745,6 +1992,30 @@ class AccountConcordActions(
}
}
private val concordPinDuties = ConcordPinDutyScheduler()
/**
* Schedules, in [scope], the delayed pin duties (CORD-04 §7) this account owes in every joined
* community where it may write pins: for each Channel with a Pin List head whose read owes a
* republish, one [settleConcordPins] after the 3–15 s random wait ([ConcordPinDutyScheduler]).
* Called by the account on the Concord revision tick and when a delete or an Edit lands — the
* duties no longer depend on the channel screen being open.
*/
internal fun scheduleConcordPinDuties(scope: CoroutineScope) {
if (!account.isWriteable()) return
for (session in account.concordSessions.sessions()) {
val communityId = session.entry.id
if (!canPinConcord(communityId)) continue
for (channelIdHex in session.pinHeads.value.keys) {
val debt = ConcordPinDutyScheduler.debtOf(concordChannelPins(communityId, channelIdHex))
concordPinDuties.schedule(scope, ConcordPinDutyScheduler.keyOf(communityId, channelIdHex), debt) {
runCatching { settleConcordPins(communityId, channelIdHex) }
.onFailure { Log.w("Concord", "pin duty for $channelIdHex in $communityId failed", it) }
}
}
}
}
// ── Concord refounding / rekey (CORD-06) ──────────────────────────────────
// A ban is a soft removal — the banned member still holds the room key and can
// still decrypt traffic; every client just declines to *show* their posts. A
@@ -1921,9 +2192,14 @@ class AccountConcordActions(
}
if (!compactionLanded) Log.w("Concord") { "Refounding ${entry.id}: some compacted Control Plane heads were not accepted at epoch ${build.newEpoch}" }
// 5b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among the
// kept members, sealed under the PRIOR root so a base-fork loser can still open it. A
// channel that fails to land keeps its key (and is logged): resumable, not atomic.
val rotatedEntry = rotatePrivateChannelsForRefounding(entry, recipients.toSet(), priorRoot, citation)
// 6. Adopt the new epoch ourselves. This rebuilds our session under the new root and
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
val adopted = adoptConcordRoot(entry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot)
val adopted = adoptConcordRoot(rotatedEntry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot)
pendingConcordRefoundings.remove(entry.id)
// 7. Move every link we minted to the new epoch. Without this the Refounding orphans them,
@@ -1935,6 +2211,40 @@ class AccountConcordActions(
return compactionLanded
}
/**
* The Refounding's channel duty (CORD-06 §3): every held key of a live Private Channel is
* rotated to the members still entitled to it ∩ [kept], plus ourselves, sealed under
* [priorRoot]. Returns [entry] with each rotated channel moved to its new key (the caller adopts
* the new root from it); a channel whose rotation didn't land keeps its old key.
*/
private suspend fun rotatePrivateChannelsForRefounding(
entry: ConcordCommunityListEntry,
kept: Set<HexKey>,
priorRoot: ByteArray,
citation: AuthorityCitation?,
): ConcordCommunityListEntry {
val session = account.concordSessions.sessionFor(entry.id) ?: return entry
val state = session.state.value ?: return entry
val me = account.signer.pubKey.lowercase()
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
val keptLower = kept.mapTo(HashSet()) { it.lowercase() }
var next = entry
for (held in entry.privateChannels) {
val id = held.channelId.lowercase()
if (id !in state.privateChannelIds || ConcordChannelKeyring.heldKey(entry, id) == null) continue
val keep = ConcordPrivateChannels.keepSet(state.authority, id, me).filterTo(HashSet()) { it in keptLower || it == me }
val newKey = ConcordChannelRekey.mintKey()
val wraps = ConcordPrivateChannels.buildRotation(account.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation)
val landed = wraps.all { runCatching { account.client.publishAndConfirm(it, publishTo) }.getOrDefault(false) }
if (!landed) {
Log.w("Concord") { "Refounding ${entry.id}: the rekey of private channel $id did not land; it keeps its key" }
continue
}
next = ConcordChannelKeyring.withRotatedKey(next, id, newKey.toHexKey(), held.epoch + 1) ?: next
}
return next
}
// Keys reserved for a Refounding in flight, per community (CORD-06 §3): a retry of the same
// rotation reuses them. Process-local — a restart mid-rotation mints afresh, which is why the
// rekey chunks are all confirmed before anything is adopted.
@@ -2265,12 +2575,15 @@ class AccountConcordActions(
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
// Start from the folded metadata so a field this form doesn't edit — the CORD-08 timer, above
// all — is carried forward instead of reset (CORD-02 §6 round-trip).
val standing = session.state.value?.metadata ?: MetadataEntity()
// all — is carried forward instead of reset (CORD-02 §6 round-trip). Only from a drained fold:
// minting over a head we were never served would chain a fresh v1 (or a stale version) that
// wipes the name, relays and timer — the owner included, who may otherwise act before the fold.
val folded = session.foldForWrite() ?: return false
val standing = folded.metadata ?: MetadataEntity()
val metadata = standing.copy(name = name, icon = icon, banner = banner, description = description, relays = relays)
// CORD-02 §6 caps are fold gates too: an edition past them would be dropped by every reader.
if (!ConcordLimits.metadataFits(metadata)) return false
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner, floors = session.controlFloors())
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2290,9 +2603,11 @@ class AccountConcordActions(
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
val timer = secs?.takeIf { it >= 1 }
val standing = session.state.value?.metadata ?: MetadataEntity()
// Same rule as editConcordMetadata: never lay the timer over a head we were not served.
val folded = session.foldForWrite() ?: return false
val standing = folded.metadata ?: MetadataEntity()
if (standing.messageExpirationSecs() == timer) return false
val wrap = ConcordModeration.setMessageExpiration(account.signer, cp, communityId.hexToByteArray(), standing, timer, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.setMessageExpiration(account.signer, cp, communityId.hexToByteArray(), standing, timer, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner, floors = session.controlFloors())
publishConcordWrap(session.entry, wrap)
postConcordTimerNotices(session, timer ?: 0)
return true
@@ -2337,10 +2652,13 @@ class AccountConcordActions(
suspend fun createConcordChannel(
communityId: String,
name: String,
private: Boolean = false,
accessRoleName: String? = null,
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
if (private) return createPrivateConcordChannel(session, cp, communityId, name, accessRoleName)
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.
@@ -2397,6 +2715,244 @@ class AccountConcordActions(
return true
}
// ── Private Channels (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) ──────────────────
// A Private Channel reads on its own independent key. Its access list is the Roles scoped to it:
// a Grant that opens one vends the key by Direct Invite, a Grant (or ban) that closes one rotates
// it to the members still entitled. The protocol decisions live in ConcordPrivateChannels /
// ConcordChannelRekey (shared with `amy`); only the network and the List write are here.
/**
* A new Private Channel (CORD-03 §2): an independent key at channel epoch 0 stored in the List
* FIRST (a lost List write would orphan the only copy), then its access Role and the channel
* edition. Nobody holds the Role yet; granting it vends the key ([grantConcordRole]).
*/
private suspend fun createPrivateConcordChannel(
session: ConcordCommunitySession,
cp: ControlPlaneKeys,
communityId: String,
name: String,
accessRoleName: String?,
): Boolean {
val build =
ConcordPrivateChannels.create(
actor = account.signer,
cp = cp,
communityId = communityId.hexToByteArray(),
name = name,
accessRoleName = accessRoleName,
current = session.controlEditions(),
authority = session.state.value?.authority,
owner = session.entry.owner,
createdAt = TimeUtils.now(),
) ?: return false
if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false
build.wraps.forEach { publishConcordWrap(session.entry, it) }
return true
}
/**
* Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh key at the NEXT
* channel epoch — floored at the highest channel rotation seen on the wire, since a privatiser
* may never have held an earlier generation and a reused epoch is silent and unrecoverable — plus
* a new access Role, then the flag. Protects the future only. MANAGE_CHANNELS.
*/
suspend fun privatizeConcordChannel(
communityId: String,
channelIdHex: HexKey,
accessRoleName: String? = null,
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
val state = session.state.value ?: return false
val standing = state.channels[channelIdHex]?.definition ?: return false
if (standing.private) return false
val floor = observedChannelEpochFloor(session.entry, channelIdHex) ?: return false
val build =
ConcordPrivateChannels.privatize(
actor = account.signer,
cp = cp,
entry = session.entry,
channelIdHex = channelIdHex,
standing = standing,
accessRoleName = accessRoleName,
current = session.controlEditions(),
authority = state.authority,
createdAt = TimeUtils.now(),
observedFloor = floor,
) ?: return false
if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false
build.wraps.forEach { publishConcordWrap(session.entry, it) }
return true
}
/**
* The highest channel epoch a rotation of [channelIdHex] was ever published at, read off the
* rekey addresses every held root derives (CORD-06 §2 addresses need no channel key), or null
* when the read is inconclusive — a rotation at the window's top may have more above it, and
* minting on a guess could reuse an epoch (Armada `channelEpochFloor`). 0 when none is seen or no
* relay answers.
*/
private suspend fun observedChannelEpochFloor(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
): Long? {
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isEmpty()) return 0
val channelId = channelIdHex.hexToByteArray()
val window = HashMap<HexKey, Long>()
for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) {
for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId, epoch).publicKeyHex] = epoch
}
val seen = runCatching { account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }) }.getOrDefault(emptyList())
val highest = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0
return if (highest >= MAX_PROBED_CHANNEL_EPOCH) null else highest
}
/**
* Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The
* held key stays, so its holders keep reading the private era. MANAGE_CHANNELS.
*/
suspend fun publicizeConcordChannel(
communityId: String,
channelIdHex: HexKey,
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
val standing =
session.state.value
?.channels
?.get(channelIdHex)
?.definition ?: return false
val wrap = ConcordPrivateChannels.publicize(account.signer, cp, communityId.hexToByteArray(), channelIdHex, standing, session.controlEditions(), session.entry.owner, TimeUtils.now()) ?: return false
publishConcordWrap(session.entry, wrap)
return true
}
// Channel keys reserved for a rotation in flight, keyed by (community, channel, new epoch,
// prevcommit): a retry re-delivers the SAME key rather than minting a sibling that would split
// the members across two keys at one epoch (Armada `mintOrReuseRotationKey`). Process-local.
private val pendingConcordChannelRotations = ConcurrentMap<String, ByteArray>()
/**
* Rotates Private Channel [channelIdHex] (a single-channel Rekey, CORD-06 §1-2) to exactly the
* members entitled to it today plus ourselves, cutting everyone else. [removed] names who the
* rotation cuts, for the authority check — the Rotator must hold MANAGE_CHANNELS and strictly
* outrank each of them; null takes every known member who is not kept. Every chunk must land on
* a relay before we adopt the new key. Returns whether the rotation was published and adopted.
*/
suspend fun rekeyConcordChannel(
communityId: String,
channelIdHex: HexKey,
removed: Set<HexKey>? = null,
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val state = session.state.value ?: return false
if (state.dissolved) return false
val entry = session.entry
val me = account.signer.pubKey
val held = ConcordChannelKeyring.heldKey(entry, channelIdHex) ?: return false
val authority = state.authority
val keep = ConcordPrivateChannels.keepSet(authority, channelIdHex, me)
val cut = removed ?: (session.allMembers() - keep)
if (!ConcordPrivateChannels.canRotate(authority, me, cut)) {
Log.w("Concord") { "Refusing to rotate $channelIdHex in $communityId: not MANAGE_CHANNELS, or does not outrank a cut member (CORD-06 §3)" }
return false
}
val editions = session.controlEditions()
val citation = ConcordReceive.rotationCitation(entry, editions, me)
if (citation == null && !authority.isOwner(me)) return false
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (publishTo.isEmpty()) return false
val newEpoch = held.epoch + 1
val reservation = "${entry.id}:${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}"
val newKey = pendingConcordChannelRotations.getOrPut(reservation) { ConcordChannelRekey.mintKey() }
val wraps = ConcordPrivateChannels.buildRotation(account.signer, entry.root.hexToByteArray(), held, newKey, keep, TimeUtils.now(), citation)
for (wrap in wraps) {
if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) {
Log.w("Concord") { "Channel rekey of $channelIdHex aborted: a chunk was not accepted by any relay; retrying reuses the same key" }
return false
}
}
// Adopt at once: the rotator must never keep writing under the severed key.
val adopted =
updateConcordEntry(entry.id) { cur ->
if (ConcordChannelKeyring.heldKey(cur, channelIdHex)?.epoch != held.epoch) null else ConcordChannelKeyring.withRotatedKey(cur, channelIdHex, newKey.toHexKey(), newEpoch)
}
if (adopted) pendingConcordChannelRotations.remove(reservation)
return adopted
}
/**
* Follows a roster change with the keys it implies (Armada `handleToggleRole`): every Private
* Channel whose entitled set moved between [before] and the current fold ([ConcordInviteVend.accessChanges])
* — a member who gained one is handed its key by Direct Invite (only the channels gained), and
* one who lost one is cut by rotating it. Only keys we hold can move; a channel we can't vend or
* rotate is logged, since a revoke that cuts nobody is the failure to hear about.
*/
private suspend fun reconcileConcordChannelAccess(
communityId: String,
before: AuthorityResolver?,
) {
val session = account.concordSessions.sessionFor(communityId) ?: return
val state = session.state.value ?: return
if (before == null || state.dissolved) return
val me = account.signer.pubKey.lowercase()
val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds)
if (changes.isEmpty()) return
val vend = HashMap<HexKey, MutableSet<HexKey>>()
for (change in changes) {
val held = ConcordChannelKeyring.heldKey(session.entry, change.channelIdHex)
if (held == null) {
Log.w("Concord") { "Access to ${change.channelIdHex} changed, but we hold no key to vend or rotate it" }
continue
}
for (member in change.gained - me) vend.getOrPut(member) { HashSet() }.add(change.channelIdHex)
val cut = change.lost - me
if (cut.isNotEmpty() && !rekeyConcordChannel(communityId, change.channelIdHex, cut)) {
Log.w("Concord") { "Could not rotate ${change.channelIdHex}: ${cut.size} member(s) may keep reading it until someone who can rotates it" }
}
}
for ((member, channels) in vend) {
val sent = sendConcordDirectInvite(communityId, member, onlyChannelIds = channels)
if (sent != ConcordDirectInviteSendResult.SENT) Log.w("Concord") { "Could not deliver ${channels.size} channel key(s) to $member: $sent" }
}
}
/**
* Drains the buffered channel rekeys of every joined community (CORD-06 §2 receive path): for
* each held Private Channel, a complete, honored rotation carrying our blob off the key we hold
* moves the key forward; a complete one from a Rotator who outranks us that omits us drops it and
* records the cut. Runs on the revision tick; idempotent once applied (the held epoch moves on).
*/
internal suspend fun drainConcordChannelRekeys() {
if (!account.isWriteable()) return
for (session in account.concordSessions.sessions()) {
val state = session.state.value ?: continue
// Death wins every race (CORD-02 §9).
if (state.dissolved) continue
val wraps = session.pendingChannelRekeyWraps()
if (wraps.isEmpty()) continue
val entry = session.entry
val outcomes = ConcordPrivateChannels.receive(entry, wraps, session.controlEditions(), state.authority, account.signer)
if (outcomes.isEmpty()) continue
val fromEpochs = entry.privateChannels.associate { it.channelId.lowercase() to it.epoch }
if (updateConcordEntry(entry.id) { cur -> ConcordPrivateChannels.applyOutcome(cur, outcomes, fromEpochs) }) {
for ((id, outcome) in outcomes) {
when (outcome) {
is ChannelRekeyOutcome.Adopted -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: adopted epoch ${outcome.epoch}" }
is ChannelRekeyOutcome.Removed -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: cut at epoch ${outcome.epoch}" }
ChannelRekeyOutcome.None -> Unit
}
}
}
}
}
/**
* Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from
* the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT
@@ -2523,7 +3079,7 @@ class AccountConcordActions(
* never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the
* whole plane every run.
* - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can
* crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until`
* crop a busy Control Plane. This **pages past the cap** ([fetchAllPages] walks `until`
* cursors until a plane is drained), so the fold sees every edition regardless of the cap.
*
* Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the
@@ -2543,9 +3099,74 @@ class AccountConcordActions(
if (authorsByRelay.isEmpty()) return
// No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a
// plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap.
val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) }
var drained = 0
account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ }
Log.d("Concord") { "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)" }
// Paged per relay (8 at a time) rather than through the pool helper, because the drained flag
// below needs each relay's ending: only DRAINED proves the relay had nothing more.
val gate = Semaphore(8)
val perRelay =
coroutineScope {
authorsByRelay
.map { (relay, authors) ->
async {
gate.withPermit {
val wraps = ArrayList<Event>()
val end =
try {
account.client.fetchAllPages(relay, listOf(ConcordActions.planeFilterFor(authors.toList()))) { wraps.add(it) }.end
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Concord", "Control Plane sweep failed on $relay", e)
null
}
Triple(authors, end == PagedFetchResult.End.DRAINED, wraps)
}
}
}.awaitAll()
}
// Fold the swept wraps in before flagging anything drained: the global cache connector also
// ingests them, but asynchronously, and the flag must never run ahead of the fold (the session
// dedups by wrap id, so the second delivery is a no-op).
var swept = 0
for ((_, _, wraps) in perRelay) {
for (wrap in wraps) {
account.concordSessions.ingest(wrap)
swept++
}
}
val drainedAddresses = perRelay.filter { it.second }.flatMapTo(HashSet()) { it.first }
// One relay drained whole is enough for everyday writes (a dead relay must not freeze pins and
// metadata forever); the Refounding compaction keeps its own majority rule.
for (entry in entries) {
val session = account.concordSessions.sessionFor(entry.id) ?: continue
if (session.controlPlaneAddress in drainedAddresses) session.markControlDrained()
}
Log.d("Concord") { "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), swept $swept control wrap(s), ${drainedAddresses.size} plane(s) drained" }
pruneExpiredConcordRegistries(entries)
}
// Communities (at an epoch) whose registry was already checked for elapsed links this process.
private val registryPruneChecked = ConcurrentSet<String>()
/**
* CORD-05 §5: once a community's Control Plane has drained, republish this account's Invite
* Registry there pruned when it still lists a link the Invite List says has expired (or no longer
* holds). Otherwise an elapsed link — which nothing else ever retires — keeps the community Public
* forever, and a Private ban would never Refound. Once per community and epoch per process; the
* Invite List is read only when some drained community actually has a registry of ours.
*/
private suspend fun pruneExpiredConcordRegistries(entries: List<ConcordCommunityListEntry>) {
if (!account.isWriteable()) return
val me = account.signer.pubKey
val due =
entries.filter { entry ->
val state = account.concordSessions.sessionFor(entry.id)?.foldForWrite() ?: return@filter false
!state.dissolved && state.registryOf(me).isNotEmpty() && registryPruneChecked.add("${entry.id}@${entry.rootEpoch}")
}
if (due.isEmpty()) return
val list = readConcordInviteList() ?: return
for (entry in due) {
// Publishes only when the pruned list differs from the honored one.
if (publishConcordInviteRegistry(entry, list)) Log.i("Concord") { "Pruned elapsed invite links from this account's registry in ${entry.id}" }
}
}
}
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.IEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
@@ -346,7 +347,21 @@ class GiftWrapEventHandler(
eventNote: Note,
publicNote: Note,
) {
val innerGift = event.unwrapOrNull(account.signer) ?: return
val innerGift =
try {
event.unwrapThrowing(account.signer)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// A Direct Invite-tagged wrap (CORD-05 §6) that will never open is written off in the
// invite inbox too, so its sweep does not decrypt it again; a signer that merely
// could not answer now leaves it to be retried.
if (ConcordDirectInvite.isInviteTagged(event) && !ConcordDirectInvite.isTransientSignerFailure(e)) {
account.concord.directInviteInbox.markNotInvite(event.id)
}
Log.d("GiftWrapEvent") { "Couldn't Decrypt the content " + event.toNostrUri() }
return
}
eventNote.event = event.copyNoContent()
@@ -535,18 +550,33 @@ class SealEventHandler(
eventNote: Note,
publicNote: Note,
) {
val innerRumor = event.unsealOrNull(account.signer) ?: return
// Decrypted once, kept as the seal carries it (claimed author intact) so a Direct Invite can be
// checked against NIP-59 anti-spoofing without a second decrypt.
val rumor =
try {
event.unsealRumorThrowing(account.signer)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("RumorEvent", "Fail to decrypt or parse Rumor", e)
return
}
val innerRumor = event.unsealed(rumor)
// A Concord Direct Invite (CORD-05 §6) is a standard NIP-59 giftwrap, so the DM inbox sees
// it too — tagged `k=3313` or not. It is not a DM: its rumor carries a community's keys. Hand
// the seal to the Concord invite inbox, which re-opens it with the NIP-59 anti-spoofing check
// the generic unseal skips and parks it for the user, and keep the rumor out of the cache and
// every chat feed. Must run before the seal's content is stripped below.
// the seal and its rumor to the Concord invite inbox, which runs the NIP-59 anti-spoofing
// check the generic unseal skips and parks it for the user, and keep the rumor out of the
// cache and every chat feed.
if (innerRumor.kind == ConcordDirectInvite.KIND) {
account.concord.directInviteInbox.offerSeal(publicNote.event ?: event, event)
account.concord.directInviteInbox.offerRumor(publicNote.event ?: event, event, rumor)
eventNote.event = event.copyNoContent()
return
}
// Tagged as an invite but carrying something else: never one, so the inbox sweep skips it.
(publicNote.event as? GiftWrapEvent)?.let { wrap ->
if (ConcordDirectInvite.isInviteTagged(wrap)) account.concord.directInviteInbox.markNotInvite(wrap.id)
}
eventNote.event = event.copyNoContent()
@@ -196,6 +196,19 @@ class ConcordChannelListState(
emptyList(),
)
/**
* When this account left each community it no longer holds (community id → the List
* tombstone's `removed_at`, unix ms, CORD-02 §8). A Direct Invite sent at or before that moment
* stays buried instead of resurfacing right after the leave.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val removedAt: StateFlow<Map<String, Long>> =
listChanges
.transformLatest { emit(document().residue.removals()) }
.onStart { emit(document().residue.removals()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
/** The distinct community ids across the joined list — the "servers" rail. */
@OptIn(ExperimentalCoroutinesApi::class)
val liveServers: StateFlow<Set<String>> =
@@ -243,6 +256,28 @@ class ConcordChannelListState(
write(set, doc.entries.filterNot { it.id == entry.id } + live, doc.residue)
}
/**
* Read-modify-write one membership: [transform] receives the entry for [communityId] **as the
* List holds it inside the write lock** and returns its replacement, or null to write nothing.
* Returns the fragment events to publish (empty when the community isn't held or nothing
* changed).
*
* Use this, never [follow] with a snapshot, for any change that touches one field of a live
* entry (a channel key, a cut): the List can move between reading a snapshot and writing it —
* a rekey adopted, a new root imported — and following the stale copy would write the old
* root back over it.
*/
suspend fun update(
communityId: String,
transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?,
): List<Event> =
writeLock.withLock {
val (set, doc) = snapshot()
val current = doc.entries.firstOrNull { it.id == communityId } ?: return@withLock emptyList()
val next = transform(current) ?: return@withLock emptyList()
write(set, doc.entries.map { if (it.id == communityId) next else it }, doc.residue)
}
/**
* Leave [communityId]: drop its membership and tombstone it (CORD-02 §8 — only a tombstone
* subtracts a membership; a missing entry is just unseen news another fragment may still
@@ -27,14 +27,18 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordLocalEdit
import com.vitorpamplona.amethyst.commons.actions.ConcordPinSource
import com.vitorpamplona.amethyst.commons.actions.ConcordPinVerifier
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord02Community.Guestbook
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordWebxdc
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
@@ -73,6 +77,8 @@ data class ExpiredConcordRumor(
val wrapId: HexKey,
val rumorId: HexKey,
val expiresAt: Long,
/** The URLs of the rumor's encrypted attachments, whose decryption keys go with it (CORD-08 §3). */
val attachmentUrls: List<String> = emptyList(),
)
/**
@@ -283,6 +289,14 @@ class ConcordCommunitySession(
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
private val siblingRekeyWraps = LinkedHashMap<HexKey, Event>()
// Channel-rekey addresses (CORD-06 §2) for each held Private Channel's next epochs, under the
// current root and the prior one (a Refounding seals its channel rekeys under the prior root,
// CORD-06 §3) -> key. Re-derived whenever the held channel keys change, since each adoption
// moves the window forward.
@Volatile
private var channelRekeyKeys: Map<HexKey, GroupKey> = ConcordPrivateChannels.watchKeys(entry)
private val channelRekeyWraps = LinkedHashMap<HexKey, Event>()
// 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
@@ -301,6 +315,46 @@ class ConcordCommunitySession(
private val _state = MutableStateFlow<ConcordCommunityState?>(null)
val state: StateFlow<ConcordCommunityState?> = _state
private val _controlDrained = MutableStateFlow(false)
/**
* True once this session's current Control Plane has been swept whole at least once — every
* relay page drained and ingested ([markControlDrained]) — so [state] is a fold of the full plane
* rather than of whatever the live subscription delivered first.
*
* Until then the fold may be partial (a cropped first page, an edition below the live `since`
* cursor), and a write built on it can erase what it never saw: a Pin List edition replaces the
* list entire (CORD-04 §7: never write from a list the fold was not served), a metadata edition
* minted without the head resets the name and relays, a registry edit chains onto a stale head.
* Writers refuse while this is false; readers may show a partial fold but must not call an
* absent entity "none". One way: a session never un-drains (a Refounding builds a new session).
*/
val controlDrained: StateFlow<Boolean> = _controlDrained
/** Records that the whole current Control Plane has been paged in and ingested (see [controlDrained]). */
fun markControlDrained() {
_controlDrained.value = true
}
/**
* The fold a Control Plane write may be built from: [state] once the plane has drained, else
* null. Every writer that lays its edition over a folded head (metadata, timer, registry, pins)
* goes through this, the owner included — the owner may act before the fold, but not write over
* a head they have not been served.
*/
fun foldForWrite(): ConcordCommunityState? = if (_controlDrained.value) _state.value else null
// The anti-rollback floors the last fold used, so a writer can chain onto the same floor-aware head.
@Volatile
private var lastFloors: Map<String, EntityFloor> = emptyMap()
/**
* The per-entity anti-rollback floors (from the prior epochs' Control Planes) the current fold
* honors — what a writer passes so it chains onto the head readers fold to, not the head of the
* current epoch's editions alone.
*/
fun controlFloors(): Map<String, EntityFloor> = lastFloors
private val _pinHeads = MutableStateFlow<Map<HexKey, ControlEdition>>(emptyMap())
/**
@@ -312,9 +366,22 @@ class ConcordCommunitySession(
private val _members = MutableStateFlow<Set<HexKey>>(emptySet())
/** The live Guestbook membership set (self-signed joins minus later leaves). */
/** The live Guestbook membership set (self-signed joins minus later leaves and honored Kicks). */
val members: StateFlow<Set<HexKey>> = _members
private val _guestbook = MutableStateFlow<Map<HexKey, GuestbookEntry>>(emptyMap())
/**
* The coalesced Guestbook (CORD-02 §5): each npub's latest Join, Leave or honored Kick, by
* lowercase pubkey. Kicks are judged against the current roster, so this re-derives on every
* control fold as well as on every Guestbook arrival.
*/
val guestbook: StateFlow<Map<HexKey, GuestbookEntry>> = _guestbook
// Author (lowercase) -> the newest CORD-02 §4 ms of a message of theirs we decrypted: observation
// only counts *forward* of their latest Leave or Kick (CORD-02 §5).
private val observedAtMs = HashMap<HexKey, Long>()
private val _observedAuthors = MutableStateFlow<Set<HexKey>>(emptySet())
/**
@@ -362,7 +429,37 @@ class ConcordCommunitySession(
val s = _state.value
val roster = if (s != null) s.authority.roleHolders() + s.ownerPubKey.lowercase() else emptySet()
val banned = s?.authority?.bannedMembers().orEmpty()
return (_members.value + _observedAuthors.value + roster) - banned
return (_members.value + _observedAuthors.value + roster) - banned - departedMembers().keys
}
/**
* Members the Guestbook shows as departed (lowercase hex -> their winning Leave or Kick): their
* latest motion is a Leave or an honored Kick and we have seen nothing of theirs since
* (CORD-02 §5: observation only counts forward). The owner never departs by a Kick.
*/
fun departedMembers(): Map<HexKey, GuestbookEntry> {
val coalesced = _guestbook.value
if (coalesced.isEmpty()) return emptyMap()
val owner = entry.owner.lowercase()
return lock.withLock {
coalesced.filter { (pubkey, motion) ->
motion.action != GuestbookAction.JOIN &&
pubkey != owner &&
(observedAtMs[pubkey] ?: Long.MIN_VALUE) <= motion.ms
}
}
}
/**
* The honored Kick naming this account that postdates this membership (CORD-04 §6), or null.
* A Kick older than the entry's `added_at` judged an earlier membership — a re-join (a later
* Join, or a re-invite that re-added the entry) leaves it behind. Never for the owner.
*/
fun kickedMe(): GuestbookEntry? {
val me = myPubKey.lowercase()
if (me == entry.owner.lowercase()) return null
val mine = _guestbook.value[me] ?: return null
return mine.takeIf { it.action == GuestbookAction.KICK && it.ms > entry.addedAt }
}
/** The size of [allMembers] — the community's true (best-effort) member count. */
@@ -389,6 +486,7 @@ class ConcordCommunitySession(
address == nextBaseRekeyAddress ||
address == siblingBaseRekeyAddress ||
address == dissolvedAddress ||
address in channelRekeyKeys ||
address in historicalControlKeys ||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
@@ -474,7 +572,13 @@ class ConcordCommunitySession(
* The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address)
* for their own isolated AUTH.
*/
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey)
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) + channelRekeyKeys.values
/** The channel-rekey addresses this session watches (CORD-06 §2), for the auxiliary subscription. */
fun channelRekeyAddresses(): Set<HexKey> = channelRekeyKeys.keys
/** The buffered kind-3303 wraps seen at [channelRekeyAddresses], for the account's channel-rekey drain. */
fun pendingChannelRekeyWraps(): List<Event> = lock.withLock { channelRekeyWraps.values.toList() }
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) }
@@ -540,6 +644,7 @@ class ConcordCommunitySession(
// Control material may already have swapped in an entry carrying the new keys.
if (privateKeySet(newEntry) == derivedPrivateKeys) return false
entry = newEntry
channelRekeyKeys = ConcordPrivateChannels.watchKeys(newEntry)
true
}
// Nothing folded yet: the first control wrap derives the planes from the swapped-in entry.
@@ -606,6 +711,14 @@ class ConcordCommunitySession(
lock.withLock { siblingRekeyWraps[wrap.id] = wrap }
return ConcordIngestOutcome.STRUCTURAL
}
in channelRekeyKeys -> {
// Buffer only, like the base rekeys: opening a blob takes the account signer, and the
// rotator's authority is judged against the fold at drain time.
lock.withLock {
if (channelRekeyWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
}
return ConcordIngestOutcome.STRUCTURAL
}
else -> {
// A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback
// floor rises as the old epochs drain in. Structural — the floor can change the
@@ -646,6 +759,8 @@ class ConcordCommunitySession(
ingestTyping(wrap, channelIdHex, key, epoch)
return ConcordIngestOutcome.NON_STRUCTURAL
}
// An expired wrap this session already swept, delivered again: ours, but never opened again.
if (wasSwept(wrap.id)) return ConcordIngestOutcome.NON_STRUCTURAL
val isNew =
lock.withLock {
channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null
@@ -700,6 +815,7 @@ class ConcordCommunitySession(
val wraps = controlWraps.values.toList()
val editions = editionsLocked(wraps, controlKeys)
val floors = controlFloorsLocked()
lastFloors = floors
val folded = ConcordCommunityState.fold(editions, communityIdBytes, entry.owner, floors)
val prevAddresses = channelKeysByAddress.keys.toHashSet()
@@ -725,6 +841,9 @@ class ConcordCommunitySession(
next.filterKeys { it !in prevAddresses }.values.map { it.channelIdHex }
}
// Kicks are judged against the roster, so a fold can honor (or drop) a held Kick.
refoldGuestbook()
// 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
@@ -785,7 +904,9 @@ class ConcordCommunitySession(
ConcordActions.guestbookEntry(wrap, guestbookKey).also { guestbookEntryByWrapId[wrap.id] = it }
}
}
_members.value = ConcordActions.projectGuestbook(entries)
val coalesced = Guestbook.coalesce(entries, TimeUtils.nowMillis(), _state.value?.authority)
_guestbook.value = coalesced
_members.value = ConcordActions.joinedMembers(coalesced)
}
}
@@ -813,7 +934,17 @@ class ConcordCommunitySession(
val authors = HashSet<HexKey>()
val now = TimeUtils.now()
for (wrap in wraps) {
val rumor = ConcordActions.openChannelRumorAnyExpiry(wrap, key, channelIdHex, epoch) ?: continue
val rumor = ConcordActions.openChannelRumorAnyExpiry(wrap, key, channelIdHex, epoch, ChannelChat.PLANE_KINDS) ?: continue
// A WebXDC signal (kind 3310) rides the plane but is never a chat row: held apart for a
// WebXDC host, never handed to the store, so it can't reach a feed, a preview or an
// unread count. Its author is still observably present (CORD-02 §5).
if (ConcordWebxdc.isWebxdc(rumor)) {
if (!ConcordDisappearing.isExpired(rumor, now) && holdWebxdc(channelIdHex, rumor)) {
observe(rumor)
authors.add(rumor.pubKey.lowercase())
}
continue
}
// Pins reopen the carrying wrap to disclose this one message's keys (CORD-04 §7).
lock.withLock { wrapIdByRumorId[rumor.id] = wrap.id }
// CORD-08 §3: only the rumor's own tag counts. A rumor carrying one is remembered so the
@@ -821,10 +952,11 @@ class ConcordCommunitySession(
// never handed to the store — and queued for the next sweep so its wrap goes too.
val expiresAt = ConcordDisappearing.expirationOf(rumor)
if (expiresAt != null) {
trackExpiring(wrap.id, channelIdHex, rumor.id, expiresAt)
trackExpiring(wrap.id, channelIdHex, rumor.id, expiresAt, ChannelChat.encryptedImagesOf(rumor).map { it.url })
if (expiresAt <= now) continue
}
authors.add(rumor.pubKey.lowercase())
observe(rumor)
onRumor(entry.id, channelIdHex, rumor, seenOnRelays)
}
// Every author we just decrypted is observably present (CORD-02 §5), so fold them into the
@@ -834,6 +966,54 @@ class ConcordCommunitySession(
}
}
/** Records [rumor]'s author as seen at its CORD-02 §4 time (observation counts forward only). */
private fun observe(rumor: Event) {
val author = rumor.pubKey.lowercase()
val atMs = ChannelChat.orderingMs(rumor) ?: (rumor.createdAt * 1000)
lock.withLock { if (atMs > (observedAtMs[author] ?: Long.MIN_VALUE)) observedAtMs[author] = atMs }
}
// ── WebXDC signals (kind 3310) ───────────────────────────────────────────
// Channel id -> its WebXDC signals by rumor id, in arrival order, bounded per channel.
private val webxdcByChannel = HashMap<HexKey, LinkedHashMap<HexKey, Event>>()
private val _webxdcRevision = MutableStateFlow(0L)
/** Bumps whenever a new WebXDC signal is held — what a WebXDC host re-reads [webxdcSignals] on. */
val webxdcRevision: StateFlow<Long> = _webxdcRevision
/**
* [channelIdHex]'s held WebXDC signals (kind 3310: app state updates and realtime peer signals,
* [ConcordWebxdc]) not yet expired (CORD-08: app state disappears with the chat plane), oldest
* first on the CORD-02 §4 basis. Amethyst has no WebXDC host; this is the plumbing one would read.
*/
fun webxdcSignals(
channelIdHex: HexKey,
now: Long = TimeUtils.now(),
): List<Event> =
lock
.withLock { webxdcByChannel[channelIdHex]?.values?.toList() }
.orEmpty()
.filterNot { ConcordDisappearing.isExpired(it, now) }
.sortedBy { ChannelChat.orderingMs(it) ?: (it.createdAt * 1000) }
/** Holds [rumor] for [channelIdHex]; false when already held. Keeps the newest [MAX_WEBXDC_PER_CHANNEL] arrivals. */
private fun holdWebxdc(
channelIdHex: HexKey,
rumor: Event,
): Boolean {
val added =
lock.withLock {
val held = webxdcByChannel.getOrPut(channelIdHex) { LinkedHashMap() }
if (held.put(rumor.id, rumor) != null) return@withLock false
while (held.size > MAX_WEBXDC_PER_CHANNEL) held.remove(held.keys.first())
true
}
if (added) _webxdcRevision.update { it + 1 }
return added
}
// ── Disappearing messages (CORD-08) ──────────────────────────────────────
/** Wrap id -> the expiring rumor it carries, for every rumor with an `expiration` we emitted or refused. */
@@ -854,42 +1034,86 @@ class ConcordCommunitySession(
*/
fun messageExpirationSecs(): Long? = _state.value?.metadata?.messageExpirationSecs()
/**
* The same entries as [expiringByWrapId], kept sorted by `expiresAt` (then wrap id), so a sweep
* pops only what is due instead of scanning every tracked message, and the next deadline is the
* head. Common code has no priority queue; a binary-searched insert into an array list is the
* same order of cost here.
*/
private val expiringByDeadline = ArrayList<ExpiredConcordRumor>()
/**
* Wrap ids this session already swept, newest last and bounded: relays keep re-delivering an
* expired wrap (a relay that ignores NIP-40, a backfill page), and each would otherwise be opened
* again only to be refused and swept again.
*/
private val sweptWrapIds = LinkedHashSet<HexKey>()
private val deadlineOrder = compareBy<ExpiredConcordRumor>({ it.expiresAt }, { it.wrapId })
private fun trackExpiring(
wrapId: HexKey,
channelIdHex: HexKey,
rumorId: HexKey,
expiresAt: Long,
attachmentUrls: List<String> = emptyList(),
) {
lock.withLock {
expiringByWrapId[wrapId] = ExpiredConcordRumor(channelIdHex, wrapId, rumorId, expiresAt)
_nextExpiry.update { if (it == null || expiresAt < it) expiresAt else it }
lock.withLock { trackLocked(ExpiredConcordRumor(channelIdHex, wrapId, rumorId, expiresAt, attachmentUrls)) }
}
private fun trackLocked(entry: ExpiredConcordRumor) {
val prior = expiringByWrapId[entry.wrapId]
if (prior != null) {
// A re-projection re-emits the same wrap: same rumor, same deadline — nothing to move.
if (prior.expiresAt == entry.expiresAt) return
val at = expiringByDeadline.binarySearch(prior, deadlineOrder)
if (at >= 0) expiringByDeadline.removeAt(at)
}
expiringByWrapId[entry.wrapId] = entry
val at = expiringByDeadline.binarySearch(entry, deadlineOrder)
expiringByDeadline.add(if (at < 0) -at - 1 else at, entry)
_nextExpiry.value = expiringByDeadline.first().expiresAt
}
/** True when [wrapId] was already swept as expired: a re-delivery is dropped before it is opened. */
private fun wasSwept(wrapId: HexKey): Boolean = lock.withLock { wrapId in sweptWrapIds }
/**
* Forgets every rumor whose `expiration` is at or before [now] (CORD-08 §3): its wrap leaves the
* channel buffer, so no re-projection can resurrect it, and it is returned so the caller purges
* the rumor's note and the wrap's note from its store. A wrap re-delivered later is refused again
* at ingest.
* the rumor's note and the wrap's note from its store. A wrap re-delivered later is dropped at
* ingest without being opened.
*/
fun sweepExpired(now: Long = TimeUtils.now()): List<ExpiredConcordRumor> =
lock.withLock {
if (expiringByWrapId.isEmpty()) return@withLock emptyList()
if (expiringByDeadline.isEmpty() || expiringByDeadline.first().expiresAt > now) return@withLock emptyList()
val out = ArrayList<ExpiredConcordRumor>()
val it = expiringByWrapId.values.iterator()
while (it.hasNext()) {
val expiring = it.next()
if (expiring.expiresAt <= now) {
while (expiringByDeadline.isNotEmpty() && expiringByDeadline.first().expiresAt <= now) {
val expiring = expiringByDeadline.removeAt(0)
expiringByWrapId.remove(expiring.wrapId)
channelWrapsById[expiring.channelIdHex]?.remove(expiring.wrapId)
wrapIdByRumorId.remove(expiring.rumorId)
sweptWrapIds.add(expiring.wrapId)
out.add(expiring)
it.remove()
}
}
_nextExpiry.value = expiringByWrapId.values.minOfOrNull { it.expiresAt }
while (sweptWrapIds.size > MAX_SWEPT_WRAP_IDS) sweptWrapIds.remove(sweptWrapIds.first())
_nextExpiry.value = expiringByDeadline.firstOrNull()?.expiresAt
out
}
/** Every disappearing rumor this session still tracks — handed to the session that replaces it. */
fun trackedExpiring(): List<ExpiredConcordRumor> = lock.withLock { expiringByDeadline.toList() }
/**
* Adopts [entries] tracked by the session this one replaces (a Refounding rebuilds the session):
* their rumors are already in the store, and without this nothing would ever purge them once
* their deadline passes, since the new session never sees the old epoch's wraps again.
*/
fun carryExpiring(entries: Collection<ExpiredConcordRumor>) {
if (entries.isEmpty()) return
lock.withLock { entries.forEach { trackLocked(it) } }
}
/** True while [channelIdHex]'s buffer holds [wrapId] — for tests of the sweep. */
internal fun isBuffered(
channelIdHex: HexKey,
@@ -924,7 +1148,9 @@ class ConcordCommunitySession(
// An expired message leaves the pinned list too (CORD-08 §3: never display an expired rumor);
// its proof is still valid, but the rumor's own tag says it is gone.
val hidden = { pin: ConcordPins.VerifiedPin -> isKilled(pin) || pin.tags.isExpirationBefore(now) }
return ConcordPinning.read(_pinHeads.value[channelIdHex], channelIdHex, { pinUnsealKey(channelIdHex, it) }, pinVerifier, hidden, newestEdit)
// Not drained yet: the head may simply not have been served, so the result is marked partial
// (shown, never written from — CORD-04 §7).
return ConcordPinning.read(_pinHeads.value[channelIdHex], channelIdHex, { pinUnsealKey(channelIdHex, it) }, pinVerifier, hidden, newestEdit, complete = _controlDrained.value)
}
/**
@@ -955,5 +1181,11 @@ class ConcordCommunitySession(
/** A typing heartbeat is considered current for this many seconds after it's seen. */
const val TYPING_STALE_SECS = 8L
/** WebXDC signals held per channel (the reference client scans its newest 2000 for peer signals). */
const val MAX_WEBXDC_PER_CHANNEL = 2000
/** How many swept (expired) wrap ids a session remembers to drop their re-deliveries unopened. */
const val MAX_SWEPT_WRAP_IDS = 4096
}
}
@@ -31,8 +31,11 @@ import com.vitorpamplona.quartz.concord.cord05Invites.OpenedDirectInvite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -52,6 +55,12 @@ class ConcordDirectInviteView(
val opened: OpenedDirectInvite,
val catchUp: Boolean,
val expired: Boolean,
/** For a [catchUp]: the ids of the Private Channels it would newly add (not every key it carries). */
val newChannelIds: List<HexKey> = emptyList(),
/** The rumor's `sentAt` clamped to the time it was ranked, so a future date buys no rank. */
val clampedSentAt: Long = opened.sentAt,
/** True when this account follows the sender: ranked above strangers. */
val followedSender: Boolean = false,
) {
val wrapId: HexKey get() = opened.wrapId
val sender: HexKey get() = opened.sender
@@ -60,11 +69,17 @@ class ConcordDirectInviteView(
val name: String get() = opened.invite.name
val icon: ImagePointer? get() = opened.invite.icon
/** Names of the Private Channels the bundle carries (what a catch-up would add). */
val channelNames: List<String> get() =
opened.invite.channels
.filter { it.key.isNotBlank() }
.map { it.name }
/**
* Names of the Private Channels a catch-up would newly add — [newChannelIds] only, named by
* [foldedName] (the held community's folded channel name) when it knows the channel, else by the
* bundle's own label.
*/
fun newChannelNames(foldedName: (HexKey) -> String? = { null }): List<String> {
val ids = newChannelIds.mapTo(HashSet()) { it.lowercase() }
return opened.invite.channels
.filter { it.id.lowercase() in ids }
.map { foldedName(it.id)?.takeIf { name -> name.isNotBlank() } ?: it.name }
}
}
/** What accepting a Direct Invite does; see [ConcordDirectInviteInbox.acceptPlan]. */
@@ -75,9 +90,14 @@ sealed interface DirectInviteAcceptPlan {
/** A community we don't hold: run the shared join path. */
data object Join : DirectInviteAcceptPlan
/** A held community: store [entry] — the held one plus the newly granted Private Channel keys. */
/**
* A held community: store [entry] — the held one plus the newly granted Private Channel keys
* ([channelIds]). A writer re-applies [channelIds] to the entry it reads inside the List write
* ([ConcordInviteVend.adoptCatchUp] with `only`), never [entry] itself, which is a snapshot.
*/
class CatchUp(
val entry: ConcordCommunityListEntry,
val channelIds: List<HexKey>,
) : DirectInviteAcceptPlan
/** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */
@@ -96,23 +116,37 @@ sealed interface DirectInviteAcceptPlan {
* Wraps arrive from anywhere — a `{"kinds":[1059],"#p":[me],"#k":["3313"]}` sweep
* ([com.vitorpamplona.amethyst.commons.actions.ConcordActions.directInvitesFilter]), or the general
* NIP-17 giftwrap pipeline, which honours an untagged invite all the same — and are [offer]ed here.
* The inbox opens each wrap once (two NIP-44 decrypts), dedupes by wrap id, drops a wrap whose NIP-40
* `expiration` has passed, validates the bundle exactly like a fetched one, and parks it in
* [pending]. **Nothing** else happens: no relay connection, no icon fetch, no Join, until the user
* accepts (the caller's join path) or [decline]s.
* The inbox opens each wrap once (two NIP-44 decrypts; none more when the DM pipeline already
* unsealed it, [offerRumor]), dedupes by wrap id, drops a wrap whose NIP-40 `expiration` has passed,
* validates the bundle exactly like a fetched one, and parks it in [pending]. **Nothing** else
* happens: no relay connection, no icon fetch, no Join, until the user accepts (the caller's join
* path) or [decline]s.
*
* Declined wrap ids are remembered ([declined], restorable via [restoreDeclined]) so a re-delivered
* wrap never resurfaces. [newestWrapCreatedAt] is the sweep cursor; query from [since], which
* rewinds it by NIP-59's two-day backdate window.
* A wrap is written off ([seen]) only on a definitive outcome — opened, not an invite for us, or
* expired. A signer that could not answer (timed out, busy, not approved) leaves it to be retried by
* the next delivery or sweep.
*
* Bounded: at most [MAX_PENDING] invites are parked; past it the lowest-ranked one goes (a sender
* [isFollowed] outranks a stranger, then newer outranks older). Invites from senders [isHidden]
* (muted or blocked) are never parked.
*
* Declined wrap ids are remembered ([declined], restorable via [restoreDeclined], at most
* [DECLINED_CAP]) so a re-delivered wrap never resurfaces. [newestWrapCreatedAt] is the sweep cursor;
* query from [since], which rewinds it by NIP-59's two-day backdate window.
*/
class ConcordDirectInviteInbox(
private val signer: NostrSigner,
private val isHidden: (HexKey) -> Boolean = { false },
private val isFollowed: (HexKey) -> Boolean = { false },
) {
private val mutex = Mutex()
/** Wrap ids already handled this session (opened, refused, or expired), oldest first. */
/** Wrap ids with a definitive outcome this session (opened, refused, or expired), oldest first. */
private val seen = LinkedHashSet<HexKey>()
/** Wrap ids being opened right now, so a concurrent delivery of the same wrap is not decrypted twice. */
private val inFlight = HashSet<HexKey>()
private val _pending = MutableStateFlow<Map<HexKey, OpenedDirectInvite>>(emptyMap())
/** Parked invites by wrap id, as opened. See [visible] for what a UI should show. */
@@ -120,7 +154,7 @@ class ConcordDirectInviteInbox(
private val _declined = MutableStateFlow<Set<HexKey>>(emptySet())
/** Wrap ids the user declined; persisted by the front end so they stay declined across restarts. */
/** Wrap ids the user declined, oldest first; persisted by the front end so they stay declined across restarts. */
val declined: StateFlow<Set<HexKey>> = _declined.asStateFlow()
/** The newest wrap `created_at` offered so far (the sweep cursor), or null on a cold inbox. */
@@ -131,21 +165,27 @@ class ConcordDirectInviteInbox(
/** The `since` for the next sweep: the cursor rewound by the backdate window (null = everything). */
fun since(): Long? = ConcordDirectInvite.inboxSince(newestWrapCreatedAt)
/** Replaces the declined set — used to restore it from disk at startup. Drops any pending one. */
fun restoreDeclined(wrapIds: Set<HexKey>) {
_declined.value = wrapIds
/**
* Replaces the declined set — used to restore it from disk at startup — keeping the newest
* [DECLINED_CAP]. Drops any pending one. Serialized with [offer] so a restore can't race a park.
*/
suspend fun restoreDeclined(wrapIds: Set<HexKey>) {
mutex.withLock {
_declined.value = bounded(wrapIds)
_pending.update { current -> current.filterKeys { it !in wrapIds } }
}
}
/**
* Considers one kind-1059 [wrap] addressed to us. Returns the parked invite (new or already
* pending), or null when it isn't one: not a direct invite for us, a forgery, an invalid
* bundle, an expired handoff, or a wrap the user already declined. Never throws.
* bundle, an expired handoff, a hidden sender, a wrap the user already declined — or a signer
* that could not answer now (retried on the next offer). Never throws but for cancellation.
*/
suspend fun offer(
wrap: Event,
nowSecs: Long = TimeUtils.now(),
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.open(wrap, signer) }
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openOrRetry(wrap, signer) }
/**
* [offer] for a pipeline that already peeled [wrap] down to its kind-13 [seal] (the NIP-17
@@ -156,7 +196,27 @@ class ConcordDirectInviteInbox(
wrap: Event,
seal: Event,
nowSecs: Long = TimeUtils.now(),
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSeal(wrap.id, seal, signer) }
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSealOrRetry(wrap.id, seal, signer) }
/**
* [offerSeal] for a pipeline that already decrypted [seal] into [rumor] (the rumor as the seal
* carries it, its claimed author intact — [SealEvent.unsealRumorThrowing]): validated without
* any further decrypt.
*/
suspend fun offerRumor(
wrap: Event,
seal: Event,
rumor: Rumor,
nowSecs: Long = TimeUtils.now(),
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openRumor(wrap.id, seal, rumor) }
/**
* Records [wrapId] as definitively not an invite for us (it failed to open for a reason no retry
* changes, or opened to something else), so a sweep that fetches it again skips the decrypt.
*/
suspend fun markNotInvite(wrapId: HexKey) {
mutex.withLock { if (wrapId !in _pending.value) remember(wrapId) }
}
private suspend fun admit(
wrap: Event,
@@ -165,20 +225,54 @@ class ConcordDirectInviteInbox(
): OpenedDirectInvite? {
if (wrap.kind != GiftWrapEvent.KIND) return null
mutex.withLock {
// The cursor only advances to a time that has happened (plus the skew allowance): a
// future-dated wrap would otherwise push `since` past every invite sent until then.
val stamp = minOf(wrap.createdAt, nowSecs + FUTURE_SKEW_SECS)
val newest = newestWrapCreatedAt
if (newest == null || wrap.createdAt > newest) newestWrapCreatedAt = wrap.createdAt
if (newest == null || stamp > newest) newestWrapCreatedAt = stamp
_pending.value[wrap.id]?.let { return it }
if (wrap.id in _declined.value || wrap.id in seen) return null
remember(wrap.id)
if (wrap.id in _declined.value || wrap.id in seen || wrap.id in inFlight) return null
inFlight.add(wrap.id)
}
try {
// An expired handoff is never decrypted or surfaced (NIP-40 on the wrap mirrors expires_at).
if (ConcordDirectInvite.isWrapExpired(wrap, nowSecs)) return null
val opened = open() ?: return null
mutex.withLock {
if (wrap.id in _declined.value) return null
_pending.update { it + (wrap.id to opened) }
if (ConcordDirectInvite.isWrapExpired(wrap, nowSecs)) {
mutex.withLock { remember(wrap.id) }
return null
}
return opened
val opened =
try {
open()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// The signer could not answer now: not written off, so the next offer retries it.
return null
}
mutex.withLock {
remember(wrap.id)
if (opened == null || wrap.id in _declined.value) return null
// Muted or blocked senders never reach the inbox.
if (isHidden(opened.sender)) return null
_pending.update { park(it, opened, nowSecs) }
}
return _pending.value[wrap.id]
} finally {
mutex.withLock { inFlight.remove(wrap.id) }
}
}
/** [current] plus [opened], shedding the lowest-ranked invite past [MAX_PENDING]. */
private fun park(
current: Map<HexKey, OpenedDirectInvite>,
opened: OpenedDirectInvite,
nowSecs: Long,
): Map<HexKey, OpenedDirectInvite> {
val next = current + (opened.wrapId to opened)
if (next.size <= MAX_PENDING) return next
val rank = compareBy<OpenedDirectInvite>({ isFollowed(it.sender) }, { clampedSentAt(it, nowSecs) }, { it.wrapId })
val drop = next.values.minWithOrNull(rank) ?: return next
return next - drop.wrapId
}
/** The parked invite behind [wrapId], if any. */
@@ -188,7 +282,7 @@ class ConcordDirectInviteInbox(
fun decline(wrapId: HexKey): Boolean {
val id = get(wrapId)?.wrapId ?: return false
_pending.update { it - id }
_declined.update { it + id }
_declined.update { bounded(it + id) }
return true
}
@@ -209,6 +303,23 @@ class ConcordDirectInviteInbox(
/** Cap on remembered wrap ids; the oldest half is shed past it (a sweep re-dedupes deeper). */
const val SEEN_CAP = 4096
/** Cap on parked invites (the reference client's bound): a flood can't grow the inbox without end. */
const val MAX_PENDING = 256
/** Cap on remembered declines, newest kept: a declined wrap older than that has long expired or been buried. */
const val DECLINED_CAP = 4096
/** Clock skew tolerated on a wrap's `created_at` before it stops moving the sweep cursor. */
const val FUTURE_SKEW_SECS = 15 * 60L
private fun bounded(ids: Set<HexKey>): Set<HexKey> = if (ids.size <= DECLINED_CAP) ids else ids.toList().takeLast(DECLINED_CAP).toCollection(LinkedHashSet())
/** [opened]'s `sentAt` (the sender's word) clamped to [nowSecs]: a future date buys no rank. */
fun clampedSentAt(
opened: OpenedDirectInvite,
nowSecs: Long,
): Long = minOf(opened.sentAt, nowSecs)
/**
* What accepting [opened] should do (CORD-05 §6), given the community entry this account
* already [held] (if any) and its folded [heldState]:
@@ -216,9 +327,11 @@ class ConcordDirectInviteInbox(
* - not held → [DirectInviteAcceptPlan.Join] (the shared join path, which still ban-gates
* against the community's own Control Plane);
* - held on the SAME base with new Private Channel keys → [DirectInviteAcceptPlan.CatchUp],
* the held entry with only those keys merged in — never moving the base (Armada
* `catchUpChannelIds`) — unless the held roster bans [me]; refused while the roster isn't
* folded ([DirectInviteAcceptPlan.RosterNotLoaded]);
* the held entry with only those keys ADDED — never moving the base, never replacing a
* held key (Armada `catchUpChannelIds`) — and only from a sender who is staff in the held
* fold, for channels it knows as live Private Channels
* ([ConcordInviteVend.admissibleCatchUpIds]); refused when the held roster bans [me], and
* while it isn't folded ([DirectInviteAcceptPlan.RosterNotLoaded]);
* - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew].
*/
fun acceptPlan(
@@ -230,49 +343,83 @@ class ConcordDirectInviteInbox(
): DirectInviteAcceptPlan {
if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired
if (held == null) return DirectInviteAcceptPlan.Join
val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite) ?: return DirectInviteAcceptPlan.NothingNew
if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew
if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded
// Death wins every race (CORD-02 §9): a dissolved community takes no new keys.
if (heldState.dissolved) return DirectInviteAcceptPlan.NothingNew
if (heldState.authority.isBanned(me)) return DirectInviteAcceptPlan.Banned
return DirectInviteAcceptPlan.CatchUp(adopted)
val ids = ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender)
if (ids.isEmpty()) return DirectInviteAcceptPlan.NothingNew
val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite, ids) ?: return DirectInviteAcceptPlan.NothingNew
return DirectInviteAcceptPlan.CatchUp(adopted, ids)
}
/**
* What a UI shows out of [pending], given the communities this account already holds
* ([joined]): newest first, with
* ([joined]) and the ones it left ([removedAt], community id → the Community List
* tombstone's `removed_at` in unix ms): followed senders first, then newest first, with
* - an invite for a community already held on the SAME base that carries a Private Channel
* key it lacks kept as a [ConcordDirectInviteView.catchUp];
* - any other invite for a held community (nothing new, or a different base — which may
* never move the held one) hidden;
* - one invite per community (newest `sentAt`, ties by wrap id), catch-ups keyed by their
* - an invite sent at or before the user left that community hidden (it would otherwise
* resurface right after leaving; a fresh re-invite still shows — Armada `tombstonedAt`);
* - invites from [isHidden] (muted/blocked) senders hidden;
* - one invite per community and sender (newest clamped `sentAt`, ties by wrap id), so a
* future-dated invite can only ever shadow its own sender's; catch-ups keyed by their
* channel set too since each may vend a key no other wrap carries (Armada
* `dedupeParkedInvites`).
* `dedupeParkedInvites`). `sentAt` is the sender's word, so it is clamped to now for both
* ordering and the tombstone check.
*/
fun visible(
pending: Collection<OpenedDirectInvite>,
joined: List<ConcordCommunityListEntry>,
nowMs: Long = TimeUtils.nowMillis(),
removedAt: Map<String, Long> = emptyMap(),
isFollowed: (HexKey) -> Boolean = { false },
isHidden: (HexKey) -> Boolean = { false },
heldStateOf: (communityId: HexKey) -> ConcordCommunityState? = { null },
): List<ConcordDirectInviteView> {
val nowSecs = nowMs / 1000
val heldById = joined.associateBy { it.id.lowercase() }
val removedById = removedAt.mapKeys { it.key.lowercase() }
val byKey = LinkedHashMap<String, ConcordDirectInviteView>()
for (opened in pending) {
if (isHidden(opened.sender)) continue
val communityId = opened.invite.communityId.lowercase()
val sentAt = clampedSentAt(opened, nowSecs)
val buriedAt = removedById[communityId]
if (buriedAt != null && sentAt * 1000 <= buriedAt) continue
val held = heldById[communityId]
val newChannels = ConcordInviteVend.catchUpChannelIds(held, opened.invite)
// For a held community whose fold is in, only what accepting would actually adopt:
// a catch-up from a non-staff sender, for channels the fold doesn't know as Private,
// or into a dissolved community is refused by [acceptPlan], so it is not offered.
val heldState = held?.let { heldStateOf(it.id) }
val newChannels =
when {
held == null -> emptyList()
heldState == null -> ConcordInviteVend.catchUpChannelIds(held, opened.invite)
heldState.dissolved -> emptyList()
else -> ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender)
}
if (held != null && newChannels.isEmpty()) continue
val catchUp = held != null
val key = if (catchUp) communityId + "|" + newChannels.sorted().joinToString(",") else communityId
val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs))
val base = communityId + "|" + opened.sender.lowercase()
val key = if (catchUp) base + "|" + newChannels.sorted().joinToString(",") else base
val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs), newChannels.toList(), sentAt, isFollowed(opened.sender))
val existing = byKey[key]
if (existing == null ||
opened.sentAt > existing.opened.sentAt ||
(opened.sentAt == existing.opened.sentAt && opened.wrapId < existing.opened.wrapId)
sentAt > existing.clampedSentAt ||
(sentAt == existing.clampedSentAt && opened.wrapId < existing.opened.wrapId)
) {
byKey[key] = view
}
}
return byKey.values.sortedWith(compareByDescending<ConcordDirectInviteView> { it.opened.sentAt }.thenBy { it.wrapId })
return byKey.values.sortedWith(
compareByDescending<ConcordDirectInviteView> { it.followedSender }
.thenByDescending { it.clampedSentAt }
.thenBy { it.wrapId },
)
}
}
}
@@ -0,0 +1,34 @@
/*
* 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.quartz.nip01Core.core.HexKey
/**
* This account complied with an honored Kick (CORD-04 §6) and left [communityId] locally.
* [communityName] is the folded name at the time (null when unnamed), for the user notice;
* [kickedBy] is the kicker. A Kick is re-joinable: a new invite brings the member back.
*/
class ConcordKickNotice(
val communityId: String,
val communityName: String?,
val kickedBy: HexKey,
)
@@ -0,0 +1,96 @@
/*
* 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.ConcordChannelPins
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Runs the delayed Pin List duties a PIN_MESSAGES holder owes keyless readers (CORD-04 §7) — the
* deletion omission and the Edit refresh — from the account, not from an open channel screen, so a
* debt is settled whether or not anyone is looking at the channel.
*
* Rate-limited the way the spec asks: each duty waits a short random delay
* ([ConcordPinning.dutyDelayMs], 3–15 s) and the caller's settle re-reads before publishing, so
* simultaneous curators collapse to one publisher and a burst of deletes or edits costs one write;
* at most one duty per channel is in flight; and each distinct debt is attempted **once**, so a
* write that keeps failing never spins. A new debt (another delete, a newer Edit) is a new attempt.
*/
class ConcordPinDutyScheduler(
private val delayMs: () -> Long = { ConcordPinning.dutyDelayMs() },
) {
private val lock = KmpLock()
// Channel key -> the debt last attempted there.
private val attempted = HashMap<String, String>()
// Channel key -> its duty in flight.
private val inFlight = HashMap<String, Job>()
/**
* Schedules [settle] in [scope] for the channel [key] when [debt] is non-null, was not attempted
* yet, and no duty for [key] is in flight. Returns whether it scheduled one.
*/
fun schedule(
scope: CoroutineScope,
key: String,
debt: String?,
settle: suspend () -> Unit,
): Boolean {
if (debt == null) return false
return lock.withLock {
if (attempted[key] == debt || inFlight[key]?.isActive == true) return@withLock false
attempted[key] = debt
inFlight[key] =
scope.launch {
delay(delayMs())
try {
settle()
} finally {
lock.withLock { inFlight.remove(key) }
}
}
true
}
}
companion object {
/** The identity of what [pins] owes (its erased entries and the Edits to attach), or null when nothing. */
fun debtOf(pins: ConcordChannelPins?): String? {
if (pins == null || !pins.owesRepublish) return null
return (pins.killed.map { "d" + it.rumorId } + pins.pins.mapNotNull { p -> p.newerEdit?.let { "e" + it.rumorId } })
.sorted()
.joinToString("|")
}
/** The scheduler key of one channel. */
fun keyOf(
communityId: String,
channelIdHex: String,
): String = "$communityId|$channelIdHex"
}
}
@@ -79,6 +79,9 @@ class ConcordSessionManager(
*/
val nextExpiry: StateFlow<Long?> = _nextExpiry
// Guards the compute-and-assign of [nextExpiry]. Declared before `init` for the same reason.
private val expiryLock = KmpLock()
private val lock = KmpLock()
private val stateWatchers = HashMap<HexKey, Job>() // communityId -> state collector
@@ -118,8 +121,12 @@ class ConcordSessionManager(
}
private fun recomputeNextExpiry() {
// Computed and assigned under one lock: two watchers racing could otherwise let a slower,
// staler computation overwrite an earlier deadline, and the sweep would sleep past it.
expiryLock.withLock {
_nextExpiry.value = registry.sessions().mapNotNull { it.nextExpiry.value }.minOrNull()
}
}
/**
* Sweeps every session for rumors expired at [now] (CORD-08 §3): drops their wraps from the
@@ -76,7 +76,10 @@ class ConcordSessionRegistry(
for ((id, entry) in wanted) {
val existing = sessions[id]
if (existing == null || existing.entry.root != entry.root || existing.entry.rootEpoch != entry.rootEpoch) {
sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor)
// CORD-08 §3: the disappearing messages the old session tracked are already in the
// store, and the new session never sees their wraps again — carry their deadlines
// over, or nothing would ever purge them.
sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor).also { fresh -> existing?.let { fresh.carryExpiring(it.trackedExpiring()) } }
created += id
} else {
// Same epoch, but the Control Plane write key may have just arrived — a
@@ -49,6 +49,11 @@ class EncryptionKeyCache {
) = add(url, DecryptInformation(cipher, expectedMimeType))
fun get(url: String): DecryptInformation? = cache.get(url)
/** Forgets [url]'s key, e.g. when the message that carried it expired (CORD-08). */
fun remove(url: String) {
cache.remove(url)
}
}
class DecryptInformation(
@@ -118,4 +118,30 @@ class ConcordInviteRegistryPublishTest {
add(ConcordModeration.setInviteRegistry(inviter, cp, cid, emptyList(), editions, createdAt = 7L, owner = community.ownerPubKey))
assertFalse(fold().isPublic)
}
@Test
fun aRegistryEditChainsOntoTheFloorAwareHeadAcrossARefounding() =
runTest {
// The prior epoch reached v2 of the owner's registry; the current epoch has not (yet)
// re-wrapped it, so the honored head is the floor, not "no registry".
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val cp = community.controlPlane
val cid = community.communityId
val prior = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
prior += ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link1), prior, 2L, owner = community.ownerPubKey)), cp)
prior += ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link1, link2), prior, 3L, owner = community.ownerPubKey)), cp)
val v2 = prior.last()
assertEquals(2L, v2.version)
val floors = ConcordCommunityState.authorizedHeads(prior, cid, community.ownerPubKey)
val current = ConcordActions.controlEditions(community.genesisWraps, cp)
val naive = ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link2), current, 4L, owner = community.ownerPubKey)), cp).single()
assertEquals(1L, naive.version, "ignoring the floor forks a fresh v1 below the honored v2")
val chained = ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link2), current, 4L, owner = community.ownerPubKey, floors = floors)), cp).single()
assertEquals(3L, chained.version)
assertEquals(v2.hashHex, chained.prevHash?.toHexKey())
// And the fold with the same floors honors it.
assertEquals(listOf(link2), ConcordCommunityState.fold(current + chained, cid, community.ownerPubKey, floors).registryOf(owner.pubKey))
}
}
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.model.nip92IMeta.appendMissingImetaUrls
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
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.utils.ciphers.AESGCM
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* A pinned message carrying an encrypted attachment renders like the feed message it pins (CORD-04 §7
* SHOULD): the rumor rebuilt from the verified proof keeps the `imeta` tags — so the attachment's
* decryption key reaches the media pipeline even when this account never held the message — and an
* attachment-only message still gets its URL as text, as the feed gives it.
*/
class ConcordPinnedMediaTest {
private val owner = NostrSignerInternal(KeyPair())
private val alice = NostrSignerInternal(KeyPair())
@Test
fun aPinnedImageKeepsItsEncryptedAttachment() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val rumors = mutableListOf<Event>()
val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> rumors += rumor }
community.genesisWraps.forEach { session.ingest(it) }
// The genesis wraps are the whole plane; pin writes wait for a drained fold (CORD-04 §7).
session.markControlDrained()
val general = community.generalChannelIdHex
val plane = assertNotNull(session.currentChannelPlane(general))
val url = "https://blossom.example/ciphertext.bin"
val cipher = AESGCM(ByteArray(32) { 0x11 }, ByteArray(16) { 0x22 })
val imeta = ChannelChat.encryptedImageImeta(url, "image/jpeg", "800x600", null, cipher, "aa".repeat(32))
// An attachment-only message: empty words, the image only in its imeta (Armada allows it).
session.ingest(ConcordActions.buildChannelImageMessage(alice, plane.key, general, plane.epoch, "", listOf(imeta), 10L))
val message = rumors.last()
fun ctx(pins: ConcordChannelPins) =
ConcordPinContext(
actor = owner,
controlPlane = session.controlPlaneKeys(),
communityId = community.communityId,
owner = community.ownerPubKey,
current = session.controlEditions(),
channelIdHex = general,
channelIsPrivate = false,
currentPlane = plane,
pins = pins,
authorized = true,
)
val evidence = ConcordPinEvidence(rumors)
val before = assertNotNull(session.readPins(general, evidence::isKilled, evidence::newestEdit))
val write = ConcordPinning.pin(ctx(before), assertNotNull(session.pinSource(general, message.id)), 11L)
session.ingest(assertNotNull(write.wrap))
val pinned = assertNotNull(session.readPins(general, evidence::isKilled, evidence::newestEdit)).pins.single()
val rumor = pinned.toRumor()
assertEquals(message.id, rumor.id)
assertEquals(alice.pubKey, rumor.pubKey)
val attachment = ChannelChat.encryptedImagesOf(rumor).single()
assertEquals(url, attachment.url)
assertContentEquals(cipher.keyBytes, attachment.key)
assertContentEquals(cipher.nonce, attachment.nonce)
// The words carry the ciphertext URL (Armada's assembly), so the shared renderer shows it.
assertEquals(url, rumor.content)
// A client that sent only the imeta (empty words) still renders it: the feed's fallback.
assertEquals(url, appendMissingImetaUrls("", rumor))
}
}
@@ -76,9 +76,10 @@ class ConcordPinningTest {
val community: NewConcordCommunity,
entry: ConcordCommunityListEntry,
me: HexKey,
drained: Boolean = true,
) {
val rumors = mutableListOf<Event>()
val session = ConcordCommunitySession(entry, me) { _, _, rumor, _ -> rumors += rumor }
val session = ConcordCommunitySession(entry, me) { _, _, rumor, _ -> rumors += rumor }.also { if (drained) it.markControlDrained() }
fun pins(channelIdHex: HexKey) = ConcordPinEvidence(rumors).let { evidence -> assertNotNull(session.readPins(channelIdHex, evidence::isKilled, evidence::newestEdit)) }
@@ -139,6 +140,60 @@ class ConcordPinningTest {
return h
}
@Test
fun noPinWriteIsBuiltBeforeTheControlPlaneHasDrained() =
runTest {
val h = harness()
val general = h.community.generalChannelIdHex
val message = h.post(alice, general, "ship it", 10L)
assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, general, message, 11L).outcome)
// A second device that has folded only part of the plane: here the genesis without the pin.
val partial = Harness(h.community, entryFor(h.community), owner.pubKey, drained = false)
h.community.genesisWraps.forEach { partial.session.ingest(it) }
val read = partial.pins(general)
assertFalse(read.complete, "no head yet reads as not-yet-served, not as an empty list")
assertNull(read.head)
// A replace-entire write from that read would erase the pin it never saw (§7).
val refused = ConcordPinning.unpin(partial.ctx(owner, general), message.id, 12L)
assertEquals(ConcordPinOutcome.NOT_FOLDED, refused.outcome)
assertNull(refused.wrap)
// Once the plane is drained (the pin landed), writes proceed from the full list.
h.session.controlPlaneWraps().forEach { partial.session.ingest(it) }
partial.session.markControlDrained()
assertTrue(partial.pins(general).complete)
assertEquals(ConcordPinOutcome.PUBLISHED, ConcordPinning.unpin(partial.ctx(owner, general), message.id, 12L).outcome)
}
@Test
fun aPinThatLosesAConcurrentTieReappliesOnTopOfTheWinner() =
runTest {
val h = harness()
val general = h.community.generalChannelIdHex
val one = h.post(alice, general, "one", 10L)
val two = h.post(alice, general, "two", 11L)
// Two curators read the same (empty) head and each write v1 at once.
val ctx = h.ctx(owner, general)
val a = ConcordPinning.pin(ctx, h.session.pinSource(general, one.id)!!, 12L)
val b = ConcordPinning.pin(ctx, h.session.pinSource(general, two.id)!!, 12L)
h.session.ingest(a.wrap!!)
h.session.ingest(b.wrap!!)
val folded = h.pins(general)
assertTrue(folded.isPinned(one.id) xor folded.isPinned(two.id), "one edition wins the tie, the other's pin is gone")
val loser = if (folded.isPinned(one.id)) two else one
// The re-heal: the loser runs its write again on the refolded head, chaining onto the winner.
val heal = ConcordPinning.pin(h.ctx(owner, general), h.session.pinSource(general, loser.id)!!, 13L)
assertEquals(ConcordPinOutcome.PUBLISHED, heal.outcome)
h.session.ingest(heal.wrap!!)
val healed = h.pins(general)
assertTrue(healed.isPinned(one.id) && healed.isPinned(two.id))
assertEquals(2L, healed.head!!.version)
}
@Test
fun aPinRoundTripsThroughTheControlPlaneAndUnpinRemovesIt() =
runTest {
@@ -0,0 +1,237 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.model.concord.ConcordIngestOutcome
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Private Channels end to end, headless (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-2): create
* with an access Role, vend on grant through a Direct Invite limited to the gained channel, the
* recipient's click-free adoption, rotate on revoke to the remaining entitled set, and each member's
* receive (the kept adopts, the cut drops the key and records the cut). Plus privatise/publicise.
*/
class ConcordPrivateChannelsTest {
private val owner = NostrSignerInternal(KeyPair())
private val alice = NostrSignerInternal(KeyPair())
private val bob = NostrSignerInternal(KeyPair())
private class World(
val community: NewConcordCommunity,
val editions: MutableList<ControlEdition>,
) {
fun add(wrap: Event) {
editions += ConcordActions.controlEditions(listOf(wrap), community.controlPlane)
}
fun state(): ConcordCommunityState = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
}
private suspend fun world(): World {
val c = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
return World(c, ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList())
}
private fun entryOf(
c: NewConcordCommunity,
channels: List<PrivateChannelKey> = emptyList(),
) = ConcordCommunityListEntry(
id = c.communityIdHex,
owner = c.ownerPubKey,
ownerSalt = c.ownerSalt.toHexKey(),
root = c.communityRoot.toHexKey(),
rootEpoch = c.rootEpoch,
controlPk = c.controlPkHex,
privateChannels = channels,
relays = listOf("wss://relay.example"),
name = "Nostrichs",
addedAt = 1_000L,
)
@Test
fun aPrivateChannelIsVendedOnGrantAndRotatedOnRevoke() =
runTest {
val w = world()
val c = w.community
val cid = c.communityId
// 1. Create: an access Role at the bottom of the roster, the channel flagged private, a key at epoch 0.
val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, cid, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L))
build.wraps.forEach { w.add(it) }
val ch = build.channelIdHex
assertEquals(0L, build.key.epoch)
assertTrue(ch in w.state().privateChannelIds)
val role = assertNotNull(w.state().roles[build.roleIdHex])
assertEquals("channel", role.scope?.kind)
assertEquals(ch, role.scope?.channelId)
assertEquals("0", role.permissions)
val ownerEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
// 2. Grant alice and bob the access Role: both gained the channel.
val beforeGrant = w.state().authority
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
val granted = ConcordInviteVend.accessChanges(beforeGrant, w.state().authority, w.state().privateChannelIds).single()
assertEquals(setOf(alice.pubKey, bob.pubKey), granted.gained)
// 3. Vend: a Direct Invite limited to the gained channel, from staff, adopted by alice without a click.
val draft = assertIs<ConcordDirectInviteDraft.Ready>(ConcordActions.draftDirectInvite(ownerEntry, w.state(), owner.pubKey, alice.pubKey, onlyChannelIds = setOf(ch)))
assertEquals(listOf(ch), draft.invite.channels.map { it.id })
val aliceHeld = entryOf(c)
assertEquals(
ConcordInviteVend.CatchUpVerdict.ADOPT,
ConcordInviteVend.judgeCatchUp(w.state().authority, w.state().privateChannelIds, alice.pubKey, owner.pubKey, draft.invite, aliceHeld),
)
val aliceEntry = assertNotNull(ConcordInviteVend.adoptCatchUp(aliceHeld, draft.invite))
val bobEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
assertEquals(build.key.key, ConcordChannelKeyring.heldKey(aliceEntry, ch)?.key)
// 4. Revoke bob: he lost the channel, so the owner rotates it to the remaining entitled set.
val beforeRevoke = w.state().authority
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, emptyList(), w.editions, 4L, owner = c.ownerPubKey))
val revoked = ConcordInviteVend.accessChanges(beforeRevoke, w.state().authority, w.state().privateChannelIds).single()
assertEquals(setOf(bob.pubKey), revoked.lost)
val keep = ConcordPrivateChannels.keepSet(w.state().authority, ch, owner.pubKey)
assertEquals(setOf(owner.pubKey, alice.pubKey), keep)
assertTrue(ConcordPrivateChannels.canRotate(w.state().authority, owner.pubKey, revoked.lost))
// A plain member can't rotate anyone out.
assertFalse(ConcordPrivateChannels.canRotate(w.state().authority, alice.pubKey, setOf(bob.pubKey)))
val newKey = ConcordChannelRekey.mintKey()
val held = assertNotNull(ConcordChannelKeyring.heldKey(ownerEntry, ch))
val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, held, newKey, keep, 5L, authority = null)
// 5. Receive: alice adopts epoch 1, bob is cut and the cut is recorded.
val aliceOut = ConcordPrivateChannels.receive(aliceEntry, wraps, w.editions, w.state().authority, alice)
val adopted = assertIs<ChannelRekeyOutcome.Adopted>(aliceOut[ch])
assertEquals(1L, adopted.epoch)
val aliceNext = assertNotNull(ConcordPrivateChannels.applyOutcome(aliceEntry, aliceOut, mapOf(ch to 0L)))
assertEquals(newKey.toHexKey(), ConcordChannelKeyring.heldKey(aliceNext, ch)?.key)
assertEquals(1L, ConcordChannelKeyring.heldKey(aliceNext, ch)?.epoch)
val bobOut = ConcordPrivateChannels.receive(bobEntry, wraps, w.editions, w.state().authority, bob)
assertEquals(1L, assertIs<ChannelRekeyOutcome.Removed>(bobOut[ch]).epoch)
val bobNext = assertNotNull(ConcordPrivateChannels.applyOutcome(bobEntry, bobOut, mapOf(ch to 0L)))
assertNull(ConcordChannelKeyring.heldKey(bobNext, ch))
assertEquals(mapOf(ch to 1L), ConcordChannelKeyring.cutsOf(bobNext))
// The stale epoch-0 key can't come back through a bundle.
assertTrue(ConcordInviteVend.catchUpChannelIds(bobNext, draft.invite).isEmpty())
// A result computed from a stale epoch never rolls the List back.
assertNull(ConcordPrivateChannels.applyOutcome(aliceNext, aliceOut, mapOf(ch to 0L)))
}
@Test
fun aRotationFromAMemberWithoutAuthorityIsIgnored() =
runTest {
val w = world()
val c = w.community
val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, c.communityId, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L))
build.wraps.forEach { w.add(it) }
w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
val aliceEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
// Bob holds the key but no MANAGE_CHANNELS: holding a key is never authority (CORD-06 §3).
val forged = ConcordPrivateChannels.buildRotation(bob, c.communityRoot, build.key, ConcordChannelRekey.mintKey(), setOf(bob.pubKey), 5L, authority = null)
assertTrue(ConcordPrivateChannels.receive(aliceEntry, forged, w.editions, w.state().authority, alice).isEmpty())
}
@Test
fun aSessionWatchesAndBuffersItsChannelRekeys() =
runTest {
val c = world().community
val chId = ByteArray(32) { 0x5C }
val key = PrivateChannelKey(chId.toHexKey(), "10".repeat(32), 3, "mods")
val entry = entryOf(c, listOf(key))
val session = ConcordCommunitySession(entry, alice.pubKey)
// The next LOOKAHEAD channel epochs past the held one, under the current root.
val next = ConcordChannelRekey.address(c.communityRoot, chId, 4).publicKeyHex
assertTrue(next in session.channelRekeyAddresses())
assertTrue(ConcordChannelRekey.address(c.communityRoot, chId, 3 + ConcordChannelRekey.LOOKAHEAD.toLong()).publicKeyHex in session.channelRekeyAddresses())
assertFalse(ConcordChannelRekey.address(c.communityRoot, chId, 3).publicKeyHex in session.channelRekeyAddresses())
assertTrue(session.ownsPlane(next))
// Also subscribed with the auxiliary planes.
assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == next })
val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, key, ConcordChannelRekey.mintKey(), setOf(alice.pubKey), 5L, null)
assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(wraps.single()))
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(wraps.single()))
assertEquals(listOf(wraps.single().id), session.pendingChannelRekeyWraps().map { it.id })
}
@Test
fun privatizeClimbsTheChannelEpochAndPublicizeFlipsTheFlagBack() =
runTest {
val w = world()
val c = w.community
val general = c.generalChannelIdHex
val standing = assertNotNull(w.state().channels[general]).definition
assertFalse(standing.private)
val build = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, "insiders", w.editions, w.state().authority, 2L))
build.wraps.forEach { w.add(it) }
// The first privatisation is epoch 1 (CORD-03 §2); a floor seen on the wire lifts it.
assertEquals(1L, build.key.epoch)
assertTrue(general in w.state().privateChannelIds)
assertEquals("insiders", w.state().roles[build.roleIdHex]?.name)
val later = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, null, w.editions, w.state().authority, 2L, observedFloor = 4))
assertEquals(5L, later.key.epoch)
// Already private: nothing to do.
assertNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, w.state().channels[general]!!.definition, null, w.editions, w.state().authority, 2L))
val flip = assertNotNull(ConcordPrivateChannels.publicize(owner, c.controlPlane, c.communityId, general, w.state().channels[general]!!.definition, w.editions, c.ownerPubKey, 3L))
w.add(flip)
assertFalse(general in w.state().privateChannelIds)
// The held private-era key keeps reading its history once the channel is public again.
val heldEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
val planes = ConcordActions.historicalChannelPlanes(heldEntry, general, isPrivate = false)
assertTrue(planes.any { it.epoch == 1L })
// And the next privatisation climbs past it.
assertEquals(2L, ConcordChannelKeyring.nextChannelEpoch(heldEntry, general))
assertTrue(general.hexToByteArray().size == 32)
}
}
@@ -200,4 +200,24 @@ class ConcordCommunitySessionTest {
community.genesisWraps.forEach { session.ingest(it) }
assertTrue(session.state.value!!.dissolved)
}
@Test
fun noWriteIsBuiltFromAFoldThatHasNotDrained() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val session = ConcordCommunitySession(entryFor(community), owner.pubKey)
assertEquals(null, session.foldForWrite(), "nothing folded")
// The live subscription delivered part of the plane: readable, but not a base for a write.
session.ingest(community.genesisWraps.first())
assertTrue(session.state.value != null)
assertFalse(session.controlDrained.value)
assertEquals(null, session.foldForWrite())
// The sweep paged the whole plane in and flagged it: writes may chain onto this fold.
community.genesisWraps.forEach { session.ingest(it) }
session.markControlDrained()
assertEquals(session.state.value, session.foldForWrite())
assertTrue(session.controlFloors().isEmpty(), "no prior epoch held, no floor")
}
}
@@ -27,11 +27,20 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
@@ -91,6 +100,13 @@ class ConcordDirectInviteInboxTest {
private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey)
/** [c]'s fold with [vip] defined as a live Private Channel. */
private suspend fun stateWithVip(c: NewConcordCommunity): ConcordCommunityState {
val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane)
return ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey)
}
@Test
fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() =
runTest {
@@ -138,6 +154,30 @@ class ConcordDirectInviteInboxTest {
assertSame(opened, inbox.offer(wrap))
}
@Test
fun theDmPipelineRumorPathParksWithoutAnotherDecryptAndTheSweepSkipsIt() =
runTest {
val c = community()
val counting = FlakySigner(me)
val inbox = ConcordDirectInviteInbox(counting)
val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c))
// What the NIP-17 pipeline did already: one decrypt per layer.
val seal = assertIs<SealEvent>(wrap.unwrapOrNull(me))
val rumor = seal.unsealRumorThrowing(me)
val opened = assertNotNull(inbox.offerRumor(wrap.copyNoContent(), seal, rumor))
assertEquals(sender.pubKey, opened.sender)
assertEquals(0, counting.decrypts, "the inbox never decrypted again")
// The sweep fetching the same wrap later costs nothing either.
assertSame(opened, inbox.offer(wrap))
assertEquals(0, counting.decrypts)
// A spoofed rumor (claimed author differs from the seal's) is refused on this path too.
val spoofed = Rumor(rumor.id, stranger.pubKey, rumor.createdAt, rumor.kind, rumor.tags, rumor.content)
val other = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c))
val otherSeal = assertIs<SealEvent>(other.unwrapOrNull(me))
assertNull(inbox.offerRumor(other.copyNoContent(), otherSeal, spoofed))
}
@Test
fun declineDiscardsAndTheWrapNeverResurfaces() =
runTest {
@@ -191,22 +231,184 @@ class ConcordDirectInviteInboxTest {
assertFalse(byWrap.getValue(toNew.wrapId).catchUp)
assertTrue(byWrap.getValue(toNew.wrapId).expired)
assertFalse(byWrap.getValue(catchUp.wrapId).expired)
assertEquals(listOf("vip"), byWrap.getValue(catchUp.wrapId).channelNames)
assertEquals(listOf("vip"), byWrap.getValue(catchUp.wrapId).newChannelNames())
// Named by the held fold when it knows the channel.
assertEquals(listOf("VIP lounge"), byWrap.getValue(catchUp.wrapId).newChannelNames { if (it == vip) "VIP lounge" else null })
}
@Test
fun visibleKeepsOneInvitePerCommunity() =
fun aCatchUpThatAcceptWouldRefuseIsNotOffered() =
runTest {
val c = community()
val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, 2L, owner = c.ownerPubKey)), c.controlPlane)
val state = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey)
val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip"))
val inbox = ConcordDirectInviteInbox(me)
// A plain member hands over a key: accept refuses it (not staff), so it is not a card.
val fromMember = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant))))
val fromOwner = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant))))
val held = listOf(heldEntryOf(c))
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, held, heldStateOf = { state })
assertEquals(listOf(fromOwner.wrapId), views.map { it.wrapId })
assertIs<DirectInviteAcceptPlan.CatchUp>(ConcordDirectInviteInbox.acceptPlan(fromOwner, held.single(), state, me.pubKey))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held.single(), state, me.pubKey))
// Before the fold is in, the verdict is unknown: both stay (accept waits for the roster).
assertEquals(2, ConcordDirectInviteInbox.visible(inbox.pending.value.values, held).size)
// A dissolved community takes no keys at all.
assertTrue(ConcordDirectInviteInbox.visible(inbox.pending.value.values, held, heldStateOf = { state.withDissolved(true) }).isEmpty())
}
@Test
fun visibleKeepsOneInvitePerCommunityAndSender() =
runTest {
val c = community()
val inbox = ConcordDirectInviteInbox(me)
val older = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = 1_700_000_000L)))
val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = 1_700_000_100L)))
assertEquals(2, inbox.pending.value.size)
val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = 1_700_000_100L)))
val fromStranger = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = 1_700_000_050L)))
assertEquals(3, inbox.pending.value.size)
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList())
assertEquals(listOf(newer.wrapId), views.map { it.wrapId })
assertEquals(listOf(newer.wrapId, fromStranger.wrapId), views.map { it.wrapId })
assertFalse(older.wrapId in views.map { it.wrapId })
}
@Test
fun aFutureDatedInviteNeitherHidesALegitOneNorOutranksIt() =
runTest {
val c = community()
val inbox = ConcordDirectInviteInbox(me)
val now = 1_700_000_000L
val legit = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now - 10), nowSecs = now))
// A stranger's invite dated a year ahead: it may show, but it cannot shadow the sender's.
val future = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = now + 365 * 86_400L), nowSecs = now))
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000)
assertEquals(setOf(legit.wrapId, future.wrapId), views.map { it.wrapId }.toSet())
assertEquals(now, views.first { it.wrapId == future.wrapId }.clampedSentAt, "ranked as if sent now, not a year ahead")
// Nor does it drag the sweep cursor into the future (D6): `since` stays near now.
assertTrue(inbox.newestWrapCreatedAt!! <= now + ConcordDirectInviteInbox.FUTURE_SKEW_SECS)
}
@Test
fun anInviteSentBeforeTheUserLeftTheCommunityStaysBuried() =
runTest {
val c = community()
val inbox = ConcordDirectInviteInbox(me)
val now = 1_700_000_000L
assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now - 100), nowSecs = now))
val leftAtMs = (now - 50) * 1000
val removed = mapOf(c.communityIdHex to leftAtMs)
assertTrue(ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000, removedAt = removed).isEmpty())
// A fresh re-invite sent after leaving shows.
val fresh = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = now - 10), nowSecs = now))
assertEquals(listOf(fresh.wrapId), ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000, removedAt = removed).map { it.wrapId })
}
@Test
fun followedSendersRankFirstAndHiddenSendersAreNeverParked() =
runTest {
val c = community()
val other = community()
val inbox = ConcordDirectInviteInbox(me, isHidden = { it == stranger.pubKey }, isFollowed = { it == owner.pubKey })
val now = 1_700_000_000L
assertNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = now), nowSecs = now), "a muted sender never parks")
val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now), nowSecs = now))
val followed = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(other), createdAt = now - 1_000), nowSecs = now))
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000, isFollowed = { it == owner.pubKey })
assertEquals(listOf(followed.wrapId, newer.wrapId), views.map { it.wrapId })
}
@Test
fun theInboxIsBoundedAndShedsTheLowestRanked() =
runTest {
val c = community()
val inbox = ConcordDirectInviteInbox(me, isFollowed = { it == owner.pubKey })
val now = 1_700_000_000L
val followed = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c), createdAt = now - 10_000), nowSecs = now))
repeat(ConcordDirectInviteInbox.MAX_PENDING) { i ->
inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now - 5_000 + i), nowSecs = now)
}
assertEquals(ConcordDirectInviteInbox.MAX_PENDING, inbox.pending.value.size)
assertTrue(followed.wrapId in inbox.pending.value, "the followed sender's older invite outranks strangers' newer ones")
}
@Test
fun aSignerThatCannotAnswerNowLeavesTheWrapToBeRetried() =
runTest {
val c = community()
val flaky = FlakySigner(me)
val inbox = ConcordDirectInviteInbox(flaky)
val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c))
flaky.failNext = true
assertNull(inbox.offer(wrap), "the signer timed out: nothing parked")
// Not written off: the next delivery opens it.
assertNotNull(inbox.offer(wrap))
// A definitive failure (not for us) is written off: a later offer never decrypts again.
val notOurs = ConcordActions.buildDirectInvite(sender, stranger.pubKey, inviteFor(c))
assertNull(inbox.offer(notOurs))
val before = flaky.decrypts
assertNull(inbox.offer(notOurs))
assertEquals(before, flaky.decrypts)
}
/** Delegates to [inner], throwing a transient signer failure on the next decrypt when [failNext]. */
private class FlakySigner(
private val inner: NostrSignerInternal,
) : NostrSigner(inner.pubKey) {
var failNext = false
var decrypts = 0
override fun isWriteable() = inner.isWriteable()
override suspend fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T = inner.sign(createdAt, kind, tags, content)
override suspend fun nip04Encrypt(
plaintext: String,
toPublicKey: HexKey,
) = inner.nip04Encrypt(plaintext, toPublicKey)
override suspend fun nip04Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
) = inner.nip04Decrypt(ciphertext, fromPublicKey)
override suspend fun nip44Encrypt(
plaintext: String,
toPublicKey: HexKey,
) = inner.nip44Encrypt(plaintext, toPublicKey)
override suspend fun nip44Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
): String {
decrypts++
if (failNext) {
failNext = false
throw SignerExceptions.TimedOutException("signer busy")
}
return inner.nip44Decrypt(ciphertext, fromPublicKey)
}
override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = inner.decryptZapEvent(event)
override suspend fun deriveKey(nonce: HexKey) = inner.deriveKey(nonce)
override suspend fun signPsbt(psbtHex: String) = inner.signPsbt(psbtHex)
override fun hasForegroundSupport() = inner.hasForegroundSupport()
}
@Test
fun acceptRefusesAnExpiredInvite() =
runTest {
@@ -221,17 +423,23 @@ class ConcordDirectInviteInboxTest {
runTest {
val c = community()
val held = heldEntryOf(c)
val state = stateOf(c)
val state = stateWithVip(c)
val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip"))
// Same base, new key: a catch-up that keeps the held base and anchor.
val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me))
// Same base, new key, from staff (the owner): a catch-up that keeps the held base and anchor.
val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant)), me))
val plan = assertIs<DirectInviteAcceptPlan.CatchUp>(ConcordDirectInviteInbox.acceptPlan(catchUp, held, state, me.pubKey))
assertEquals(held.root, plan.entry.root)
assertEquals(held.rootEpoch, plan.entry.rootEpoch)
assertEquals(held.controlPk, plan.entry.controlPk)
assertEquals("anchor", plan.entry.inviteRef)
assertEquals(listOf(vip), plan.entry.privateChannels.map { it.channelId })
assertEquals(listOf(vip), plan.channelIds)
// A plain keyholder can't plant a key, and a channel the fold doesn't know as Private isn't one.
val fromMember = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held, state, me.pubKey))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, stateOf(c), me.pubKey))
// No fold yet: the ban verdict is unknown, so it waits.
assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey))
@@ -240,6 +448,11 @@ class ConcordDirectInviteInboxTest {
val holding = held.let { ConcordCommunityListEntry(it.id, it.owner, it.ownerSalt, it.root, it.rootEpoch, it.controlPk, privateChannels = listOf(PrivateChannelKey(vip, "db".repeat(32), 0, "vip")), relays = it.relays, name = it.name) }
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, holding, state, me.pubKey))
// Even from staff, a bundle never REPLACES a held key — not at a higher, nor an absurd, epoch.
// A held key moves only through a channel rekey, whose prevcommit proves continuity.
val hijack = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "ee".repeat(32), 1_000_000_000L, "vip")))), me))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(hijack, holding, state, me.pubKey))
// A different base for a held community is never adopted, keys or not.
val baseMove = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, root = "99".repeat(32), channels = grant)), me))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(baseMove, held, state, me.pubKey))
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
@@ -114,7 +115,6 @@ class ConcordDisappearingSessionTest {
ConcordActions.buildChannelInlineReply(owner, plane.key, general, plane.epoch, parent, "quote", at, timerSecs = timer),
ConcordActions.buildChannelReply(owner, plane.key, general, plane.epoch, parent, "thread", at, timerSecs = timer),
ConcordActions.buildChannelImageReply(owner, plane.key, general, plane.epoch, parent, "thread pic", imeta, at, timerSecs = timer),
ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, parent, "edited", at, timerSecs = timer),
ConcordActions.buildChannelReaction(owner, plane.key, general, plane.epoch, parent, "+", at, timerSecs = timer),
)
for (wrap in durable) {
@@ -142,6 +142,32 @@ class ConcordDisappearingSessionTest {
assertEquals(listOf("p"), off.tags.map { it[0] })
}
@Test
fun anEditKeepsTheOriginalMessagesDeadlineNotNowPlusTimer() =
runTest {
val (community, session) = session(day)
val general = community.generalChannelIdHex
val plane = session.currentChannelPlane(general)!!
val sentAt = 1_000_000L
val original = ChannelChat.message(owner.pubKey, general, plane.epoch, "hi", sentAt, ConcordDisappearing.withExpiration(emptyArray(), sentAt + 7 * day))
val editedAt = sentAt + 3 * day
// Default: the target's own deadline, verbatim, inside and outside.
val edit = ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, original, "hi!", editedAt)
assertEquals(sentAt + 7 * day, ConcordDisappearing.expirationOf(opened(edit, plane.key)))
assertEquals((sentAt + 7 * day).toString(), wrapExpiration(edit))
// A message sent without a timer stays timer-free when edited, even though a timer is on now.
val forever = ChannelChat.message(owner.pubKey, general, plane.epoch, "forever", sentAt)
val editForever = ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, forever, "still forever", editedAt)
assertNull(ConcordDisappearing.expirationOf(opened(editForever, plane.key)))
assertNull(wrapExpiration(editForever))
// A smuggled expiration in extraTags never overrides the original's.
val smuggled = ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, forever, "x", editedAt, arrayOf(arrayOf("expiration", "5")))
assertNull(ConcordDisappearing.expirationOf(opened(smuggled, plane.key)))
}
@Test
fun anAlreadyExpiredRumorIsRefusedAndItsWrapPurged() =
runTest {
@@ -195,6 +221,79 @@ class ConcordDisappearingSessionTest {
assertNull(session.nextExpiry.value)
}
@Test
fun theSweepPopsOnlyWhatIsDueInDeadlineOrderAndCarriesAttachmentUrls() =
runTest {
val (community, session) = session(day)
val general = community.generalChannelIdHex
val plane = session.currentChannelPlane(general)!!
val now = TimeUtils.now()
val image = listOf(ChannelChat.encryptedImageImeta("https://blossom.example/blob", "image/png", null, null, AESGCM(), null))
val late = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "late", now, timerSecs = 3 * day)
val soon = ConcordActions.buildChannelImageMessage(owner, plane.key, general, plane.epoch, "soon", image, now, timerSecs = day)
val mid = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "mid", now, timerSecs = 2 * day)
listOf(late, soon, mid).forEach { session.ingest(it) }
assertEquals(now + day, session.nextExpiry.value, "the head of the deadline order")
val first = session.sweepExpired(now + 2 * day)
assertEquals(listOf(soon.id, mid.id), first.map { it.wrapId }, "due ones only, soonest first")
// CORD-08 §3: the image's decryption key must go with the message.
assertEquals(listOf("https://blossom.example/blob"), first.first().attachmentUrls)
assertEquals(now + 3 * day, session.nextExpiry.value)
assertEquals(listOf(late.id), session.sweepExpired(now + 3 * day).map { it.wrapId })
assertNull(session.nextExpiry.value)
}
@Test
fun aSweptWrapDeliveredAgainIsNotOpenedAgain() =
runTest {
val captured = mutableListOf<Event>()
val (community, session) = session(day, captured)
val general = community.generalChannelIdHex
val plane = session.currentChannelPlane(general)!!
val stale = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "stale", TimeUtils.now() - 2 * day, timerSecs = day)
session.ingest(stale)
assertEquals(listOf(stale.id), session.sweepExpired().map { it.wrapId })
// A relay serving it again: claimed, dropped unopened — no new deadline, no re-sweep loop.
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(stale))
assertNull(session.nextExpiry.value)
assertFalse(session.isBuffered(general, stale.id))
assertTrue(captured.none { it.content == "stale" })
}
@Test
fun aRefoundingCarriesTheTrackedDeadlinesIntoTheNewSession() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val registry = ConcordSessionRegistry()
val entry = entryFor(community)
registry.sync(listOf(entry), owner.pubKey)
val old = registry.sessionFor(community.communityIdHex)!!
community.genesisWraps.forEach { old.ingest(it) }
val plane = old.currentChannelPlane(community.generalChannelIdHex)!!
val now = TimeUtils.now()
val live = ConcordActions.buildChannelMessage(owner, plane.key, community.generalChannelIdHex, plane.epoch, "bye", now, timerSecs = day)
old.ingest(live)
// The root rolls: the registry rebuilds the session, which never sees the old wrap again.
val rolled =
ConcordCommunityListEntry(
id = entry.id,
owner = entry.owner,
ownerSalt = entry.ownerSalt,
root = KeyPair().pubKey.toHexKey(),
rootEpoch = entry.rootEpoch + 1,
relays = entry.relays,
name = entry.name,
)
registry.sync(listOf(rolled), owner.pubKey)
val fresh = registry.sessionFor(community.communityIdHex)!!
assertTrue(fresh !== old)
assertEquals(now + day, fresh.nextExpiry.value)
assertEquals(listOf(live.id), fresh.sweepExpired(now + day).map { it.wrapId })
}
@Test
fun theManagerSchedulesOnTheEarliestDeadlineAndSweepsPerCommunity() =
runTest {
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
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 kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The Cooperative Kick (CORD-02 §5, CORD-04 §6) through the session fold: an honored Kick departs its
* target in everyone's roster and tells the target's own client to leave; a Kick from someone who may
* not kick is dropped; a Kick whose Grant has not folded yet parks until it does; and a re-join
* (a newer Join, or a re-added entry) leaves the Kick behind.
*/
class ConcordKickTest {
private val owner = NostrSignerInternal(KeyPair())
private val mod = NostrSignerInternal(KeyPair())
private val target = NostrSignerInternal(KeyPair())
private val bystander = NostrSignerInternal(KeyPair())
private class World(
val community: NewConcordCommunity,
val controlWraps: MutableList<Event>,
) {
fun state(): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(controlWraps, community.controlPlane), community.communityId, community.ownerPubKey)
}
/** A community whose owner defined a Mod role (position 5, KICK only) and granted it to [mod]. */
private suspend fun world(): Pair<World, Event> {
val c = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val w = World(c, c.genesisWraps.toMutableList())
val roleId = ByteArray(32) { 7 }
val role = RoleEntity(name = "Mod", position = 5, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire())
w.controlWraps += ConcordModeration.defineRole(owner, c.controlPlane, c.communityId, roleId, role, ConcordActions.controlEditions(w.controlWraps, c.controlPlane), 2L, owner = c.ownerPubKey)
val modGrant = ConcordModeration.grant(owner, c.controlPlane, c.communityId, mod.pubKey, listOf(roleId.toHexKey()), ConcordActions.controlEditions(w.controlWraps, c.controlPlane), 3L, owner = c.ownerPubKey)
w.controlWraps += modGrant
return w to modGrant
}
private fun entryOf(
c: NewConcordCommunity,
addedAt: Long,
) = ConcordCommunityListEntry(
id = c.communityIdHex,
owner = c.ownerPubKey,
ownerSalt = c.ownerSalt.toHexKey(),
root = c.communityRoot.toHexKey(),
rootEpoch = c.rootEpoch,
controlPk = c.controlPkHex,
relays = listOf("wss://relay.example"),
name = "Nostrichs",
addedAt = addedAt,
)
private fun session(
c: NewConcordCommunity,
me: String,
wraps: List<Event>,
addedAt: Long = 1_000L,
): ConcordCommunitySession = ConcordCommunitySession(entryOf(c, addedAt), me) { _, _, _, _ -> }.also { s -> wraps.forEach { s.ingest(it) } }
private fun guestbook(c: NewConcordCommunity) = ConcordActions.guestbookPlane(c.communityRoot, c.communityId, c.rootEpoch)
private fun citationOf(
w: World,
actor: String,
): AuthorityCitation? = w.state().authority.citationFor(actor)
@Test
fun anHonoredKickDepartsTheTargetAndAsksTheirClientToLeave() =
runTest {
val (w, _) = world()
val c = w.community
val gb = guestbook(c)
val join = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 5L)
val kick = ConcordActions.buildGuestbookKick(mod, gb, target.pubKey, citationOf(w, mod.pubKey), createdAt = 10L)
val ownerView = session(c, owner.pubKey, w.controlWraps + join + kick)
val t = target.pubKey.lowercase()
assertEquals(GuestbookAction.KICK, ownerView.guestbook.value[t]?.action)
assertFalse(t in ownerView.members.value)
assertEquals(GuestbookAction.KICK, ownerView.departedMembers()[t]?.action)
assertFalse(t in ownerView.allMembers())
assertNull(ownerView.kickedMe()) // the Kick names the target, not the owner
// The target's client finds the Kick against this membership and complies.
val targetView = session(c, target.pubKey, w.controlWraps + join + kick, addedAt = 1_000L)
val verdict = assertNotNull(targetView.kickedMe())
assertEquals(mod.pubKey, verdict.author)
// Re-invited after the Kick: the entry's added_at postdates it, so it judges nothing.
assertNull(session(c, target.pubKey, w.controlWraps + join + kick, addedAt = 11_000L).kickedMe())
// Re-joined: a newer self-signed Join supersedes the Kick.
val rejoin = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 20L)
targetView.ingest(rejoin)
assertNull(targetView.kickedMe())
assertTrue(t in targetView.members.value)
}
@Test
fun aKickFromSomeoneWhoMayNotKickIsDropped() =
runTest {
val (w, _) = world()
val c = w.community
val gb = guestbook(c)
val join = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 5L)
// A roleless member holds no KICK; nobody may kick the owner.
val forged = ConcordActions.buildGuestbookKick(bystander, gb, target.pubKey, citationOf(w, bystander.pubKey), createdAt = 10L)
val atOwner = ConcordActions.buildGuestbookKick(mod, gb, owner.pubKey, citationOf(w, mod.pubKey), createdAt = 10L)
val targetView = session(c, target.pubKey, w.controlWraps + join + forged + atOwner)
assertEquals(GuestbookAction.JOIN, targetView.guestbook.value[target.pubKey.lowercase()]?.action)
assertNull(targetView.kickedMe())
assertNull(targetView.guestbook.value[owner.pubKey.lowercase()])
assertTrue(targetView.departedMembers().isEmpty())
}
@Test
fun aKickParksUntilItsCitedGrantFolds() =
runTest {
val (w, modGrant) = world()
val c = w.community
val gb = guestbook(c)
val join = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 5L)
val kick = ConcordActions.buildGuestbookKick(mod, gb, target.pubKey, citationOf(w, mod.pubKey), createdAt = 10L)
// The Guestbook can lead the Control Plane: without the mod's Grant their Kick parks.
val targetView = session(c, target.pubKey, (w.controlWraps - modGrant) + join + kick)
assertNull(targetView.kickedMe())
assertEquals(GuestbookAction.JOIN, targetView.guestbook.value[target.pubKey.lowercase()]?.action)
// The Grant folds: the same held Kick is now honored, with no Guestbook arrival.
targetView.ingest(modGrant)
assertNotNull(targetView.kickedMe())
}
@Test
fun aKickWithoutACitationFromANonOwnerParks() =
runTest {
val (w, _) = world()
val c = w.community
val gb = guestbook(c)
val join = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 5L)
val uncited = ConcordActions.buildGuestbookKick(mod, gb, target.pubKey, citation = null, createdAt = 10L)
assertNull(session(c, target.pubKey, w.controlWraps + join + uncited).kickedMe())
// The owner cites nothing and needs nothing.
val byOwner = ConcordActions.buildGuestbookKick(owner, gb, target.pubKey, citation = null, createdAt = 10L)
assertNotNull(session(c, target.pubKey, w.controlWraps + join + byOwner).kickedMe())
}
}
@@ -0,0 +1,71 @@
/*
* 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.ConcordChannelPins
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The account-level pin duty scheduler (CORD-04 §7): a duty waits its random delay, a channel runs
* one duty at a time, and a debt is attempted once — so a burst of ticks costs one write and a failing
* write never spins — while a new debt is a new attempt.
*/
class ConcordPinDutySchedulerTest {
@Test
fun oneAttemptPerDebtAndOneDutyPerChannel() =
runTest {
val scheduler = ConcordPinDutyScheduler(delayMs = { 5_000L })
var runs = 0
assertTrue(scheduler.schedule(this, "c|a", "d1") { runs++ })
assertFalse(scheduler.schedule(this, "c|a", "d1") { runs++ }, "the same debt twice")
assertFalse(scheduler.schedule(this, "c|a", "d2") { runs++ }, "a second duty while one is in flight")
assertTrue(scheduler.schedule(this, "c|b", "d1") { runs++ }, "another channel is independent")
advanceTimeBy(4_999)
runCurrent()
assertEquals(0, runs, "a duty waits its delay before settling")
advanceUntilIdle()
assertEquals(2, runs)
assertFalse(scheduler.schedule(this, "c|a", "d1") { runs++ }, "an attempted debt is never respun")
assertTrue(scheduler.schedule(this, "c|a", "d2") { runs++ }, "a new debt is a new attempt")
advanceUntilIdle()
assertEquals(3, runs)
}
@Test
fun nothingOwedSchedulesNothing() =
runTest {
val scheduler = ConcordPinDutyScheduler(delayMs = { 0L })
assertFalse(scheduler.schedule(this, "c|a", null) { error("must not run") })
assertNull(ConcordPinDutyScheduler.debtOf(null))
assertNull(ConcordPinDutyScheduler.debtOf(ConcordChannelPins.none("aa".repeat(32))))
}
}
@@ -0,0 +1,96 @@
/*
* 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.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordWebxdc
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 kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* WebXDC signals (kind 3310) on a Chat Plane (F10): the session accepts them under the plane's strict
* binding and holds them for a WebXDC host, but never hands them to the chat store — so they never
* become feed rows, previews or unread messages — while ordinary messages on the same plane still do.
*/
class ConcordWebxdcSessionTest {
private val owner = NostrSignerInternal(KeyPair())
private val alice = NostrSignerInternal(KeyPair())
private val topic = "A".repeat(26) + "234567".repeat(4) + "BC"
@Test
fun webxdcSignalsAreHeldApartFromChatRows() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val stored = mutableListOf<Event>()
val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> stored += rumor }
community.genesisWraps.forEach { session.ingest(it) }
val general = community.generalChannelIdHex
val plane = assertNotNull(session.currentChannelPlane(general))
val update = ConcordWebxdc.stateUpdate(alice.pubKey, general, plane.epoch, "uuid-1", "{\"move\":1}", createdAt = 10L)
val ad = ConcordWebxdc.peerSignal(alice.pubKey, general, plane.epoch, topic, "node-addr", createdAt = 11L)
// Bound to another epoch: the plane's strict binding still refuses it.
val misbound = ConcordWebxdc.stateUpdate(alice.pubKey, general, plane.epoch + 1, "uuid-1", "{}", createdAt = 12L)
for (rumor in listOf(update, ad, misbound)) {
session.ingest(ConcordStreamEnvelope.wrap(rumor, plane.key, alice, encrypted = true, createdAt = rumor.createdAt))
}
val message = ConcordActions.buildChannelMessage(alice, plane.key, general, plane.epoch, "hello", 13L)
session.ingest(message)
// Only the chat message reached the store (feeds, previews, unread counts read from there).
assertEquals(listOf("hello"), stored.map { it.content })
assertTrue(stored.none { it.kind == ConcordWebxdc.KIND })
// The signals are held for a WebXDC host, oldest first; the misbound one is not.
val held = session.webxdcSignals(general)
assertEquals(listOf(update.id, ad.id), held.map { it.id })
assertEquals("uuid-1", ConcordWebxdc.sessionOf(held.first()))
assertEquals("node-addr", ConcordWebxdc.parsePeerSignal(held.last().content)?.addr)
assertEquals(2L, session.webxdcRevision.value)
// A duplicate delivery holds nothing new; the author counts as observed either way.
session.ingest(ConcordStreamEnvelope.wrap(update, plane.key, alice, encrypted = true, createdAt = 10L))
assertEquals(2, session.webxdcSignals(general).size)
assertTrue(alice.pubKey.lowercase() in session.observedAuthors.value)
}
}
@@ -619,6 +619,9 @@
<string name="concord_pinned_unavailable">This channel's pins are sealed under a key you don't hold, so they can't be shown here, and pinning is paused until they can be read.</string>
<string name="concord_pinned_budget">%1$d of %2$d pins · %3$d% of the size budget used</string>
<string name="concord_pinned_open_hint">Tap a pin to jump to it</string>
<string name="concord_pin_expiring_title">Pin a disappearing message?</string>
<string name="concord_pin_expiring_body">This message is set to disappear. Pinning it keeps its words in the channel's pin list after the timer erases the message itself.</string>
<string name="concord_pin_expiring_confirm">Pin anyway</string>
<string name="concord_pin_failed_title">Pins</string>
<string name="concord_pin_failed_unavailable">The pinned list is sealed under a key you don't hold. Changing it now would drop pins you can't see.</string>
<string name="concord_pin_failed_too_many">This channel already has 25 pins. Unpin one first.</string>
@@ -645,6 +648,15 @@
<string name="concord_role_owner">Owner</string>
<string name="concord_role_admin">Admin</string>
<string name="concord_role_banned">Banned</string>
<string name="concord_role_kicked">Kicked</string>
<string name="concord_role_left">Left</string>
<string name="concord_members_kick">Kick</string>
<string name="concord_members_kick_title">Kick this member?</string>
<string name="concord_members_kick_message">Their roles are removed and their app is asked to leave the community. A kick is cooperative: it does not change any keys, and they can rejoin with an invite. To cut off their access, ban or remove them instead.</string>
<string name="concord_members_kick_failed">Could not kick this member.</string>
<string name="concord_kicked_title">Removed from community</string>
<string name="concord_kicked_message">A moderator removed you from %1$s. You can rejoin with a new invite.</string>
<string name="concord_kicked_message_unnamed">A moderator removed you from a community. You can rejoin with a new invite.</string>
<string name="concord_invite_card_join">Join community</string>
<string name="concord_invite_card_subtitle">Concord community invite</string>
<string name="concord_invite_naddr_label">Concord invite (open the full invite link to join)</string>
@@ -3645,15 +3657,23 @@
<item quantity="one">%1$d channel</item>
<item quantity="other">%1$d channels</item>
</plurals>
<string name="concord_channel_access_role_label">Access role name</string>
<string name="concord_channel_create">New channel</string>
<string name="concord_channel_delete">Delete channel</string>
<string name="concord_channel_delete_confirm">Delete</string>
<string name="concord_channel_delete_message">Delete #%1$s? This can't be undone and the channel can't be recreated with the same id.</string>
<string name="concord_channel_delete_title">Delete channel?</string>
<string name="concord_channel_make_private">Make private</string>
<string name="concord_channel_make_private_message">#%1$s gets its own key from now on, and the "%2$s" role decides who can read it. Nobody holds that role yet: grant it to the members who should have access. Messages already posted stay readable to everyone in the community.</string>
<string name="concord_channel_make_public">Make public</string>
<string name="concord_channel_make_public_message">Every member of the community will be able to read #%1$s from now on. Messages posted while it was private stay readable only to the members who held its key.</string>
<string name="concord_channel_name_label">Channel name</string>
<string name="concord_channel_no_messages">No messages yet</string>
<string name="concord_channel_private_hint">Only members holding its access role can read it. Grant the role to let them in; revoking it rotates the channel key.</string>
<string name="concord_channel_private_toggle">Private channel</string>
<string name="concord_channel_rename">Rename channel</string>
<string name="concord_channel_rename_save">Rename</string>
<string name="concord_channel_rotate_key">Rotate key</string>
<string name="concord_channels_empty">No channels yet.</string>
<string name="concord_create_action">Create</string>
<string name="concord_create_failed">The community could not be created: no relay accepted it, or your community list could not be updated. Check the relays and try again.</string>
@@ -43,6 +43,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -75,15 +76,23 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_name
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.concord_channel_access_role_label
import com.vitorpamplona.amethyst.commons.resources.concord_channel_create
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_confirm
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_message
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_title
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_private
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_private_message
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_public
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_public_message
import com.vitorpamplona.amethyst.commons.resources.concord_channel_name_label
import com.vitorpamplona.amethyst.commons.resources.concord_channel_no_messages
import com.vitorpamplona.amethyst.commons.resources.concord_channel_private_hint
import com.vitorpamplona.amethyst.commons.resources.concord_channel_private_toggle
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename_save
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rotate_key
import com.vitorpamplona.amethyst.commons.resources.concord_channels_empty
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_action
import com.vitorpamplona.amethyst.commons.resources.concord_edit_title
@@ -118,6 +127,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -236,11 +246,11 @@ fun ConcordChannelListScreen(
initialName = editor.initialName,
isCreate = editor.channelIdHex == null,
onDismiss = { channelEditor = null },
onConfirm = { newName ->
onConfirm = { newName, makePrivate, accessRoleName ->
channelEditor = null
scope.launch {
if (editor.channelIdHex == null) {
account.concord.createConcordChannel(communityId, newName)
account.concord.createConcordChannel(communityId, newName, makePrivate, accessRoleName)
} else {
account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName)
}
@@ -249,6 +259,26 @@ fun ConcordChannelListScreen(
)
}
// Privatise / publicise a channel (CORD-03 §2): both are MANAGE_CHANNELS edits, and both say
// plainly what they can't do — a conversion protects the future only.
var channelToConvert by remember { mutableStateOf<ConcordChannelConversion?>(null) }
channelToConvert?.let { target ->
ConcordChannelConvertDialog(
target = target,
onDismiss = { channelToConvert = null },
onConfirm = { accessRoleName ->
channelToConvert = null
scope.launch {
if (target.toPrivate) {
account.concord.privatizeConcordChannel(communityId, target.channelIdHex, accessRoleName)
} else {
account.concord.publicizeConcordChannel(communityId, target.channelIdHex)
}
}
},
)
}
channelToDelete?.let { target ->
val id = target.channelIdHex ?: return@let
AlertDialog(
@@ -444,6 +474,8 @@ fun ConcordChannelListScreen(
.keys
.sorted()
}
// A rotation needs the current key (CORD-06): only a holder can rotate.
val holdsKey = def.private && session?.entry?.let { ConcordChannelKeyring.heldKey(it, entry.key) } != null
ConcordChannelListRow(
communityId = communityId,
channelKey = entry.key,
@@ -455,6 +487,9 @@ fun ConcordChannelListScreen(
onClick = { nav.nav(Route.Concord(communityId, entry.key)) },
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onTogglePrivate = { channelToConvert = ConcordChannelConversion(entry.key, name, toPrivate = !def.private) },
isPrivate = def.private,
onRotateKey = if (holdsKey) ({ scope.launch { account.concord.rekeyConcordChannel(communityId, entry.key) } }) else null,
)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
@@ -482,6 +517,9 @@ private fun ConcordChannelListRow(
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
onTogglePrivate: () -> Unit,
isPrivate: Boolean,
onRotateKey: (() -> Unit)?,
) {
val account = accountViewModel.account
// getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note
@@ -545,6 +583,9 @@ private fun ConcordChannelListRow(
ConcordChannelRowMenu(
onRename = onRename,
onDelete = onDelete,
onTogglePrivate = onTogglePrivate,
isPrivate = isPrivate,
onRotateKey = onRotateKey,
)
}
}
@@ -673,11 +714,62 @@ private data class ConcordChannelEditor(
val initialName: String,
)
/** The per-channel-row overflow menu (rename / delete), shown only to channel managers. */
/** A pending privatise ([toPrivate]) or publicise of [channelIdHex]. */
private data class ConcordChannelConversion(
val channelIdHex: String,
val name: String,
val toPrivate: Boolean,
)
/** Confirms a Private/Public conversion; privatising also names the new access Role. */
@Composable
private fun ConcordChannelConvertDialog(
target: ConcordChannelConversion,
onDismiss: () -> Unit,
onConfirm: (String?) -> Unit,
) {
var roleName by remember { mutableStateOf(target.name) }
val action = if (target.toPrivate) Res.string.concord_channel_make_private else Res.string.concord_channel_make_public
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(action)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (target.toPrivate) {
Text(stringRes(Res.string.concord_channel_make_private_message, target.name, roleName.ifBlank { target.name }))
OutlinedTextField(
value = roleName,
onValueChange = { roleName = it },
singleLine = true,
label = { Text(stringRes(Res.string.concord_channel_access_role_label)) },
modifier = Modifier.fillMaxWidth(),
)
} else {
Text(stringRes(Res.string.concord_channel_make_public_message, target.name))
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(roleName.trim().ifBlank { null }) }) {
Text(stringRes(action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.cancel))
}
},
)
}
/** The per-channel-row overflow menu (rename / privacy / rotate / delete), shown only to channel managers. */
@Composable
private fun ConcordChannelRowMenu(
onRename: () -> Unit,
onDelete: () -> Unit,
onTogglePrivate: () -> Unit,
isPrivate: Boolean,
onRotateKey: (() -> Unit)?,
) {
var expanded by remember { mutableStateOf(false) }
Box {
@@ -696,6 +788,22 @@ private fun ConcordChannelRowMenu(
onRename()
},
)
DropdownMenuItem(
text = { Text(stringRes(if (isPrivate) Res.string.concord_channel_make_public else Res.string.concord_channel_make_private)) },
onClick = {
expanded = false
onTogglePrivate()
},
)
onRotateKey?.let { rotate ->
DropdownMenuItem(
text = { Text(stringRes(Res.string.concord_channel_rotate_key)) },
onClick = {
expanded = false
rotate()
},
)
}
DropdownMenuItem(
text = {
Text(
@@ -718,9 +826,11 @@ private fun ConcordChannelEditDialog(
initialName: String,
isCreate: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
onConfirm: (name: String, makePrivate: Boolean, accessRoleName: String?) -> Unit,
) {
var name by remember { mutableStateOf(initialName) }
var makePrivate by remember { mutableStateOf(false) }
var roleName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = {
@@ -735,6 +845,7 @@ private fun ConcordChannelEditDialog(
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -742,11 +853,35 @@ private fun ConcordChannelEditDialog(
label = { Text(stringRes(Res.string.concord_channel_name_label)) },
modifier = Modifier.fillMaxWidth(),
)
// A new channel may be Private (CORD-03): its own key, and an access Role — the
// Roles scoped to a channel ARE its access list (CORD-04 §2).
if (isCreate) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringRes(Res.string.concord_channel_private_toggle), modifier = Modifier.weight(1f))
Switch(checked = makePrivate, onCheckedChange = { makePrivate = it })
}
if (makePrivate) {
Text(
stringRes(Res.string.concord_channel_private_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = roleName,
onValueChange = { roleName = it },
singleLine = true,
placeholder = { Text(name.trim()) },
label = { Text(stringRes(Res.string.concord_channel_access_role_label)) },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
},
confirmButton = {
TextButton(
enabled = name.isNotBlank(),
onClick = { if (name.isNotBlank()) onConfirm(name.trim()) },
onClick = { if (name.isNotBlank()) onConfirm(name.trim(), makePrivate, roleName.trim().ifBlank { null }) },
) {
Text(
stringRes(
@@ -21,11 +21,14 @@
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
@@ -84,6 +87,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserS
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
/**
@@ -178,29 +183,48 @@ private fun sendResultMessage(result: ConcordDirectInviteSendResult) =
}
/**
* The Direct Invites waiting for this account (CORD-05 §6), as cards with Accept / Decline — shown
* at the top of the Concord communities list. Renders nothing when there are none.
* Sweeps the inbox relays for Direct Invites once when the hub opens (wraps the DM pipeline sees
* arrive on their own). Call it once per screen, outside any lazy list: inside a lazy item it would
* re-run every time the item scrolled back into view.
*/
@Composable
fun RefreshConcordDirectInvites(accountViewModel: AccountViewModel) {
val concord = accountViewModel.account.concord
LaunchedEffect(concord) {
try {
concord.refreshConcordDirectInvites()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("ConcordDirectInvites", "Direct Invite sweep failed", e)
}
}
}
/**
* The Direct Invites waiting for this account (CORD-05 §6), as lazy items with Accept / Decline —
* shown at the top of the Concord communities list. Adds nothing when there are none.
*
* Opening the hub sweeps the inbox relays once; wraps the DM pipeline sees arrive on their own.
* The preview is the bundle's own name and a robohash of the community id — **no** icon fetch, no
* relay connection to the community, no Join happens before the user taps Accept. The sender is
* shown by whatever name the cache already has, without fetching their profile.
*/
@Composable
fun ConcordPendingDirectInvites(
fun LazyListScope.concordPendingDirectInvites(
invites: List<ConcordDirectInviteView>,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val concord = accountViewModel.account.concord
LaunchedEffect(concord) { runCatching { concord.refreshConcordDirectInvites() } }
val invites by concord.pendingConcordDirectInvites.collectAsStateWithLifecycle()
if (invites.isEmpty()) return
Column(modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringRes(Res.string.concord_direct_invites_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
invites.forEach { invite ->
item(key = "concord-direct-invites-title") {
Text(
stringRes(Res.string.concord_direct_invites_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp),
)
}
items(invites, key = { "concord-direct-invite-" + it.wrapId }) { invite ->
Box(Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
ConcordDirectInviteCard(invite, accountViewModel, nav)
}
}
@@ -220,7 +244,20 @@ private fun ConcordDirectInviteCard(
val subtitle =
when {
invite.expired -> stringRes(Res.string.concord_direct_invite_expired)
invite.catchUp -> stringRes(Res.string.concord_direct_invite_catch_up, invite.channelNames.joinToString(", ") { "#$it" })
invite.catchUp -> {
// Only the channels it newly adds, named as the held community folds them.
val names =
remember(invite) {
val folded =
accountViewModel.account.concordSessions
.sessionFor(invite.communityId)
?.state
?.value
?.channels
invite.newChannelNames { id -> folded?.get(id)?.definition?.name }
}
stringRes(Res.string.concord_direct_invite_catch_up, names.joinToString(", ") { "#$it" })
}
else -> stringRes(Res.string.concord_direct_invite_from, senderName)
}
@@ -80,7 +80,8 @@ import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.platform.rememberConcordImageModel
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPendingDirectInvites
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.RefreshConcordDirectInvites
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.concordPendingDirectInvites
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -120,6 +121,11 @@ fun ConcordHomeScreen(
// the stock relays for users who actually use Concord.
LaunchedEffect(Unit) { accountViewModel.importConcordCommunities() }
// Direct Invites (CORD-05 §6): one inbox sweep per visit, kept out of the lazy list so it does
// not re-run each time the invites scroll back into view.
RefreshConcordDirectInvites(accountViewModel)
val invites by account.concord.pendingConcordDirectInvites.collectAsStateWithLifecycle()
// Per-community expansion, cycled on tap: absent = CLOSED → UNREAD (peek only the channels with
// new messages) → OPEN (all channels) → CLOSED. Multi-open, so several can be expanded at once.
// rememberSaveable so the chevron states survive opening a channel and coming back to the hub.
@@ -161,10 +167,11 @@ fun ConcordHomeScreen(
) { padding ->
if (communities.isEmpty()) {
// Direct Invites (CORD-05 §6) are how a first community usually arrives, so they show
// above the empty state rather than being hidden by it.
Column(Modifier.fillMaxSize().padding(padding)) {
ConcordPendingDirectInvites(accountViewModel, nav)
Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
// above the empty state rather than being hidden by it — in a lazy list, so many scroll.
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
concordPendingDirectInvites(invites, accountViewModel, nav)
item(key = "concord-home-empty") {
Box(Modifier.fillParentMaxWidth().fillParentMaxHeight(if (invites.isEmpty()) 1f else 0.5f), contentAlignment = Alignment.Center) {
Text(
stringRes(Res.string.concord_home_empty),
style = MaterialTheme.typography.bodyMedium,
@@ -173,6 +180,7 @@ fun ConcordHomeScreen(
)
}
}
}
return@Scaffold
}
@@ -195,7 +203,7 @@ fun ConcordHomeScreen(
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
// Pending Direct Invites (CORD-05 §6), parked until the user accepts or declines.
item(key = "concord-direct-invites") { ConcordPendingDirectInvites(accountViewModel, nav) }
concordPendingDirectInvites(invites, accountViewModel, nav)
sorted.forEach { entry ->
val state =
@@ -66,6 +66,9 @@ import com.vitorpamplona.amethyst.commons.resources.concord_members_ban
import com.vitorpamplona.amethyst.commons.resources.concord_members_ban_message
import com.vitorpamplona.amethyst.commons.resources.concord_members_ban_title
import com.vitorpamplona.amethyst.commons.resources.concord_members_empty
import com.vitorpamplona.amethyst.commons.resources.concord_members_kick
import com.vitorpamplona.amethyst.commons.resources.concord_members_kick_message
import com.vitorpamplona.amethyst.commons.resources.concord_members_kick_title
import com.vitorpamplona.amethyst.commons.resources.concord_members_make_admin
import com.vitorpamplona.amethyst.commons.resources.concord_members_remove
import com.vitorpamplona.amethyst.commons.resources.concord_members_remove_admin
@@ -82,6 +85,8 @@ import com.vitorpamplona.amethyst.commons.resources.concord_members_title
import com.vitorpamplona.amethyst.commons.resources.concord_members_unban
import com.vitorpamplona.amethyst.commons.resources.concord_role_admin
import com.vitorpamplona.amethyst.commons.resources.concord_role_banned
import com.vitorpamplona.amethyst.commons.resources.concord_role_kicked
import com.vitorpamplona.amethyst.commons.resources.concord_role_left
import com.vitorpamplona.amethyst.commons.resources.concord_role_owner
import com.vitorpamplona.amethyst.commons.resources.more_options
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -91,6 +96,8 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
@@ -134,12 +141,16 @@ fun ConcordMembersScreen(
// roster collapses to just the owner + privileged roles.
val guestbookMembers by (session?.members ?: remember { MutableStateFlow(emptySet<HexKey>()) }).collectAsStateWithLifecycle()
val observedAuthors by (session?.observedAuthors ?: remember { MutableStateFlow(emptySet<HexKey>()) }).collectAsStateWithLifecycle()
// The coalesced Guestbook: a member whose latest motion is a Leave or an honored Kick (and who has
// not posted since) shows as departed (CORD-02 §5, CORD-04 §6).
val guestbook by (session?.guestbook ?: remember { MutableStateFlow(emptyMap<HexKey, GuestbookEntry>()) }).collectAsStateWithLifecycle()
val myPubKey = account.signer.pubKey
val roster =
remember(state, guestbookMembers, observedAuthors) {
remember(state, guestbookMembers, observedAuthors, guestbook) {
val s = state ?: return@remember emptyList<RosterEntry>()
val authority = s.authority
val departed = session?.departedMembers().orEmpty()
val pubkeys =
(listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers() + guestbookMembers + observedAuthors)
.map { it.lowercase() }
@@ -154,8 +165,8 @@ fun ConcordMembersScreen(
.minByOrNull { r -> r.position }
?.name
?.takeIf { n -> n.isNotBlank() }
RosterEntry(it, ConcordMembership.of(authority, it), roleName, authority.rolesOf(it))
}.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey }))
RosterEntry(it, ConcordMembership.of(authority, it), roleName, authority.rolesOf(it), departed[it]?.action)
}.sortedWith(compareBy({ it.sortRank() }, { it.pubkey }))
}
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
@@ -164,6 +175,7 @@ fun ConcordMembersScreen(
// which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md.
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true
val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true
val iCanKick = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.KICK) } == true
// The roles this viewer may actually hand out. The fold drops a grant whose granter does
// not *strictly* outrank every assigned role, so offering a role at or above our own
@@ -224,6 +236,8 @@ fun ConcordMembersScreen(
canBanTarget =
iAmOwner ||
state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.BAN) == true,
// A Kick needs KICK and a strict outrank of the target (CORD-04 §6), the same rank rule.
canKickTarget = iCanKick && state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.KICK) == true,
viewerCanManageRoles = iCanManageRoles,
// canActOn folds the whole rank rule for us: we hold MANAGE_ROLES, we're not
// banned, the target isn't the owner (unremovable), and we strictly outrank
@@ -248,6 +262,7 @@ private fun ConcordMemberRow(
viewerIsOwner: Boolean,
viewerCanBan: Boolean,
canBanTarget: Boolean,
canKickTarget: Boolean,
viewerCanManageRoles: Boolean,
canManageRolesOnTarget: Boolean,
assignableRoles: List<AssignableRole>,
@@ -266,6 +281,8 @@ private fun ConcordMemberRow(
val canBan = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
// Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban.
val canRemove = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
// A Kick is the cooperative removal (CORD-04 §6): pointless against a banned or already-kicked member.
val canKick = canKickTarget && !isOwnerTarget && !isSelf && !isBanned && entry.departure != GuestbookAction.KICK
// Shown to any MANAGE_ROLES holder, but disabled with a reason when this particular
// member (or every defined role) is out of our reach — a grant we don't outrank
// publishes fine and is then dropped by every client's fold, so a silently no-op
@@ -277,7 +294,7 @@ private fun ConcordMemberRow(
assignableRoles.isEmpty() -> stringRes(Res.string.concord_members_roles_none_assignable)
else -> null
}
val hasMenu = canToggleAdmin || canBan || canRemove || viewerCanManageRoles
val hasMenu = canToggleAdmin || canBan || canRemove || canKick || viewerCanManageRoles
var editRoles by remember { mutableStateOf(false) }
if (editRoles) {
@@ -292,6 +309,17 @@ private fun ConcordMemberRow(
)
}
var confirmKick by remember { mutableStateOf(false) }
if (confirmKick) {
ConcordKickMemberDialog(
onConfirm = {
accountViewModel.kickConcordMember(communityId, entry.pubkey)
confirmKick = false
},
onDismiss = { confirmKick = false },
)
}
// A ban is reversible here, but not for the banned member: clients such as Armada drop the
// community from a banned member's list on sight, so a mis-tap still costs them the community.
var confirmBan by remember { mutableStateOf(false) }
@@ -335,7 +363,7 @@ private fun ConcordMemberRow(
Text(entry.pubkey.take(8), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
MemberBadge(entry.membership, entry.roleName)
MemberBadge(entry.membership, entry.roleName, entry.departure)
if (hasMenu) {
var expanded by remember { mutableStateOf(false) }
// One Box for button + menu: an expanded DropdownMenu emits a node, and as a direct child
@@ -375,6 +403,15 @@ private fun ConcordMemberRow(
},
)
}
if (canKick) {
DropdownMenuItem(
text = { Text(stringRes(Res.string.concord_members_kick)) },
onClick = {
confirmKick = true
expanded = false
},
)
}
if (canBan) {
DropdownMenuItem(
text = { Text(stringRes(if (isBanned) Res.string.concord_members_unban else Res.string.concord_members_ban)) },
@@ -405,15 +442,18 @@ private fun ConcordMemberRow(
}
}
/** A small pill labelling the member's standing (owner / role name / banned; plain members render nothing). */
/** A small pill labelling the member's standing (owner / role name / banned / kicked / left; plain members render nothing). */
@Composable
private fun MemberBadge(
membership: ConcordMembership,
roleName: String?,
departure: GuestbookAction?,
) {
val label =
when {
membership == ConcordMembership.BANNED -> stringRes(Res.string.concord_role_banned)
departure == GuestbookAction.KICK -> stringRes(Res.string.concord_role_kicked)
departure == GuestbookAction.LEAVE -> stringRes(Res.string.concord_role_left)
membership == ConcordMembership.OWNER -> stringRes(Res.string.concord_role_owner)
// Show the actual granted role ("Admin", "Moderator", or a custom role) rather than a
// one-size-fits-all badge; fall back to the generic "Admin" label if a role-holder's
@@ -422,8 +462,18 @@ private fun MemberBadge(
membership == ConcordMembership.ADMIN -> stringRes(Res.string.concord_role_admin)
else -> return
}
val container = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer
val content = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer
val container =
when {
membership == ConcordMembership.BANNED -> MaterialTheme.colorScheme.errorContainer
departure != null -> MaterialTheme.colorScheme.surfaceVariant
else -> MaterialTheme.colorScheme.primaryContainer
}
val content =
when {
membership == ConcordMembership.BANNED -> MaterialTheme.colorScheme.onErrorContainer
departure != null -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onPrimaryContainer
}
Surface(shape = RoundedCornerShape(6.dp), color = container) {
Text(
text = label,
@@ -492,6 +542,27 @@ private fun ConcordRolesDialog(
)
}
/** Confirms a Kick — spells out that it is cooperative and re-joinable (CORD-04 §6). */
@Composable
private fun ConcordKickMemberDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.concord_members_kick_title)) },
text = { Text(stringRes(Res.string.concord_members_kick_message)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringRes(Res.string.concord_members_kick), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) }
},
)
}
/** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */
@Composable
private fun ConcordConfirmMemberActionDialog(
@@ -523,6 +594,8 @@ private class RosterEntry(
val roleName: String?,
/** Every role id the member currently holds — the preselection for the role picker. */
val roleIds: Set<String>,
/** Their winning Leave or honored Kick when the Guestbook shows them departed, else null. */
val departure: GuestbookAction?,
)
/** One role the viewer is allowed to hand out, ordered by [position] (lower ranks higher). */
@@ -532,7 +605,9 @@ private class AssignableRole(
val position: Long,
)
/** Owner first, then admins, then plain members, then banned last. */
/** Owner first, then admins, then plain members, then departed (left/kicked) members, then banned last. */
private fun RosterEntry.sortRank(): Int = if (departure != null && membership != ConcordMembership.BANNED && membership != ConcordMembership.OWNER) 35 else membership.sortRank() * 10
private fun ConcordMembership.sortRank(): Int =
when (this) {
ConcordMembership.OWNER -> 0
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -39,11 +40,12 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -51,6 +53,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins
import com.vitorpamplona.amethyst.commons.actions.ConcordPinnedMessage
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
@@ -58,8 +61,14 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip92IMeta.appendMissingImetaUrls
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_body
import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_confirm
import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_title
import com.vitorpamplona.amethyst.commons.resources.concord_pinned_budget
import com.vitorpamplona.amethyst.commons.resources.concord_pinned_empty
import com.vitorpamplona.amethyst.commons.resources.concord_pinned_open_hint
@@ -68,6 +77,8 @@ import com.vitorpamplona.amethyst.commons.resources.concord_pinned_unavailable
import com.vitorpamplona.amethyst.commons.resources.message_edited
import com.vitorpamplona.amethyst.commons.resources.relay_group_pinned_content_description
import com.vitorpamplona.amethyst.commons.resources.relay_group_unpin_message
import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
@@ -76,8 +87,13 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
@@ -86,9 +102,15 @@ import org.jetbrains.compose.resources.StringResource
/**
* [channelId]'s verified pins (CORD-04 §7), re-read whenever the Control Plane seats a new Pin List
* head, the fold changes, or a delete / Edit lands in the cache (a held delete hides its entry at
* once; a held newer Edit marks it edited). Null until the community has folded the channel.
* head, the fold changes or finishes draining, a delete / Edit naming a pinned message lands in the
* cache (a held delete hides its entry at once; a held newer Edit marks it edited), or a pinned
* message's disappearing-message deadline passes (CORD-08 §3). Null until the community has folded
* the channel.
*
* The session is looked up again on every session-set change: it may not exist at first
* composition, and a Refounding replaces it — a captured one would read the dead epoch forever.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun rememberConcordChannelPins(
communityId: String,
@@ -97,56 +119,100 @@ fun rememberConcordChannelPins(
): State<ConcordChannelPins?> {
val account = accountViewModel.account
return produceState<ConcordChannelPins?>(null, account, communityId, channelId) {
val session = account.concordSessions.sessionFor(communityId) ?: return@produceState
account.concordSessions.revision
.map { account.concordSessions.sessionFor(communityId) }
.distinctUntilChanged { a, b -> a === b }
.collectLatest { session ->
if (session == null) {
value = null
return@collectLatest
}
// Only deletes and Edits that name a message this list carries can change the read.
val evidence =
account.cache.live.newEventBundles.filter { notes ->
notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent }
val carried = value?.rumorIds ?: return@filter true
notes.any { note ->
val event = note.event
(event is DeletionRequestEvent || event is ConcordChatEditEvent) &&
event.tags.any { it.size >= 2 && it[0] == "e" && it[1] in carried }
}
}
merge(session.pinHeads.map { }, session.state.map { }, session.controlDrained.map { }, evidence.map { })
.conflate()
.collectLatest {
// Re-read, then sleep until the next pinned message expires: an expired one
// leaves the list, and nothing else would trigger that re-read. A new trigger
// cancels the wait.
while (true) {
val pins = withContext(Dispatchers.Default) { account.concord.concordChannelPins(communityId, channelId) }
value = pins
val now = TimeUtils.now()
val next = pins?.nextExpiry(now) ?: break
delay((next - now) * 1000 + 250)
}
}
merge(session.pinHeads.map { }, session.state.map { }, evidence.map { }).collect {
value = withContext(Dispatchers.Default) { account.concord.concordChannelPins(communityId, channelId) }
}
}
}
/**
* The deletion omission and the Edit refresh a PIN_MESSAGES holder owes keyless readers (§7), run
* the way the spec asks: after a short random wait, re-read, and publish only if still owed — so
* simultaneous curators collapse to one publisher and a burst of edits costs one write. One attempt
* per distinct debt, so a failure never spins.
* [pins] as a reader should see them: entries by a banned author (CORD-04 §4 — every client declines
* to show their posts) or by someone this account mutes or blocks are left out.
*/
@Composable
fun ConcordPinDuties(
fun rememberVisibleConcordPins(
communityId: String,
channelId: String,
pins: ConcordChannelPins?,
pins: ConcordChannelPins,
accountViewModel: AccountViewModel,
) {
val debt =
remember(pins) {
pins
?.takeIf { it.owesRepublish }
?.let { p -> (p.killed.map { "d" + it.rumorId } + p.pins.mapNotNull { it.newerEdit?.let { e -> "e" + e.rumorId } }).sorted().joinToString("|") }
} ?: return
val attempted = remember(communityId, channelId) { HashSet<String>() }
LaunchedEffect(communityId, channelId, debt) {
if (debt in attempted || !accountViewModel.account.concord.canPinConcord(communityId)) return@LaunchedEffect
delay(ConcordPinning.dutyDelayMs())
attempted.add(debt)
accountViewModel.launchSigner { accountViewModel.account.concord.settleConcordPins(communityId, channelId) }
): List<ConcordPinnedMessage> {
val account = accountViewModel.account
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
val hidden by account.hiddenUsers.flow.collectAsStateWithLifecycle()
return remember(pins, revision, hidden) {
val authority =
account.concordSessions
.sessionFor(communityId)
?.state
?.value
?.authority
ConcordPinning.visible(pins, isBanned = { authority?.isBanned(it) == true }, isHidden = { account.isHidden(it) })
}
}
/**
* The Pin action on a disappearing message (one carrying a CORD-08 `expiration`) asks first: the pin
* carries the message's words in its proof, so it keeps them readable after the timer erased the
* message everywhere else.
*/
@Composable
fun ConcordExpiringPinDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.concord_pin_expiring_title)) },
text = { Text(stringRes(Res.string.concord_pin_expiring_body)) },
confirmButton = { TextButton(onClick = onConfirm) { Text(stringRes(Res.string.concord_pin_expiring_confirm)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } },
)
}
/** The channel header's pinned-messages entry point: a pin with a count badge. Hidden when there is nothing to show. */
@Composable
fun ConcordPinnedButton(
communityId: String,
pins: ConcordChannelPins?,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
if (pins == null || (pins.count == 0 && !pins.sealedUnavailable)) return
if (pins == null) return
val count = rememberVisibleConcordPins(communityId, pins, accountViewModel).size
if (count == 0 && !pins.sealedUnavailable) return
IconButton(onClick = onClick) {
BadgedBox(
badge = {
if (pins.count > 0) Badge { Text(pins.count.toString()) }
if (count > 0) Badge { Text(count.toString()) }
},
) {
Icon(symbol = MaterialSymbols.PushPin, contentDescription = stringRes(Res.string.relay_group_pinned_content_description))
@@ -156,8 +222,9 @@ fun ConcordPinnedButton(
/**
* The pinned-messages sheet: each verified pin with its author, time and words (marked edited when
* revised), an "unavailable" notice when the list is sealed under a key this account never held,
* a jump to the message when it resolves locally, and Unpin for those who may write pins.
* revised) rendered like the chat feed renders the message — links, mentions and its `imeta`
* attachments included — an "unavailable" notice when the list is sealed under a key this account
* never held, a jump to the message when it resolves locally, and Unpin for those who may write pins.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -166,11 +233,16 @@ fun ConcordPinnedMessagesSheet(
channelId: String,
pins: ConcordChannelPins,
accountViewModel: AccountViewModel,
nav: INav,
onJumpToMessage: (HexKey) -> Unit,
onDismiss: () -> Unit,
) {
val canPin = remember(pins) { accountViewModel.account.concord.canPinConcord(communityId) }
val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) }
val revision by accountViewModel.account.concordSessions.revision
.collectAsStateWithLifecycle()
val session = remember(communityId, revision) { accountViewModel.account.concordSessions.sessionFor(communityId) }
// Banned authors and muted/blocked users stay out of the sheet, as they do from the feed.
val shown = rememberVisibleConcordPins(communityId, pins, accountViewModel)
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -198,7 +270,7 @@ fun ConcordPinnedMessagesSheet(
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (pins.count > 0) {
if (shown.isNotEmpty()) {
Text(
text = stringRes(Res.string.concord_pinned_open_hint),
style = MaterialTheme.typography.labelSmall,
@@ -210,16 +282,18 @@ fun ConcordPinnedMessagesSheet(
if (pins.sealedUnavailable) {
PinNotice(Res.string.concord_pinned_unavailable, MaterialSymbols.Lock)
} else if (pins.count == 0) {
} else if (shown.isEmpty() && pins.complete) {
// Only a drained fold may say "no pins"; before that the list may simply not be served yet.
PinNotice(Res.string.concord_pinned_empty, MaterialSymbols.PushPin)
}
LazyColumn {
items(pins.pins, key = { it.rumorId }) { pinned ->
items(shown, key = { it.rumorId }) { pinned ->
val jumpable = remember(pinned.rumorId, session) { session?.holdsRumor(pinned.rumorId) == true }
PinnedRow(
pinned = pinned,
accountViewModel = accountViewModel,
nav = nav,
onClick =
if (jumpable) {
{
@@ -257,6 +331,7 @@ private fun PinNotice(
private fun PinnedRow(
pinned: ConcordPinnedMessage,
accountViewModel: AccountViewModel,
nav: INav,
onClick: (() -> Unit)?,
onUnpin: (() -> Unit)?,
) {
@@ -292,12 +367,7 @@ private fun PinnedRow(
)
}
}
Text(
text = pinned.content,
style = MaterialTheme.typography.bodyMedium,
maxLines = 6,
overflow = TextOverflow.Ellipsis,
)
PinnedContent(pinned, accountViewModel, nav)
}
if (onUnpin != null) {
IconButton(onClick = onUnpin) {
@@ -307,6 +377,35 @@ private fun PinnedRow(
}
}
/**
* The pinned message's words and attachments through the chat feed's own pipeline: the rumor rebuilt
* from the proof (its encrypted attachments' keys registered), an attachment-only message given its
* `imeta` URLs as text exactly as the feed does, and the shared rich-text viewer rendering the media.
*/
@Composable
private fun PinnedContent(
pinned: ConcordPinnedMessage,
accountViewModel: AccountViewModel,
nav: INav,
) {
val rumor = remember(pinned) { accountViewModel.account.concord.concordPinnedRumor(pinned) }
val tags = remember(rumor) { rumor.tags.toImmutableListOfLists() }
val content = remember(rumor) { appendMissingImetaUrls(rumor.content, rumor) }
val background = MaterialTheme.colorScheme.surface
val backgroundColor = remember(background) { mutableStateOf(background) }
TranslatableRichTextViewer(
content = content,
canPreview = true,
quotesLeft = 0,
tags = tags,
backgroundColor = backgroundColor,
id = pinned.rumorId,
authorPubKey = pinned.author,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** [hex]'s best display name, reactively, falling back to a short hex. */
@Composable
private fun rememberPinAuthorName(
@@ -75,8 +75,13 @@ import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption
import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption_explainer_error_msg
import com.vitorpamplona.amethyst.commons.resources.cashu_successful_redemption
import com.vitorpamplona.amethyst.commons.resources.cashu_successful_redemption_explainer
import com.vitorpamplona.amethyst.commons.resources.concord_kicked_message
import com.vitorpamplona.amethyst.commons.resources.concord_kicked_message_unnamed
import com.vitorpamplona.amethyst.commons.resources.concord_kicked_title
import com.vitorpamplona.amethyst.commons.resources.concord_members_kick_failed
import com.vitorpamplona.amethyst.commons.resources.concord_members_roles_failed
import com.vitorpamplona.amethyst.commons.resources.concord_members_roles_title
import com.vitorpamplona.amethyst.commons.resources.concord_members_title
import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_generic
import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_message
import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_title
@@ -288,6 +293,18 @@ class AccountViewModel(
}
}
}
// An honored Kick made us leave a Concord community (CORD-04 §6); the removal is otherwise silent.
viewModelScope.launch {
account.concord.concordKicks.collect { notice ->
val name = notice.communityName?.takeIf { it.isNotBlank() }
if (name != null) {
toastManager.toast(Res.string.concord_kicked_title, Res.string.concord_kicked_message, name)
} else {
toastManager.toast(Res.string.concord_kicked_title, Res.string.concord_kicked_message_unnamed)
}
}
}
}
/**
@@ -668,6 +685,16 @@ class AccountViewModel(
if (ban) account.concord.banConcordMember(communityId, member) else account.concord.unbanConcordMember(communityId, member)
}
/** Kick [member] from [communityId] (CORD-04 §6: strip their roles, then the Guestbook directive). */
fun kickConcordMember(
communityId: String,
member: HexKey,
) = launchSigner {
if (!account.concord.kickConcordMember(communityId, member)) {
toastManager.toast(Res.string.concord_members_title, Res.string.concord_members_kick_failed)
}
}
/**
* Remove [member] from [communityId] absolutely (CORD-06 Refounding): rotate the
* community key so the member's key stops working for anything sent afterwards.
@@ -70,7 +70,7 @@ Ranked security > interop > feature inside each group.
| I9 | 06 §3 | Rotations carry no `vac` | **fixed** — rotations carry `vac` on every chunk (`ConcordRotationAuthority.citationFor`, owner none); receivers require `ConcordReceive.isHonoredRotation` (BAN + `citationSatisfied`, Armada semantics) in the app drain and CLI `rekey`; a rotator whose chunks cite different Grants is dropped |
| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | **fixed** — `ConcordRekey.chunkBlobs` budgets the rumor JSON at 40,960 bytes (Armada `REKEY_RUMOR_MAX_BYTES`) plus the 120 count cap; test pins the seal (wrap plaintext) ≤ 65,535 with 136-byte blobs |
| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | **fixed** — `encodeFragment` truncates non-stock lists to 3 (stock set stays a flag); `decodeFragment` refuses count > 3 |
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys (no private channels yet, F7) |
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys on the winning chain (F7 rotates private channels inside a Refounding under the prior root, which both branches can open, but a losing refounder does not yet re-issue its channel keys after the heal) |
| 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`) |
@@ -82,19 +82,59 @@ Ranked security > interop > feature inside each group.
| # | Spec | Finding | Status |
|---|---|---|---|
| F1 | 04 §7 | Pins | **fixed** — commons `ConcordPinning` reads each Channel's Pin List off the session fold (`pinHeads`, gated on PIN_MESSAGES + `vac`, with the fold's floors), opens the sealed form with the held key of its epoch (`sealedUnavailable` kept distinct from empty), verifies entries through a per-entry-identity cache, hides entries killed by the author's held kind 5 and marks entries behind a newer held Edit as edited; pin/unpin reopen the message's original wrap (session rumor→wrap index) and write the next edition over the head read, in the channel's folded form (a private-era sealed list is never re-formed public), withheld when unreadable, refused past 25 entries / 32,768 bytes; deleting your own pinned message publishes the omission at once, and an open channel runs the delayed (3–15 s) re-read-then-publish duty for other holders' omissions and the Edit refresh. App: Pin/Unpin in the message sheet, header pin badge + pinned sheet (author, time, edited, unavailable, jump), budget line; `amy concord pins/pin/unpin`. Also fixed `DeletionIndex.DeletionRequest.compareTo` (compared the pubkey with itself, so any author's kind 5 matched on JVM/Android). Open SHOULDs: the duties run only while the channel screen is open (no background scheduler); a Rotator does not republish under the new key after a private-channel rekey, and a Banlist revert is not re-healed; compaction does not omit a deleted Channel's Pin List; pinned attachments render as text only |
| F1 | 04 §7 | Pins | **fixed** — commons `ConcordPinning` reads each Channel's Pin List off the session fold (`pinHeads`, gated on PIN_MESSAGES + `vac`, with the fold's floors), opens the sealed form with the held key of its epoch (`sealedUnavailable` kept distinct from empty), verifies entries through a per-entry-identity cache, hides entries killed by the author's held kind 5 and marks entries behind a newer held Edit as edited; pin/unpin reopen the message's original wrap (session rumor→wrap index) and write the next edition over the head read, in the channel's folded form (a private-era sealed list is never re-formed public), withheld when unreadable, refused past 25 entries / 32,768 bytes; deleting your own pinned message publishes the omission at once, and an open channel runs the delayed (3–15 s) re-read-then-publish duty for other holders' omissions and the Edit refresh. App: Pin/Unpin in the message sheet, header pin badge + pinned sheet (author, time, edited, unavailable, jump), budget line; `amy concord pins/pin/unpin`. Also fixed `DeletionIndex.DeletionRequest.compareTo` (compared the pubkey with itself, so any author's kind 5 matched on JVM/Android). Open SHOULDs: a Rotator does not republish under the new key after a private-channel rekey, and a Banlist revert is not re-healed; compaction does not omit a deleted Channel's Pin List. **Fixed since** (features batch): the delayed duties run from the account, not the screen — `ConcordPinDutyScheduler` + `AccountConcordActions.scheduleConcordPinDuties`, fired on every Concord revision tick and whenever a delete or an Edit lands (sampled 1 s), for every community where we may write pins and every Channel with a Pin List head; each duty waits the 3–15 s random delay, one per channel in flight, one attempt per distinct debt, and `settleConcordPins` re-reads before publishing (the screen's `ConcordPinDuties` composable is gone); pinned messages render through the feed's pipeline — the rumor rebuilt from the proof (`ConcordPinnedMessage.toRumor`, keeping the original's `imeta` tags) registers its encrypted attachments' keys and goes through `appendMissingImetaUrls` + the shared `TranslatableRichTextViewer`, so images (and links, mentions) show in the pinned sheet even for a message this account never held |
| F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | **fixed** — every durable Chat rumor (9/1111/7/3302, image variants) signs `created_at + timer` from the send-time fold and its wrap repeats it (`ConcordStreamEnvelope.wrap(outerTags)`, random `p` kept; never on 5/1740/typing); expired rumors refused at ingest (`openChannelRumor`, session, rumor sink), hidden in feed/preview/unread (`Account.isAcceptable`), and purged from LocalCache + wrap note + session buffer by a sweep scheduled on the earliest deadline (`ConcordSessionManager.nextExpiry`); typed `ConcordTimerNoticeEvent` posted per held channel after a timer change and rendered as a system row only for MANAGE_METADATA authors; timer picker in the edit screen + composer indicator; `amy concord timer`, `send` tags, `read` filters |
| F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass |
| F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | open → chat-plane batch (quartz only) |
| F3 | 07 | A/V calls: only key derivation, the 27235 grant (now with Armada's nonce, F4) and the 23313 presence fold exist; no broker/SFU client, no media E2EE | open — **deferred** (maintainer decision, 2026-09-29). Two blockers found: (1) `io.livekit:livekit-android` 2.29.0 pulls runtime `javax.sip:android-jain-sip-ri` 1.3.0-91, whose `android.javax.sip.*` API classes carry the Sun/BEA "use is subject to license terms" header under the proprietary JSIP spec license (no linking exception; STOP under our licensing rule); it can't simply be excluded because LiveKit's `PeerConnectionTransport` munges SDP through its `SdpFactory` on every publish. (2) Interop: Armada keys LiveKit E2EE with `keySize: 256` (AES-256-GCM per CORD-07 §3), but every native LiveKit/webrtc-sdk frame cryptor derives 128 bits (`frame_crypto_transformer.h` `DeriveKeys(..., 128)`), so an Android client can't decode Armada frames. Matching settings otherwise: HKDF-SHA256 (salt `LKFrameEncryptionKey`, info 128 zero bytes), per-identity key = quartz `voiceSenderKey`, `sharedKey=false`, ratchet window 0, failure tolerance -1, keyring 16, trailer `IV(12) ‖ [12, keyIndex]`. Paths when resumed: CORD-07 allows the 128-bit native key, or a patched WebRTC honouring 256; own LiveKit signaling (Apache-2.0 protocol) instead of livekit-android avoids jain-sip; desktop via webrtc-java 0.19 encoded-frame transforms (WARN: statically linked LGPL FFmpeg). Armada's default broker `https://armada.buzz` answers the capability probe and mints LiveKit HS256 JWTs (6 h TTL, 128-bit identity, SFU `wss://av.armada.buzz`) |
| F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | **fixed** — `ConcordBrokerToken.buildAuthEvent` signs a fresh `["nonce", <64 hex>]` after `u`/`method` (Armada `signAvGrant`'s tag, order and 32-byte width), so two members' same-second grants never share an id; `VoicePresenceInfo` carries the CORD-02 §4 `ms` basis + rumor id, `parse` drops a bad verb, an identity-less `joined` or a malformed `ms`, and `VoicePresence.fold`/`latestPerAuthor` take each author's latest presence (ms, lower rumor id on a tie, as Armada `foldVoicePresence`) before staleness and the one-claimant identity check; `joined`/`left` stamp `ms`. No app caller yet (the voice UI is not built) |
| F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | **fixed** — `ConcordInviteRegistry` (builder, strict-array decode, `nextLinks` pruning expired/tombstoned links) + `ConcordCommunityState.inviteRegistries`/`liveInviteLinks`/`isPublic`/`hasForeignLiveLinks`/`banRequiresRefounding`/`retiringWouldPrivatize` (gated on CREATE_INVITE, coordinate bound to author); mint/revoke publish the registry (app + amy); a Private ban Refounds, a Public one is the Banlist alone; retiring the last live link runs a privatizing Refounding (`privatizeConcordCommunity`; amy reports it and adds `refound --privatize`); Public/Private shown in the server view and warned in the revoke dialog. Deviation from Armada, following the spec: a ban Refounds iff the community is Private without the targets' registries (Armada rotates whenever no *foreign* link exists, and only warns on privatizing revokes) |
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. Not done: Armada's auto-adopt of staff-sent catch-ups (`judgeCatchUp`) and `channel_cuts` (not modeled here) |
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) |
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. F7 follow-ups done: staff-sent catch-ups auto-adopt (`judgeCatchUp`), `channel_cuts` is modeled, and a catch-up now only ADDS a missing key from a staff sender for a live Private Channel (never replaces a held key), re-applied inside the List write |
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | **fixed** — quartz `ConcordChannelRekey`: root-keyed `concord/rekey-pseudonym` address, 72-byte scope-bound blobs, chunked 3303 with `prevcommit` over the held channel key and `vac` on every chunk; the receive walk adopts only complete, honored (owner / MANAGE_CHANNELS / BAN + synced `vac`) rotations off the held key (multi-epoch, racing rotators → lowest key) and treats "no blob" as a cut only from a rotator who outranks us, published after our join. `ConcordChannelKeyring` rotates keys in place, reads older keys from `seed`/peer `priors` (never writes intermediate keys, CORD-02 §8) and models Armada's `channel_cuts` floor (extension, round-tripped). `ConcordInviteVend.entitledMembers`/`accessChanges`/`judgeCatchUp`. Commons `ConcordPrivateChannels` + app verbs: create Private channel (key at channel epoch 0 + bit-less access Role, as Armada), privatise (next channel epoch, floored by probing the rekey addresses) / publicise, `rekeyConcordChannel`, vend on grant (Direct Invite limited to the gained channels) and rotate on revoke/ban, the Refounding rotates every held private channel under the prior root, sessions subscribe/AUTH/buffer the channel-rekey window and the revision tick drains it; staff catch-ups auto-adopt. Links carry no channel keys (F6's `vendableChannels`, audience link). UI: Private toggle + access-role name on create, Make private/public + Rotate key per channel. `amy concord channel create/privatize/publicize/rekey`, `rekey`/`grant`/`refound` follow channel keys. Not done: republishing a rotated channel's sealed Pin List under the new key (a SHOULD); history across our own rotations after restart (intermediate keys stay out of the List by spec, and the key walk from `seed` is not implemented); a role *scope edit* in the UI does not trigger reconcile (only grants/revokes/bans do) |
| F8 | 06 §2, 02 §8 | Walk forward from `seed`; we still keep intermediate roots in a `held_roots` List extension the spec says doesn't belong there | open |
| F9 | 04 §6 | Kick (kind 3309) | open |
| F10 | 03 | WebXDC (kind 3310) | open |
| F9 | 04 §6 | Kick (kind 3309) | **fixed** — quartz `Guestbook.kick` writes the examples' shape (`ms`, `p`, `vac`), `Guestbook.parse` reads Kicks too (target, author, `ms` basis, rumor id, citation; malformed `ms`/target dropped; a 3306 "kick" verb is not a Kick), `Guestbook.canKick` honors one only from a KICK holder who strictly outranks the target with a synced `vac` (block-until-synced), and `Guestbook.coalesce` is the CORD-02 §5 fold (latest per npub by ms, lower rumor id on a tie, >1 h future dropped, banned authors dropped) — the old projection was createdAt-only and ignored Kicks. Commons: the session coalesces against its roster and re-coalesces on every control fold (a parked Kick lands when its Grant folds), exposes `guestbook`, `departedMembers()` (Leave/Kick not followed by newer observed activity — observation counts forward only) and `kickedMe()` (honored Kick postdating the entry's `added_at`); `allMembers()` drops the departed; guestbook seals must be encrypted. `kickConcordMember` strips roles first (MANAGE_ROLES + outrank, best-effort, as Armada) then publishes the directive; the revision tick's `drainConcordKicks` leaves the community locally (List tombstone, like Leave; network-silent) and emits a `ConcordKickNotice` the app toasts. Re-join works: `follow` bumps `added_at` past the tombstone, which also puts the old Kick behind the new membership. UI: Kick (KICK + outrank, confirm dialog) next to Ban in the members roster; departed members show a Kicked/Left badge. `amy concord kick COMMUNITY USER`. Not done: Armada's double-read debounce before self-removal, the dissolution ordering rule for Kicks (we refuse to *write* one on a dissolved community but do not date-check received ones), and Guestbook snapshots (kind 3312) |
| F10 | 03 | WebXDC (kind 3310) | **fixed (plumbing only)** — 3310 was refused by the Chat gate and silently dropped. quartz `ConcordWebxdc` builds/parses what Armada puts on the kind (the spec leaves the payload opaque, examples §2.6): app **state updates** (`["i", <session>]`, `["alt","Webxdc update"]`, optional `info`/`document`/`summary`, JSON content) and realtime **peer signals** (`{"op":"ad","topic":<52-char base32>,"addr":…}` / `{"op":"left","topic":…}`, addr ≤ 2048, as Vector bounds it), both under the usual `channel`/`epoch`/`ms` binding; `ChannelChat.PLANE_KINDS` = `CHAT_KINDS` + 3310 and `acceptOpened(…, kinds)`, so 3310 stays out of `CHAT_KINDS` (never a row, never pinnable, still refused by `EventCache.consumeConcordRumor`). The session opens its planes with `PLANE_KINDS` and routes 3310 into a bounded per-channel buffer (`webxdcSignals(channel)`, `webxdcRevision`; 2000 per channel, CORD-08-expired ones filtered) instead of the chat store, so feeds, previews and unread counts never see it; its author still counts as observed. Amethyst has no WebXDC host. **Full support** (Armada `useConcordAppSync` + `XdcAttachment`, interoperating with Vector) would take: rendering an `.xdc` attachment (`application/x-webxdc` imeta carrying a `webxdc` session/topic id, or a topic derived from URL + message id) as an app card; a sandboxed WebView runtime exposing the webxdc JS API (`sendUpdate`/`setUpdateListener` → durable 3310 state updates for the session, read back by `#i`, with the CORD-08 `expiration`; `joinRealtimeChannel` → realtime); and for realtime an iroh gossip transport (Vector's frame format with the 36-byte `seq‖sender` trailer, topic = base32(sha256(…))) advertised/withdrawn by 3310 peer signals and folded latest-per-author (ties to `left`, >1 h future refused) — there is no relay fallback by design. The `:napplet` WebView sandbox is the natural host; iroh has no Kotlin implementation (our `:quic` is plain QUIC/HTTP3) |
## Audit 2 (2026-09-29) — batch A (non-Refounding)
Root cause shared by P1/P13/R7: nothing told a session its Control Plane had finished its
initial drain. Now `ConcordCommunitySession.controlDrained` is set by
`syncConcordControlPlanes` once a relay pages the whole current plane (`DRAINED`) and the
swept wraps are ingested; `foldForWrite()` is the fold writers may build on (null before).
Batch B can reuse it for the ban/privatize decisions.
| # | Status |
|---|---|
| P1 | **fixed** — `ConcordChannelPins.complete`; `ConcordPinning.refusal` → `NOT_FOLDED` until drained; the sheet never says "no pins" before the drain |
| P2 | **fixed** — `buildChannelEdit(expiration = expirationOf(target))`: an Edit carries the original's deadline verbatim (none if it has none); a smuggled `expiration` in `extraTags` is dropped (Armada `useTransport.ts`; the spec's "computed from the rumor's own created_at" reads otherwise — noted) |
| P3 | **fixed** — pin writes `publishAndConfirm` (`NOT_CONFIRMED` otherwise) and re-apply the same op atop a concurrent winner, ≤2 retries |
| P4 | **fixed** — `rememberConcordChannelPins`, the pinned sheet and `ConcordTimerIndicator` re-resolve the session on `concordSessions.revision` |
| P5 | **fixed** — the pins re-read at the soonest pinned message's NIP-40 deadline (`ConcordChannelPins.nextExpiry`) |
| P6 | **fixed** — list reads memoized by head rumor id (`ConcordPinVerifier.readList`); `PinListRead.sealedForm` (no second parse); evidence trigger filtered to deletes/Edits naming a pinned rumor; action-sheet pin state via `produceState` on `Dispatchers.Default`. The per-channel verifier cache stays the session-wide 512-entry one |
| P7 | **fixed** — deadlines kept sorted (binary-searched insert; no PriorityQueue in common code); sweeps pop only what is due; the account sweep waits 2 s past a deadline to coalesce |
| P8 | **fixed** — bounded (4096) set of swept wrap ids; re-deliveries dropped before opening |
| P9 | **fixed** — `ConcordSessionRegistry.sync` carries `trackedExpiring()` into the rebuilt session |
| P10 | **fixed** — sheet and badge leave out banned authors and muted/blocked users (`ConcordPinning.visible`) |
| P11 | **fixed** — confirm dialog before pinning a message carrying `expiration`; `amy concord pin` refuses (`expiring_message`) without `--force` |
| P12 | **fixed** — `amy concord pins` hides expired pinned messages |
| P13 | **fixed** — `editConcordMetadata` / `setConcordMessageExpiration` return false until drained (the owner too) and chain onto the floor-aware head |
| P14 | **fixed** — `ExpiredConcordRumor.attachmentUrls`; the sweep evicts them from `encryptionKeyCache` |
| recomputeNextExpiry | **fixed** — computed and assigned under a lock |
| D3 | **fixed** — `SealEvent.unsealRumorThrowing` + `ConcordDirectInvite.openRumor`/`offerRumor`: the NIP-17 seal handler decrypts once; k=3313 wraps that never open are marked seen (GiftWrap and Seal handlers), so the hub's sweep skips them. Chosen over a persisted cursor: the DM pipeline sees every invite wrap first in the same process, so the sweep re-decrypts nothing it already handled; a cold start still re-sweeps from `since = null` once per process |
| D4 | **fixed** — ≤256 parked (followed senders outrank strangers, then newer), hidden senders never park and are filtered, followed senders listed first, invites rendered as lazy items (the empty state scrolls) |
| D5 | **fixed** — an invite whose clamped `sentAt` is at or before the Community List tombstone's `removed_at` stays hidden (`ConcordChannelListState.removedAt`) |
| D6 | **fixed** — the cursor advances to `min(created_at, now + 15 min)` |
| D7 | **fixed** — dedupe per (community, sender), ranked by `sentAt` clamped to now |
| D8 | **fixed** — written off only on a definitive outcome; `openOrRetry` rethrows a transient signer failure (timeout, not approved, backgrounded, not found) and the inbox leaves the wrap for a retry |
| D10 | **fixed** — declines capped at 4096 (app + amy); `restoreDeclined` is `suspend` under the inbox mutex; the sweep runs once per hub visit outside the lazy list and rethrows cancellation; catch-up cards list only newly adopted channels, by folded name. Also (F7 note): `visible()` hides a catch-up `acceptPlan` would refuse against the held fold |
| R3 | **fixed** — a readable Invite List is authoritative in `nextLinks` (no resurrected links); registry edits are `publishAndConfirm`ed |
| R7 | **fixed** — `publishConcordInviteRegistry` skips until drained and chains onto the floor-aware head (`ConcordModeration.setInviteRegistry(floors = session.controlFloors())`) |
| R8 | **fixed** — no registry edit on a dissolved community; `retiringWouldPrivatize` is false once dissolved, so a revoke there reports `REVOKED` |
| R9 | **fixed** — after a drain, this account's registry is republished pruned when it lists an elapsed/unbacked link (once per community and epoch per process) |
| R11 | **fixed** — `invite_links_locator` memoized per (community, author) in `AuthorityResolver` |
## Spec issues to raise upstream
- CORD-07 §3 mandates AES-256-GCM frames, but LiveKit's native SDKs (Android, Swift, Rust, C++) only derive 128-bit frame keys (`livekit-client` documents keySize 128 as "the only value supported by non-web SDKs"), so a conforming native client can't interoperate with a 256-bit web client. The spec should allow (or require) the 128-bit derived key, or pin the KDF output length explicitly.
- CORD-06 §1 counts rekey capacity in blobs ("up to 120 participants per event"), but 120 base
blobs overflow NIP-44's 65,535-byte plaintext once wrapped; the spec should state the byte budget
(Armada uses a 40,960-byte rumor ceiling).
@@ -143,6 +143,18 @@ class ConcordListResidue(
return ConcordListResidue(extras, tombstones.filterNot { it === prior } + next, unparsedEntries)
}
/** The latest `removed_at` (unix ms) per community this residue tombstones. */
fun removals(): Map<String, Long> {
val out = HashMap<String, Long>()
for (t in tombstones) {
val id = (t["community_id"] as? JsonPrimitive)?.contentOrNull ?: continue
val at = (t["removed_at"] as? JsonPrimitive)?.longOrNull ?: continue
val prev = out[id]
if (prev == null || at > prev) out[id] = at
}
return out
}
/** The latest `removed_at` this residue holds for [communityId], or null when it was never left. */
fun removedAt(communityId: String): Long? =
tombstones
@@ -67,6 +67,9 @@ data class ConcordCommunityState(
*/
val inviteRegistries: Map<HexKey, List<HexKey>> = emptyMap(),
) {
/** The ids of the live (non-deleted) Private Channels (CORD-03), lowercase hex. */
val privateChannelIds: Set<HexKey> by lazy { channels.filterValues { it.definition.private }.keys.mapTo(HashSet()) { it.lowercase() } }
/** The aggregate active-set of live public links: every honored registry's link signers (CORD-05 §5). */
val liveInviteLinks: Set<HexKey> by lazy { inviteRegistries.values.flatMapTo(HashSet()) { it } }
@@ -114,9 +117,11 @@ data class ConcordCommunityState(
/**
* Whether retiring [linkSigners] would flip the community Private (CORD-05 §2): it is Public
* now and no live link would remain. Retiring the last live link is a Refounding (CORD-06).
* Never for a [dissolved] community: death wins every race (CORD-02 §9), so there is nothing
* left to Refound and a revoke there is only a revoke.
*/
fun retiringWouldPrivatize(linkSigners: Collection<HexKey>): Boolean {
if (!isPublic) return false
if (dissolved || !isPublic) return false
val retiring = linkSigners.mapTo(HashSet()) { it.lowercase() }
return liveInviteLinks.all { it in retiring }
}
@@ -20,31 +20,58 @@
*/
package com.vitorpamplona.quartz.concord.cord02Community
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
/** A self-signed membership motion on the Guestbook Plane. */
/**
* A membership motion on the Guestbook Plane: the self-signed Join and Leave verbs of kind 3306,
* and an authorized [KICK] (kind 3309), which is never a 3306 verb.
*/
enum class GuestbookAction(
val wire: String,
) {
JOIN("join"),
LEAVE("leave"),
/** An authorized Kick (kind 3309): the target is departed, and asked to leave (CORD-04 §6). */
KICK("kick"),
;
companion object {
fun of(wire: String) = entries.firstOrNull { it.wire == wire }
/** The kind-3306 verb [wire] names — only `join` and `leave` ride that kind. */
fun of(wire: String) =
when (wire) {
JOIN.wire -> JOIN
LEAVE.wire -> LEAVE
else -> null
}
}
}
/** A parsed Guestbook join/leave: who ([member]), what ([action]), and invite attribution. */
/**
* A parsed Guestbook motion: whose state it sets ([member] — the target, for a Kick), what
* ([action]), who signed it ([author] — the member themself for a Join/Leave, the kicker for a
* Kick), and invite attribution (Joins only). [ms] is the CORD-02 §4 ordering basis
* (`created_at * 1000 + ms`) and [rumorId] the equal-time tiebreak; [citation] is a Kick's `vac`.
*/
class GuestbookEntry(
val member: HexKey,
val action: GuestbookAction,
val createdAt: Long,
val inviteCreator: HexKey?,
val inviteLabel: String?,
val author: HexKey = member,
val ms: Long = createdAt * 1000,
val rumorId: HexKey = "",
val citation: AuthorityCitation? = null,
)
/**
@@ -66,6 +93,9 @@ object Guestbook {
const val TAG_INVITE = "invite"
const val TAG_P = "p"
/** An entry dated further than this ahead of the receiver's clock is dropped outright (CORD-02 §5). */
const val MAX_FUTURE_MS = 60 * 60 * 1000L
/** A self-signed join (kind 3306), optionally attributing the invite used. */
fun join(
memberPubKey: HexKey,
@@ -99,31 +129,126 @@ object Guestbook {
}
/**
* An authorized Kick (kind 3309) directing [target] to leave. A Kick is only
* honored by clients when its signer holds [com.vitorpamplona.quartz.concord
* .cord04Roles.ConcordPermissions.KICK] and outranks the target — a Kick from
* a non-KICK holder is dropped (CORD-04 §The Three Removals).
* An authorized Kick (kind 3309) directing [target] to leave, citing the Grant the actor acts
* under ([citation], the `vac` — null only for the owner, who cites nothing). Tags in the
* examples' order: `ms`, `p`, `vac`. A Kick is only honored by clients when its signer holds
* [ConcordPermissions.KICK] and strictly outranks the target ([canKick]) — a Kick from a
* non-KICK holder is dropped (CORD-04 §6).
*/
fun kick(
actorPubKey: HexKey,
target: HexKey,
createdAt: Long,
): Event = RumorAssembler.assembleRumor(actorPubKey, createdAt, KIND_KICK, arrayOf(arrayOf(TAG_P, target)), "")
subMs: Int = MsTag.remainderFor(createdAt),
citation: AuthorityCitation? = null,
): Event {
val tags = ArrayList<Array<String>>(3)
tags.add(MsTag.assemble(subMs))
tags.add(arrayOf(TAG_P, target))
if (citation != null) tags.add(VacTag.assemble(citation))
return RumorAssembler.assembleRumor(actorPubKey, createdAt, KIND_KICK, tags.toTypedArray(), "")
}
/** Parses a Guestbook join/leave rumor, or null if it is not one. */
/**
* Parses a Guestbook Join/Leave (kind 3306) or Kick (kind 3309), or null if it is neither or is
* malformed: an unknown verb, a Kick naming no valid 64-hex target, or a malformed/duplicated
* `ms` tag (dropped, never interpreted — CORD-02 §5).
*/
fun parse(rumor: Event): GuestbookEntry? {
if (rumor.kind != KIND_JOIN_LEAVE) return null
val ms = MsTag.orderingMs(rumor.createdAt, rumor.tags)
when (rumor.kind) {
KIND_JOIN_LEAVE -> {
val action = GuestbookAction.of(rumor.content) ?: return null
val invite = rumor.tags.firstOrNull { it.size >= 2 && it[0] == TAG_INVITE }
if (ms == null) return null
val invite = if (action == GuestbookAction.JOIN) rumor.tags.firstOrNull { it.size >= 2 && it[0] == TAG_INVITE } else null
return GuestbookEntry(
member = rumor.pubKey,
action = action,
createdAt = rumor.createdAt,
inviteCreator = invite?.getOrNull(1),
inviteLabel = invite?.getOrNull(2),
author = rumor.pubKey,
ms = ms,
rumorId = rumor.id,
)
}
KIND_KICK -> {
val target = kickTarget(rumor)?.lowercase()?.takeIf { it.isValid() } ?: return null
if (ms == null) return null
return GuestbookEntry(
member = target,
action = GuestbookAction.KICK,
createdAt = rumor.createdAt,
inviteCreator = null,
inviteLabel = null,
author = rumor.pubKey,
ms = ms,
rumorId = rumor.id,
citation = rumor.tags.firstOrNull { VacTag.isTag(it) }?.let { VacTag.parse(it) },
)
}
else -> return null
}
}
/** The kick target's pubkey from a kind-3309 rumor, or null. */
fun kickTarget(rumor: Event): HexKey? = if (rumor.kind == KIND_KICK) rumor.tags.firstTagValue(TAG_P) else null
/**
* Whether the Kick [entry] is honored against the folded [authority] (CORD-04 §5/§6): its
* author holds [ConcordPermissions.KICK], is not banned, and strictly outranks the target (the
* owner is never a valid target), judged against the **current** roster — and its `vac` is
* synced (block-until-synced: a citation we cannot yet match parks the Kick, here drops it
* until a later fold).
*/
fun canKick(
authority: AuthorityResolver,
entry: GuestbookEntry,
): Boolean =
entry.action == GuestbookAction.KICK &&
authority.canActOn(entry.author, entry.member, ConcordPermissions.KICK) &&
authority.citationSatisfied(entry.author, entry.citation)
/** True when [next] supersedes [prev]: later on the ms basis, or the lower rumor id on a tie. */
private fun supersedes(
prev: GuestbookEntry?,
next: GuestbookEntry,
): Boolean = prev == null || next.ms > prev.ms || (next.ms == prev.ms && next.rumorId < prev.rumorId)
/**
* Coalesces [entries] flat (CORD-02 §5): one final state per npub (lowercase), where their
* latest Join, Leave or honored Kick wins — later ms, ties to the lower rumor id.
* - an entry more than [MAX_FUTURE_MS] ahead of [nowMs] is dropped;
* - a [banned] author's entries, Kicks included, are dropped (CORD-04 §4);
* - a Kick counts only when [canKick] honors it (a Kick from a non-KICK holder is dropped).
*/
fun coalesce(
entries: Collection<GuestbookEntry>,
nowMs: Long,
canKick: (GuestbookEntry) -> Boolean,
banned: Set<HexKey> = emptySet(),
): Map<HexKey, GuestbookEntry> {
val out = HashMap<HexKey, GuestbookEntry>()
for (entry in entries) {
if (entry.ms > nowMs + MAX_FUTURE_MS) continue
if (entry.author.lowercase() in banned) continue
if (entry.action == GuestbookAction.KICK && !canKick(entry)) continue
val key = entry.member.lowercase()
if (supersedes(out[key], entry)) out[key] = entry
}
return out
}
/** [coalesce] with Kicks judged by [canKick] against [authority], and its banlist applied. */
fun coalesce(
entries: Collection<GuestbookEntry>,
nowMs: Long,
authority: AuthorityResolver?,
): Map<HexKey, GuestbookEntry> =
coalesce(
entries,
nowMs,
canKick = { authority != null && canKick(authority, it) },
banned = authority?.bannedMembers().orEmpty(),
)
}
@@ -435,11 +435,20 @@ object ChannelChat {
/** True when [kind] may ride a Chat Plane ([CHAT_KINDS]). */
fun isChatKind(kind: Int): Boolean = kind in CHAT_KINDS
/**
* Every rumor kind a Chat Plane carries (CORD-02 Appendix B): the chat kinds plus the WebXDC
* signal ([ConcordWebxdc], kind 3310), which rides the plane under the same binding but is never
* a chat row — so it is kept out of [CHAT_KINDS], and only a caller that routes it apart (the
* session's WebXDC buffer) opens a plane with this set.
*/
val PLANE_KINDS: Set<Int> = CHAT_KINDS + ConcordWebxdc.KIND
/**
* The Chat Plane ingest gate for a wrap already opened under [channelId]'s key at [epoch]:
* returns its rumor only when every Chat rule holds, else null (drop it).
* - the seal is the encrypted kind 20013 (CORD-02 §5: a plaintext 20014 seal is Control-only);
* - the rumor kind is a Chat kind ([CHAT_KINDS]), never another plane's;
* - the rumor kind is one of [kinds] — by default the Chat kinds ([CHAT_KINDS]), never another
* plane's; [PLANE_KINDS] also admits the WebXDC signal for a caller that routes it apart;
* - the binding is strict: exactly one `channel` and one `epoch`, equal to the plane's
* ([isBoundTo], CORD-03 §3);
* - its `ms` tag, if any, is well formed (CORD-02 §4/§5 — a malformed one is dropped, never
@@ -449,10 +458,11 @@ object ChannelChat {
opened: OpenedStreamEvent,
channelId: HexKey,
epoch: Long,
kinds: Set<Int> = CHAT_KINDS,
): Event? {
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null
val rumor = opened.rumor
if (!isChatKind(rumor.kind)) return null
if (rumor.kind !in kinds) return null
if (!isBoundTo(rumor, channelId, epoch)) return null
if (orderingMs(rumor) == null) return null
return rumor
@@ -0,0 +1,239 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordEntryResidue
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.longOrNull
/** A Private Channel key held for an older channel epoch — it still reads its own era's history. */
class HistoricalChannelKey(
val key: HexKey,
val epoch: Long,
)
/**
* The Private Channel keys a Community List entry holds, and how a rotation rewrites them
* (CORD-03 §1-2, CORD-06 §2, CORD-02 §8).
*
* - **Current keys** are the entry's `channels` (`privateChannels`): exactly one per channel, the
* newest epoch held. A rotation replaces it in place, keeping any unknown keys another client
* wrote inside the channel object (the round-trip rule, CORD-02 §6/§8).
* - **Older keys** are never written by this client: CORD-02 §8 keeps intermediate keys out of the
* List ("a client's own optimisation … it does not belong in the List"). They are still *read*
* wherever they already are — the entry's `seed` snapshot (the earliest epoch held, the backfill
* anchor) and the reference client's `priors` extension inside a channel object — so history
* written before a rotation stays readable.
* - **Cuts** are the reference client's `channel_cuts` extension on the entry: per channel, the
* channel epoch whose rotation cut this member out. A floor, never rolled back: a key below it is
* refused, so a stale bundle or catch-up cannot quietly restore revoked access. Kept as the raw
* extension (`[{ "id", "epoch" }]`) with every other field in each object preserved.
*/
object ConcordChannelKeyring {
const val CHANNEL_CUTS = "channel_cuts"
const val PRIORS = "priors"
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
/** The current key held for [channelIdHex], or null (a keyless listing is not a key). */
fun heldKey(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
): PrivateChannelKey? = entry.privateChannels.firstOrNull { it.channelId.equals(channelIdHex, ignoreCase = true) && HEX64.matches(it.key) }
// ---- cuts ------------------------------------------------------------------
/** The `channel_cuts` floors on [entry], channel id (lowercase) → cut epoch (max wins). */
fun cutsOf(entry: ConcordCommunityListEntry): Map<HexKey, Long> {
val raw = entry.residue.entryExtras[CHANNEL_CUTS] as? JsonArray ?: return emptyMap()
val out = HashMap<HexKey, Long>()
for (element in raw) {
val obj = element as? JsonObject ?: continue
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() ?: continue
val epoch = (obj["epoch"] as? JsonPrimitive)?.longOrNull ?: continue
if (epoch > (out[id] ?: Long.MIN_VALUE)) out[id] = epoch
}
return out
}
/** True when a key for [channelIdHex] at [epoch] sits below a recorded cut and must be refused. */
fun isCutOff(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
epoch: Long,
): Boolean = cutsOf(entry)[channelIdHex.lowercase()]?.let { epoch < it } ?: false
/**
* [entry] with a cut for [channelIdHex] at [epoch] merged into `channel_cuts` (max wins — a
* removal never rolls back). Other channels' cut objects, and unknown keys inside the replaced
* one, ride through untouched.
*/
fun withCut(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
epoch: Long,
): ConcordCommunityListEntry {
val id = channelIdHex.lowercase()
val existing = (entry.residue.entryExtras[CHANNEL_CUTS] as? JsonArray)?.toList() ?: emptyList()
if ((cutsOf(entry)[id] ?: Long.MIN_VALUE) >= epoch) return entry
val mine = existing.filter { (it as? JsonObject)?.let { o -> (o["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() == id } == true }
val base = (mine.firstOrNull() as? JsonObject) ?: JsonObject(emptyMap())
val next = JsonObject(base + mapOf("id" to JsonPrimitive(id), "epoch" to JsonPrimitive(epoch)))
val cuts = JsonArray(existing.filterNot { it in mine } + next)
val extras = JsonObject(entry.residue.entryExtras + (CHANNEL_CUTS to cuts))
return entry.copyChannels(entry.privateChannels, ConcordEntryResidue(extras, entry.residue.seed, entry.residue.currentExtras))
}
// ---- current keys ----------------------------------------------------------
/**
* [entry] holding [key] as the current key for its channel — replacing a lower epoch, keeping
* the replaced object's unknown fields — or null when it would not move anything forward: a key
* at or below the held epoch, or one below a recorded cut.
*/
fun withChannelKey(
entry: ConcordCommunityListEntry,
key: PrivateChannelKey,
): ConcordCommunityListEntry? {
if (!HEX64.matches(key.key) || !HEX64.matches(key.channelId)) return null
if (isCutOff(entry, key.channelId, key.epoch)) return null
val held = entry.privateChannels.firstOrNull { it.channelId.equals(key.channelId, ignoreCase = true) }
if (held != null && HEX64.matches(held.key) && held.epoch >= key.epoch) return null
val next =
PrivateChannelKey(
channelId = key.channelId.lowercase(),
key = key.key.lowercase(),
epoch = key.epoch,
name = key.name.ifBlank { held?.name ?: "" },
extras = held?.extras ?: key.extras,
)
return entry.copyChannels(entry.privateChannels.filterNot { it.channelId.equals(key.channelId, ignoreCase = true) } + next, entry.residue)
}
/** [entry] with [channelIdHex]'s key moved to [newKeyHex] at [newEpoch] (a rotation it launched or adopted). */
fun withRotatedKey(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
newKeyHex: HexKey,
newEpoch: Long,
): ConcordCommunityListEntry? {
val held = heldKey(entry, channelIdHex) ?: return null
return withChannelKey(entry, PrivateChannelKey(held.channelId, newKeyHex, newEpoch, held.name, held.extras))
}
/**
* [entry] after a rotation to [cutEpoch] cut this member from [channelIdHex] (CORD-06 §2): the
* key leaves `channels` and the cut is recorded, so no older key can come back.
*/
fun withoutChannel(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
cutEpoch: Long,
): ConcordCommunityListEntry {
val dropped = entry.copyChannels(entry.privateChannels.filterNot { it.channelId.equals(channelIdHex, ignoreCase = true) }, entry.residue)
return withCut(dropped, channelIdHex, cutEpoch)
}
// ---- older keys --------------------------------------------------------------
/**
* Every older key this entry still carries for [channelIdHex] — the `seed` snapshot's and any
* `priors` a peer wrote — excluding the current one, newest first. Each reads its own epoch's
* history.
*/
fun historicalKeys(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
): List<HistoricalChannelKey> {
val id = channelIdHex.lowercase()
val current = heldKey(entry, id)
val out = LinkedHashMap<Pair<Long, String>, HistoricalChannelKey>()
fun add(
key: String?,
epoch: Long?,
) {
if (key == null || epoch == null || !HEX64.matches(key)) return
val k = key.lowercase()
if (current != null && current.key.equals(k, ignoreCase = true) && current.epoch == epoch) return
out.getOrPut(epoch to k) { HistoricalChannelKey(k, epoch) }
}
current?.extras?.get(PRIORS)?.let { priors ->
for (p in (priors as? JsonArray).orEmpty()) {
val obj = p as? JsonObject ?: continue
add((obj["key"] as? JsonPrimitive)?.contentOrNull, (obj["epoch"] as? JsonPrimitive)?.longOrNull)
}
}
val seedChannels = entry.residue.seed?.get("channels") as? JsonArray
for (c in seedChannels.orEmpty()) {
val obj = c as? JsonObject ?: continue
if ((obj["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() != id) continue
add((obj["key"] as? JsonPrimitive)?.contentOrNull, (obj["epoch"] as? JsonPrimitive)?.longOrNull)
}
return out.values.sortedByDescending { it.epoch }
}
/**
* The channel epoch a privatisation must mint at (CORD-03 §2): one past the highest generation
* this entry knows of — the held key, any older key, a recorded cut — and [observedFloor] (the
* highest rotation epoch seen on the wire, for a privatiser who never held earlier
* generations). Monotonic, so a stale key is always a lower epoch; 1 for a never-private channel.
*/
fun nextChannelEpoch(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
observedFloor: Long = 0,
): Long {
val id = channelIdHex.lowercase()
var highest = observedFloor
entry.privateChannels.filter { it.channelId.equals(id, ignoreCase = true) }.forEach { highest = maxOf(highest, it.epoch) }
historicalKeys(entry, id).forEach { highest = maxOf(highest, it.epoch) }
cutsOf(entry)[id]?.let { highest = maxOf(highest, it) }
return highest + 1
}
private fun ConcordCommunityListEntry.copyChannels(
privateChannels: List<PrivateChannelKey>,
residue: ConcordEntryResidue,
) = ConcordCommunityListEntry(
id = id,
owner = owner,
ownerSalt = ownerSalt,
root = root,
rootEpoch = rootEpoch,
controlPk = controlPk,
controlRoot = controlRoot,
heldRoots = heldRoots,
privateChannels = privateChannels,
relays = relays,
name = name,
addedAt = addedAt,
inviteRef = inviteRef,
excludedAtEpoch = excludedAtEpoch,
residue = residue,
)
}
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* A WebXDC realtime peer signal: [topic] is the app's gossip topic (52 base32 characters), and
* [addr] the advertised, opaque node address — null for a departure (`"left"`).
*/
class WebxdcPeerSignal(
val topic: String,
val addr: String?,
) {
val isAdvert: Boolean get() = addr != null
}
/**
* WebXDC on the Chat Plane (kind 3310, CORD-02 Appendix B; examples §2.6). The CORDs register the
* kind — a Chat rumor with the usual `channel`/`epoch`/`ms` binding — but do not pin its payload: the
* content is app-level and opaque to the protocol. A 3310 is **never a chat message**: it is not a
* feed row, not a preview, not unread; a client with no WebXDC host just holds it for one.
*
* What rides it in practice is the reference client's (Armada, interoperating with Vector), which
* this object builds and parses so a future host has the plumbing:
* - an app **state update** for one app session: `["i", <session>]` and `["alt", "Webxdc update"]`
* (plus optional `info`, `document`, `summary`), the content being the JSON payload;
* - a realtime **peer signal**, untagged beyond the binding: content
* `{"op":"ad","topic":<52-char base32>,"addr":<node address>}` to advertise a gossip endpoint, or
* `{"op":"left","topic":…}` to withdraw it.
* Amethyst has no WebXDC runtime; see the conformance review (F10) for what full support would take.
*/
object ConcordWebxdc {
const val KIND = 3310
const val TAG_SESSION = "i"
const val TAG_ALT = "alt"
const val ALT_UPDATE = "Webxdc update"
const val TAG_INFO = "info"
const val TAG_DOCUMENT = "document"
const val TAG_SUMMARY = "summary"
/** A topic id is 32 bytes, so its RFC 4648 base32 form (no padding) is always this long. */
const val TOPIC_ID_CHARS = 52
/** The longest advertised node address honored (Vector's cap); anything longer is not one. */
const val MAX_NODE_ADDR_CHARS = 2048
private const val OP_AD = "ad"
private const val OP_LEFT = "left"
/** True when [rumor] is a WebXDC signal. */
fun isWebxdc(rumor: Event): Boolean = rumor.kind == KIND
/** An app state update for app session [session] on [channelId]/[epoch]; [payload] is the app's JSON. */
fun stateUpdate(
authorPubKey: HexKey,
channelId: HexKey,
epoch: Long,
session: String,
payload: String,
createdAt: Long,
info: String? = null,
document: String? = null,
summary: String? = null,
ms: Int = MsTag.remainderFor(createdAt),
): Event {
val tags = binding(channelId, epoch, ms)
tags.add(arrayOf(TAG_SESSION, session))
tags.add(arrayOf(TAG_ALT, ALT_UPDATE))
if (info != null) tags.add(arrayOf(TAG_INFO, info))
if (document != null) tags.add(arrayOf(TAG_DOCUMENT, document))
if (summary != null) tags.add(arrayOf(TAG_SUMMARY, summary))
return RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, tags.toTypedArray(), payload)
}
/**
* A realtime peer signal on [channelId]/[epoch]: an advert of [nodeAddr] for [topic], or, when
* [nodeAddr] is null, the departure from it.
*/
fun peerSignal(
authorPubKey: HexKey,
channelId: HexKey,
epoch: Long,
topic: String,
nodeAddr: String?,
createdAt: Long,
ms: Int = MsTag.remainderFor(createdAt),
): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, binding(channelId, epoch, ms).toTypedArray(), peerSignalContent(topic, nodeAddr))
/** The peer-signal body, key order `op`, `topic`, `addr` as the reference client writes it. */
fun peerSignalContent(
topic: String,
nodeAddr: String?,
): String =
buildJsonObject {
put("op", if (nodeAddr == null) OP_LEFT else OP_AD)
put("topic", topic)
if (nodeAddr != null) put("addr", nodeAddr)
}.toString()
/** The app session a state update belongs to, or null (a peer signal carries none). */
fun sessionOf(rumor: Event): String? = if (rumor.kind == KIND) rumor.tags.firstTagValue(TAG_SESSION) else null
/** Exactly [TOPIC_ID_CHARS] uppercase RFC 4648 base32 characters (Vector's receive-side check). */
fun isTopicId(value: String?): Boolean = value != null && value.length == TOPIC_ID_CHARS && value.all { it in 'A'..'Z' || it in '2'..'7' }
/**
* Parses an untrusted peer-signal body: null unless it is `{"op":"ad","topic","addr"}` with a
* valid topic and a non-empty address of at most [MAX_NODE_ADDR_CHARS], or `{"op":"left","topic"}`.
*/
fun parsePeerSignal(content: String): WebxdcPeerSignal? {
val obj = runCatching { ConcordJson.instance.parseToJsonElement(content) }.getOrNull() as? JsonObject ?: return null
val topic = (obj["topic"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
if (!isTopicId(topic)) return null
val op = (obj["op"] as? JsonPrimitive)?.takeIf { it.isString }?.content
return when (op) {
OP_LEFT -> WebxdcPeerSignal(topic, null)
OP_AD -> {
val addr = (obj["addr"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
if (addr.isEmpty() || addr.length > MAX_NODE_ADDR_CHARS) return null
WebxdcPeerSignal(topic, addr)
}
else -> null
}
}
/** The binding every Chat rumor commits, in the examples' order: channel, epoch, ms. */
private fun binding(
channelId: HexKey,
epoch: Long,
ms: Int,
): ArrayList<Array<String>> = arrayListOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch), MsTag.assemble(ms))
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache
/**
* Resolves the owner-rooted authority state of a Concord community from its
@@ -84,6 +85,9 @@ data class AuthorityResolver private constructor(
fun isOwner(pubKey: String): Boolean = pubKey.lowercase() == ownerLower
/** The owner's pubkey (lowercase hex), proven by the `community_id` rather than any fold. */
fun owner(): String = ownerLower
fun isBanned(pubKey: String): Boolean = pubKey.lowercase() in banned
/** The role ids a member currently holds (empty for the owner and for plain members). */
@@ -277,6 +281,25 @@ data class AuthorityResolver private constructor(
return g.takeIf { coordinate == edition.entityIdHex }
}
/**
* `invite_links_locator(community, author)` as hex, memoized: it is an HKDF per call, and the
* well-formedness gate asks it for every registry edition on every refold of every community.
* The derivation is a pure function of its inputs, so a cached value can never go stale.
*/
private val inviteLinksCoordinates = ConcurrentLruCache<String, String>(1024)
internal fun inviteLinksCoordinateHex(
communityId: ByteArray,
communityIdHex: String,
author: String,
): String {
val key = communityIdHex + author.lowercase()
inviteLinksCoordinates.get(key)?.let { return it }
val coordinate = ConcordKeyDerivation.inviteLinksCoordinate(communityId, author.hexToByteArray()).toHexKey()
inviteLinksCoordinates.put(key, coordinate)
return coordinate
}
/** See [isWellFormed]. */
private fun wellFormed(
edition: ControlEdition,
@@ -294,7 +317,7 @@ data class AuthorityResolver private constructor(
// CORD-05 §5: the coordinate binds to the author, so each creator owns exactly their own
// list; the content must be a JSON array (a malformed one falls back to the previous head).
ControlEntityKind.INVITE_REGISTRY ->
edition.entityIdHex == ConcordKeyDerivation.inviteLinksCoordinate(communityId, edition.author.hexToByteArray()).toHexKey() &&
edition.entityIdHex == inviteLinksCoordinateHex(communityId, communityIdHex, edition.author) &&
ConcordInviteRegistry.isWellFormed(edition.content)
else -> true
}
@@ -106,10 +106,13 @@ object ConcordPins {
val sealedUnavailable: Boolean,
/** True when the content broke a cap or the format, so every reader treats it as empty. */
val violating: Boolean,
/** True when the content is the sealed form (`{"epoch","sealed"}`) — the same answer as [isSealedForm]. */
val sealedForm: Boolean = false,
) {
companion object {
val EMPTY = PinListRead(emptyList(), sealedUnavailable = false, violating = false)
val VIOLATING = PinListRead(emptyList(), sealedUnavailable = false, violating = true)
val VIOLATING_SEALED = PinListRead(emptyList(), sealedUnavailable = false, violating = true, sealedForm = true)
}
}
@@ -137,18 +140,23 @@ object ConcordPins {
val entries = root["entries"]
if (entries != null) return entriesOf(entries)
// From here the content is the sealed form exactly when [isSealedForm] says so — reported on
// the read so a caller need not parse the content a second time.
val sealedForm = root["sealed"] != null
val violating = if (sealedForm) PinListRead.VIOLATING_SEALED else PinListRead.VIOLATING
val epoch = (root["epoch"] as? JsonPrimitive)?.takeIf { it.isString }?.content
val sealed = (root["sealed"] as? JsonPrimitive)?.takeIf { it.isString }?.content
if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return PinListRead.VIOLATING
val epochValue = epoch.toLongOrNull() ?: return PinListRead.VIOLATING
val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false)
if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return violating
val epochValue = epoch.toLongOrNull() ?: return violating
val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false, sealedForm = true)
val inner =
try {
parse(Nip44.v2.decrypt(sealed, key)) as? JsonObject
} catch (_: Exception) {
null
} ?: return PinListRead.VIOLATING
return entriesOf(inner["entries"] ?: return PinListRead.VIOLATING)
} ?: return violating
val read = entriesOf(inner["entries"] ?: return violating)
return PinListRead(read.entries, read.sealedUnavailable, read.violating, sealedForm = true)
}
private fun entriesOf(element: JsonElement): PinListRead {
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
@@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* A Direct Invite opened by its recipient (CORD-05 §6): the bundle plus the seal-verified [sender].
@@ -118,6 +120,9 @@ object ConcordDirectInvite {
)
}
/** True when [wrap] is a giftwrap carrying the `["k","3313"]` Direct Invite index tag (a hint, not authority). */
fun isInviteTagged(wrap: Event): Boolean = wrap.kind == GiftWrapEvent.KIND && wrap.tags.any { it.size >= 2 && it[0] == TAG_K && it[1] == KIND.toString() }
/**
* True when [wrap]'s NIP-40 `expiration` (unix seconds) is at or before [nowSecs]: an expired
* handoff is never decrypted or surfaced.
@@ -144,15 +149,27 @@ object ConcordDirectInvite {
suspend fun open(
wrap: Event,
recipientSigner: NostrSigner,
): OpenedDirectInvite? = definitiveOrNull { openOrRetry(wrap, recipientSigner) }
/**
* [open], except that a failure that says nothing about the wrap — the coroutine cancelled, or
* the signer unable to answer right now (timed out, busy, not approved, not found) — is thrown
* instead of reported as "not an invite", so an inbox can leave the wrap to be retried rather
* than write it off for good. Null still means definitively not a valid invite for us.
*/
suspend fun openOrRetry(
wrap: Event,
recipientSigner: NostrSigner,
): OpenedDirectInvite? {
if (wrap.kind != GiftWrapEvent.KIND) return null
val plaintext = decryptOrNull { recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey) } ?: return null
val seal =
try {
Event.fromJson(recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey))
Event.fromJson(plaintext)
} catch (_: Exception) {
return null
}
return openSeal(wrap.id, seal, recipientSigner)
return openSealOrRetry(wrap.id, seal, recipientSigner)
}
/**
@@ -163,11 +180,41 @@ object ConcordDirectInvite {
wrapId: HexKey,
seal: Event,
recipientSigner: NostrSigner,
): OpenedDirectInvite? = definitiveOrNull { openSealOrRetry(wrapId, seal, recipientSigner) }
/** [openSeal] with [openOrRetry]'s transient-failure contract. */
suspend fun openSealOrRetry(
wrapId: HexKey,
seal: Event,
recipientSigner: NostrSigner,
): OpenedDirectInvite? {
if (seal !is SealEvent) return null
if (!runCatching { seal.verify() }.getOrDefault(false)) return null
val plaintext = decryptOrNull { recipientSigner.nip44Decrypt(seal.content, seal.pubKey) } ?: return null
val rumor =
try {
Rumor.fromJson(plaintext)
} catch (_: Exception) {
return null
}
return openRumor(wrapId, seal, rumor)
}
/**
* [openSeal] for a pipeline that already decrypted [seal] into [rumor] — the rumor exactly as the
* seal carries it, its claimed `pubkey` NOT yet overwritten by the seal's (see
* [SealEvent.unsealRumorThrowing]) — so the invite costs no second decrypt. Validates only: the
* seal's signature, the NIP-59 anti-spoofing author check, the rumor kind, the §1 bounds and the
* owner proof. Never throws.
*/
fun openRumor(
wrapId: HexKey,
seal: Event,
rumor: Rumor,
): OpenedDirectInvite? {
if (seal !is SealEvent) return null
return try {
if (!seal.verify()) return null
val rumor = Rumor.fromJson(recipientSigner.nip44Decrypt(seal.content, seal.pubKey))
// NIP-59 anti-spoofing: the rumor's claimed author must be the seal's signer. The generic
// unseal path overwrites the rumor's pubkey with the seal's, which hides a mismatch; here
// a mismatch is a forgery and the whole invite is refused.
@@ -189,6 +236,41 @@ object ConcordDirectInvite {
}
}
/**
* True for a signer failure that says the signer could not answer *now* — timed out, not
* approved (yet), backgrounded without permission, not found — not that the payload is
* undecryptable, so the same wrap may open on a later try. A signer that tried and failed
* ([SignerExceptions.CouldNotPerformException], which is also how a local key reports a payload
* that is not for it) is definitive.
*/
fun isTransientSignerFailure(e: Throwable): Boolean =
e is SignerExceptions.TimedOutException ||
e is SignerExceptions.ManuallyUnauthorizedException ||
e is SignerExceptions.AutomaticallyUnauthorizedException ||
e is SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException ||
e is SignerExceptions.SignerNotFoundException
/** [decrypt]'s plaintext, null when the payload is not for us, rethrowing a transient failure. */
private suspend fun decryptOrNull(decrypt: suspend () -> String): String? =
try {
decrypt()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (isTransientSignerFailure(e)) throw e
null
}
/** Runs [block], turning a transient failure into null for the callers that never retry. */
private suspend fun definitiveOrNull(block: suspend () -> OpenedDirectInvite?): OpenedDirectInvite? =
try {
block()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
/**
* Opens a direct-invite giftwrap addressed to [recipientSigner] and returns the
* [CommunityInvite], or null if it isn't a valid direct invite for this user. See [open], which
@@ -115,13 +115,18 @@ object ConcordInviteRegistry {
/**
* The link signers [creator]'s next registry edition lists (CORD-05 §5, "a Registry edit
* accompanies every mint and every retire"): the registry they currently publish ([published],
* their honored head), plus every link their Invite List [list] still holds for [communityIdHex],
* plus [minted]; minus [retired], and minus every link the list records as tombstoned or past its
* `expires_at` at [nowSecs] — an elapsed link can no longer be joined, so it must stop keeping the
* community Public. A null [list] (unreadable) contributes nothing and prunes nothing.
* their honored head) and [minted], minus [retired].
*
* The Invite List half heals a registry that fell behind: a link minted before any registry was
* published (or by a device whose registry edit never landed) is re-listed on the next edit.
* When the Invite List [list] is readable it is **authoritative**: the result is exactly the
* links it still holds live for [communityIdHex] (not tombstoned, not past `expires_at` at
* [nowSecs]) plus [minted]. A registry entry no live list entry backs is dropped — a retired
* link's list entry is gone after the tombstone merge (so it can no longer be marked dead by its
* token), and carrying the [published] entry forward would resurrect it and keep the community
* Public forever. Every link is recorded in the list before its URL is handed out, so nothing
* live is lost; and the list half heals a registry that fell behind (a link minted before any
* registry was published is re-listed on the next edit).
*
* A null [list] (unreadable) keeps [published] as is: it contributes nothing and prunes nothing.
*/
fun nextLinks(
published: Collection<HexKey>,
@@ -133,8 +138,9 @@ object ConcordInviteRegistry {
): List<HexKey> {
val dead = retired.mapTo(HashSet()) { it.lowercase() }
val live = LinkedHashSet<HexKey>()
if (list == null) {
published.forEach { live += it.lowercase() }
if (list != null) {
} else {
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
for (entry in list.entries) {
if (!entry.communityId.equals(communityIdHex, ignoreCase = true)) continue
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withPrivateChannels
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -80,6 +80,51 @@ object ConcordInviteVend {
return held.filter { it.key.isNotBlank() && isEntitled(authority, memberHex, it.channelId) }
}
/**
* Everyone entitled to [channelIdHex]'s key under [authority]: the owner plus every non-banned
* holder of a Role scoped to the channel. Entitlement needs a Grant, so the roster enumerates it
* exactly — no member census required. This is the keep-set of a channel rotation (CORD-06).
*/
fun entitledMembers(
authority: AuthorityResolver,
channelIdHex: HexKey,
): Set<HexKey> {
val scoped = channelRoleIds(authority, channelIdHex)
val out = HashSet<HexKey>()
out.add(authority.owner())
for (member in authority.roleHolders()) {
if (authority.isBanned(member)) continue
if (authority.rolesOf(member).any { it in scoped }) out.add(member.lowercase())
}
return out
}
/** Who gained and who lost a Private Channel's entitlement between two folds. */
class AccessChange(
val channelIdHex: HexKey,
val gained: Set<HexKey>,
val lost: Set<HexKey>,
)
/**
* How a roster change — a Grant, a revoke, a Role's scope edit or deletion, a ban — moved
* entitlement to each of [channelIds] (Armada `channelsHingingOn`, generalised to any edit):
* the members to vend each channel's key to, and the ones a rotation must now cut. Channels
* nobody gained or lost are omitted.
*/
fun accessChanges(
before: AuthorityResolver,
after: AuthorityResolver,
channelIds: Collection<HexKey>,
): List<AccessChange> =
channelIds.mapNotNull { id ->
val was = entitledMembers(before, id)
val now = entitledMembers(after, id)
val gained = now - was
val lost = was - now
if (gained.isEmpty() && lost.isEmpty()) null else AccessChange(id.lowercase(), gained, lost)
}
/** The [held] keys as bundle channel grants (lowercase hex, as Armada writes them). */
fun toInviteChannels(held: List<PrivateChannelKey>): List<InviteChannel> = held.map { InviteChannel(it.channelId.lowercase(), it.key.lowercase(), it.epoch, it.name) }
@@ -92,6 +137,12 @@ object ConcordInviteVend {
* the member onto attacker-read streams. So it counts only on the SAME `community_root`,
* `root_epoch` and `control_pk` (swapping `control_pk` alone would eclipse the member onto an
* attacker's Control Plane); the base advances only by a CORD-06 rekey.
*
* And it only ever ADDS a channel this member holds no key for. A held key moves forward only
* through a channel rekey (CORD-06 §2), whose `prevcommit` proves it extends the very key held;
* a bare bundle proves nothing, so letting one replace a held key would let any keyholder
* park a member on a dead key at an absurd epoch that every later honest delivery then loses
* to. A key below a recorded cut (the rotation that removed us) never comes back either.
*/
fun catchUpChannelIds(
held: ConcordCommunityListEntry?,
@@ -102,34 +153,109 @@ object ConcordInviteVend {
if (!bundle.communityRoot.equals(held.root, ignoreCase = true)) return emptyList()
if (bundle.rootEpoch != held.rootEpoch) return emptyList()
if (!sameOptionalHex(bundle.controlPk, held.controlPk)) return emptyList()
val heldEpochs = held.privateChannels.filter { it.key.isNotBlank() }.associate { it.channelId.lowercase() to it.epoch }
val heldIds = held.privateChannels.filter { HEX64.matches(it.key) }.mapTo(HashSet()) { it.channelId.lowercase() }
return bundle.channels
.filter { HEX64.matches(it.id) && HEX64.matches(it.key) }
.filter { c ->
val heldEpoch = heldEpochs[c.id.lowercase()]
heldEpoch == null || c.epoch > heldEpoch
}.map { it.id.lowercase() }
.filter { it.id.lowercase() !in heldIds }
.filterNot { ConcordChannelKeyring.isCutOff(held, it.id, it.epoch) }
.map { it.id.lowercase() }
.distinct()
}
/**
* [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds]) merged
* in — a newer epoch replaces the held one — or null when the bundle contributes nothing. The
* base, epoch, control keys and every other field stay exactly as held.
* The subset of [catchUpChannelIds] a catch-up may actually deliver against the held fold: only
* from a [sender] who is staff there (the owner or a Control-writing permission holder,
* CORD-04 §3 — a plain keyholder could otherwise plant a wrong key that blocks the right one),
* and only for channels the fold knows as live Private Channels ([privateChannelIds]). Empty
* when either fails.
*/
fun admissibleCatchUpIds(
held: ConcordCommunityListEntry?,
bundle: CommunityInvite,
authority: AuthorityResolver,
privateChannelIds: Set<HexKey>,
sender: HexKey,
): List<HexKey> {
if (!authority.isStaff(sender)) return emptyList()
val live = privateChannelIds.mapTo(HashSet()) { it.lowercase() }
return catchUpChannelIds(held, bundle).filter { it in live }
}
/**
* [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds], narrowed
* to [only] when given) added, or null when the bundle contributes nothing. A held key is never
* replaced; the base, epoch, control keys and every other field stay exactly as held.
*/
fun adoptCatchUp(
held: ConcordCommunityListEntry,
bundle: CommunityInvite,
only: Collection<HexKey>? = null,
): ConcordCommunityListEntry? {
val newIds = catchUpChannelIds(held, bundle).toSet()
val newIds = catchUpChannelIds(held, bundle).filter { only == null || it in only }.toSet()
if (newIds.isEmpty()) return null
val delivered =
bundle.channels
.filter { it.id.lowercase() in newIds && HEX64.matches(it.key) }
.groupBy { it.id.lowercase() }
.map { (id, grants) -> grants.maxBy { it.epoch }.let { PrivateChannelKey(id, it.key.lowercase(), it.epoch, it.name) } }
val kept = held.privateChannels.filterNot { it.channelId.lowercase() in newIds }
return held.withPrivateChannels(kept + delivered)
// Through the keyring: a replaced key keeps the unknown fields another client wrote in it.
var next = held
for (key in delivered) next = ConcordChannelKeyring.withChannelKey(next, key) ?: next
return if (next === held) null else next
}
/** Why a catch-up Direct Invite may or may not be adopted without a click ([judgeCatchUp]). */
enum class CatchUpVerdict {
/** The Grant was the consent: adopt now. */
ADOPT,
/** No folded roster yet (the Grant's fold lags): wait. */
NO_FOLD,
/** Not a catch-up at all ([catchUpChannelIds] is empty). */
NOTHING_NEW,
/** The recipient is banned (CORD-04 §4). */
BANNED,
/** A plain keyholder sent it; only staff may plant a key automatically. */
SENDER_NOT_STAFF,
/** It carries a channel the recipient's Roles don't entitle them to. */
NOT_ENTITLED,
}
/**
* Whether a parked catch-up invite — a Direct Invite carrying a Private Channel key an existing
* member lacks, which is how a role grant's key arrives (CORD-05 §6) — may be adopted WITHOUT a
* click (Armada `judgeCatchUp`). Consent came from the Grant; this checks the bundle is the
* delivery it prescribes, against the folded [authority]:
* - the [sender] is the owner or staff (CORD-04 §3), so a plain keyholder can't plant a wrong
* key that would block the right one;
* - the [recipient] isn't banned;
* - EVERY newly contributed channel is one the recipient's Roles entitle them to ([isEntitled]).
*
* - every such channel is a live Private Channel in the fold ([privateChannelIds]).
*
* A manual Accept still needs a staff sender and a live Private Channel
* ([admissibleCatchUpIds]); only the entitlement check is waived by the click.
*/
fun judgeCatchUp(
authority: AuthorityResolver?,
privateChannelIds: Set<HexKey>,
recipient: HexKey,
sender: HexKey,
bundle: CommunityInvite,
held: ConcordCommunityListEntry?,
): CatchUpVerdict {
val vended = catchUpChannelIds(held, bundle)
if (vended.isEmpty()) return CatchUpVerdict.NOTHING_NEW
if (authority == null) return CatchUpVerdict.NO_FOLD
if (authority.isBanned(recipient)) return CatchUpVerdict.BANNED
if (!authority.isStaff(sender)) return CatchUpVerdict.SENDER_NOT_STAFF
val live = privateChannelIds.mapTo(HashSet()) { it.lowercase() }
if (vended.any { it !in live || !isEntitled(authority, recipient, it) }) return CatchUpVerdict.NOT_ENTITLED
return CatchUpVerdict.ADOPT
}
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
@@ -0,0 +1,328 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* One Private Channel's rotation as a receiver sees it (CORD-06 §2): the kind-3303 chunks one
* [rotator] published at one ([newEpoch], [prevCommit]) — two Rotators concurrently rekeying the
* same epoch never merge into one set. [complete] once every chunk `1..total` is held; a missing
* chunk is never a removal. [createdAt] is the newest chunk's `created_at` (seconds), which a
* receiver compares against its join time: a rotation predating the join is not an exclusion.
*/
class ChannelRotation(
val rotator: HexKey,
val channelIdHex: HexKey,
val newEpoch: Long,
val prevEpoch: Long,
val prevCommit: HexKey,
val total: Int,
val chunks: Map<Int, List<RekeyBlob>>,
val authority: AuthorityCitation?,
val createdAt: Long,
) {
val complete: Boolean get() = (1..total).all { it in chunks }
/** Every blob across the held chunks. */
fun blobs(): List<RekeyBlob> = chunks.values.flatten()
}
/** A key the receiver stepped off while walking a channel's rotations: it still reads its own era. */
class SteppedChannelKey(
val key: ByteArray,
val epoch: Long,
/** When the rotation that superseded it was published (seconds). */
val retiredAt: Long,
)
/** What a Private Channel's pending rotations mean for the key this account holds (CORD-06 §2). */
sealed class ChannelRekeyOutcome {
/** Nothing to act on (no honored complete rotation past the held epoch, or one we cannot yet verify). */
object None : ChannelRekeyOutcome()
/** Adopt [key] at [epoch]; [steppedOver] are the keys the walk left behind, newest first. */
class Adopted(
val key: ByteArray,
val epoch: Long,
val steppedOver: List<SteppedChannelKey>,
) : ChannelRekeyOutcome()
/**
* A complete, honored rotation to [epoch] that could have carried our blob did not: we were cut
* from the channel. Drop the key and record the cut, so a stale bundle cannot restore it.
*/
class Removed(
val epoch: Long,
) : ChannelRekeyOutcome()
}
/**
* Single-channel Rekeys (CORD-06 §1-2): rotate one Private Channel's independent key to exactly the
* members who should keep reading it, and follow such a rotation as a member.
*
* - **Address.** A channel rotation to `new_epoch` rides `group_key("concord/rekey-pseudonym",
* community_root, channel_id, new_epoch)` — keyed by the *community* root, not the channel key
* (CORD-02 derivation table), so every member can precompute it. A Refounding seals its channel
* rekeys under the **prior** root (CORD-06 §3), so a receiver watches under the root it holds and
* the one before it.
* - **Blob.** 72 bytes, `scope_id[32] ‖ epoch_be[8] ‖ new_key[32]`, with the channel id as the
* scope ([RekeyPayload]); scope and epoch are verified against the tags before adoption.
* - **Continuity.** `prevcommit` is the epoch-key commitment over the channel key being replaced at
* its epoch; a receiver adopts only a rotation off the exact key it holds, walking several
* rotations in one pass when it missed some.
* - **Authority.** A single-channel Rekey needs `MANAGE_CHANNELS`, a Refounding's needs `BAN`, and
* the Rotator must strictly outrank every removed target — so a receiver treats "no blob for me"
* as a removal only from a Rotator that outranks it (Armada `useChannelRekeyWatch`).
*
* Pinned byte-for-byte to the reference client's `lib/rekey.ts` (`encodeWrappedKey`,
* `buildRekeyRumors`, `channelRekeyGroupKey`) and walk (`useChannelRekeyWatch`).
*/
object ConcordChannelRekey {
/**
* How many channel epochs past the held one a member watches. Watching only `held + 1` strands
* anyone who missed a rotation — they'd never learn they were removed. The reference client's
* `CHANNEL_REKEY_LOOKAHEAD`.
*/
const val LOOKAHEAD = 8
/** The rekey address a rotation of [channelId] to [newEpoch] rides, sealed under [sealingRoot]. */
fun address(
sealingRoot: ByteArray,
channelId: ByteArray,
newEpoch: Long,
): GroupKey = ConcordKeyDerivation.channelRekeyAddress(sealingRoot, channelId, newEpoch)
/** The `prevcommit` a rotation off [heldKey] at [heldEpoch] carries (CORD-02 A.5). */
fun prevCommit(
heldEpoch: Long,
heldKey: ByteArray,
): HexKey = ConcordKeyDerivation.epochKeyCommitment(heldEpoch, heldKey).toHexKey()
/** A fresh 32-byte channel key. */
fun mintKey(): ByteArray = RandomInstance.bytes(32)
/**
* The kind-1059 wraps of one channel rotation: the chunked kind-3303 rumors delivering
* [newKey] at `heldEpoch + 1` to [recipients] (the rotator should include itself, or nobody
* could rotate the channel next time), each chunk carrying [authority] (`vac`), sealed
* (encrypted, rotator-signed) at [address] under [sealingRoot].
*/
suspend fun build(
rotatorSigner: NostrSigner,
sealingRoot: ByteArray,
channelId: ByteArray,
heldKey: ByteArray,
heldEpoch: Long,
newKey: ByteArray,
recipients: Collection<HexKey>,
createdAt: Long,
authority: AuthorityCitation? = null,
): List<Event> {
val newEpoch = heldEpoch + 1
val prevCommit = prevCommit(heldEpoch, heldKey)
val blobs =
recipients.map { it.lowercase() }.distinct().map { recipient ->
ConcordRekey.blobForSigner(rotatorSigner, recipient.hexToByteArray(), channelId, newEpoch, newKey)
}
val widest = blobs.size.coerceAtLeast(1)
val envelopeTags = ConcordRekey.tags(channelId, newEpoch, heldEpoch, prevCommit, widest, widest, authority)
val envelope =
RumorAssembler
.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, envelopeTags, "")
.toJson()
.encodeToByteArray()
.size
val chunks = ConcordRekey.chunkBlobs(blobs, envelope)
val stream = address(sealingRoot, channelId, newEpoch)
return chunks.mapIndexed { index, chunk ->
val tags = ConcordRekey.tags(channelId, newEpoch, heldEpoch, prevCommit, index + 1, chunks.size, authority)
val rumor = RumorAssembler.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk))
ConcordStreamEnvelope.wrap(rumor, stream, rotatorSigner, encrypted = true, createdAt = createdAt)
}
}
/**
* Opens the kind-1059 [wraps] seen at channel-rekey addresses ([keys], address hex → key) and
* groups the well-formed chunks for [channelIdHex] into [ChannelRotation]s, keyed by
* (rotator, newepoch, prevcommit). Drops: a wrap at no known address, a plaintext seal (a rekey
* seal is encrypted, CORD-02 §5), a non-3303 rumor, a scope other than the channel, a
* non-decimal or 0-based chunk, a malformed `vac`. A rotation whose chunks disagree on
* `prevepoch`, `total` or citation is distrusted whole.
*/
fun rotations(
wraps: Collection<Event>,
keys: Map<HexKey, GroupKey>,
channelIdHex: HexKey,
): List<ChannelRotation> {
val scope = channelIdHex.lowercase()
val groups = LinkedHashMap<String, MutableList<Parsed>>()
for (wrap in wraps.distinctBy { it.id }) {
val key = keys[wrap.pubKey] ?: continue
val opened = ConcordStreamEnvelope.openOrNull(wrap, key) ?: continue
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) continue
val rumor = opened.rumor
if (rumor.kind != ConcordRekey.KIND) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE)?.lowercase() != scope) continue
val newEpoch = strictLong(rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)) ?: continue
val prevEpoch = strictLong(rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)) ?: continue
val prevCommit = rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() ?: continue
if (!HEX64.matches(prevCommit)) continue
val (index, total) = ConcordRekey.chunkOf(rumor.tags) ?: continue
val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME }
val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue
val rotator = opened.author.lowercase()
groups
.getOrPut("$rotator:$newEpoch:$prevCommit") { ArrayList() }
.add(Parsed(rotator, newEpoch, prevEpoch, prevCommit, index, total, ConcordRekey.decodeContent(rumor.content), citation, rumor.createdAt))
}
return groups.values.mapNotNull { parsed ->
val first = parsed.first()
if (parsed.any { it.prevEpoch != first.prevEpoch || it.total != first.total }) return@mapNotNull null
val citations = parsed.map { p -> p.citation?.let { VacTag.assemble(it).joinToString(",") } }.distinct()
if (citations.size > 1) return@mapNotNull null
ChannelRotation(
rotator = first.rotator,
channelIdHex = scope,
newEpoch = first.newEpoch,
prevEpoch = first.prevEpoch,
prevCommit = first.prevCommit,
total = first.total,
chunks = parsed.groupBy { it.index }.mapValues { (_, same) -> same.first().blobs },
authority = first.citation,
createdAt = parsed.maxOf { it.createdAt },
)
}
}
/**
* Walks [rotations] of the channel whose key this account holds ([heldKey] at [heldEpoch]) and
* decides what to do (Armada `useChannelRekeyWatch`):
*
* - only **complete** rotations past [heldEpoch] from an [honored] Rotator count;
* - epoch by epoch, ascending: a rotation carrying our blob **and** continuing the key we hold
* at that point (`prevepoch`/`prevcommit`) hands us the next key — racing Rotators at one
* epoch converge on the lexicographically lowest key — and the walk moves on from it;
* - a rotation that carries our blob off a key we can't verify is neither adoption nor removal
* (a gap to fetch);
* - a rotation with no blob for us, published at or after [joinedAtSecs] by a Rotator who
* [outranksMe], is the read-cut (removal needs no chain: hiding is local and safe);
* - a key adopted above the newest exclusion is a re-admission; otherwise the exclusion wins.
*
* The blob opens through [recipientSigner] (one NIP-44 decrypt: bunker-friendly).
*/
suspend fun walk(
rotations: List<ChannelRotation>,
channelIdHex: HexKey,
heldKey: ByteArray,
heldEpoch: Long,
recipientSigner: NostrSigner,
joinedAtSecs: Long,
honored: (ChannelRotation) -> Boolean,
outranksMe: (HexKey) -> Boolean,
): ChannelRekeyOutcome {
val channelId = channelIdHex.hexToByteArray()
val byEpoch =
rotations
.filter { it.channelIdHex.equals(channelIdHex, ignoreCase = true) && it.newEpoch > heldEpoch && it.complete && honored(it) }
.groupBy { it.newEpoch }
.entries
.sortedBy { it.key }
if (byEpoch.isEmpty()) return ChannelRekeyOutcome.None
var chainEpoch = heldEpoch
var chainKey = heldKey
var adoptedEpoch: Long? = null
var excludedAt: Long? = null
val stepped = ArrayList<SteppedChannelKey>()
for ((epoch, candidates) in byEpoch) {
var keyHere: ByteArray? = null
var publishedHere: Long? = null
var addressedHere = false
for (set in candidates) {
val locator =
ConcordKeyDerivation
.recipientLocator(set.rotator.hexToByteArray(), recipientSigner.pubKey.hexToByteArray(), channelId, epoch)
.toHexKey()
if (set.blobs().none { it.locator == locator }) continue
addressedHere = true
// Only a rotation off the key we hold at this point can hand us the next one.
if (set.prevEpoch != chainEpoch || set.prevCommit != prevCommit(chainEpoch, chainKey)) continue
val payload =
ConcordRekey.findPayloadWithSigner(set.blobs(), recipientSigner, set.rotator.hexToByteArray(), channelId, epoch)
// A channel blob is exactly 72 bytes; a wider (base-form) payload is malformed here.
if (payload == null || payload.newControlPk != null) continue
keyHere = keyHere?.let { if (ConcordRefounding.compareKeys(payload.newKey, it) < 0) payload.newKey else it } ?: payload.newKey
publishedHere = publishedHere?.let { minOf(it, set.createdAt) } ?: set.createdAt
}
val next = keyHere
if (next != null) {
stepped.add(0, SteppedChannelKey(chainKey, chainEpoch, publishedHere ?: 0))
chainEpoch = epoch
chainKey = next
adoptedEpoch = epoch
continue
}
if (addressedHere) continue
if (candidates.any { it.createdAt >= joinedAtSecs && outranksMe(it.rotator) }) excludedAt = epoch
}
val adopted = adoptedEpoch
if (adopted != null && (excludedAt == null || adopted > excludedAt)) {
return ChannelRekeyOutcome.Adopted(chainKey, adopted, stepped)
}
return excludedAt?.let { ChannelRekeyOutcome.Removed(it) } ?: ChannelRekeyOutcome.None
}
private class Parsed(
val rotator: HexKey,
val newEpoch: Long,
val prevEpoch: Long,
val prevCommit: HexKey,
val index: Int,
val total: Int,
val blobs: List<RekeyBlob>,
val citation: AuthorityCitation?,
val createdAt: Long,
)
private val HEX64 = Regex("^[0-9a-f]{64}$")
/** Strict decimal (`0|[1-9][0-9]*`), as the reference client's `isTagDecimal`. */
private fun strictLong(value: String?): Long? {
if (value.isNullOrEmpty() || value.length > 18 || !value.all { it in '0'..'9' }) return null
if (value.length > 1 && value[0] == '0') return null
return value.toLong()
}
}
@@ -22,8 +22,10 @@ package com.vitorpamplona.quartz.concord.cord07Voice
import com.vitorpamplona.quartz.concord.crypto.GroupKey
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.NostrSignerSync
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -42,6 +44,10 @@ object ConcordBrokerToken {
const val AUTH_SCHEME = "Concord"
const val TAG_URL = "u"
const val TAG_METHOD = "method"
const val TAG_NONCE = "nonce"
/** Bytes of fresh entropy in the grant's [TAG_NONCE] tag (64 lowercase hex chars, as the reference client). */
const val NONCE_BYTES = 32
/** The broker path for a voice room (the room = the voice signer's x-only pubkey hex). */
fun wellKnownPath(voiceRoomHex: String): String = "/.well-known/concord/av/$voiceRoomHex"
@@ -49,15 +55,27 @@ object ConcordBrokerToken {
/**
* Builds the kind-27235 auth event for [url]/[method], signed by the channel's
* [voiceSigner] key (its public key is the voice room / SFU name).
*
* Every member of a Channel signs with the **same** `voice_key.sk`, so two members
* requesting in the same second would otherwise build byte-identical events — one id —
* and the broker's anti-replay set (keyed on the id, CORD-07 §2) would refuse the second.
* The random [nonce] tag (`["nonce", <64 hex>]`, after `u` and `method`, as the reference
* client signs it) keeps every grant's id unique.
*/
fun buildAuthEvent(
voiceSigner: GroupKey,
url: String,
createdAt: Long,
method: String = "GET",
nonce: String = RandomInstance.bytes(NONCE_BYTES).toHexKey(),
): Event {
val signer = NostrSignerSync(KeyPair(privKey = voiceSigner.secretKey))
return signer.signNormal(createdAt, KIND, arrayOf(arrayOf(TAG_URL, url), arrayOf(TAG_METHOD, method)), "")
return signer.signNormal(
createdAt,
KIND,
arrayOf(arrayOf(TAG_URL, url), arrayOf(TAG_METHOD, method), arrayOf(TAG_NONCE, nonce)),
"",
)
}
/** The `Authorization` header value carrying a base64 of the signed auth [event]. */
@@ -23,12 +23,17 @@ package com.vitorpamplona.quartz.concord.cord07Voice
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
/** A parsed voice presence: who is in the call, under which SFU [identity], and on which [broker]. */
/**
* A parsed voice presence: who is in the call, under which SFU [identity], and on which [broker].
* [ms] is the CORD-02 §4 ordering basis (`created_at * 1000 + ms`) and [rumorId] the equal-time
* tiebreak — together they decide which of an author's presences is the latest.
*/
class VoicePresenceInfo(
val author: HexKey,
val channelId: HexKey?,
@@ -37,6 +42,18 @@ class VoicePresenceInfo(
val identity: String?,
val broker: String?,
val createdAt: Long,
val ms: Long,
val rumorId: HexKey,
)
/**
* The folded view of one Channel's call (CORD-07 §4): [present] holds each author's latest
* presence when it is a fresh `joined`; [verified] maps an SFU identity to the single author
* whose fresh presence claims it (contested and unclaimed identities are absent).
*/
class VoicePresenceFold(
val present: List<VoicePresenceInfo>,
val verified: Map<String, HexKey>,
)
/**
@@ -44,9 +61,11 @@ class VoicePresenceInfo(
* Channel plane (like Chat Plane messages), announcing that a member is in the
* call under a broker-assigned SFU [VoicePresenceInfo.identity].
*
* A participant renders as a verified member only when **exactly one author's
* fresh signed presence** claims an identity ([verifiedParticipants]); contested
* identities render unverified. Presence is heartbeated every
* Per author the **latest** presence wins ([latestPerAuthor]: millisecond basis, lower rumor id
* on a tie — the reference client's `foldVoicePresence`), so a `left` supersedes an earlier
* `joined` and a heartbeat under a new identity drops the old claim. A participant renders as a
* verified member only when **exactly one author's fresh signed presence** claims an identity
* ([fold]); contested identities render unverified. Presence is heartbeated every
* [HEARTBEAT_MS] and considered absent after [STALE_MS].
*/
object VoicePresence {
@@ -67,48 +86,104 @@ object VoicePresence {
identity: String,
createdAt: Long,
broker: String? = null,
subMs: Int? = null,
subMs: Int? = MsTag.remainderFor(createdAt),
): Event {
val tags = ArrayList<Array<String>>()
tags.add(ChannelTag.assemble(channelId))
tags.add(EpochTag.assemble(epoch))
tags.add(arrayOf(TAG_IDENTITY, identity))
if (broker != null) tags.add(arrayOf(TAG_BROKER, broker))
if (subMs != null) tags.add(arrayOf("ms", subMs.toString()))
if (subMs != null) tags.add(MsTag.assemble(subMs))
return RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, tags.toTypedArray(), CONTENT_JOINED)
}
/** A "left" presence bound to the channel/epoch. */
/** A "left" presence bound to the channel/epoch (identity and broker omitted). */
fun left(
authorPubKey: HexKey,
channelId: HexKey,
epoch: Long,
createdAt: Long,
): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)), CONTENT_LEFT)
subMs: Int? = MsTag.remainderFor(createdAt),
): Event {
val tags = ArrayList<Array<String>>(3)
tags.add(ChannelTag.assemble(channelId))
tags.add(EpochTag.assemble(epoch))
if (subMs != null) tags.add(MsTag.assemble(subMs))
return RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, tags.toTypedArray(), CONTENT_LEFT)
}
/**
* Parses a kind-23313 rumor, or null when malformed: the content is not `joined`/`left`, a
* `joined` names no identity, or the `ms` tag is malformed/duplicated (dropped, never
* interpreted — CORD-02 §5). The caller checks the channel/epoch binding.
*/
fun parse(rumor: Event): VoicePresenceInfo? {
if (rumor.kind != KIND) return null
val joined =
when (rumor.content) {
CONTENT_JOINED -> true
CONTENT_LEFT -> false
else -> return null
}
val ms = MsTag.orderingMs(rumor.createdAt, rumor.tags) ?: return null
val identity = rumor.tags.firstTagValue(TAG_IDENTITY)?.takeIf { it.isNotEmpty() }
if (joined && identity == null) return null
return VoicePresenceInfo(
author = rumor.pubKey,
channelId = ChannelChat.channelOf(rumor),
epoch = ChannelChat.epochOf(rumor),
joined = rumor.content == CONTENT_JOINED,
identity = rumor.tags.firstTagValue(TAG_IDENTITY),
broker = rumor.tags.firstTagValue(TAG_BROKER),
joined = joined,
identity = if (joined) identity else null,
broker = if (joined) rumor.tags.firstTagValue(TAG_BROKER) else null,
createdAt = rumor.createdAt,
ms = ms,
rumorId = rumor.id,
)
}
/** True if [presence] is within [STALE_MS] of [nowMs] (createdAt is unix seconds). */
/** True if [presence] is within [STALE_MS] of [nowMs], on the millisecond basis. */
fun isFresh(
presence: VoicePresenceInfo,
nowMs: Long,
): Boolean = nowMs - presence.createdAt * 1000 <= STALE_MS
): Boolean = nowMs - presence.ms <= STALE_MS
/** True when [candidate] supersedes [current]: later on the ms basis, or the lower rumor id on a tie. */
private fun isLater(
candidate: VoicePresenceInfo,
current: VoicePresenceInfo,
): Boolean = candidate.ms > current.ms || (candidate.ms == current.ms && candidate.rumorId < current.rumorId)
/** Each author's latest presence (CORD-07 §4: ms basis, lower rumor id on equal ms). */
fun latestPerAuthor(presences: Collection<VoicePresenceInfo>): Map<HexKey, VoicePresenceInfo> {
val latest = HashMap<HexKey, VoicePresenceInfo>()
for (p in presences) {
val prev = latest[p.author]
if (prev == null || isLater(p, prev)) latest[p.author] = p
}
return latest
}
/**
* Maps each SFU identity to its single verified author across the given fresh
* [presences]. An identity claimed by zero or more-than-one author is omitted
* (contested identities render unverified).
* Folds every received presence of one call: per author the latest wins, then a `joined`
* older than [STALE_MS] counts as absent, then identities claimed by exactly one of the
* remaining authors verify. [VoicePresenceFold.present] is ordered by time, then author.
*/
fun fold(
presences: Collection<VoicePresenceInfo>,
nowMs: Long,
): VoicePresenceFold {
val present =
latestPerAuthor(presences)
.values
.filter { it.joined && it.identity != null && isFresh(it, nowMs) }
.sortedWith(compareBy<VoicePresenceInfo> { it.ms }.thenBy { it.author })
return VoicePresenceFold(present, verifiedParticipants(present))
}
/**
* Maps each SFU identity to its single verified author across [presences], which must
* already be the per-author latest, fresh presences ([fold] does both). An identity claimed
* by more than one author is omitted (contested identities render unverified).
*/
fun verifiedParticipants(presences: List<VoicePresenceInfo>): Map<String, HexKey> {
val claimants = HashMap<String, MutableSet<HexKey>>()
@@ -66,9 +66,18 @@ class SealEvent(
override fun isContentEncoded() = true
suspend fun unsealThrowing(signer: NostrSigner): Event {
val rumor = Rumor.fromJson(plainContent(signer))
suspend fun unsealThrowing(signer: NostrSigner): Event = unsealed(unsealRumorThrowing(signer))
/**
* The rumor exactly as this seal carries it — its claimed `pubkey` NOT yet overwritten by the
* seal's — in one decrypt. For a caller that must run the NIP-59 anti-spoofing check itself
* (rumor author == seal author) and then still wants the merged event: pass the result to
* [unsealed] rather than decrypting again.
*/
suspend fun unsealRumorThrowing(signer: NostrSigner): Rumor = Rumor.fromJson(plainContent(signer))
/** [rumor] (decrypted from this seal) merged into the inner event, recorded as [innerEventId]. */
fun unsealed(rumor: Rumor): Event {
val event = rumor.mergeWith(this)
innerEventId = event.id
@@ -20,11 +20,21 @@
*/
package com.vitorpamplona.quartz.concord.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class GuestbookTest {
private val member = KeyPair().pubKey.toHexKey()
@@ -42,6 +52,8 @@ class GuestbookTest {
assertEquals(member, entry?.member)
assertEquals(creator, entry?.inviteCreator)
assertEquals("Reddit", entry?.inviteLabel)
assertEquals(1_700_000_000_128L, entry?.ms)
assertEquals(rumor.id, entry?.rumorId)
}
@Test
@@ -59,7 +71,150 @@ class GuestbookTest {
}
@Test
fun parseIgnoresNonGuestbookRumors() {
assertNull(Guestbook.parse(Guestbook.kick(creator, target, 1L))) // kick is not a join/leave
fun kickMatchesTheExampleWireShape() {
// examples.md §3.2: content "", tags ms, p, vac.
val citation = AuthorityCitation(ByteArray(32) { 1 }, 3, ByteArray(32) { 2 })
val rumor = Guestbook.kick(creator, target, createdAt = 1_722_410_000L, subMs = 301, citation = citation)
assertEquals("", rumor.content)
assertEquals(listOf("ms", "p", "vac"), rumor.tags.map { it[0] })
assertEquals(listOf("ms", "301"), rumor.tags[0].toList())
assertEquals(listOf("vac", "01".repeat(32), "3", "02".repeat(32)), rumor.tags[2].toList())
val entry = assertNotNull(Guestbook.parse(rumor))
assertEquals(GuestbookAction.KICK, entry.action)
assertEquals(target, entry.member) // the state it sets is the target's
assertEquals(creator, entry.author)
assertEquals(1_722_410_000_301L, entry.ms)
assertEquals(3L, entry.citation?.grantVersion)
}
@Test
fun parseIgnoresNonGuestbookAndMalformedRumors() {
assertNull(Guestbook.parse(Event("00".repeat(32), member, 1L, 9, emptyArray(), "join", "")))
// A 3306 whose verb is "kick" is not a Kick: only kind 3309 can kick.
assertNull(Guestbook.parse(Event("00".repeat(32), member, 1L, Guestbook.KIND_JOIN_LEAVE, emptyArray(), "kick", "")))
// A malformed ms is dropped, never interpreted (CORD-02 §5).
assertNull(Guestbook.parse(Event("00".repeat(32), member, 1L, Guestbook.KIND_JOIN_LEAVE, arrayOf(arrayOf("ms", "1000")), "join", "")))
// A Kick naming no valid target.
assertNull(Guestbook.parse(Event("00".repeat(32), member, 1L, Guestbook.KIND_KICK, arrayOf(arrayOf("p", "zz")), "", "")))
}
// ---- Kick authority (CORD-04 §5/§6) --------------------------------------
private val owner = "0f".repeat(32)
private val admin = "a1".repeat(32)
private val mod = "b2".repeat(32)
private val plain = "c3".repeat(32)
private val other = "d4".repeat(32)
private val adminRole = "11".repeat(32)
private val modRole = "22".repeat(32)
private fun role(
roleId: String,
json: String,
) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0)
private fun grant(
member: String,
roleIds: List<String>,
) = ControlEdition(
ControlEntityKind.GRANT,
ControlFixtures.grantEid(member).hexToByteArray(),
0,
null,
null,
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
owner,
"grant-$member",
0,
)
private fun banlist(vararg banned: String) = ControlEdition(ControlEntityKind.BANLIST, ControlFixtures.banlistEid().hexToByteArray(), 0, null, null, "[${banned.joinToString(",") { "\"$it\"" }}]", owner, "ban", 0)
/** Admin at position 1 (KICK|BAN|MANAGE_ROLES), Mod at position 5 (KICK only). */
private fun roster(vararg extra: ControlEdition): AuthorityResolver =
ControlFixtures.resolve(
listOf(
role(adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""),
role(modRole, """{"name":"Mod","position":5,"permissions":"8"}"""),
grant(admin, listOf(adminRole)),
grant(mod, listOf(modRole)),
) + extra,
owner,
)
private fun kickBy(
actor: String,
target: String,
authority: AuthorityResolver,
at: Long = 100L,
citation: AuthorityCitation? = authority.citationFor(actor),
) = Guestbook.parse(Guestbook.kick(actor, target, at, subMs = 0, citation = citation))!!
private fun join(
who: String,
at: Long,
) = Guestbook.parse(Guestbook.join(who, at, subMs = 0))!!
@Test
fun kickHonoredOnlyFromAKickHolderWhoOutranksTheTarget() {
val r = roster()
assertTrue(Guestbook.canKick(r, kickBy(owner, plain, r))) // owner, no citation needed
assertTrue(Guestbook.canKick(r, kickBy(mod, plain, r))) // KICK at position 5 over a roleless member
assertTrue(Guestbook.canKick(r, kickBy(admin, mod, r))) // 1 outranks 5
assertFalse(Guestbook.canKick(r, kickBy(mod, admin, r))) // 5 does not outrank 1
assertFalse(Guestbook.canKick(r, kickBy(plain, other, r))) // no KICK bit
assertFalse(Guestbook.canKick(r, kickBy(admin, owner, r))) // the owner is never a target
assertFalse(Guestbook.canKick(r, kickBy(mod, mod, r))) // equal cannot act on equal
}
@Test
fun kickWithoutASyncedCitationParks() {
val r = roster()
// No vac from a non-owner: the sync floor cannot be met.
assertFalse(Guestbook.canKick(r, kickBy(mod, plain, r, citation = null)))
// A vac ahead of the Grant we hold (not yet synced) parks too.
val held = r.citationFor(mod)!!
val ahead = AuthorityCitation(held.grantId, held.grantVersion + 1, held.grantHash)
assertFalse(Guestbook.canKick(r, kickBy(mod, plain, r, citation = ahead)))
}
@Test
fun coalesceTakesTheLatestHonoredMotionPerMember() {
val r = roster()
val joined = join(plain, 50L)
val kicked = kickBy(mod, plain, r, at = 100L)
val folded = Guestbook.coalesce(listOf(kicked, joined), nowMs = 200_000L, authority = r)
assertEquals(GuestbookAction.KICK, folded[plain]?.action)
// A kicked member may re-join: a newer Join supersedes the Kick.
val rejoined = join(plain, 150L)
assertEquals(GuestbookAction.JOIN, Guestbook.coalesce(listOf(kicked, joined, rejoined), 200_000L, r)[plain]?.action)
// A Kick from someone who may not kick leaves the member joined.
val forged = kickBy(plain, other, r, at = 100L)
val otherJoined = join(other, 50L)
assertEquals(GuestbookAction.JOIN, Guestbook.coalesce(listOf(otherJoined, forged), 200_000L, r)[other]?.action)
// Without a roster no Kick is honored.
assertEquals(GuestbookAction.JOIN, Guestbook.coalesce(listOf(joined, kicked), 200_000L, authority = null)[plain]?.action)
}
@Test
fun coalesceDropsFutureAndBannedEntriesAndTiesToTheLowerRumorId() {
val now = 1_000_000L
val future = Guestbook.parse(Guestbook.leave(plain, now / 1000 + 3601, subMs = 0))!!
val joined = join(plain, 10L)
assertEquals(GuestbookAction.JOIN, Guestbook.coalesce(listOf(joined, future), now, authority = null)[plain]?.action)
// A banned member's own motions are dropped.
val banned = roster(banlist(other))
assertNull(Guestbook.coalesce(listOf(join(other, 10L)), now, banned)[other])
val a = Guestbook.parse(Guestbook.join(plain, 10L, subMs = 5))!!
val b = Guestbook.parse(Guestbook.leave(plain, 10L, subMs = 5))!!
val lower = if (a.rumorId < b.rumorId) a else b
assertEquals(lower.rumorId, Guestbook.coalesce(listOf(a, b), now, authority = null)[plain]?.rumorId)
assertEquals(lower.rumorId, Guestbook.coalesce(listOf(b, a), now, authority = null)[plain]?.rumorId)
}
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The List side of Private Channel keys (CORD-02 §8, CORD-03 §2, CORD-06 §2): rotations replace
* the one current key in place, older keys are read from `seed` / a peer's `priors` but never
* written, and the reference client's `channel_cuts` floor survives a round trip and refuses a
* key below it.
*/
class ConcordChannelKeyringTest {
private val chan = "a1".repeat(32)
private val other = "b2".repeat(32)
private val k0 = "10".repeat(32)
private val k1 = "11".repeat(32)
private val k2 = "12".repeat(32)
private fun entry(channels: List<PrivateChannelKey>) =
ConcordCommunityListEntry(
id = "c0".repeat(32),
owner = "0f".repeat(32),
ownerSalt = "5a".repeat(32),
root = "22".repeat(32),
rootEpoch = 2,
privateChannels = channels,
name = "Test",
addedAt = 1,
)
private fun roundTrip(e: ConcordCommunityListEntry): ConcordCommunityListEntry = ConcordCommunityList.decode(ConcordCommunityList.encode(listOf(e))).single()
@Test
fun aRotationReplacesTheKeyInPlaceKeepingUnknownFields() {
val wire =
"""{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,"current":{"community_id":"${"c0".repeat(32)}",
"owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}","root_epoch":2,
"channels":[{"id":"$chan","key":"$k0","epoch":0,"name":"mods","tint":"red"}],"relays":[],"name":"Test"}}]}"""
val held = ConcordCommunityList.decode(wire).single()
val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, k1, 1))
val ch = rotated.privateChannels.single()
assertEquals(k1, ch.key)
assertEquals(1, ch.epoch)
assertEquals("mods", ch.name)
// Another client's field inside the channel object survives.
val back = roundTrip(rotated).privateChannels.single()
assertEquals(JsonPrimitive("red"), back.extras["tint"])
// Never backward, never sideways.
assertNull(ConcordChannelKeyring.withRotatedKey(rotated, chan, k2, 1))
assertNull(ConcordChannelKeyring.withRotatedKey(rotated, chan, k2, 0))
}
@Test
fun aCutRoundTripsAndRefusesOlderKeys() {
val held = entry(listOf(PrivateChannelKey(chan, k0, 0, "mods"), PrivateChannelKey(other, k1, 3, "vip")))
val cut = ConcordChannelKeyring.withoutChannel(held, chan, 1)
assertEquals(listOf(other), cut.privateChannels.map { it.channelId })
// Written as the reference client's entry-level extension and read back.
val back = roundTrip(cut)
val encoded = ConcordJson.instance.parseToJsonElement(ConcordCommunityList.encode(listOf(cut))).jsonObject
val cuts = encoded["entries"]!!.jsonArray[0].jsonObject["channel_cuts"]!!.jsonArray
assertEquals(listOf(JsonObject(mapOf("id" to JsonPrimitive(chan), "epoch" to JsonPrimitive(1L)))), cuts.toList())
assertEquals(mapOf(chan to 1L), ConcordChannelKeyring.cutsOf(back))
// A stale key below the cut never comes back; one at/above it (a re-grant) does.
assertTrue(ConcordChannelKeyring.isCutOff(back, chan, 0))
assertNull(ConcordChannelKeyring.withChannelKey(back, PrivateChannelKey(chan, k0, 0)))
assertNotNull(ConcordChannelKeyring.withChannelKey(back, PrivateChannelKey(chan, k2, 1)))
// Max wins; a lower cut never rolls it back.
assertEquals(1L, ConcordChannelKeyring.cutsOf(ConcordChannelKeyring.withCut(back, chan, 0))[chan])
assertEquals(4L, ConcordChannelKeyring.cutsOf(ConcordChannelKeyring.withCut(back, chan, 4))[chan])
}
@Test
fun anUnknownFieldInsideACutSurvivesARaise() {
val wire =
"""{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,"channel_cuts":[{"id":"$chan","epoch":1,"why":"x"},{"id":"$other","epoch":2}],
"current":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}",
"community_root":"${"22".repeat(32)}","root_epoch":2,"channels":[],"relays":[],"name":"Test"}}]}"""
val held = ConcordCommunityList.decode(wire).single()
val raised = roundTrip(ConcordChannelKeyring.withCut(held, chan, 3))
val cuts = raised.residue.entryExtras["channel_cuts"] as JsonArray
val mine = cuts.map { it.jsonObject }.single { (it["id"] as JsonPrimitive).content == chan }
assertEquals(JsonPrimitive("x"), mine["why"])
assertEquals(mapOf(chan to 3L, other to 2L), ConcordChannelKeyring.cutsOf(raised))
}
@Test
fun olderKeysAreReadFromSeedAndPriorsButNeverWritten() {
val wire =
"""{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,
"seed":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}",
"root_epoch":0,"channels":[{"id":"$chan","key":"$k0","epoch":0,"name":"mods"}],"relays":[],"name":"Test"},
"current":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}",
"root_epoch":2,"channels":[{"id":"$chan","key":"$k2","epoch":2,"name":"mods","priors":[{"key":"$k1","epoch":1,"retired_at":5}]}],"relays":[],"name":"Test"}}]}"""
val held = ConcordCommunityList.decode(wire).single()
assertEquals(listOf(1L to k1, 0L to k0), ConcordChannelKeyring.historicalKeys(held, chan).map { it.epoch to it.key })
// The next privatisation climbs past every generation this entry knows of.
assertEquals(3, ConcordChannelKeyring.nextChannelEpoch(held, chan))
assertEquals(10, ConcordChannelKeyring.nextChannelEpoch(held, chan, observedFloor = 9))
assertEquals(1, ConcordChannelKeyring.nextChannelEpoch(held, other))
// A rotation we launch adds no prior of its own (CORD-02 §8 keeps intermediate keys out of the List).
val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, "13".repeat(32), 3))
val priors = rotated.privateChannels.single().extras[ConcordChannelKeyring.PRIORS] as JsonArray
assertEquals(1, priors.size)
assertFalse(ConcordChannelKeyring.historicalKeys(rotated, chan).any { it.key == "13".repeat(32) })
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ConcordLabels
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** WebXDC signals (kind 3310): Chat-plane plumbing only — built, bound, gated apart from chat rows, parsed. */
class ConcordWebxdcTest {
private val author = NostrSignerInternal(KeyPair())
private val channelId = "42".repeat(32)
private val topic = "A".repeat(26) + "234567".repeat(4) + "BC" // 52 base32 chars
@Test
fun theExamplesBindingAndTheStateUpdateTags() {
val rumor = ConcordWebxdc.stateUpdate(author.pubKey, channelId, 0L, session = "uuid-1", payload = "{\"x\":1}", createdAt = 1_686_840_700L, info = "moved", ms = 266)
assertEquals(3310, rumor.kind)
// examples.md §2.6: channel, epoch, ms — then the app's own tags.
assertEquals(listOf("channel", "epoch", "ms", "i", "alt", "info"), rumor.tags.map { it[0] })
assertTrue(ChannelChat.isBoundTo(rumor, channelId, 0L))
assertEquals("uuid-1", ConcordWebxdc.sessionOf(rumor))
assertEquals("{\"x\":1}", rumor.content)
}
@Test
fun peerSignalsRoundTripInTheReferenceShape() {
val ad = ConcordWebxdc.peerSignal(author.pubKey, channelId, 0L, topic, nodeAddr = "node-addr", createdAt = 5L)
assertEquals("{\"op\":\"ad\",\"topic\":\"$topic\",\"addr\":\"node-addr\"}", ad.content)
assertNull(ConcordWebxdc.sessionOf(ad))
val parsed = assertNotNull(ConcordWebxdc.parsePeerSignal(ad.content))
assertTrue(parsed.isAdvert)
assertEquals("node-addr", parsed.addr)
val left = ConcordWebxdc.peerSignal(author.pubKey, channelId, 0L, topic, nodeAddr = null, createdAt = 6L)
assertEquals("{\"op\":\"left\",\"topic\":\"$topic\"}", left.content)
assertFalse(assertNotNull(ConcordWebxdc.parsePeerSignal(left.content)).isAdvert)
}
@Test
fun untrustedPeerSignalsAreBounded() {
assertNull(ConcordWebxdc.parsePeerSignal("not json"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"short\",\"addr\":\"a\"}"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"${topic.lowercase()}\",\"addr\":\"a\"}"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"$topic\",\"addr\":\"\"}"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"$topic\",\"addr\":\"${"a".repeat(ConcordWebxdc.MAX_NODE_ADDR_CHARS + 1)}\"}"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"join\",\"topic\":\"$topic\"}"))
}
@Test
fun theChatGateKeepsWebxdcOutOfChatRowsButThePlaneAdmitsIt() =
runTest {
val plane = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, ByteArray(32) { 9 }, channelId.hexToByteArray(), 0)
val rumor = ConcordWebxdc.stateUpdate(author.pubKey, channelId, 0L, "uuid-1", "{}", createdAt = 5L)
val wrap = ConcordStreamEnvelope.wrap(rumor, plane, author, encrypted = true, createdAt = 5L)
val opened = assertNotNull(ConcordStreamEnvelope.openOrNull(wrap, plane))
assertFalse(ChannelChat.isChatKind(ConcordWebxdc.KIND), "never a chat row")
assertNull(ChannelChat.acceptOpened(opened, channelId, 0L), "the chat gate refuses it")
assertEquals(rumor.id, ChannelChat.acceptOpened(opened, channelId, 0L, ChannelChat.PLANE_KINDS)?.id, "the plane carries it")
assertNull(ChannelChat.acceptOpened(opened, channelId, 1L, ChannelChat.PLANE_KINDS), "under the same strict binding")
}
}
@@ -167,6 +167,12 @@ class ConcordPinsTest {
val noKey = ConcordPins.read(sealed) { null }
assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it")
assertTrue(noKey.entries.isEmpty())
// The read reports the form itself, matching isSealedForm, so nobody parses twice.
for (content in listOf(sealed, ConcordPins.serializePublic(listOf(entry)), "not json", """{"sealed":1}""", """{"epoch":"x","sealed":"y"}""")) {
assertEquals(ConcordPins.isSealedForm(content), ConcordPins.read(content) { plane.conversationKey }.sealedForm, content)
assertEquals(ConcordPins.isSealedForm(content), ConcordPins.read(content) { null }.sealedForm, content)
}
}
@Test
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
@@ -196,6 +197,8 @@ class ConcordInviteRegistryTest {
assertFalse(state.retiringWouldPrivatize(listOf(link1)))
assertTrue(state.retiringWouldPrivatize(listOf(link1, link2)))
assertFalse(ControlFixtures.fold(emptyList(), owner).retiringWouldPrivatize(listOf(link1)), "already Private: nothing flips")
// A dissolved community is never Refounded (CORD-02 §9): the last retire is just a retire.
assertFalse(state.withDissolved(true).retiringWouldPrivatize(listOf(link1, link2)))
}
@Test
@@ -259,15 +262,47 @@ class ConcordInviteRegistryTest {
val livePk = live.pubKey.toHexKey()
val expiredPk = expired.pubKey.toHexKey()
// The published registry still lists the expired link and an unrecorded one (link1); a new mint adds link2.
// The published registry still lists the expired link and one no list entry backs (link1): the
// readable list is authoritative, so both go; the recorded live link heals in; a mint adds link2.
val next = ConcordInviteRegistry.nextLinks(listOf(expiredPk, link1), doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, minted = listOf(link2))
assertEquals(listOf(link1, link2, livePk).sorted(), next)
assertEquals(listOf(link2, livePk).sorted(), next)
// Retiring the recorded live link and link1 leaves only the mint.
assertEquals(listOf(link2), ConcordInviteRegistry.nextLinks(next, doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, retired = listOf(livePk, link1)))
// Retiring the recorded live link, with the next mint recorded too, leaves only that mint.
val withMint = ConcordInviteListDocument(entries = doc.entries + entry("05", KeyPair()), tombstones = doc.tombstones)
val link3 = withMint.entries.last().signerPubKeyHex()
assertEquals(listOf(link3), ConcordInviteRegistry.nextLinks(next, withMint, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, retired = listOf(livePk)))
// Before its expiry the link is still live; an unreadable list prunes nothing.
assertTrue(expiredPk in ConcordInviteRegistry.nextLinks(emptyList(), doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 50))
assertEquals(listOf(expiredPk), ConcordInviteRegistry.nextLinks(listOf(expiredPk), null, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200))
}
@Test
fun theMemoizedRegistryCoordinateIsTheDerivedOne() {
val author = KeyPair().pubKey.toHexKey()
val derived = ConcordInviteRegistry.coordinateHex(ControlFixtures.COMMUNITY_ID_HEX.hexToByteArray(), author)
repeat(2) {
assertEquals(derived, AuthorityResolver.inviteLinksCoordinateHex(ControlFixtures.COMMUNITY_ID_HEX.hexToByteArray(), ControlFixtures.COMMUNITY_ID_HEX, author))
}
// Keyed by community too: the same author elsewhere is a different coordinate.
val other = "ab".repeat(32)
assertEquals(ConcordInviteRegistry.coordinateHex(other.hexToByteArray(), author), AuthorityResolver.inviteLinksCoordinateHex(other.hexToByteArray(), other, author))
}
@Test
fun aRetiredLinkWhoseEntryTheMergeDroppedIsNotResurrectedFromThePublishedRegistry() {
val retired = KeyPair()
val kept = KeyPair()
val retiredPk = retired.pubKey.toHexKey()
val keptPk = kept.pubKey.toHexKey()
// After the tombstone merge, the retired link's entry (and its token) is gone from the list:
// only the tombstone remains, which no longer names the signer.
val merged =
ConcordInviteListDocument(
entries = listOf(entry("0a", kept)),
tombstones = listOf(ConcordInviteListTombstone("0b", ControlFixtures.COMMUNITY_ID_HEX)),
)
// A later, unrelated registry edit (e.g. the next mint) must not carry the retired signer forward.
assertEquals(listOf(keptPk), ConcordInviteRegistry.nextLinks(listOf(retiredPk, keptPk), merged, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200))
}
}
@@ -22,6 +22,12 @@ package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@@ -87,15 +93,15 @@ class ConcordInviteVendTest {
}
@Test
fun aNewerEpochOfAHeldChannelReplacesIt() {
val newer = "ee".repeat(32)
val b = bundle(channels = listOf(InviteChannel(chanA, newer, 2, "mods")))
assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(held, b))
val adopted = assertNotNull(ConcordInviteVend.adoptCatchUp(held, b))
assertEquals(listOf(Triple(chanA, newer, 2L)), adopted.privateChannels.map { Triple(it.channelId, it.key, it.epoch) })
// Same or older epoch contributes nothing.
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanA, newer, 1)))).isEmpty())
fun aBundleNeverReplacesAHeldKey() {
// A held key moves only through a channel rekey (prevcommit continuity). A bare bundle at a
// higher — even absurd — epoch contributes nothing, so a keyholder can't park us on a dead key.
val bogus = "ee".repeat(32)
for (epoch in listOf(2L, 1_000_000_000L)) {
val b = bundle(channels = listOf(InviteChannel(chanA, bogus, epoch, "mods")))
assertTrue(ConcordInviteVend.catchUpChannelIds(held, b).isEmpty())
assertNull(ConcordInviteVend.adoptCatchUp(held, b))
}
}
@Test
@@ -114,4 +120,106 @@ class ConcordInviteVendTest {
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanB, "", 0)))).isEmpty())
assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(channels = emptyList())))
}
// ---- entitlement (Armada channelAccess.ts) and catch-up adoption (catchUpAdoption.ts) --------
private val owner = "0f".repeat(32)
private val alice = "a1".repeat(32)
private val bob = "b2".repeat(32)
private val modRole = "71".repeat(32)
private val accessRole = "72".repeat(32)
private fun role(
roleId: String,
json: String,
) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0)
private fun grant(
member: String,
roleIds: List<String>,
version: Long = 0,
prev: ControlEdition? = null,
) = ControlEdition(
ControlEntityKind.GRANT,
ControlFixtures.grantEid(member).hexToByteArray(),
version,
prev?.hash,
null,
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
owner,
"grant-$member-$version",
version,
)
private val roles =
listOf(
// A staff role (MANAGE_CHANNELS) with server scope, and a bit-less access role scoped to chanA.
role(modRole, """{"role_id":"$modRole","name":"Mod","position":2,"permissions":"2"}"""),
role(accessRole, """{"role_id":"$accessRole","name":"mods-room","position":10,"permissions":"0","scope":{"kind":"channel","channel_id":"$chanA"}}"""),
)
private fun authority(vararg extra: ControlEdition): AuthorityResolver = ControlFixtures.resolve(roles + extra, owner)
@Test
fun theOwnerAndScopedRoleHoldersAreEntitled() {
val aliceIn = grant(alice, listOf(accessRole))
val bobMod = grant(bob, listOf(modRole))
val a = authority(aliceIn, bobMod)
assertEquals(setOf(owner, alice), ConcordInviteVend.entitledMembers(a, chanA))
assertTrue(ConcordInviteVend.isEntitled(a, owner, chanB))
// A server-scoped staff role grants authority, never read access.
assertTrue(!ConcordInviteVend.isEntitled(a, bob, chanA))
// A link has no recipient and vends nothing; a member gets exactly their channels.
val held = listOf(PrivateChannelKey(chanA, keyA, 1, "mods"), PrivateChannelKey(chanB, keyB, 0, "vip"))
assertTrue(ConcordInviteVend.vendableChannels(held, a, null).isEmpty())
assertEquals(listOf(chanA), ConcordInviteVend.vendableChannels(held, a, alice).map { it.channelId })
assertEquals(listOf(chanA, chanB), ConcordInviteVend.vendableChannels(held, a, owner).map { it.channelId })
}
@Test
fun accessChangesNameWhoToVendAndWhoARotationMustCut() {
val aliceIn = grant(alice, listOf(accessRole))
val before = authority()
val afterGrant = authority(aliceIn)
val granted = ConcordInviteVend.accessChanges(before, afterGrant, listOf(chanA, chanB)).single()
assertEquals(chanA, granted.channelIdHex)
assertEquals(setOf(alice), granted.gained)
assertTrue(granted.lost.isEmpty())
val afterRevoke = authority(aliceIn, grant(alice, emptyList(), version = 1, prev = aliceIn))
val revoked = ConcordInviteVend.accessChanges(afterGrant, afterRevoke, listOf(chanA)).single()
assertEquals(setOf(alice), revoked.lost)
assertTrue(ConcordInviteVend.accessChanges(afterGrant, afterGrant, listOf(chanA)).isEmpty())
}
@Test
fun aStaffSentCatchUpForAnEntitledChannelIsAdoptedWithoutAClick() {
val member = held
val grantedChan = bundle(channels = listOf(InviteChannel(chanB, keyB, 0, "vip")))
val scopedB = role("73".repeat(32), """{"role_id":"${"73".repeat(32)}","name":"vip","position":11,"permissions":"0","scope":{"kind":"channel","channel_id":"$chanB"}}""")
val a = authority(scopedB, grant(alice, listOf("73".repeat(32))), grant(bob, listOf(modRole)))
val live = setOf(chanA, chanB)
assertEquals(ConcordInviteVend.CatchUpVerdict.ADOPT, ConcordInviteVend.judgeCatchUp(a, live, alice, owner, grantedChan, member))
assertEquals(ConcordInviteVend.CatchUpVerdict.ADOPT, ConcordInviteVend.judgeCatchUp(a, live, alice, bob, grantedChan, member))
assertEquals(ConcordInviteVend.CatchUpVerdict.NO_FOLD, ConcordInviteVend.judgeCatchUp(null, live, alice, owner, grantedChan, member))
// A plain keyholder (alice) can't plant a key in bob's list automatically.
assertEquals(ConcordInviteVend.CatchUpVerdict.SENDER_NOT_STAFF, ConcordInviteVend.judgeCatchUp(a, live, bob, alice, grantedChan, member))
// Bob holds no role scoped to chanB.
assertEquals(ConcordInviteVend.CatchUpVerdict.NOT_ENTITLED, ConcordInviteVend.judgeCatchUp(a, live, bob, owner, grantedChan, member))
assertEquals(ConcordInviteVend.CatchUpVerdict.NOTHING_NEW, ConcordInviteVend.judgeCatchUp(a, live, alice, owner, bundle(channels = listOf(InviteChannel(chanA, keyA, 1))), member))
// A channel the fold doesn't know as a live Private Channel is never auto-adopted.
assertEquals(ConcordInviteVend.CatchUpVerdict.NOT_ENTITLED, ConcordInviteVend.judgeCatchUp(a, setOf(chanA), alice, owner, grantedChan, member))
// Manual accept: staff sender and a live Private Channel, entitlement waived by the click.
assertEquals(listOf(chanB), ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, live, owner))
assertTrue(ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, live, alice).isEmpty())
assertTrue(ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, setOf(chanA), owner).isEmpty())
}
@Test
fun aCatchUpNeverRestoresAKeyBelowACut() {
val cut = ConcordChannelKeyring.withoutChannel(held, chanA, 2)
assertTrue(ConcordInviteVend.catchUpChannelIds(cut, bundle(channels = listOf(InviteChannel(chanA, keyA, 1)))).isEmpty())
assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(cut, bundle(channels = listOf(InviteChannel(chanA, keyB, 2)))))
}
}
@@ -0,0 +1,230 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ConcordLabels
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import kotlinx.coroutines.test.runTest
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* CORD-06 §1-2 single-channel Rekeys: the channel rekey address, the 72-byte scope-bound blob,
* the `prevcommit` continuity walk, chunk completeness, racing rotators and the removal rule —
* pinned to the reference client's `lib/rekey.ts` / `useChannelRekeyWatch`.
*/
class ConcordChannelRekeyTest {
private val admin = NostrSignerInternal(KeyPair())
private val alice = NostrSignerInternal(KeyPair())
private val bob = NostrSignerInternal(KeyPair())
private val root = ByteArray(32) { 0x21 }
private val channelId = ByteArray(32) { 0x5C }
private val channelHex = channelId.toHexKey()
private val key0 = ByteArray(32) { 0x10 }
private val now = 1_700_000_000L
private fun keysFor(vararg epochs: Long): Map<HexKey, GroupKey> = epochs.associate { e -> ConcordChannelRekey.address(root, channelId, e).let { it.publicKeyHex to it } }
private suspend fun rotate(
heldKey: ByteArray,
heldEpoch: Long,
newKey: ByteArray,
recipients: List<HexKey>,
rotator: NostrSignerInternal = admin,
createdAt: Long = now,
): List<Event> = ConcordChannelRekey.build(rotator, root, channelId, heldKey, heldEpoch, newKey, recipients + rotator.pubKey, createdAt)
private suspend fun walk(
wraps: List<Event>,
me: NostrSignerInternal,
heldKey: ByteArray = key0,
heldEpoch: Long = 0,
joinedAt: Long = 0,
honored: (ChannelRotation) -> Boolean = { true },
outranksMe: (HexKey) -> Boolean = { true },
): ChannelRekeyOutcome {
val keys = keysFor(*(heldEpoch + 1..heldEpoch + ConcordChannelRekey.LOOKAHEAD).toList().toLongArray())
return ConcordChannelRekey.walk(ConcordChannelRekey.rotations(wraps, keys, channelHex), channelHex, heldKey, heldEpoch, me, joinedAt, honored, outranksMe)
}
@Test
fun theChannelRekeyAddressIsTheRootKeyedRekeyPseudonym() {
// CORD-02 derivation table: concord/rekey-pseudonym, prior community_root, channel_id, new_epoch.
val address = ConcordChannelRekey.address(root, channelId, 3)
assertEquals(ConcordKeyDerivation.groupKey(ConcordLabels.REKEY_PSEUDONYM, root, channelId, 3).publicKeyHex, address.publicKeyHex)
// Keyed by the ROOT, never the channel key: every member can precompute it.
assertNotEquals(ConcordKeyDerivation.groupKey(ConcordLabels.REKEY_PSEUDONYM, key0, channelId, 3).publicKeyHex, address.publicKeyHex)
// Distinct per epoch and from the base-rotation address.
assertNotEquals(address.publicKeyHex, ConcordChannelRekey.address(root, channelId, 4).publicKeyHex)
assertNotEquals(address.publicKeyHex, ConcordKeyDerivation.baseRekeyAddress(root, channelId, 3).publicKeyHex)
}
@OptIn(ExperimentalEncodingApi::class)
@Test
fun aChannelRotationCarriesTheSpecTagsAnd72ByteScopeBoundBlobs() =
runTest {
val newKey = ByteArray(32) { 0x77 }
val wraps = rotate(key0, 0, newKey, listOf(alice.pubKey))
assertEquals(1, wraps.size)
val opened = assertNotNull(ConcordStreamEnvelope.openOrNull(wraps.single(), ConcordChannelRekey.address(root, channelId, 1)))
// A rekey seal is encrypted; the seal names the rotator.
assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind)
assertEquals(admin.pubKey, opened.author)
val rumor = opened.rumor
assertEquals(ConcordRekey.KIND, rumor.kind)
// ["scope", channel_id] ["newepoch", held+1] ["prevepoch", held] ["prevcommit", A.5 over the held key] ["chunk","1","1"]
assertEquals(
listOf(
listOf("scope", channelHex),
listOf("newepoch", "1"),
listOf("prevepoch", "0"),
listOf("prevcommit", ConcordKeyDerivation.epochKeyCommitment(0, key0).toHexKey()),
listOf("chunk", "1", "1"),
),
rumor.tags.map { it.toList() },
)
// Alice's blob opens under the admin<->alice pairwise key to exactly scope ‖ epoch_be ‖ key.
val blobs = ConcordRekey.decodeContent(rumor.content)
assertEquals(2, blobs.size)
val locator = ConcordKeyDerivation.recipientLocator(admin.pubKey.hexToByteArray(), alice.pubKey.hexToByteArray(), channelId, 1).toHexKey()
val mine = blobs.single { it.locator == locator }
val plain = Base64.Default.decode(Nip44.v2.decrypt(mine.wrapped, Nip44.v2.getConversationKey(alice.keyPair.privKey!!, admin.pubKey.hexToByteArray())))
assertEquals(72, plain.size)
assertContentEquals(channelId, plain.copyOfRange(0, 32))
assertContentEquals(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 1), plain.copyOfRange(32, 40))
assertContentEquals(newKey, plain.copyOfRange(40, 72))
}
@Test
fun aKeptMemberAdoptsTheNewKeyAndARemovedOneIsCut() =
runTest {
val newKey = ByteArray(32) { 0x42 }
val wraps = rotate(key0, 0, newKey, listOf(alice.pubKey))
val kept = assertIs<ChannelRekeyOutcome.Adopted>(walk(wraps, alice))
assertContentEquals(newKey, kept.key)
assertEquals(1, kept.epoch)
assertEquals(1, kept.steppedOver.size)
assertContentEquals(key0, kept.steppedOver.single().key)
val cut = assertIs<ChannelRekeyOutcome.Removed>(walk(wraps, bob))
assertEquals(1, cut.epoch)
}
@Test
fun noBlobIsARemovalOnlyFromAnOutrankingRotatorAfterTheJoin() =
runTest {
val wraps = rotate(key0, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey))
// A rotator that does not strictly outrank us cannot cut us (CORD-06 Authority).
assertSame(ChannelRekeyOutcome.None, walk(wraps, bob, outranksMe = { false }))
// A rotation that predates our join is history, not an exclusion.
assertSame(ChannelRekeyOutcome.None, walk(wraps, bob, joinedAt = now + 1))
// An unauthorized rotator is ignored entirely.
assertSame(ChannelRekeyOutcome.None, walk(wraps, alice, honored = { false }))
}
@Test
fun aRotationOffAKeyWeDoNotHoldIsNeitherAdoptedNorARemoval() =
runTest {
// The rotator claims to extend a different key at our epoch: a fork, never adopted.
val forged = rotate(ByteArray(32) { 0x66 }, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey))
assertSame(ChannelRekeyOutcome.None, walk(forged, alice))
}
@Test
fun aMissedRotationIsWalkedInOnePass() =
runTest {
val key1 = ByteArray(32) { 0x31 }
val key2 = ByteArray(32) { 0x32 }
val wraps = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, listOf(alice.pubKey), createdAt = now + 10)
val adopted = assertIs<ChannelRekeyOutcome.Adopted>(walk(wraps, alice))
assertContentEquals(key2, adopted.key)
assertEquals(2, adopted.epoch)
assertEquals(listOf(1L, 0L), adopted.steppedOver.map { it.epoch })
}
@Test
fun aReadmissionAboveTheCutWinsAndACutAboveTheKeyWins() =
runTest {
val key1 = ByteArray(32) { 0x31 }
val key2 = ByteArray(32) { 0x32 }
// Cut at 1, re-admitted at 2 — but 2 extends key1, which bob never got: no adoption, cut stands.
val wraps = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, listOf(alice.pubKey, bob.pubKey))
assertIs<ChannelRekeyOutcome.Removed>(walk(wraps, bob))
// Alice kept through 1 and then cut at 2: the cut above her key wins.
val cutLater = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, emptyList())
assertEquals(2, assertIs<ChannelRekeyOutcome.Removed>(walk(cutLater, alice)).epoch)
}
@Test
fun racingRotatorsConvergeOnTheLowestKey() =
runTest {
val low = ByteArray(32) { 0x01 }
val high = ByteArray(32) { 0x7F }
val other = NostrSignerInternal(KeyPair())
val wraps = rotate(key0, 0, high, listOf(alice.pubKey)) + rotate(key0, 0, low, listOf(alice.pubKey), rotator = other)
val rotations = ConcordChannelRekey.rotations(wraps, keysFor(1), channelHex)
// Two Rotators at one epoch never merge into one set.
assertEquals(2, rotations.size)
assertContentEquals(low, assertIs<ChannelRekeyOutcome.Adopted>(walk(wraps, alice)).key)
}
@Test
fun anIncompleteRotationIsNeverARemoval() =
runTest {
// 130 recipients span two chunks; drop the second.
val crowd = List(130) { KeyPair().pubKey.toHexKey() }
val wraps = rotate(key0, 0, ByteArray(32) { 0x42 }, crowd)
assertEquals(2, wraps.size)
val rotations = ConcordChannelRekey.rotations(wraps.take(1), keysFor(1), channelHex)
assertTrue(rotations.none { it.complete })
assertSame(ChannelRekeyOutcome.None, walk(wraps.take(1), bob))
assertIs<ChannelRekeyOutcome.Removed>(walk(wraps, bob))
}
@Test
fun aRotationForAnotherChannelOrAtAnUnwatchedAddressIsIgnored() =
runTest {
val otherChannel = ByteArray(32) { 0x5D }
val elsewhere = ConcordChannelRekey.build(admin, root, otherChannel, key0, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey), now)
// Not at our channel's addresses, and its scope names another channel.
assertTrue(ConcordChannelRekey.rotations(elsewhere, keysFor(1), channelHex).isEmpty())
val atOurAddress = mapOf(ConcordChannelRekey.address(root, otherChannel, 1).let { it.publicKeyHex to it })
assertTrue(ConcordChannelRekey.rotations(elsewhere, atOurAddress, channelHex).isEmpty())
}
}
@@ -21,17 +21,21 @@
package com.vitorpamplona.quartz.concord.cord07Voice
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
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.crypto.verify
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcordVoiceTest {
private val alice = KeyPair().pubKey.toHexKey()
private val bob = KeyPair().pubKey.toHexKey()
private val carol = KeyPair().pubKey.toHexKey()
private val channelId = "42".repeat(32)
@Test
@@ -44,20 +48,75 @@ class ConcordVoiceTest {
assertEquals(channelId, info?.channelId)
assertEquals(0L, info?.epoch)
assertTrue(info?.joined == true)
// The examples' tag order: channel, epoch, identity, broker, ms.
assertEquals(listOf("channel", "epoch", "identity", "broker", "ms"), rumor.tags.map { it[0] })
}
@Test
fun onlyUncontestedIdentitiesVerify() {
val aliceP = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-alice", 1L))!!
val aliceP = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-x", 1L))!!
val bobP = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-bob", 1L))!!
// both Alice and Bob claim the same identity -> contested
val contestedA = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-x", 1L))!!
val contestedB = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-x", 1L))!!
val carolP = VoicePresence.parse(VoicePresence.joined(carol, channelId, 0, "id-x", 1L))!!
val verified = VoicePresence.verifiedParticipants(listOf(aliceP, bobP, contestedA, contestedB))
assertEquals(alice, verified["id-alice"])
assertEquals(bob, verified["id-bob"])
assertFalse(verified.containsKey("id-x")) // contested identity omitted
val fold = VoicePresence.fold(listOf(aliceP, bobP, carolP), nowMs = 1_000L)
assertEquals(bob, fold.verified["id-bob"])
assertFalse(fold.verified.containsKey("id-x")) // Alice and Carol both claim it: contested
assertEquals(3, fold.present.size)
}
@Test
fun latestPresencePerAuthorWins() {
// Alice joined as id-a, left, then rejoined as id-b: only her latest counts.
val joinedA = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 0))!!
val left = VoicePresence.parse(VoicePresence.left(alice, channelId, 0, 20L, subMs = 0))!!
val joinedB = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-b", 30L, subMs = 0))!!
// Bob's older heartbeat claimed id-b too, but his latest presence is a left.
val bobOld = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-b", 5L, subMs = 0))!!
val bobLeft = VoicePresence.parse(VoicePresence.left(bob, channelId, 0, 6L, subMs = 0))!!
// Arrival order must not matter.
val fold = VoicePresence.fold(listOf(joinedB, bobLeft, joinedA, left, bobOld), nowMs = 31_000L)
assertEquals(listOf(alice), fold.present.map { it.author })
assertEquals("id-b", fold.present.single().identity)
assertEquals(mapOf("id-b" to alice), fold.verified)
// Before the rejoin arrived, Alice's latest is the left: absent.
assertTrue(VoicePresence.fold(listOf(left, joinedA), nowMs = 21_000L).present.isEmpty())
}
@Test
fun msTagOrdersPresencesWithinOneSecond() {
val early = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 100))!!
val late = VoicePresence.parse(VoicePresence.left(alice, channelId, 0, 10L, subMs = 900))!!
assertEquals(10_100L, early.ms)
assertEquals(10_900L, late.ms)
assertTrue(VoicePresence.fold(listOf(late, early), nowMs = 11_000L).present.isEmpty())
}
@Test
fun equalTimeTiesBreakByLowerRumorId() {
val a = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 0))!!
val b = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-b", 10L, subMs = 0))!!
val winner = if (a.rumorId < b.rumorId) a else b
assertEquals(winner.rumorId, VoicePresence.latestPerAuthor(listOf(a, b))[alice]?.rumorId)
assertEquals(winner.rumorId, VoicePresence.latestPerAuthor(listOf(b, a))[alice]?.rumorId)
}
@Test
fun staleLatestJoinedIsAbsent() {
val p = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 0))!!
assertEquals(1, VoicePresence.fold(listOf(p), nowMs = 10_000L + VoicePresence.STALE_MS).present.size)
assertTrue(VoicePresence.fold(listOf(p), nowMs = 10_001L + VoicePresence.STALE_MS).present.isEmpty())
}
@Test
fun malformedPresenceIsDropped() {
val base = VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 0)
assertNull(VoicePresence.parse(Event(base.id, base.pubKey, base.createdAt, base.kind, base.tags, "here", "")))
val noIdentity = base.tags.filterNot { it[0] == VoicePresence.TAG_IDENTITY }.toTypedArray()
assertNull(VoicePresence.parse(Event(base.id, base.pubKey, base.createdAt, base.kind, noIdentity, "joined", "")))
val badMs = base.tags.map { if (it[0] == "ms") arrayOf("ms", "1000") else it }.toTypedArray()
assertNull(VoicePresence.parse(Event(base.id, base.pubKey, base.createdAt, base.kind, badMs, "joined", "")))
}
@Test
@@ -83,5 +142,22 @@ class ConcordVoiceTest {
assertTrue(header.startsWith("Concord "))
}
@Test
fun sameSecondBrokerGrantsNeverShareAnId() {
// Every member signs with the same voice_key.sk; without the nonce two joiners in one
// second build one id and the broker's anti-replay set drops the second (CORD-07 §2).
val voiceSigner = ConcordKeyDerivation.voiceSignerKey(ByteArray(32) { 0x5A }, channelId.chunkedToBytes(), epoch = 0)
val url = "https://broker.example" + ConcordBrokerToken.wellKnownPath(voiceSigner.publicKeyHex)
val a = ConcordBrokerToken.buildAuthEvent(voiceSigner, url, createdAt = 1_700_000_000L)
val b = ConcordBrokerToken.buildAuthEvent(voiceSigner, url, createdAt = 1_700_000_000L)
val nonceOf = { e: Event -> e.tags.firstOrNull { it[0] == ConcordBrokerToken.TAG_NONCE }?.getOrNull(1) }
assertTrue(Regex("^[0-9a-f]{64}$").matches(nonceOf(a)!!))
assertNotEquals(nonceOf(a), nonceOf(b))
assertNotEquals(a.id, b.id)
// Tag order matches the reference client: u, method, nonce.
assertEquals(listOf("u", "method", "nonce"), a.tags.map { it[0] })
}
private fun String.chunkedToBytes(): ByteArray = ByteArray(length / 2) { substring(it * 2, it * 2 + 2).toInt(16).toByte() }
}