mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +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`. */
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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 kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.elementNames
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||
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
|
||||
}
|
||||
|
||||
/** 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]. */
|
||||
class ConcordInviteListDocument(
|
||||
val entries: List<ConcordInviteListEntry> = emptyList(),
|
||||
val tombstones: List<ConcordInviteListTombstone> = emptyList(),
|
||||
val residue: JsonObject = NoExtras,
|
||||
) {
|
||||
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. A malformed document yields [ConcordInviteListDocument.EMPTY]
|
||||
* rather than throwing — but note the sharp edge this shape shares with the community list: one
|
||||
* unparseable entry aborts the whole array, so every field defaults instead of being required.
|
||||
*/
|
||||
fun decode(json: String): ConcordInviteListDocument =
|
||||
try {
|
||||
val doc = ConcordJson.instance.decodeFromString(WireDocumentSerializer, json)
|
||||
ConcordInviteListDocument(
|
||||
entries =
|
||||
doc.entries.map {
|
||||
ConcordInviteListEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.extras)
|
||||
},
|
||||
tombstones = doc.tombstones.map { ConcordInviteListTombstone(it.token, it.communityId, it.extras) },
|
||||
residue = doc.extras,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ConcordInviteListDocument.EMPTY
|
||||
}
|
||||
|
||||
fun encode(doc: ConcordInviteListDocument): String =
|
||||
ConcordJson.instance.encodeToString(
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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.
|
||||
* 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.decode(signer.nip44Decrypt(content, signer.pubKey))
|
||||
} catch (_: Exception) {
|
||||
ConcordInviteListDocument.EMPTY
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* 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.decode(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.decode(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.decode(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 aMalformedDocumentYieldsEmptyRatherThanThrowing() {
|
||||
assertEquals(0, ConcordInviteList.decode("not json").entries.size)
|
||||
assertEquals(0, ConcordInviteList.decode("{\"entries\":\"wrong type\"}").entries.size)
|
||||
}
|
||||
|
||||
@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