Merge pull request #3888 from vitorpamplona/fix/concord-followups

feat(concord): close the CORD-05/06 invite lifecycle — Invite List, re-mint on Refounding, revocation
This commit is contained in:
Vitor Pamplona
2026-08-10 11:14:13 -04:00
committed by GitHub
29 changed files with 2372 additions and 89 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
@@ -35,17 +36,17 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
@@ -53,8 +54,11 @@ 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.relay.client.accessories.anyRelayServed
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -64,6 +68,9 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.util.concurrent.ConcurrentHashMap
/** Name of the default Concord community Admin role minted by "Make admin". */
@@ -168,6 +175,138 @@ class AccountConcordActions(
return community.communityIdHex
}
// ---- CORD-05 Invite List (kind 13303) -------------------------------------
/**
* This account's Invite List (kind 13303): the creator's private, self-encrypted record of every
* link they minted (`token` + `signer_sk` per entry).
*
* Returns **null** when the list could not be read — no relay answered, or the signer refused
* the decrypt — and an empty document only when the account genuinely has no list yet. Callers
* must not conflate the two: republishing an "empty" list over this replaceable coordinate
* destroys every `signer_sk` it failed to read, and those secrets cannot be regenerated.
*
* Read on the account's OUTBOX relays, never a community's: the coordinate is
* (13303, me, "") — one list for the whole account — so scoping it per community would fork it
* into divergent versions that the newest-wins rule then silently collapses.
*
* Fetched rather than read from [LocalCache] because nothing subscribes to 13303: it is
* bookkeeping the user never sees, needed only at mint and at rotation.
*/
private suspend fun readConcordInviteList(): ConcordInviteListDocument? {
val relays = account.outboxRelays.flow.value
if (relays.isEmpty()) return null
val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey))
// Terminal reasons, not just events: `fetchAll` returns an empty list both when a relay
// served us and had nothing AND when nothing answered at all (cannot-connect, CLOSED, idle
// timeout). Treating the second as "no list yet" is precisely how a read-merge-write wipes
// the signer_sk of every link it failed to read, so the two must be told apart.
val reasons = mutableMapOf<NormalizedRelayUrl, String>()
val events =
account.client.fetchAllWithHooks(
filters = relays.associateWith { listOf(filter) },
doneOut = reasons,
) { _, _ -> true }
val newest =
events
.mapNotNull { it.second as? ConcordInviteListEvent }
// Filter by kind BEFORE picking the newest: taking the newest of anything and then
// casting means one stray event at this coordinate reads as "unreadable" forever.
.maxByOrNull { it.createdAt }
?: return if (reasons.anyRelayServed()) {
ConcordInviteListDocument.EMPTY // a relay answered and had nothing — safe to start one
} else {
null // nobody answered; we know nothing about what is published
}
return newest.decrypt(account.signer)
}
/**
* Merges [patch] into the published Invite List and republishes it, returning whether it landed.
*
* Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is
* replaceable, so publishing a patch-only document over an unread list deletes every other
* link's `signer_sk` — unrecoverable, and it strands every holder of those links at the next
* rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is
* enough to trigger that, which is exactly how the kind-13302 community list was once emptied.
*/
private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean {
val publishTo = account.outboxRelays.flow.value
if (publishTo.isEmpty()) return false
val base =
readConcordInviteList() ?: run {
Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" }
return false
}
// publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event
// and never reports acceptance — so a `runCatching { publish(); true }` is true whenever
// local signing worked, and every caller's "did the record land?" gate becomes decorative.
return runCatching {
account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo)
}.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false)
}
/**
* Re-posts every live link this account minted for [entry]'s community at its own coordinate,
* carrying [entry]'s epoch (CORD-05). The kind-33301 bundle is addressable and authored by the
* link signer, so this moves the link behind the same URL instead of orphaning it at a dead
* epoch — which is the whole premise stranded recovery rests on.
*
* [entry] MUST be the post-rotation entry, passed in rather than re-read: the joined-list flow
* decrypts asynchronously, so reading it straight after adopting a new root yields the OLD
* epoch and would re-mint every link onto the epoch we just left.
*
* Each link is refreshed from its own CURRENT bundle, not rebuilt from scratch, so per-link
* fields the bundle carries — expiry, channel grants, icon, label — survive the rotation. A
* coordinate whose newest event is a revocation tombstone is left alone: re-posting a live
* bundle over it would silently un-revoke the link.
*/
private suspend fun refreshConcordInviteLinks(entry: ConcordCommunityListEntry): Int {
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
if (relays.isEmpty()) return 0
val list = readConcordInviteList() ?: return 0
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
val now = TimeUtils.now()
// An elapsed or retired link can no longer be joined; re-posting it would only resurrect a
// dead URL at a live epoch.
val links = list.entries.filter { it.communityId == entry.id && !it.isExpired(now) && it.token !in tombstoned }
if (links.isEmpty()) return 0
// One REQ for every link's bundle rather than a round trip each. This runs inside the
// user-visible Refounding, and a serial fetch per link makes a removal take time linear in
// how many links the creator ever minted, each able to wait out its own idle timeout.
val byAuthor = links.associateBy { it.signerPubKeyHex().lowercase() }
val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundlesFilter(byAuthor.keys.toList())) })
val wrapsByAuthor = wraps.groupBy { it.pubKey.lowercase() }
return coroutineScope {
byAuthor
.map { (author, link) ->
async {
runCatching {
val token = link.token.hexToByteArray()
// Classify per coordinate, never over the pooled set: one link's newer
// revocation tombstone must not decide another link's status.
val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false
val moved =
current.invite.copy(
communityRoot = entry.root,
rootEpoch = entry.rootEpoch,
controlPk = entry.controlPk,
relays = entry.relays,
)
// Confirmed: a link counted as moved but never stored is a link its
// holders can no longer redeem, reported as a success.
account.client.publishAndConfirm(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays)
}.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) }.getOrDefault(false)
}
}.awaitAll()
.count { it }
}
}
/**
* Mint a shareable invite link for a joined community and publish its
* kind-33301 public bundle to the community relays. Returns the `…/invite/…`
@@ -212,10 +351,103 @@ class AccountConcordActions(
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
// Record the link BEFORE handing the URL out (CORD-05, kind 13303). A link whose `signer_sk`
// was never stored can never be refreshed, so the next Refounding orphans it and everyone
// holding it is stranded — with nothing to have warned them. Failing the mint is the honest
// outcome; a stored entry for a link nobody received is harmless by comparison.
if (!publishConcordInviteList(
ConcordInviteListDocument(
entries =
listOf(
ConcordInviteListEntry(
token = minted.token.toHexKey(),
signerSk = minted.linkSignerPrivKey.toHexKey(),
communityId = entry.id,
url = minted.url,
createdAt = TimeUtils.now(),
),
),
),
)
) {
Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" }
return null
}
if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo)
return minted.url
}
/**
* Every link this account minted for [communityId] that is still live, newest first — the
* backing list for the invite-links screen.
*
* Null means the list could not be read (no relay answered, or the signer refused the decrypt),
* which the UI must show as an error rather than as "you have no links": telling a creator their
* leaked link doesn't exist is worse than telling them we couldn't check.
*
* Retired tokens are filtered out here rather than rendered as dead rows — [ConcordInviteList]
* already drops a tombstoned entry on merge, so a tombstoned entry only appears in the window
* between our revoke and the next merge.
*/
suspend fun listConcordInviteLinks(communityId: String): List<ConcordInviteListEntry>? {
val list = readConcordInviteList() ?: return null
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
return list.entries
.filter { it.communityId == communityId && it.token !in tombstoned }
.sortedByDescending { it.createdAt }
}
/**
* Retires the link [token] (CORD-05 §2): publishes a `vsk=9` tombstone at its coordinate, then
* records the retirement in the kind-13303 list. Returns false if the link could not be retired.
*
* No community permission is checked, deliberately. The coordinate is authored by the link
* signer, whose secret only the creator holds, so revoking is an act on your own key rather than
* on the community — and gating it on CREATE_INVITE would mean a demoted admin could no longer
* retire the links they had already handed out, which is precisely when they most need to.
*
* The wire tombstone goes first and the list second. That is the inverse of minting and it is
* deliberate: the entry holds the only copy of the `signer_sk` this needs, and a merge drops a
* tombstoned token's entry terminally, so recording first and then failing to publish would
* leave the link live with its signer gone and no way left to retire it. A failed list write is
* recoverable — the link is already dead on the wire, and the refresh path re-mints only a
* coordinate that still resolves Live.
*/
suspend fun revokeConcordInvite(
communityId: String,
token: String,
): Boolean {
if (!account.isWriteable()) return false
val entry =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId } ?: return false
val link =
readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId }
?: run {
Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" }
return false
}
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
if (relays.isEmpty()) return false
// Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a
// tombstone no relay stored — and the list write below would then drop this entry on merge,
// destroying the only `signer_sk` that could ever retire the link while the link stays live.
val published =
runCatching {
account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays)
}.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false)
if (!published) return false
if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) {
// The link is already dead on the wire, so this is bookkeeping we can retry rather than a
// failed revocation. Reported as success for exactly that reason.
Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" }
}
return true
}
/** Drop a joined Concord community from the private kind-13302 list by its id. */
suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId))
@@ -278,6 +510,42 @@ class AccountConcordActions(
return ConcordInviteResult.Joined(bundle.communityId)
}
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
// unlock token forever — so without this the rotation meant to expel them hands them the new
// keys instead. `recoverStrandedConcordCommunities` has always been ban-gated; this is the
// other door into the same room.
//
// Fails CLOSED on an unreadable plane: the banlist is only knowable once the bundle yields
// the root, and no verdict means no join. Two things make that safe to insist on rather than
// a way to brick valid invites:
//
// - the plane is fetched over the SAME relays that just served the bundle, not the relay
// list inside the bundle alone, which can be stale (a moved relay, a link minted before a
// relay change) and would otherwise refuse a community we can plainly reach;
// - it is PAGED, because a single REQ is truncated at the relay's per-filter cap. A missing
// older ban edition fails the gate open — it re-admits the very account it exists to
// refuse — so the one direction we must not economise on is completeness.
val joinKeys =
ConcordActions.controlPlaneKeys(
communityRoot = bundle.communityRoot.hexToByteArray(),
communityId = bundle.communityId.hexToByteArray(),
rootEpoch = bundle.rootEpoch,
controlPk = bundle.controlPk,
)
// Union, not `ifEmpty`: the relays that served the bundle are known-good for this community,
// and the bundle's own list is the one that goes stale.
val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + relays
val planeWraps = mutableListOf<Event>()
account.client.fetchAllPagesFromPool(
filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) },
) { event, _ -> planeWraps.add(event) }
val joinEditions = ConcordActions.controlEditions(planeWraps, joinKeys)
if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable
if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) {
return ConcordInviteResult.Banned
}
val entry =
ConcordCommunityListEntry(
id = bundle.communityId,
@@ -865,7 +1133,14 @@ class AccountConcordActions(
// 5. 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.
adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
// 6. Move every link we minted to the new epoch. Without this the Refounding orphans them,
// and a member it left out — no rekey blob, no message to miss — has no way back at all.
// Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so
// reading it here would hand us the epoch we just left and re-mint every link onto it.
val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0
Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" }
return true
}
@@ -930,39 +1205,16 @@ class AccountConcordActions(
newEpoch: Long,
newControlPk: ByteArray? = null,
newControlRoot: ByteArray? = null,
) {
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return
// The epoch we're leaving is banked with the address it was folded at, so its Control
// Plane stays subscribable for the anti-rollback floor (a split epoch's address can
// never be re-derived, only remembered — CORD-02 §2).
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }
val next =
ConcordCommunityListEntry(
id = entry.id,
owner = entry.owner,
ownerSalt = entry.ownerSalt,
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
// A rotation that delivered no control material is a legacy, pre-split one
// (CORD-06 §3): the new epoch keeps folding at the legacy address, and the
// stale prior-epoch values must NOT be carried into it.
controlPk = newControlPk?.toHexKey(),
controlRoot = newControlRoot?.toHexKey(),
heldRoots = held,
privateChannels = entry.privateChannels,
relays = entry.relays,
name = entry.name,
addedAt = entry.addedAt,
// The invite_ref anchor must survive a rotation, or the *next* Refounding we're left
// out of would be unrecoverable.
inviteRef = entry.inviteRef,
excludedAtEpoch = entry.excludedAtEpoch,
// Unknown keys another client wrote (Armada's list is `[k: string]: unknown`)
// must survive our rotation write, or we delete their data on every rekey.
residue = entry.residue,
)
): ConcordCommunityListEntry? {
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return null
// The rewrite itself — banking the leaving epoch's address for the anti-rollback floor,
// dropping stale control material on a legacy rotation, preserving invite_ref and residue —
// is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and
// the Guestbook re-announce below are Android's.
val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot)
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next))
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
return next
}
/**
@@ -1008,7 +1260,17 @@ class AccountConcordActions(
// who has themselves been banned could still rotate the whole community.
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
if (!authorized) continue
adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
// Move our own links onto the epoch we just adopted. Rotating is not the only way to end
// up on a new epoch — being re-keyed is the common one — and a link creator who is merely
// re-keyed would otherwise leave every link they handed out pointing at the dead root,
// which is exactly the orphaning this branch exists to stop. Stranded recovery reads the
// bundle's epoch, so a link nobody re-mints is a member nobody can recover.
adopted?.let { next ->
val moved = refreshConcordInviteLinks(next)
if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" }
}
}
}
@@ -1030,39 +1292,16 @@ class AccountConcordActions(
*/
internal suspend fun drainConcordStaffGrants() {
if (!account.isWriteable()) return
val me = account.signer.pubKey.lowercase()
for (session in account.concordSessions.sessions()) {
val entry = session.entry
// Already staff at this epoch, or a legacy community with no split to join.
val heldControlPk = entry.controlPk
if (entry.controlRoot != null || heldControlPk == null) continue
val state = session.state.value ?: continue
// Only a Grant our fold honors can deliver: an unauthorized edition hands us nothing.
if (!state.authority.isStaff(me)) continue
val myGrantCoordinate =
ConcordKeyDerivation
.grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray())
.toHexKey()
val delivered =
session
.controlEditions()
.filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate }
// Newest first: a re-issued Grant (a lost key, a head superseded before we
// fetched it) carries the fresher wrap.
.sortedByDescending { it.version }
.firstNotNullOfOrNull { edition ->
val wrap = ConcordJson.decodeOrNull<GrantEntity>(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null
val opened = ControlRootWrap.openOrNull(wrap, account.signer, edition.author) ?: return@firstNotNullOfOrNull null
if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null
// Fails closed: a secret that doesn't derive to the pk we hold is dropped,
// never adopted — we will not split ourselves off from the plane's readers.
if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null
opened.controlRoot
} ?: continue
// The whole decision — are we staff, does a Grant carry a wrap, does it open, name our
// epoch, and derive to the control_pk we hold — is shared with `amy` in
// [ConcordReceive.deliveredControlRoot]. Only the persist + publish below is Android's.
val delivered = ConcordReceive.deliveredControlRoot(entry, session.controlEditions(), state.authority, account.signer) ?: continue
account.sendMyPublicAndPrivateOutbox(
account.concordChannelList.follow(entry.withControlRoot(delivered.toHexKey())),
account.concordChannelList.follow(entry.withControlRoot(delivered)),
)
}
}
@@ -54,6 +54,15 @@ sealed interface ConcordInviteResult {
*/
data object Expired : ConcordInviteResult
/**
* The link opens, but this community's roster has banned us (CORD-04).
*
* A Refounding re-mints every outstanding link onto the new root, and a removed member keeps the
* URL and its unlock token forever — so honouring the link alone would hand the new keys to the
* very account the rotation expelled.
*/
data object Banned : ConcordInviteResult
/**
* The bundle event was found but could not be opened with the link's token —
* typically because it was minted by a newer/incompatible Concord client whose
@@ -138,6 +138,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
@@ -747,6 +748,14 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.ConcordInviteLinks> {
ConcordInviteLinksScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordEdit> {
ConcordEditScreen(
communityId = it.communityId,
@@ -830,6 +830,10 @@ sealed class Route {
val communityId: String,
) : Route()
@Serializable data class ConcordInviteLinks(
val communityId: String,
) : Route()
@Serializable object ConcordCreate : Route()
// Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow.
@@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable
// resolution on low-density screens. Deriving the size from the available column width keeps
@@ -294,6 +294,17 @@ fun ConcordChannelListScreen(
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// Deliberately not gated on CREATE_INVITE, unlike minting: the links listed
// there are this account's own, authored by link-signer keys only we hold.
// Gating on the bit would mean a demoted admin could no longer retire the
// links they had already handed out — exactly when that matters most.
DropdownMenuItem(
text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_links_action)) },
onClick = {
menuOpen = false
nav.nav(Route.ConcordInviteLinks(communityId))
},
)
DropdownMenuItem(
text = {
Text(
@@ -0,0 +1,273 @@
/*
* 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.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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Date
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/** What the screen is currently showing. The unreadable case is deliberately not "empty" — see below. */
private sealed interface LinksState {
data object Loading : LinksState
data class Loaded(
val links: List<ConcordInviteListEntry>,
) : LinksState
/**
* The kind-13303 list could not be read. Distinct from an empty list on purpose: rendering
* "no links yet" here would tell a creator that the link they came to kill does not exist.
*/
data object Unreadable : LinksState
}
/**
* Every invite link this account minted for one community, with the ability to retire one
* (CORD-05 §2).
*
* The list is the creator's own kind-13303 Invite List, which is where a link's `signer_sk` lives —
* so this shows only links *this account* minted, from any of its devices. Another admin's links are
* invisible here and un-revokable from here, because the secret that authors their coordinate was
* never ours. That is a property of the protocol, not a gap in the screen.
*
* Fetched on entry rather than collected from a flow: nothing subscribes to kind 13303 (it is
* bookkeeping the user never sees), so there is no cache to observe.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordInviteLinksScreen(
communityId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val account = accountViewModel.account
val scope = rememberCoroutineScope()
val clipboard = LocalClipboard.current
var state by remember(communityId) { mutableStateOf<LinksState>(LinksState.Loading) }
var reloads by remember(communityId) { mutableIntStateOf(0) }
var confirming by remember { mutableStateOf<ConcordInviteListEntry?>(null) }
var revoking by remember { mutableStateOf(false) }
LaunchedEffect(communityId, reloads) {
state = LinksState.Loading
state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable
}
val communityName =
remember(account, communityId) {
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId }
?.name
.orEmpty()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(stringRes(R.string.concord_invite_links_title), fontWeight = FontWeight.Bold)
if (communityName.isNotBlank()) {
Text(communityName, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
}
},
)
},
) { padding ->
when (val current = state) {
is LinksState.Loading ->
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
is LinksState.Unreadable -> CenteredMessage(padding, stringRes(R.string.concord_invite_links_unreadable))
is LinksState.Loaded ->
if (current.links.isEmpty()) {
CenteredMessage(padding, stringRes(R.string.concord_invite_links_empty))
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(current.links, key = { it.token }) { link ->
InviteLinkRow(
link = link,
enabled = !revoking,
onCopy = { scope.launch { clipboard.setText(link.url) } },
onRevoke = { confirming = link },
)
HorizontalDivider()
}
}
}
}
}
confirming?.let { link ->
AlertDialog(
onDismissRequest = { if (!revoking) confirming = null },
title = { Text(stringRes(R.string.concord_invite_revoke_title)) },
text = { Text(stringRes(R.string.concord_invite_revoke_explainer)) },
confirmButton = {
TextButton(
enabled = !revoking,
onClick = {
revoking = true
scope.launch {
try {
val ok = account.concord.revokeConcordInvite(communityId, link.token)
accountViewModel.toastManager.toast(
R.string.concord_invite_links_title,
if (ok) R.string.concord_invite_revoked_ok else R.string.concord_invite_revoked_failed,
)
// Re-read either way: on success the link is gone from the list, and on
// failure the list is the only thing that can say whether it changed.
reloads++
} finally {
revoking = false
confirming = null
}
}
},
) {
Text(stringRes(R.string.concord_invite_revoke_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(enabled = !revoking, onClick = { confirming = null }) {
Text(stringRes(R.string.cancel))
}
},
)
}
}
@Composable
private fun CenteredMessage(
padding: PaddingValues,
message: String,
) {
Box(Modifier.fillMaxSize().padding(padding).padding(24.dp), contentAlignment = Alignment.Center) {
Text(
message,
// This Box sits on the bare window background, so LocalContentColor is still the M3
// default black — see the sibling invite screen, where that made the text invisible.
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyLarge,
)
}
}
@Composable
private fun InviteLinkRow(
link: ConcordInviteListEntry,
enabled: Boolean,
onCopy: () -> Unit,
onRevoke: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f).padding(end = 8.dp)) {
// The token prefix is what tells two links to the same community apart; their URLs share
// a long prefix, so they are useless as labels until well past where the row wraps.
Text(link.token.take(8), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge)
Text(
stringRes(R.string.concord_invite_links_created, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(link.createdAt * 1000))),
style = MaterialTheme.typography.bodySmall,
)
Text(link.url, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
IconButton(enabled = enabled, onClick = { menuOpen = true }) {
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_to_clipboard)) },
onClick = {
menuOpen = false
onCopy()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.concord_invite_revoke_action), color = MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
onRevoke()
},
)
}
}
}
@@ -124,6 +124,8 @@ fun ConcordInviteScreen(
RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false)
is ConcordInviteResult.Revoked ->
RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false)
is ConcordInviteResult.Banned ->
RedeemState.Failed(R.string.concord_invite_failed_banned, canRetry = false)
is ConcordInviteResult.Expired ->
RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false)
is ConcordInviteResult.NotReachable ->
@@ -161,6 +163,10 @@ fun ConcordInviteScreen(
Text(
stringRes(R.string.concord_redeeming_invite),
modifier = Modifier.padding(top = 16.dp),
// Explicit: this Column sits on the bare window background with no Surface
// above it, so LocalContentColor is still the M3 default black — which renders
// every one of these labels invisible in the dark theme.
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
}
@@ -170,6 +176,7 @@ fun ConcordInviteScreen(
Text(
stringRes(failed.messageRes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
if (failed.canRetry) {
@@ -49,7 +49,15 @@ import com.google.zxing.qrcode.encoder.Encoder
import com.google.zxing.qrcode.encoder.QRCode
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
const val QR_MARGIN_PX = 100f
/**
* The quiet zone around the code, in **modules** the QR spec's minimum of 4.
*
* It was a fixed 100px per side, which does not scale: at a small draw size those 200px ate most of
* the canvas, so a long payload (a Concord invite link, an nprofile) rendered as a postage stamp
* floating in white. Expressed in modules the zone stays proportional, so the code fills whatever
* box it is given at every size while remaining scannable.
*/
const val QR_QUIET_ZONE_MODULES = 4f
@Preview
@Composable
@@ -78,13 +86,16 @@ fun QrCodeDrawer(
) {
Canvas(modifier = Modifier.fillMaxSize()) {
// Calculate the height and width of each column/row
val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height
val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width
// Solve for the module size with the quiet zone measured in modules, so the whole code
// (zone included) is exactly as wide as the canvas.
val rowHeight = size.height / (qrCode.matrix.height + QR_QUIET_ZONE_MODULES * 2f)
val columnWidth = size.width / (qrCode.matrix.width + QR_QUIET_ZONE_MODULES * 2f)
val radius = CornerRadius(20f)
// Draw all of the finder patterns required by the QR spec. Calculate the ratio
// of the number of rows/columns to the width and height
drawQrCodeFinders(
quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES,
sideLength = size.width,
finderPatternSize =
Size(
@@ -97,6 +108,7 @@ fun QrCodeDrawer(
// Draw data bits (encoded data part)
drawAllQrCodeDataBits(
quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES,
bytes = qrCode.matrix,
size =
Size(
@@ -119,7 +131,7 @@ private fun createQrCode(contents: String): QRCode {
ErrorCorrectionLevel.Q,
mapOf(
EncodeHintType.CHARACTER_SET to "UTF-8",
EncodeHintType.MARGIN to QR_MARGIN_PX,
EncodeHintType.MARGIN to QR_QUIET_ZONE_MODULES,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q,
),
)
@@ -132,6 +144,7 @@ fun newPath(withPath: Path.() -> Unit) =
}
fun DrawScope.drawAllQrCodeDataBits(
quietZonePx: Float,
bytes: ByteMatrix,
size: Size,
color: Color,
@@ -182,8 +195,8 @@ fun DrawScope.drawAllQrCodeDataBits(
Rect(
offset =
Offset(
x = QR_MARGIN_PX + x * size.width,
y = QR_MARGIN_PX + y * size.height,
x = quietZonePx + x * size.width,
y = quietZonePx + y * size.height,
),
size = newSize,
),
@@ -212,6 +225,7 @@ private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f
* @param finderPatternSize [Size] of each finder patten, based on the QR code spec
*/
internal fun DrawScope.drawQrCodeFinders(
quietZonePx: Float,
sideLength: Float,
finderPatternSize: Size,
cornerRadius: CornerRadius,
@@ -219,11 +233,11 @@ internal fun DrawScope.drawQrCodeFinders(
) {
setOf(
// Draw top left finder pattern.
Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX),
Offset(x = quietZonePx, y = quietZonePx),
// Draw top right finder pattern.
Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX),
Offset(x = sideLength - (quietZonePx + finderPatternSize.width), y = quietZonePx),
// Draw bottom finder pattern.
Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)),
Offset(x = quietZonePx, y = sideLength - (quietZonePx + finderPatternSize.height)),
).forEach { offset ->
drawQrCodeFinder(
topLeft = offset,
+12
View File
@@ -321,6 +321,18 @@
<string name="concord_invite_failed_invalid">This invite link is invalid or can\'t be opened with this account.</string>
<string name="concord_invite_failed_incompatible">This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link.</string>
<string name="concord_invite_failed_revoked">This invite link has been revoked and can no longer be used. Ask for a new one.</string>
<string name="concord_invite_failed_banned">This community has removed you. The link still works, but its member list does not admit you.</string>
<string name="concord_invite_links_title">Invite links</string>
<string name="concord_invite_links_action">Invite links…</string>
<string name="concord_invite_links_created">Created %1$s</string>
<string name="concord_invite_links_empty">You haven\'t created any invite links for this community yet. Links other admins created are managed on their own devices.</string>
<string name="concord_invite_links_unreadable">Your invite links couldn\'t be loaded, so none can be revoked right now. Check your connection and try again.</string>
<string name="concord_invite_revoke_action">Revoke link</string>
<string name="concord_invite_revoke_title">Revoke this link?</string>
<string name="concord_invite_revoke_explainer">Anyone still holding this link will no longer be able to join. People who already joined with it stay in the community. This can\'t be undone.</string>
<string name="concord_invite_revoke_confirm">Revoke</string>
<string name="concord_invite_revoked_ok">Invite link revoked.</string>
<string name="concord_invite_revoked_failed">The link couldn\'t be revoked. Check your connection and try again.</string>
<string name="concord_invite_failed_expired">This invite link has expired and can no longer be used. Ask for a fresh link.</string>
<string name="concord_invite_preview_unknown_name">Community name is only revealed after you join</string>
<string name="concord_invite_preview_explainer">Joining connects to this invite\'s relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join.</string>
+1
View File
@@ -669,6 +669,7 @@ also carried on-relay as an encrypted kind:13302.
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). |
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. |
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. |
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. |
| `amy concord join URL` | Redeem an invite link and save the community. |
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). |
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`). |
+1 -1
View File
@@ -67,7 +67,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| NIP-65 outbox model queries | ✅ | `OutboxCommand``amy outbox USER [--refresh]`, cache-first. |
| CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. |
| Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. |
| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 13 sub-verbs (create/list/import/channels/send/read/invite/join/roles/role/grant/ban/unban) over shared `commons` `ConcordActions`; secrets in `concord.json`. |
| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 17 sub-verbs (create/list/import/channels/send/read/invite/revoke/join/recover/rekey/roles/role/grant/ban/unban/refound) over shared `commons` `ConcordActions`; secrets in `concord.json`. |
| NIP-5A nsites + NIP-5D napplets | ✅ | `NsiteCommands` + `NappletCommands` — fetch/publish/serve/list with sha256 + aggregate-hash verification and `requires` capability reporting. |
| Podcasting 2.0 / podstr (`amy podcast20`) | ✅ | `Podcast20Commands` — kind:30078 metadata, 30054 episodes, 30055 trailers, list. |
| Follows-of-follows (`amy fof get/list/sync`) | ✅ | `FofCommand` — single-hop social proof from the local store (`wot` kept as deprecation alias). |
@@ -582,12 +582,15 @@ class Context(
diagnoseSlow: Boolean = false,
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
pendingOnAuthRequired: Boolean = false,
/** Per-relay terminal reason, so a caller can tell an empty answer from no answer. */
doneOut: MutableMap<NormalizedRelayUrl, String>? = null,
): List<Pair<NormalizedRelayUrl, Event>> =
client.fetchAllWithHooks(
filters = filters,
idleTimeoutMs = idleTimeoutMs,
pendingOnAuthRequired = pendingOnAuthRequired,
deadOut = deadOut,
doneOut = doneOut,
onTimeout =
if (diagnoseSlow) {
{ stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) }
@@ -869,6 +869,7 @@ private fun printUsage() {
| concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id)
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages
| concord invite COMMUNITY [--base URL] mint + publish a shareable invite link
| concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone)
| concord join URL redeem an invite link and save the community
|
|Local event store (shared, under `<data-dir>/shared/`):
@@ -28,9 +28,22 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
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.InviteBundleStatus
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -55,12 +68,23 @@ object ConcordCommands {
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50);
| [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane
| concord invite COMMUNITY [--base URL] mint + publish a shareable invite link
| concord revoke COMMUNITY TOKEN|URL retire a link you minted: publishes a vsk=9
| tombstone at its coordinate, then tombstones
| it in your invite list so it stays retired
| concord join URL redeem an invite link and save the community
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for:
| open our blob and adopt the new epoch
| concord recover [COMMUNITY] re-resolve the joined-through invite link and
| follow a Refounding we were left out of
| (CORD-06); refuses if that epoch banned us
| concord roles COMMUNITY list live roles + current banlist (CORD-04)
| concord role COMMUNITY NAME POSITION PERM define a role (perms by name, e.g. BAN KICK)
| concord grant COMMUNITY USER ROLE-ID grant a role to a member
| concord ban COMMUNITY USER ban a member
| concord unban COMMUNITY USER unban a member
| concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the
| control_root) so removed members lose every
| key the hard removal a ban cannot give
""".trimMargin()
suspend fun dispatch(
@@ -70,7 +94,7 @@ object ConcordCommands {
route(
"concord",
tail,
"concord <create|list|import|channels|send|read|invite|join|roles|role|grant|ban|unban>",
"concord <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound>",
help = USAGE,
routes =
mapOf(
@@ -81,12 +105,16 @@ object ConcordCommands {
"send" to { rest -> ConcordChannelCommands.send(dataDir, rest) },
"read" to { rest -> ConcordChannelCommands.read(dataDir, rest) },
"invite" to { rest -> invite(dataDir, rest) },
"revoke" to { rest -> revoke(dataDir, rest) },
"join" to { rest -> join(dataDir, rest) },
"recover" to { rest -> recover(dataDir, rest) },
"rekey" to { rest -> rekey(dataDir, rest) },
"roles" to { rest -> ConcordModCommands.roles(dataDir, rest) },
"role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) },
"grant" to { rest -> ConcordModCommands.grant(dataDir, rest) },
"ban" to { rest -> ConcordModCommands.ban(dataDir, rest) },
"unban" to { rest -> ConcordModCommands.unban(dataDir, rest) },
"refound" to { rest -> ConcordModCommands.refound(dataDir, rest) },
),
)
@@ -208,7 +236,10 @@ object ConcordCommands {
controlRoot = e.controlRoot ?: priorSameEpoch?.controlRoot ?: "",
generalChannelId = prior?.generalChannelId ?: "",
relays = e.relays,
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") },
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") },
// Survives every merge: losing the anchor makes the NEXT exclusion
// unrecoverable, so a list entry without one must not clear ours.
inviteRef = e.inviteRef ?: prior?.inviteRef ?: "",
),
)
mapOf(
@@ -241,6 +272,33 @@ object ConcordCommands {
// (CORD-05 §1); omitted for a legacy community, which has none to carry.
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null })
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
// Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose
// `signer_sk` was never stored can never be refreshed, so the next Refounding orphans
// it and every holder is stranded. Better to mint nothing than to hand out a link that
// is already doomed.
val recorded =
publishInviteList(
ctx,
ConcordInviteListDocument(
entries =
listOf(
ConcordInviteListEntry(
token = minted.token.toHexKey(),
signerSk = minted.linkSignerPrivKey.toHexKey(),
communityId = sc.communityId,
url = minted.url,
createdAt = TimeUtils.now(),
),
),
),
)
if (!recorded) {
return Output.error(
"invite_unrecordable",
"could not record the link signer in your invite list (kind 13303), so this link could never be refreshed after a Refounding — not minting it",
)
}
val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc))
RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it }
@@ -255,6 +313,92 @@ object ConcordCommands {
}
}
/**
* `amy concord revoke <community> <token|url>` retires one link this account minted.
*
* Two records have to agree for a link to be gone, and they fail differently, so the order is
* deliberate. The wire tombstone (`vsk=9` at the link's own coordinate) is what actually stops
* a join, and publishing it needs the `signer_sk` that only the kind-13303 Invite List holds.
* The list tombstone is bookkeeping: it stops a later Refounding from re-minting the link.
*
* So the wire goes first and the list second. The reverse order would delete the entry a
* merge drops a tombstoned token's entry terminally and if the publish then failed, the link
* would stay live with its `signer_sk` gone and no way left to retire it. A failed list write
* is recoverable by comparison: the link is already dead on the wire, and the refresh path
* re-mints only a coordinate that still resolves Live, so it will not resurrect this one.
*/
private suspend fun revoke(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val link = args.positional(1, "token|url")
args.rejectUnknown()
// Accept either the shareable URL (what a creator actually has to hand) or the bare token.
val token =
ConcordActions
.parseInviteLink(link)
?.fragment
?.token
?.toHexKey() ?: link.lowercase()
if (!TOKEN_HEX.matches(token)) {
return Output.error("bad_args", "expected an invite URL or a 32-hex-character link token, got '$link'").let { 2 }
}
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val list =
readInviteList(ctx)
?: return Output.error("invite_list_unreadable", "could not read your invite list (kind 13303), so the link signer needed to revoke is unknown — refusing to guess")
val entry = list.entries.firstOrNull { it.token == token }
if (entry == null) {
return if (list.tombstones.any { it.token == token }) {
Output.error("already_revoked", "this link was already revoked; its signer_sk is gone from the list, so there is nothing left to re-publish")
} else {
Output.error("not_found", "no link with token $token in your invite list — only the account that minted a link can revoke it")
}
}
if (entry.communityId != sc.communityId) {
return Output.error("wrong_community", "that link belongs to community ${entry.communityId}, not '$handle' (${sc.communityId})")
}
val tombstone = ConcordActions.revokeBundleAt(entry.signerSk.hexToByteArray(), TimeUtils.now())
val ack = ctx.publish(tombstone, relaysFor(ctx, sc))
RawEventSupport.publishGuard(ack, tombstone.id)?.let { return it }
val recorded =
publishInviteList(
ctx,
ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = sc.communityId))),
)
if (!recorded) {
System.err.println(
"[concord] the link is revoked on the wire but the tombstone could not be recorded in your invite list (kind 13303); re-run this command once your outbox relays are reachable",
)
}
Output.emit(
mapOf(
"revoked" to true,
"token" to token,
"community_id" to sc.communityId,
"link_signer" to entry.signerPubKeyHex(),
"tombstone_event_id" to tombstone.id,
"tombstoned_in_list" to recorded,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
/** A link token is 16 bytes on the wire, so 32 hex characters once stored in the list. */
private val TOKEN_HEX = Regex("^[0-9a-f]{32}$")
private suspend fun join(
dataDir: DataDir,
rest: Array<String>,
@@ -268,9 +412,47 @@ object ConcordCommands {
ctx.prepare()
val relays = (normalize(parsed.fragment.relays) + ctx.bootstrapRelays())
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
// Resolve the coordinate per CORD-05 §2 rather than opening whatever happens to decrypt:
// the newest event wins, so a vsk=9 tombstone retires the link even when a stale but
// still-openable copy is also present. Opening the first wrap that decrypts would let a
// relay that kept the old version hand out a link its creator revoked — and it cannot
// tell the user which of "revoked", "expired" or "gone" they are looking at.
val bundle =
wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) }
?: return Output.error("not_found", "no valid bundle for this link").let { 1 }
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
is InviteBundleStatus.Live -> status.invite
is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined")
InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator")
InviteBundleStatus.Unreadable -> return Output.error("incompatible", "something is published at this link's coordinate, but it is not a bundle this client can open")
InviteBundleStatus.Absent -> return Output.error("not_found", "no bundle for this link on any of its relays")
}
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
// unlock token forever — so without this check the rotation that was supposed to expel
// them hands them the new keys instead. `recover` has always been ban-gated; `join` is
// the other door into the same room.
//
// Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable
// after the bundle yields the root, which is why the check lives here rather than before.
val joinKeys =
ConcordActions.controlPlaneKeys(
communityRoot = bundle.communityRoot.hexToByteArray(),
communityId = bundle.communityId.hexToByteArray(),
rootEpoch = bundle.rootEpoch,
controlPk = bundle.controlPk,
)
val joinRelays = normalize(bundle.relays).ifEmpty { relays }
val joinEditions =
ConcordActions.controlEditions(
ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second },
joinKeys,
)
if (joinEditions.isEmpty()) {
return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join")
}
if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(ctx.signer.pubKey)) {
return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)")
}
ConcordStore(dataDir.concordFile).upsert(
StoredCommunity(
@@ -284,6 +466,9 @@ object ConcordCommands {
// community is still pre-split and folds at the legacy address.
controlPk = bundle.controlPk ?: "",
relays = bundle.relays,
// The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving
// this link is the only way back (CORD-05/06). Stored bare, domain-agnostic.
inviteRef = ConcordActions.bareInviteRef(url) ?: "",
),
)
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays))
@@ -316,6 +501,267 @@ object ConcordCommands {
controlRoot = sc.controlRoot.ifBlank { null },
)
/**
* Adopts the `control_root` a staff-making Grant delivered to us (CORD-04 §3), persisting it to
* the local store and returning the now-writable keys or null when nothing was delivered.
*
* The decision itself is [ConcordReceive.deliveredControlRoot], shared with Amethyst: it fails
* closed unless our own fold seats us as staff, the wrap opens under the grantermember pairwise
* key, it names this epoch, and the secret derives to exactly the `control_pk` we already hold.
*
* Local-only on purpose: Amethyst republishes the kind-13302 list on adoption so a user's other
* devices follow, and doing that here would need amy to rebuild and sign the whole list. A CLI
* adoption therefore unblocks *this* account's writes; other devices adopt from their own fold.
*/
suspend fun adoptDeliveredControlRoot(
ctx: Context,
dataDir: DataDir,
sc: StoredCommunity,
editions: List<ControlEdition>,
): Pair<StoredCommunity, ControlPlaneKeys>? {
val entry = entryFor(sc)
val authority = AuthorityResolver.resolve(editions, sc.owner)
val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null
val updated = sc.copy(controlRoot = delivered)
ConcordStore(dataDir.concordFile).upsert(updated)
return updated to controlPlaneKeysFor(updated)
}
/** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */
fun entryFor(sc: StoredCommunity) =
ConcordCommunityListEntry(
id = sc.communityId,
owner = sc.owner,
ownerSalt = sc.ownerSalt,
root = sc.root,
rootEpoch = sc.rootEpoch,
controlPk = sc.controlPk.ifBlank { null },
controlRoot = sc.controlRoot.ifBlank { null },
heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }, it.controlRoot.ifBlank { null }) },
relays = sc.relays,
name = sc.name,
inviteRef = sc.inviteRef.ifBlank { null },
)
/** Folds [entry] back into the stored shape after a rotation is adopted. */
fun storedFrom(
sc: StoredCommunity,
entry: ConcordCommunityListEntry,
) = sc.copy(
root = entry.root,
rootEpoch = entry.rootEpoch,
controlPk = entry.controlPk ?: "",
controlRoot = entry.controlRoot ?: "",
heldRoots = entry.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") },
relays = entry.relays,
name = entry.name.ifBlank { sc.name },
inviteRef = entry.inviteRef ?: sc.inviteRef,
)
/**
* `concord recover [COMMUNITY]` the stranded-recovery receive path (CORD-05/06 A2).
*
* A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a
* member simply left out of the rekey receives nothing and sits on the dead epoch forever while
* everyone else moves on. There is no message to miss, which is why the rekey drain cannot help.
* The way back is the invite link the membership was joined through: the community keeps
* re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly
* higher** epoch than ours proves we were left behind and carries the new root.
*
* Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and
* scriptable rather than a background loop.
*
* The ban gate is the point of care. A removed member keeps the link's unlock token forever, so
* without it this walks them straight back into the epoch they were rotated out of. It reads the
* banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails
* closed**: a community whose plane will not fold yields no verdict and is skipped, never
* recovered.
*/
private suspend fun recover(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positionalOrNull(0)
args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile)
val targets =
if (handle != null) {
listOf(store.find(handle) ?: return notFound(handle))
} else {
store.load()
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val results = mutableListOf<Map<String, Any?>>()
for (sc in targets) {
val inviteRef = sc.inviteRef.ifBlank { null }
if (inviteRef == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref")
continue
}
val parsed = ConcordActions.parseInviteLink(inviteRef)
if (parsed == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref")
continue
}
val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() }
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
// Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
if (bundle == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle")
continue
}
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict,
// no recovery — the gate fails closed rather than assuming "not banned".
val cp = controlPlaneKeysFor(sc)
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
val editions = ConcordActions.controlEditions(controlWraps, cp)
if (editions.isEmpty()) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded")
continue
}
val bannedHere = AuthorityResolver.resolve(editions, sc.owner).isBanned(ctx.signer.pubKey)
val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere)
if (merged == null) {
results +=
mapOf(
"community_id" to sc.communityId,
"name" to sc.name,
"recovered" to false,
"reason" to if (bannedHere) "banned" else "already_current",
"root_epoch" to sc.rootEpoch,
)
continue
}
store.upsert(storedFrom(sc, merged))
results +=
mapOf(
"community_id" to sc.communityId,
"name" to sc.name,
"recovered" to true,
"from_epoch" to sc.rootEpoch,
"root_epoch" to merged.rootEpoch,
)
}
Output.emit(mapOf("communities" to results))
return 0
}
}
/**
* `concord rekey [COMMUNITY]` follow a Refounding we WERE re-keyed for (CORD-06).
*
* The normal counterpart to [recover]: a retained member gets a per-recipient blob on the next
* epoch's base-rekey plane, and opening it yields the new root. Amethyst drains this on its
* revision tick; amy has no tick, so it is a verb. Without it a Refounding launched from the CLI
* strands every other CLI member even though their blob is sitting on the relay.
*
* The rotator is authorized against the roster of the epoch being **left** `hasPermission`,
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed:
* a plane that will not fold yields no verdict and the community is skipped.
*/
private suspend fun rekey(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positionalOrNull(0)
args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile)
val targets = if (handle != null) listOf(store.find(handle) ?: return notFound(handle)) else store.load()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val results = mutableListOf<Map<String, Any?>>()
for (sc in targets) {
val relays = relaysFor(ctx, sc)
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
val received =
ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
if (received == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch)
continue
}
if (received.newEpoch <= sc.rootEpoch) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
continue
}
// Authorize the rotator against the epoch we are LEAVING — the last plane we can fold.
val cp = controlPlaneKeysFor(sc)
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
val editions = ConcordActions.controlEditions(controlWraps, cp)
if (editions.isEmpty()) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded")
continue
}
if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator)
continue
}
val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
store.upsert(storedFrom(sc, adopted))
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))
return 0
}
}
/**
* This account's CORD-05 Invite List (kind 13303) the creator's private, self-encrypted record
* of every link they minted, so a rotation can refresh those links instead of orphaning them.
* Empty when none was ever published.
*/
suspend fun readInviteList(ctx: Context): ConcordInviteListDocument? {
val relays = ctx.outboxRelays()
if (relays.isEmpty()) return null
val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(ctx.signer.pubKey))
// Terminal reasons, not just events: a drain returns nothing both when a relay served us and
// had nothing AND when nobody answered. Reading the second as "no list yet" is how the
// read-merge-write below wipes the signer_sk of every link it failed to read.
val reasons = mutableMapOf<NormalizedRelayUrl, String>()
val newest =
ctx
.drain(relays.associateWith { listOf(filter) }, doneOut = reasons)
// Filter by kind BEFORE picking the newest — a stray event at this coordinate would
// otherwise make the list read as unreadable and refuse every later write.
.mapNotNull { it.second as? ConcordInviteListEvent }
.maxByOrNull { it.createdAt }
?: return if (reasons.anyRelayServed()) ConcordInviteListDocument.EMPTY else null
return newest.decrypt(ctx.signer)
}
/**
* Merges [patch] into the published list and republishes it, returning whether it landed.
*
* Read-merge-write, and **aborts rather than overwriting** when the read fails: kind 13303 is
* replaceable, so writing a patch-only document over a list we could not read deletes every
* other link's `signer_sk`. Those secrets cannot be regenerated, and losing one orphans its
* link at the next rotation, stranding everyone holding that URL.
*
* Account-scoped, like the coordinate itself (13303, me, "") is one list for every community,
* so reading or writing it on a single community's relays would fork it.
*/
suspend fun publishInviteList(
ctx: Context,
patch: ConcordInviteListDocument,
): Boolean {
val relays = ctx.outboxRelays()
if (relays.isEmpty()) return false
val base = readInviteList(ctx) ?: return false
val event = ConcordInviteListEvent.create(ctx.signer, ConcordInviteList.merge(base, patch), TimeUtils.now())
return ctx.publish(event, relays).values.any { it.accepted }
}
fun notFound(handle: String): Int {
Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`")
return 1
@@ -28,11 +28,14 @@ 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.ConcordReceive
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
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.utils.RandomInstance
@@ -51,7 +54,7 @@ object ConcordModCommands {
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (_, editions) = load(ctx, sc)
val (_, editions) = load(ctx, sc, dataDir)
val state = ConcordCommunityState.fold(editions, sc.owner)
Output.emit(
mapOf(
@@ -92,7 +95,7 @@ object ConcordModCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (cp, editions) = load(ctx, sc)
val (cp, editions) = load(ctx, sc, dataDir)
writeGuard(cp)?.let { return it }
val roleId = RandomInstance.bytes(32)
val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire())
@@ -119,7 +122,8 @@ object ConcordModCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val member = ctx.requireUserHex(userRef)
val (cp, editions) = load(ctx, sc)
val loaded = load(ctx, sc, dataDir)
val (cp, editions) = loaded
writeGuard(cp)?.let { return it }
// A Grant that first makes its member staff must carry the write secret in the same
// edition (CORD-04 §3); ConcordModeration wraps it pairwise when the granted roles
@@ -134,7 +138,10 @@ object ConcordModCommands {
current = editions,
createdAt = TimeUtils.now(),
owner = sc.owner,
controlRoot = sc.controlRoot.ifBlank { null }?.hexToByteArray(),
controlRoot =
loaded.community.controlRoot
.ifBlank { null }
?.hexToByteArray(),
epoch = sc.rootEpoch,
)
val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc))
@@ -170,7 +177,7 @@ object ConcordModCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val member = ctx.requireUserHex(userRef)
val (cp, editions) = load(ctx, sc)
val (cp, editions) = load(ctx, sc, dataDir)
writeGuard(cp)?.let { return it }
val cid = sc.communityId.hexToByteArray()
val wrap =
@@ -186,11 +193,283 @@ object ConcordModCommands {
}
}
/**
* The drained Control Plane: the community as stored *after* any adoption, its keys, and the
* editions to chain onto. [community] matters because adopting a delivered `control_root`
* rewrites the stored record a caller that kept the pre-load copy would then fail to pass the
* secret on in its own Grant (CORD-04 §3).
*/
private class LoadedControl(
val community: StoredCommunity,
val keys: ControlPlaneKeys,
val editions: List<ControlEdition>,
) {
operator fun component1() = keys
operator fun component2() = editions
}
/**
* `concord refound COMMUNITY --remove USER[,USER]` a CORD-06 Refounding: the hard removal.
*
* A ban only strips standing; the removed member keeps every key they ever held, so the room is
* only truly closed to them by rotating the `community_root` (and, since CORD-02 §2, a fresh
* `control_root` beside it, so a demoted staffer's retained secret dies with the epoch). The
* compacted Control Plane is re-sealed at the new epoch and each retained member gets a rekey
* blob; nobody else can follow.
*
* Authority mirrors Amethyst exactly: `hasPermission`, never `effectivePermissions`, so a banned
* BAN-holder cannot launch one; the owner is never a valid target; and removal takes the same
* rank rule as a ban (CORD-04 §3) an admin cannot Refound a peer admin out.
*
* **The recipient set is a floor, not a census.** It is the roster Guestbook the authors of
* every channel message we can decrypt ourselves, minus the removed and already-banned the
* same union Amethyst builds, because a member who only ever posted holds no role and leaves no
* Guestbook motion, and omitting them silently expels them. A member with no trace at all still
* cannot be re-keyed; `concord recover` is how they get back.
*/
suspend fun refound(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound <community> --remove USER[,USER…]").let { 2 }
args.rejectUnknown()
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val removed =
removeArg
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { ctx.requireUserHex(it).lowercase() }
.toSet()
if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user")
val loaded = load(ctx, sc, dataDir)
val (cp, editions) = loaded
val state = ConcordCommunityState.fold(editions, sc.owner)
val authority = state.authority
val me = ctx.signer.pubKey
if (!ConcordReceive.isAuthorizedRotator(authority, me)) {
return Output.error("forbidden", "this account cannot refound: a Refounding takes BAN (or ownership), and a banned holder is refused (CORD-06)")
}
if (removed.any { authority.isOwner(it) }) {
return Output.error("forbidden", "the owner is never a valid removal target (CORD-04 §3)")
}
// An admin cannot Refound a peer admin out any more than they could ban one.
if (!authority.isOwner(me) && removed.any { !authority.canActOn(me, it, ConcordPermissions.BAN) }) {
return Output.error("forbidden", "you do not outrank every member you are removing (CORD-04 §3, equal cannot act on equal)")
}
// A Refounding writes the current plane (the pre-rotation bans) and the new one, so on a
// split epoch it takes the current control_root (CORD-02 §2).
writeGuard(cp)?.let { return it }
val relays = ConcordCommands.relaysFor(ctx, sc)
// 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the
// new epoch — carries the ban. Each edition chains onto the updated banlist head.
var chain = editions
val banWraps = mutableListOf<Event>()
for (target in removed) {
val banWrap = ConcordModeration.ban(ctx.signer, cp, sc.communityId.hexToByteArray(), target, chain, TimeUtils.now(), owner = sc.owner)
val ack = ctx.publish(banWrap, relays)
if (ack.values.none { it.accepted }) {
return Output.error("ban_not_published", "the pre-rotation ban for $target was not accepted by any relay; refusing to refound with a banlist that would not survive")
}
banWraps += banWrap
chain = chain + (ConcordActions.controlEditions(listOf(banWrap), cp))
}
// 2. Everyone we are keeping. See the note above on why this reaches past the roster.
val candidates =
(rosterOf(authority) + guestbookMembersOf(ctx, sc) + channelAuthorsOf(ctx, sc, state) + me)
.mapTo(HashSet()) { it.lowercase() }
.apply {
removeAll(removed)
removeAll(authority.bannedMembers().map { it.lowercase() }.toSet())
}
val recipients = boundRecipients(candidates, authority)
// 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff
// get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one).
val newRoot = RandomInstance.bytes(32)
val newControlRoot = RandomInstance.bytes(32)
// Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just
// published. Re-draining alone would race the relay's indexing, and a relay that has not
// yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch
// whose roster never banned the member we are removing.
val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
val controlWraps = (drained + banWraps).distinctBy { it.id }
val build =
ConcordActions.buildRefounding(
rotatorSigner = ctx.signer,
communityId = sc.communityId,
priorRoot = sc.root.hexToByteArray(),
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = sc.rootEpoch,
priorControlWraps = controlWraps,
priorControlKeys = cp,
recipientsXOnly = recipients,
staffXOnly = authority.staffMembers(),
createdAt = TimeUtils.now(),
ownerPubKey = sc.owner,
)
// 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it).
build.controlWraps.forEach { ctx.publish(it, relays) }
build.rekeyWraps.forEach { ctx.publish(it, relays) }
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
// epoch we are leaving for the anti-rollback floor.
val adopted =
ConcordReceive.withAdoptedRoot(
ConcordCommands.entryFor(loaded.community),
newRoot,
build.newEpoch,
build.newControlKeys.address.hexToByteArray(),
newControlRoot,
)
val stored = ConcordCommands.storedFrom(loaded.community, adopted)
ConcordStore(dataDir.concordFile).upsert(stored)
// 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new
// epoch. This is the liveness half of stranded recovery (A2): a member this Refounding
// left out has no rekey blob and no message to miss, so re-resolving their link is the
// only way back — and it only works if the bundle moves with the community instead of
// being orphaned at a dead epoch. Minting a fresh link would not help them; the link
// they hold is the one that must move.
//
// Safe for every link because recovery is ban-gated at the epoch being left, and step 1
// banned everyone being removed — so a removed member's own `recover` is refused even
// though their link now resolves.
val now = TimeUtils.now()
var refreshed = 0
val list = ConcordCommands.readInviteList(ctx)
val tombstoned = list?.tombstones?.mapTo(HashSet()) { it.token } ?: emptySet<String>()
for (link in list?.entries.orEmpty()) {
if (link.communityId != stored.communityId) continue
// An elapsed or retired link can no longer be joined, so re-posting it would only
// resurrect a dead URL at a live epoch (CORD-05).
if (link.isExpired(now) || link.token in tombstoned) continue
runCatching {
val token = link.token.hexToByteArray()
// Refresh from the link's CURRENT bundle so its own fields — expiry, channel
// grants, icon, label — survive the rotation, and so a coordinate whose newest
// event is a revocation tombstone is left revoked instead of being re-opened.
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second }
val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching
val moved =
live.invite.copy(
communityRoot = stored.root,
rootEpoch = stored.rootEpoch,
controlPk = stored.controlPk.ifBlank { null },
relays = stored.relays,
)
ctx.publish(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays)
refreshed++
}
}
Output.emit(
mapOf(
"community_id" to sc.communityId,
"removed" to removed.toList(),
"from_epoch" to sc.rootEpoch,
"root_epoch" to build.newEpoch,
"recipients" to recipients.size,
"control_wraps" to build.controlWraps.size,
"rekey_wraps" to build.rekeyWraps.size,
"invites_refreshed" to refreshed,
),
)
return 0
}
}
/**
* How many recipients one Refounding will re-key, mirroring Amethyst's own cap.
*
* Two thirds of the recipient union Guestbook joins and observed channel authors are
* attacker-writable: any key can announce a join or post once. Without a bound, padding those
* sets inflates the cost of the only hard removal Concord has until rotating becomes
* impractical, so the attack raises the price of its own remedy (B4 in the soft-ban audit).
*/
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
/**
* Caps [candidates], keeping the members whose standing is owner-rooted and therefore cannot be
* padded from outside. Anything dropped is reported rather than silently truncated a dropped
* member is stranded on the dead epoch and their only way back is `concord recover`.
*/
private fun boundRecipients(
candidates: Set<String>,
authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver,
): List<String> {
if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList()
val vouched = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() }
val kept = LinkedHashSet<String>()
candidates.filterTo(kept) { it in vouched }
for (candidate in candidates) {
if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break
kept.add(candidate)
}
val dropped = candidates.size - kept.size
if (dropped > 0) {
System.err.println("[concord] refounding recipient set trimmed to ${kept.size} of ${candidates.size}: $dropped member(s) will be stranded on the prior epoch")
}
return kept.toList()
}
/** Owner + everyone holding a role — owner-rooted, so it cannot be padded from outside. */
private fun rosterOf(authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver): Set<String> = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() }
/** Live Guestbook membership at this epoch (joins minus later leaves, CORD-02 §5). */
private suspend fun guestbookMembersOf(
ctx: Context,
sc: StoredCommunity,
): Set<String> =
runCatching {
val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
val relays = ConcordCommands.relaysFor(ctx, sc)
ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
ConcordActions.guestbookMembers(wraps, gb).mapTo(HashSet()) { it.lowercase() }
}.getOrDefault(emptySet())
/**
* Authors of every channel message we can decrypt. Most members never send a Guestbook motion,
* so without this a Refounding silently expels everyone who had only ever posted.
*/
private suspend fun channelAuthorsOf(
ctx: Context,
sc: StoredCommunity,
state: ConcordCommunityState,
): Set<String> {
val out = HashSet<String>()
val relays = ConcordCommands.relaysFor(ctx, sc)
for ((channelIdHex, _) in state.channels) {
runCatching {
val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch)
ctx.registerConcordStreamKeys(relays, listOf(key.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() }
}
}
return out
}
/** Drain the control plane and return its keys + current editions to chain onto. */
private suspend fun load(
ctx: Context,
sc: StoredCommunity,
): Pair<ControlPlaneKeys, List<ControlEdition>> {
dataDir: DataDir? = null,
): LoadedControl {
val cp = ConcordCommands.controlPlaneKeysFor(sc)
val relays = ConcordCommands.relaysFor(ctx, sc)
// Concord relays serve the plane's kind-1059 only to a connection AUTHed as the stream
@@ -198,7 +477,18 @@ object ConcordModCommands {
// that secret is staff-only (CORD-02 §2), and a member simply has nothing to register.
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
return cp to ConcordActions.controlEditions(wraps, cp)
val editions = ConcordActions.controlEditions(wraps, cp)
// 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 the key arrives. Amethyst
// drains this on its revision tick; amy has no tick, so the fold a command already does is
// the moment to adopt — otherwise a CLI-promoted staffer holds a rank it can never write
// under. Same shared, fail-closed check both clients use.
if (dataDir != null && !cp.canWrite) {
val adopted = ConcordCommands.adoptDeliveredControlRoot(ctx, dataDir, sc, editions)
if (adopted != null) return LoadedControl(adopted.first, adopted.second, ConcordActions.controlEditions(wraps, adopted.second))
}
return LoadedControl(sc, cp, editions)
}
/**
@@ -48,6 +48,11 @@ data class StoredCommunity(
// Past access roots kept per epoch (CORD-06 Refounding rotates the root). Lets `read --epoch <n>`
// re-derive a prior epoch's Chat Plane to reach pre-refounding history. Populated by `import`.
val heldRoots: List<StoredHeldRoot> = emptyList(),
// The bare `<naddr>#<fragment>` invite this membership was joined through — the stranded-recovery
// anchor (CORD-05/06). A Refounding carries no recipient list, so a member simply left out of the
// rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct
// invite or a community joined before amy stored it.
val inviteRef: String = "",
)
/** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
@@ -56,6 +61,12 @@ data class StoredHeldRoot(
val root: String = "",
/** That epoch's Control Plane address; blank for a legacy, pre-split epoch (CORD-02 §5). */
val controlPk: String = "",
/**
* That epoch's staff write key, banked only if we held it. A relay that gates the prior epoch's
* Control Plane on NIP-42 AUTH as the stream key will not serve those wraps without it and
* those wraps are what rebuild the anti-rollback floor.
*/
val controlRoot: String = "",
)
/**
@@ -201,6 +201,16 @@ object ConcordActions {
/** The public invite bundle for a link signer. */
fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = listOf(linkSignerPubKeyHex))
/**
* The bundles of several links at once one REQ over every link signer instead of a round trip
* per link, which is what a Refounding needs when it re-mints a creator's whole set.
*
* Partition the result by `pubKey` before classifying: [ConcordInviteBundle.classify] resolves a
* single coordinate, so handing it a pooled set would let one link's revocation tombstone decide
* another link's status purely by being newer.
*/
fun bundlesFilter(linkSignerPubKeyHexes: List<HexKey>): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = linkSignerPubKeyHexes)
/** Pending direct invites addressed to the given member (indexed by k=3313). */
fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString())))
@@ -436,6 +446,44 @@ object ConcordActions {
relays: List<String>? = null,
): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays)
/**
* Re-publishes a bundle at an **existing** link's coordinate, carrying [invite] refreshed for the
* current epoch (CORD-05 §1). The kind-33301 bundle is addressable and authored by the link
* signer, so re-signing with the same [linkSignerPrivKey] and re-encrypting under the same
* [token] replaces what is there every holder of that link keeps working, now pointing at the
* new root.
*
* This is what makes stranded recovery live: a member a Refounding left out has no rekey blob and
* no message to miss, and re-resolving their link is the only way back which requires the
* community to re-mint at the *same* coordinate rather than issuing a fresh link. Minting a new
* link leaves the old one pointing at a dead epoch forever.
*
* Safe to call for every live link because recovery is ban-gated at the epoch being left
* (CORD-06, A2): a member the Refounding removed was banned on the way out, so their own
* `recover` is refused even though their link now resolves.
*/
fun remintBundleAt(
linkSignerPrivKey: ByteArray,
token: ByteArray,
invite: CommunityInvite,
createdAt: Long,
): Event = ConcordInviteBundle.build(linkSignerPrivKey, token, invite, createdAt)
/**
* Retires an existing link by publishing a `vsk=9` revocation tombstone at its coordinate
* (CORD-05 §2). Once this lands, every client resolving that URL gets
* [com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus.Revoked] instead of keys.
*
* Publish this *before* recording the tombstone in the kind-13303 Invite List the list entry
* carries the only copy of the `signer_sk` this call needs, and the list merge drops a
* tombstoned token's entry for good. Recording first and failing to publish would leave the link
* live on the wire with no way left to retire it.
*/
fun revokeBundleAt(
linkSignerPrivKey: ByteArray,
createdAt: Long,
): Event = ConcordInviteBundle.buildRevocation(linkSignerPrivKey, createdAt)
/** Parses a shareable invite URL into its pointer + private fragment. */
fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url)
@@ -0,0 +1,145 @@
/*
* 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.HeldRoot
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
/**
* The **receive** half of Concord's key lifecycle, as pure functions: what a client must adopt
* when a Grant hands it the Control Plane write key (CORD-04 §3), and how an entry is rewritten
* when a base rotation moves the community to a new epoch (CORD-06).
*
* These lived only in Amethyst's `AccountConcordActions`, which meant a headless client (`amy`)
* could hold a rank it could never write under, and could not follow a Refounding at all. The
* logic is platform-agnostic the only Android-shaped parts were the persistence and publish,
* which stay with the caller. Every function here decides *what* to adopt and returns it; the
* caller owns storing it and republishing the kind-13302 list.
*
* Everything fails closed: an undecryptable, mis-epoched or non-deriving delivery yields null,
* never a partially-adopted entry.
*/
object ConcordReceive {
/**
* The `control_root` a staff-making Grant delivered to the account behind [recipientSigner],
* or null when there is nothing to adopt (CORD-04 §3).
*
* Gated three ways, each of which fails closed:
* - only a Grant **our own fold honors** can deliver, so [authority] must already seat us as
* staff a rogue cannot feed us a key by minting an edition nobody accepts;
* - the wrap must open under the grantermember pairwise key, and name [entry]'s epoch,
* because compaction re-wraps a Grant head verbatim across Refoundings and a folded head
* can legitimately carry a wrap minted for a prior epoch;
* - the secret must derive to exactly the `control_pk` we already hold, or adopting it would
* split us off from the plane's readers.
*
* Returns null (not an error) when the entry already holds the secret, holds no `control_pk`
* to check against (a legacy pre-split community), or when we are not staff.
*/
suspend fun deliveredControlRoot(
entry: ConcordCommunityListEntry,
editions: List<ControlEdition>,
authority: AuthorityResolver,
recipientSigner: NostrSigner,
): HexKey? {
val heldControlPk = entry.controlPk
if (entry.controlRoot != null || heldControlPk == null) return null
val me = recipientSigner.pubKey.lowercase()
if (!authority.isStaff(me)) return null
val myGrantCoordinate =
ConcordKeyDerivation
.grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray())
.toHexKey()
return editions
.filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate }
// Newest first: a re-issued Grant (a lost key, a head superseded before we fetched it)
// carries the fresher wrap.
.sortedByDescending { it.version }
.firstNotNullOfOrNull { edition ->
val wrap = ConcordJson.decodeOrNull<GrantEntity>(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null
val opened = ControlRootWrap.openOrNull(wrap, recipientSigner, edition.author) ?: return@firstNotNullOfOrNull null
if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null
if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null
opened.controlRoot.toHexKey()
}
}
/**
* Whether [rotator] was allowed to launch the base rotation that [entry] is being moved by
* (CORD-06). `hasPermission`, never `effectivePermissions`: the latter ignores the banlist, so
* a banned BAN-holder could rotate the whole community out from under it.
*/
fun isAuthorizedRotator(
authority: AuthorityResolver,
rotator: HexKey,
): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN)
/**
* The entry that results from adopting a base rotation to [newEpoch] a pure rewrite, so the
* caller can diff, persist and publish it however its platform does.
*
* The epoch being left is banked in `heldRoots` **with the address it was folded at**, because
* a split epoch's Control address can never be re-derived, only remembered (CORD-02 §2) that
* banked address is what keeps the anti-rollback floor rebuildable. A rotation that delivered
* no control material is a legacy pre-split one (CORD-06 §3): the new epoch folds at the legacy
* address, and the stale prior-epoch values must NOT be carried into it. `inviteRef` survives,
* or the *next* Refounding we are left out of becomes unrecoverable; `residue` survives, or we
* delete another client's unknown keys on every rekey.
*/
fun withAdoptedRoot(
entry: ConcordCommunityListEntry,
newRoot: ByteArray,
newEpoch: Long,
newControlPk: ByteArray? = null,
newControlRoot: ByteArray? = null,
): ConcordCommunityListEntry =
ConcordCommunityListEntry(
id = entry.id,
owner = entry.owner,
ownerSalt = entry.ownerSalt,
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
controlPk = newControlPk?.toHexKey(),
controlRoot = newControlRoot?.toHexKey(),
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch },
privateChannels = entry.privateChannels,
relays = entry.relays,
name = entry.name,
addedAt = entry.addedAt,
inviteRef = entry.inviteRef,
excludedAtEpoch = entry.excludedAtEpoch,
residue = entry.residue,
)
}
@@ -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.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.NewConcordCommunity
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.utils.TimeUtils
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The receive half of the soft-ban audit's A4: a banned member's typing heartbeat must not reach
* the "… is typing" row. The send half is a guard in the app's own action layer, which a malicious
* or modified client simply won't run so this filter, on the receive side, is the only one that
* actually protects the room. It shipped without a test; this is it.
*/
class ConcordBannedTypingTest {
private val owner = NostrSignerInternal(KeyPair())
private val troll = NostrSignerInternal(KeyPair())
private val regular = NostrSignerInternal(KeyPair())
private fun entryFor(community: NewConcordCommunity) =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
@Test
fun dropsABannedMembersTypingHeartbeatAndKeepsEveryoneElses() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val session = ConcordCommunitySession(entryFor(community), owner.pubKey)
community.genesisWraps.forEach { session.ingest(it) }
val channelId = community.generalChannelIdHex
val plane = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch)
val now = TimeUtils.now()
// Ban first, so what follows tests the filter rather than an entry seated before the ban.
// The banlist edition folds through the Control Plane exactly as it would on the wire.
session.ingest(
ConcordModeration.ban(
actor = owner,
controlPlane = session.controlPlaneKeys(),
communityId = community.communityIdHex.hexToByteArray(),
member = troll.pubKey,
current = session.controlEditions(),
createdAt = now,
owner = community.ownerPubKey,
),
)
assertTrue(
session.state.value
?.authority
?.isBanned(troll.pubKey) == true,
"the ban must have folded before the heartbeats are judged",
)
// The banned member keeps broadcasting — a modified client ignores the send-side guard.
session.ingest(ConcordActions.buildChannelTyping(troll, plane, channelId, community.rootEpoch, now))
assertEquals(
null,
session.typing.value[channelId]?.get(troll.pubKey.lowercase()),
"a banned member must never be seated in the typing row",
)
// The filter is targeted, not a blanket mute: an ordinary member still types normally.
session.ingest(ConcordActions.buildChannelTyping(regular, plane, channelId, community.rootEpoch, now))
assertTrue(
session.typing.value[channelId]?.containsKey(regular.pubKey.lowercase()) == true,
"an unbanned member's typing heartbeat must still show",
)
assertEquals(
null,
session.typing.value[channelId]?.get(troll.pubKey.lowercase()),
"seating one member must not drag the banned one in",
)
}
}
@@ -52,7 +52,7 @@ class InviteChannel(
* into a kind-33301 bundle (link invites) or a NIP-59 giftwrap (direct invites).
*/
@Serializable
class CommunityInvite(
data class CommunityInvite(
@SerialName("community_id") val communityId: String,
val owner: String,
@SerialName("owner_salt") val ownerSalt: String,
@@ -109,6 +109,22 @@ object ConcordInviteBundle {
return signer.sign(ConcordInviteBundleEvent.build(content, createdAt))
}
/**
* Builds the kind-33301 revocation tombstone that retires the link owned by [linkSignerPrivKey]
* (CORD-05 §2). It re-posts the link's own coordinate with empty content and `vsk=9`, so the
* newest event there is a grave rather than keys and [classify] resolves the link
* [InviteBundleStatus.Revoked] for everyone who resolves it afterwards.
*
* Only the creator can do this: the coordinate is addressable and authored by the link signer,
* so retiring a link requires the `link_signer` secret which lives in the creator's kind-13303
* Invite List and nowhere else. Losing that secret makes a link permanently un-revokable, which
* is why the list is written before a link is ever handed out.
*/
fun buildRevocation(
linkSignerPrivKey: ByteArray,
createdAt: Long,
): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt))
/** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */
fun parse(
event: Event,
@@ -0,0 +1,275 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
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 kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.elementNames
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonTransformingSerializer
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
private val NoExtras: JsonObject = JsonObject(emptyMap())
/**
* One minted invite link, as the creator's own bookkeeping (CORD-05, kind 13303).
*
* [token] is both the link's unlock secret and the **merge key** across devices, and [signerSk] is
* the link signer's private key which is what makes a link *refreshable*. The kind-33301 bundle is
* addressable and authored by that signer, so re-posting under it moves the link to the current
* epoch without changing the URL anyone already holds. Lose the secret and the link is orphaned at a
* dead epoch forever, which is what made stranded recovery unreachable in practice.
*
* [residue] carries wire keys this build does not model. Armada types both the entry and the
* tombstone as `[k: string]: unknown`, so unknown keys are part of the contract: dropping them on a
* re-encode deletes another client's data.
*/
class ConcordInviteListEntry(
val token: String,
val signerSk: String,
val communityId: String,
val url: String,
val label: String? = null,
val createdAt: Long = 0,
val expiresAt: Long? = null,
val residue: JsonObject = NoExtras,
) {
/** True when this link can no longer be joined, so it must not be refreshed (CORD-05). */
fun isExpired(nowSecs: Long): Boolean = expiresAt != null && expiresAt <= nowSecs
/**
* The link signer's pubkey the addressable coordinate the bundle lives at, derived from the
* secret we kept. Refreshing or retiring a link means writing at exactly this author.
*/
fun signerPubKeyHex(): HexKey = KeyPair(privKey = signerSk.hexToByteArray()).pubKey.toHexKey()
}
/** A retired link: the creator's record that [token] is gone, kept so a merge cannot resurrect it. */
class ConcordInviteListTombstone(
val token: String,
val communityId: String,
val residue: JsonObject = NoExtras,
)
/**
* The decoded kind-13303 document: live [entries], [tombstones], and document-level [residue].
*
* [opaqueEntries] holds entries that did not type-check a wrong-typed field from another client or
* a newer schema. They are carried verbatim rather than dropped (re-encoding without them would
* delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every
* future mint and revoke for this account until someone else repaired the list).
*/
class ConcordInviteListDocument(
val entries: List<ConcordInviteListEntry> = emptyList(),
val tombstones: List<ConcordInviteListTombstone> = emptyList(),
val residue: JsonObject = NoExtras,
val opaqueEntries: List<JsonObject> = emptyList(),
) {
companion object {
val EMPTY = ConcordInviteListDocument()
}
}
/**
* Codec + merge for the CORD-05 Invite List (kind 13303), the creator's private, NIP-44 self-
* encrypted bookkeeping of the links they minted. Wire-compatible with Armada's `invite.ts`:
*
* ```jsonc
* { "entries": [ { "token", "signer_sk", "community_id", "url", "label?", "created_at", "expires_at?" } ],
* "tombstones": [ { "token", "community_id" } ] }
* ```
*/
object ConcordInviteList {
private const val EXTRAS = "__extras"
/** Wraps a generated serializer so unknown keys survive a decode → modify → encode. */
private open class ExtrasPreserving<T>(
delegate: KSerializer<T>,
) : JsonTransformingSerializer<T>(delegate) {
@OptIn(ExperimentalSerializationApi::class)
private val known = delegate.descriptor.elementNames.toSet() - EXTRAS
override fun transformDeserialize(element: JsonElement): JsonElement {
val obj = element as? JsonObject ?: return element
val extras = obj.filterKeys { it !in known }
if (extras.isEmpty()) return obj
return JsonObject(obj.filterKeys { it in known } + (EXTRAS to JsonObject(extras)))
}
override fun transformSerialize(element: JsonElement): JsonElement {
val obj = element as? JsonObject ?: return element
val extras = obj[EXTRAS]?.jsonObject ?: return obj
return JsonObject(extras + (obj - EXTRAS))
}
}
@Serializable
private class WireEntry(
val token: String = "",
@SerialName("signer_sk") val signerSk: String = "",
@SerialName("community_id") val communityId: String = "",
val url: String = "",
val label: String? = null,
@SerialName("created_at") val createdAt: Long = 0,
@SerialName("expires_at") val expiresAt: Long? = null,
@SerialName(EXTRAS) val extras: JsonObject = NoExtras,
)
@Serializable
private class WireTombstone(
val token: String = "",
@SerialName("community_id") val communityId: String = "",
@SerialName(EXTRAS) val extras: JsonObject = NoExtras,
)
private object WireEntrySerializer : ExtrasPreserving<WireEntry>(WireEntry.serializer())
private object WireTombstoneSerializer : ExtrasPreserving<WireTombstone>(WireTombstone.serializer())
@Serializable
private class WireDocument(
val entries: List<
@Serializable(WireEntrySerializer::class)
WireEntry,
> = emptyList(),
val tombstones: List<
@Serializable(WireTombstoneSerializer::class)
WireTombstone,
> = emptyList(),
@SerialName(EXTRAS) val extras: JsonObject = NoExtras,
)
private object WireDocumentSerializer : ExtrasPreserving<WireDocument>(WireDocument.serializer())
/**
* Decodes the plaintext document, or **null** when the document itself cannot be read.
*
* Null rather than an empty document on purpose: this list is replaceable, so a caller that
* treats "I could not read it" as "it is empty" and republishes destroys every `signer_sk` it
* did not manage to read secrets that cannot be regenerated, orphaning every outstanding
* invite at a dead epoch. Callers MUST distinguish the two (see [ConcordInviteList.merge]'s
* callers).
*
* Null is reserved for a *document-level* failure not JSON, or `entries`/`tombstones` present
* but not arrays. A single entry that does not type-check is kept verbatim in
* [ConcordInviteListDocument.opaqueEntries] instead: failing the whole read for one odd row
* would refuse every future mint and revoke for the account, permanently, since a replaceable
* coordinate never ages out turning the old silent data loss into a permanent write lock.
*/
fun decodeOrNull(json: String): ConcordInviteListDocument? =
try {
val root = ConcordJson.instance.parseToJsonElement(json).jsonObject
val opaque = mutableListOf<JsonObject>()
val entries =
(root["entries"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element ->
val obj = element.jsonObject
try {
val it = ConcordJson.instance.decodeFromJsonElement(WireEntrySerializer, obj)
ConcordInviteListEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.extras)
} catch (_: Exception) {
opaque.add(obj)
null
}
}
val tombstones =
(root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element ->
try {
val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject)
ConcordInviteListTombstone(it.token, it.communityId, it.extras)
} catch (_: Exception) {
// A tombstone we cannot read must not silently un-retire its link, but we
// have no token to key it by, so it can only ride along as document residue.
null
}
}
ConcordInviteListDocument(
entries = entries,
tombstones = tombstones,
residue = JsonObject(root - "entries" - "tombstones"),
opaqueEntries = opaque,
)
} catch (_: Exception) {
null
}
fun encode(doc: ConcordInviteListDocument): String {
val wire =
ConcordJson.instance
.encodeToJsonElement(
WireDocumentSerializer,
WireDocument(
entries =
doc.entries.map {
WireEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.residue)
},
tombstones = doc.tombstones.map { WireTombstone(it.token, it.communityId, it.residue) },
extras = doc.residue,
),
).jsonObject
// Entries we could not type ride back out untouched. Dropping them here is the data loss
// this whole class exists to prevent — they are somebody's link signer too.
if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries)
return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries)))
}
/**
* Merges [patch] onto [base], keyed by `token` the spec's own merge key. A token present in
* either side's tombstones is dropped from the result and kept tombstoned, so a retired link
* cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a
* token both sides carry, which is what makes "read remote, apply my change, publish" converge.
*/
fun merge(
base: ConcordInviteListDocument,
patch: ConcordInviteListDocument,
): ConcordInviteListDocument {
val tombstones = LinkedHashMap<String, ConcordInviteListTombstone>()
for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t
val entries = LinkedHashMap<String, ConcordInviteListEntry>()
for (e in base.entries + patch.entries) {
if (e.token in tombstones) continue
entries[e.token] = e
}
return ConcordInviteListDocument(
entries = entries.values.toList(),
tombstones = tombstones.values.toList(),
residue = JsonObject(base.residue + patch.residue),
// Untyped entries survive the merge for the same reason they survive a decode: we cannot
// read them, so we are in no position to decide they are disposable.
opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(),
)
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* The CORD-05 **Invite List** (kind 13303): the creator's private, NIP-44 self-encrypted record of
* every link they minted `token` (the unlock secret and merge key) and `signer_sk` (the link
* signer's private key) per entry.
*
* It exists so a link can be *refreshed*: the kind-33301 bundle is addressable and authored by the
* link signer, so re-posting under it moves the link to the current epoch behind the same URL (e.g.
* after a Rekey). Without the list a client cannot re-sign at that coordinate, every rotation
* orphans every outstanding link, and stranded recovery whose whole premise is re-resolving the
* link you joined through can never fire.
*
* Replaceable and per-creator: the coordinate is (kind, creator pubkey, ""), so a creator's devices
* converge on one list. Merge by `token` ([ConcordInviteList.merge]) rather than overwriting, or two
* devices minting concurrently lose each other's links.
*/
@Immutable
class ConcordInviteListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/**
* Decrypts the whole document with [signer] entries, tombstones and the document residue or
* **null** if it cannot be decrypted or parsed.
*
* Null, never empty: a caller that reads a decrypt failure as "no links yet" and republishes
* wipes every `signer_sk` on this replaceable coordinate. A bunker signer that momentarily
* refuses is enough to trigger it. Use this (never a partial read) whenever the result will be
* re-encoded, or another client's unknown keys are dropped on the next publish.
*/
suspend fun decrypt(signer: NostrSigner): ConcordInviteListDocument? =
try {
ConcordInviteList.decodeOrNull(signer.nip44Decrypt(content, signer.pubKey))
} catch (_: Exception) {
null
}
companion object {
const val KIND = 13303
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "")
suspend fun create(
signer: NostrSigner,
document: ConcordInviteListDocument,
createdAt: Long = TimeUtils.now(),
): ConcordInviteListEvent {
val content = signer.nip44Encrypt(ConcordInviteList.encode(document), signer.pubKey)
return signer.sign(createdAt, KIND, emptyArray(), content)
}
}
}
@@ -68,5 +68,22 @@ class ConcordInviteBundleEvent(
addUnique(VskTag.assemble(ControlEntityKind.INVITE_LIVE))
initializer()
}
/**
* Builds the revocation tombstone that retires a link: the **same** `["d",""]` coordinate,
* empty content, and `["vsk","9"]` ([ControlEntityKind.INVITE_REVOKED]).
*
* Empty content is the interop contract, not an omission the spec's "a fetcher finds the
* grave instead of keys", and byte-for-byte what Armada's `buildRevocationEvent` emits.
* There is nothing to encrypt: the point is that no bundle key opens anything here.
*/
fun buildRevocation(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ConcordInviteBundleEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag("")
addUnique(VskTag.assemble(ControlEntityKind.INVITE_REVOKED))
initializer()
}
}
}
@@ -79,6 +79,14 @@ suspend fun INostrClient.fetchAllWithHooks(
subscriptionId: String = newSubId(),
pendingOnAuthRequired: Boolean = false,
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
/**
* Receives the terminal reason per relay ("eose", "closed:…", "cannot:…"), so a caller can
* tell "a relay served us and had nothing" from "nobody served us". An empty result alone
* cannot: both look like zero events, and treating the second as the first is how a
* read-merge-write on a replaceable event destroys the entries it failed to read. See
* [anyRelayServed].
*/
doneOut: MutableMap<NormalizedRelayUrl, String>? = null,
onTimeout: ((stalled: Set<NormalizedRelayUrl>, doneReasons: Map<NormalizedRelayUrl, String>, collected: List<Pair<NormalizedRelayUrl, Event>>) -> Unit)? = null,
/**
* Hard wall-clock ceiling. The idle window alone is unbounded when a relay
@@ -237,9 +245,22 @@ suspend fun INostrClient.fetchAllWithHooks(
classifyDrainFailure(reason)?.let { out[relay] = it }
}
}
doneOut?.putAll(doneReasons)
return collected
}
/** The terminal reason recorded when a relay finished serving a subscription normally. */
const val DONE_REASON_EOSE = "eose"
/**
* True when at least one relay completed the fetch normally, i.e. answered and reached EOSE.
*
* Read against the map filled by `fetchAllWithHooks`'s `doneOut`. An empty event list means
* "nothing matched" only when this is true; otherwise it means "nobody told us", and a caller
* that overwrites a replaceable event on that basis deletes whatever it could not read.
*/
fun Map<NormalizedRelayUrl, String>.anyRelayServed(): Boolean = values.any { it == DONE_REASON_EOSE }
/**
* [fetchAllPagesFromPool] with a suspending per-event hook: paginates every relay
* to completion (each on its own `until` cursor, up to [maxConcurrentRelays] at
@@ -101,6 +101,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
@@ -804,6 +805,7 @@ class EventFactory {
RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig)
ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig)
ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig)
ConcordInviteListEvent.KIND -> ConcordInviteListEvent(id, pubKey, createdAt, tags, content, sig)
ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig)
SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig)
SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -29,6 +29,7 @@ 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.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
@@ -152,6 +153,40 @@ class ConcordInviteClassifyTest {
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16)))
}
@Test
fun buildRevocationEmitsTheWireShapeArmadaEmits() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L)
// The interop contract, byte for byte: kind 33301 at the SAME addressable coordinate
// (same author, same empty d tag), empty content, vsk=9. Anything else here and a
// non-Amethyst client keeps serving a link its creator believes is dead.
assertEquals(ConcordInviteBundleEvent.KIND, grave.kind)
assertEquals(minted.linkSignerPubKey, grave.pubKey, "a tombstone at a different author retires nothing")
assertEquals("", grave.content, "the grave carries no keys — nothing to encrypt")
assertEquals(listOf(listOf("d", ""), listOf("vsk", "9")), grave.tags.map { it.toList() })
assertTrue(grave.verify(), "must be signed by the link signer the creator kept")
}
@Test
fun aBuiltRevocationRetiresItsOwnLink() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
// End to end: what the creator publishes is what every redeemer then resolves.
val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L)
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.token))
// And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the
// refresh path must skip a coordinate it did not resolve Live first.
val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L)
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.token) is InviteBundleStatus.Live)
}
@Test
fun realRelayopBundleIsUnreadable() {
// The actual kind-33301 event behind the reported relayop.xyz/invite link (vsk=8), plus the
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Wire conformance for the CORD-05 Invite List (kind 13303). The whole point of this document is
* cross-client: a link minted in Armada must be refreshable from Amethyst and back, so the field
* names and the merge key are contract, not preference.
*/
class ConcordInviteListTest {
// The spec's own example document, verbatim in shape.
private val specJson =
"""
{ "entries": [
{ "token": "aa11",
"signer_sk": "bb22",
"community_id": "cc33",
"url": "https://vector.chat/invite/naddr1abc#frag",
"label": "Reddit",
"created_at": 1719800000,
"expires_at": 1722400000 } ],
"tombstones": [ { "token": "dd44", "community_id": "cc33" } ] }
""".trimIndent()
@Test
fun readsTheSpecDocumentIntoTypedEntries() {
val doc = ConcordInviteList.decodeOrNull(specJson)!!
assertEquals(1, doc.entries.size)
val e = doc.entries.first()
assertEquals("aa11", e.token)
assertEquals("bb22", e.signerSk)
assertEquals("cc33", e.communityId)
assertEquals("https://vector.chat/invite/naddr1abc#frag", e.url)
assertEquals("Reddit", e.label)
assertEquals(1719800000L, e.createdAt)
assertEquals(1722400000L, e.expiresAt)
assertEquals(1, doc.tombstones.size)
assertEquals("dd44", doc.tombstones.first().token)
assertEquals("cc33", doc.tombstones.first().communityId)
}
@Test
fun emitsTheSnakeCaseKeysAnotherClientReads() {
val json = ConcordInviteList.encode(ConcordInviteList.decodeOrNull(specJson)!!)
// Field names are the interop contract — a camelCase slip silently orphans every link.
for (key in listOf("\"token\"", "\"signer_sk\"", "\"community_id\"", "\"url\"", "\"created_at\"", "\"expires_at\"", "\"entries\"", "\"tombstones\"")) {
assertTrue(json.contains(key), "missing wire key $key")
}
}
@Test
fun keepsUnknownKeysAcrossADecodeEncodeCycle() {
// Armada types the entry and tombstone as `[k: string]: unknown`, so dropping a key we do
// not model deletes another client's data on our next publish.
val withExtras =
"""
{ "entries": [ { "token": "aa11", "signer_sk": "bb22", "community_id": "cc33",
"url": "u", "created_at": 1, "future_field": {"a":1} } ],
"tombstones": [ { "token": "dd44", "community_id": "cc33", "why": "revoked" } ],
"doc_level_unknown": 7 }
""".trimIndent()
val round = ConcordInviteList.encode(ConcordInviteList.decodeOrNull(withExtras)!!)
assertTrue(round.contains("future_field"), "entry-level unknown key dropped")
assertTrue(round.contains("doc_level_unknown"), "document-level unknown key dropped")
assertTrue(round.contains("\"why\""), "tombstone unknown key dropped")
}
@Test
fun mergesByTokenAndLetsTombstonesWin() {
val base =
ConcordInviteListDocument(
entries =
listOf(
ConcordInviteListEntry("t1", "sk1", "c", "url1", createdAt = 1),
ConcordInviteListEntry("t2", "sk2", "c", "url2", createdAt = 2),
),
)
// Another device minted t3 and retired t1.
val patch =
ConcordInviteListDocument(
entries = listOf(ConcordInviteListEntry("t3", "sk3", "c", "url3", createdAt = 3)),
tombstones = listOf(ConcordInviteListTombstone("t1", "c")),
)
val merged = ConcordInviteList.merge(base, patch)
val tokens = merged.entries.map { it.token }.toSet()
assertEquals(setOf("t2", "t3"), tokens, "merge is keyed by token; a tombstoned link is dropped")
assertTrue(merged.tombstones.any { it.token == "t1" }, "the tombstone must persist or a stale device resurrects the link")
}
@Test
fun aMalformedDocumentYieldsNullSoCallersCannotOverwriteWithIt() {
// Null, not empty: a caller that republishes an "empty" list over this replaceable
// coordinate destroys every signer_sk it failed to read.
assertEquals(null, ConcordInviteList.decodeOrNull("not json"))
assertEquals(null, ConcordInviteList.decodeOrNull("{\"entries\":\"wrong type\"}"))
}
@Test
fun oneUnreadableEntryDoesNotFailTheWholeDocumentOrGetDropped() {
// One structurally incompatible entry — a newer schema turning a scalar into an object, the
// realistic version, since the lenient parser already coerces plain scalar mismatches — used
// to null the whole document. Because null now means "refuse to write", that turned a single
// odd row into a permanent lock on mint and revoke for the account: a replaceable coordinate
// never ages out, so nothing would ever clear it.
val mixed =
"""
{ "entries": [
{ "token": "aa", "signer_sk": "bb", "community_id": "cc", "url": "u1" },
{ "token": {"v": "dd"}, "signer_sk": "dd", "community_id": "cc", "url": "u2", "mark": "keepme" }
],
"tombstones": [] }
""".trimIndent()
val doc = ConcordInviteList.decodeOrNull(mixed)
assertEquals(listOf("aa"), doc!!.entries.map { it.token }, "the readable entry still decodes")
assertEquals(1, doc.opaqueEntries.size, "the unreadable entry is kept, not discarded")
// And it survives a re-encode: dropping it would delete somebody's signer_sk, which is the
// exact data loss this class exists to prevent.
assertTrue(ConcordInviteList.encode(doc).contains("keepme"), "unreadable entry lost on re-encode")
}
@Test
fun aMergeCarriesUnreadableEntriesThrough() {
val base = ConcordInviteList.decodeOrNull("""{"entries":[{"token":{"v":7},"mark":"opaque"}],"tombstones":[]}""")!!
val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t", "sk", "c", "u")))
val merged = ConcordInviteList.merge(base, patch)
assertEquals(listOf("t"), merged.entries.map { it.token })
// We cannot read it, so we are in no position to decide it is disposable.
assertTrue(ConcordInviteList.encode(merged).contains("opaque"), "merge dropped an unreadable entry")
}
@Test
fun anUnreadableListIsDistinguishableFromAnEmptyOne() {
// The whole point of the null: a caller must be able to tell "I could not read it" from
// "there is nothing in it". Publishing a merge onto the latter is fine; onto the former it
// destroys every signer_sk on this replaceable coordinate.
assertEquals(null, ConcordInviteList.decodeOrNull("<not json>"))
val empty = ConcordInviteList.decodeOrNull("""{"entries":[],"tombstones":[]}""")
assertEquals(0, empty!!.entries.size, "a genuinely empty list decodes, it does not fail")
// And a merge onto an empty base keeps the patch, so starting a first list still works.
val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t", "sk", "c", "u")))
assertEquals(listOf("t"), ConcordInviteList.merge(empty, patch).entries.map { it.token })
}
@Test
fun theSignerPubKeyIsTheCoordinateTheBundleLivesAt() {
// Refreshing or revoking a link means writing at exactly this author, so it must derive from
// the secret we kept rather than being stored (and drifting) separately.
val sk = "11".repeat(32)
val entry = ConcordInviteListEntry("t", sk, "c", "u")
assertEquals(64, entry.signerPubKeyHex().length)
assertEquals(entry.signerPubKeyHex(), ConcordInviteListEntry("t2", sk, "c", "u2").signerPubKeyHex())
}
@Test
fun anExpiredLinkIsNotRefreshable() {
val live = ConcordInviteListEntry("t", "sk", "c", "u", expiresAt = 100)
val forever = ConcordInviteListEntry("t", "sk", "c", "u", expiresAt = null)
assertTrue(live.isExpired(nowSecs = 101), "an elapsed link can no longer be joined")
assertTrue(!live.isExpired(nowSecs = 99))
assertTrue(!forever.isExpired(nowSecs = Long.MAX_VALUE), "no expiry means it never elapses")
}
}