diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 3e06aa5b72..76e7a8d59b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -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 = 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 = 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 342ae2501d..23ec2a47ef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -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++ diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index e08bfcc8f8..d2de85a258 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -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 = 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`. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt new file mode 100644 index 0000000000..3eea2095c3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -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 = emptyList(), + val tombstones: List = 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( + delegate: KSerializer, + ) : JsonTransformingSerializer(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.serializer()) + + private object WireTombstoneSerializer : ExtrasPreserving(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.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() + for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t + + val entries = LinkedHashMap() + 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), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt new file mode 100644 index 0000000000..f4bbd80f1c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt @@ -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>, + 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) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 622fa0b889..3955952fdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt new file mode 100644 index 0000000000..e488aeccb5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -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") + } +}