mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+11
@@ -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(
|
||||
|
||||
+273
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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) {
|
||||
|
||||
+23
-9
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 granter↔member 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 = "",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+48
@@ -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)
|
||||
|
||||
|
||||
+145
@@ -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 granter↔member 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,
|
||||
)
|
||||
}
|
||||
+113
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+16
@@ -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,
|
||||
|
||||
+275
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+84
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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)
|
||||
|
||||
+35
@@ -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
|
||||
|
||||
+197
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user