mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat(concord): implement the CORD-05 Invite List (kind 13303), wire-compatible with Armada
The previous commit kept link secrets in amy's local store, which made link
refresh work but only for that one client. The spec already defines where
they belong, and Armada implements it, so this replaces the local field with
the real cross-client document.
Kind 13303, replaceable, NIP-44-encrypted to self — the creator's private
bookkeeping:
{ "entries": [ { "token", "signer_sk", "community_id", "url",
"label?", "created_at", "expires_at?" } ],
"tombstones": [ { "token", "community_id" } ] }
`token` is both the link's unlock secret and the merge key; `signer_sk` is
what lets any of the creator's clients re-sign at that link's addressable
coordinate. Armada types both the entry and the tombstone as
`[k: string]: unknown`, so unknown keys are contract: the codec preserves
entry-, tombstone- and document-level residue, and re-encoding never deletes
another client's data.
Merge is by token, read-merge-write rather than overwrite — the list is
replaceable and per-creator, so two devices minting concurrently would
otherwise destroy each other's `signer_sk`, which is unrecoverable. A token
tombstoned on either side stays dropped, so a stale device cannot resurrect
a retired link.
Registers 13303 in EventFactory (without it the kind deserializes as a plain
Event and every typed read fails), and points amy's mint and Refounding
refresh at the list instead of its own store.
Semantics were taken from the spec and confirmed against Armada's observable
behaviour — read for semantics only, never copied: Armada is AGPLv3 and
Amethyst is MIT.
Verified against a loopback geode: `concord invite` publishes an encrypted,
untagged 13303; a Refounding reads it back, refreshes the live links, and a
member with no role who never posted — unfindable by any rotation — recovers
epoch 0 → 1 through the link he already held.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
409339b375
commit
293ffd0bc5
@@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredMintedInvite
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
@@ -35,6 +34,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven
|
||||
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.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -266,18 +269,25 @@ object ConcordCommands {
|
||||
val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc))
|
||||
RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it }
|
||||
|
||||
// Keep the link signer + token so a later Refounding can refresh THIS coordinate rather
|
||||
// than orphaning the link at a dead epoch — the liveness half of stranded recovery (A2).
|
||||
ConcordStore(dataDir.concordFile).upsert(
|
||||
sc.copy(
|
||||
mintedInvites =
|
||||
sc.mintedInvites +
|
||||
StoredMintedInvite(
|
||||
linkSignerPrivKey = minted.linkSignerPrivKey.toHexKey(),
|
||||
token = minted.token.toHexKey(),
|
||||
createdAt = TimeUtils.now(),
|
||||
// Record the link in the CORD-05 Invite List (kind 13303) so any of this creator's
|
||||
// clients — Amethyst, Armada — can later refresh THIS coordinate instead of orphaning
|
||||
// the link at a dead epoch. That list is the liveness half of stranded recovery (A2).
|
||||
publishInviteList(
|
||||
ctx,
|
||||
extraRelays = relaysFor(ctx, sc),
|
||||
patch =
|
||||
ConcordInviteListDocument(
|
||||
entries =
|
||||
listOf(
|
||||
ConcordInviteListEntry(
|
||||
token = minted.token.toHexKey(),
|
||||
signerSk = minted.linkSignerPrivKey.toHexKey(),
|
||||
communityId = sc.communityId,
|
||||
url = minted.url,
|
||||
createdAt = TimeUtils.now(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Output.emit(
|
||||
@@ -570,6 +580,43 @@ object ConcordCommands {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
extraRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
): ConcordInviteListDocument {
|
||||
val relays = ctx.outboxRelays() + extraRelays
|
||||
if (relays.isEmpty()) return ConcordInviteListDocument.EMPTY
|
||||
val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(ctx.signer.pubKey))
|
||||
val newest =
|
||||
ctx
|
||||
.drain(relays.associateWith { listOf(filter) })
|
||||
.map { it.second }
|
||||
.maxByOrNull { it.createdAt }
|
||||
return (newest as? ConcordInviteListEvent)?.decrypt(ctx.signer) ?: ConcordInviteListDocument.EMPTY
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges [patch] into the published list and republishes it. Read-merge-write rather than
|
||||
* overwrite: the list is replaceable and per-creator, so two devices minting concurrently would
|
||||
* otherwise delete each other's links (and their `signer_sk`, which is unrecoverable).
|
||||
*/
|
||||
suspend fun publishInviteList(
|
||||
ctx: Context,
|
||||
patch: ConcordInviteListDocument,
|
||||
extraRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
) {
|
||||
val relays = ctx.outboxRelays() + extraRelays
|
||||
if (relays.isEmpty()) return
|
||||
val merged = ConcordInviteList.merge(readInviteList(ctx, extraRelays), patch)
|
||||
val event = ConcordInviteListEvent.create(ctx.signer, merged, TimeUtils.now())
|
||||
ctx.publish(event, relays)
|
||||
}
|
||||
|
||||
fun notFound(handle: String): Int {
|
||||
Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`")
|
||||
return 1
|
||||
|
||||
@@ -346,15 +346,20 @@ object ConcordModCommands {
|
||||
stored.relays,
|
||||
stored.controlPk.ifBlank { null },
|
||||
)
|
||||
val now = TimeUtils.now()
|
||||
var refreshed = 0
|
||||
for (link in stored.mintedInvites) {
|
||||
for (link in ConcordCommands.readInviteList(ctx, relays).entries) {
|
||||
if (link.communityId != stored.communityId) continue
|
||||
// An elapsed 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)) continue
|
||||
runCatching {
|
||||
val event =
|
||||
ConcordActions.remintBundleAt(
|
||||
linkSignerPrivKey = link.linkSignerPrivKey.hexToByteArray(),
|
||||
linkSignerPrivKey = link.signerSk.hexToByteArray(),
|
||||
token = link.token.hexToByteArray(),
|
||||
invite = refreshedInvite,
|
||||
createdAt = TimeUtils.now(),
|
||||
createdAt = now,
|
||||
)
|
||||
ctx.publish(event, relays)
|
||||
refreshed++
|
||||
|
||||
@@ -53,22 +53,6 @@ data class StoredCommunity(
|
||||
// rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct
|
||||
// invite or a community joined before amy stored it.
|
||||
val inviteRef: String = "",
|
||||
// Invite links WE minted for this community, kept so a Refounding can re-publish each bundle at
|
||||
// its own coordinate for the new epoch. Without this the link a member joined through points at
|
||||
// a dead epoch forever and stranded recovery can never fire (A2). Holds link-signer secrets, so
|
||||
// it sits beside `root`/`controlRoot` in the same already-secret file.
|
||||
val mintedInvites: List<StoredMintedInvite> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One invite link this account minted: enough to re-sign at its addressable coordinate later. The
|
||||
* coordinate is the link signer's pubkey, so keeping the private key is what lets a Refounding
|
||||
* refresh the link (and, in future, revoke it) instead of orphaning it.
|
||||
*/
|
||||
data class StoredMintedInvite(
|
||||
val linkSignerPrivKey: String = "",
|
||||
val token: String = "",
|
||||
val createdAt: Long = 0,
|
||||
)
|
||||
|
||||
/** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
|
||||
|
||||
Reference in New Issue
Block a user