diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2aeafc7c84..d24b5878a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1837,6 +1837,7 @@ class Account( rootEpoch = community.rootEpoch, relays = relayUrls, name = name, + addedAt = TimeUtils.now() * 1000, ), ) return community.communityIdHex @@ -1901,6 +1902,7 @@ class Account( rootEpoch = bundle.rootEpoch, relays = bundle.relays, name = bundle.name, + addedAt = TimeUtils.now() * 1000, ) joinConcordCommunity(entry) return bundle.communityId diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index a0fbd34643..c59e604dc5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.quartz.concord.cord02Community import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.ListSerializer /** A past root key for a specific epoch, kept so historical channel keys stay derivable. */ @Serializable @@ -39,6 +39,7 @@ class PrivateChannelKey( val channelId: String, val key: String, val epoch: Long, + val name: String = "", ) /** @@ -46,7 +47,8 @@ class PrivateChannelKey( * needed to re-derive the community's planes on any device: identity ([id], * [owner], [ownerSalt]), the current access [root] at [rootEpoch] plus past * [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached - * display [name]. + * display [name]. [addedAt] is the wire join timestamp (ms) that tiebreaks + * liveness against tombstones. */ @Serializable class ConcordCommunityListEntry( @@ -59,6 +61,7 @@ class ConcordCommunityListEntry( val privateChannels: List = emptyList(), val relays: List = emptyList(), val name: String = "", + val addedAt: Long = 0, ) /** @@ -67,10 +70,97 @@ class ConcordCommunityListEntry( * lets a client return to the groups the user signed up for. Replaceable and * NIP-44-encrypted to the member's own key, so relays store only ciphertext. * - * (Channels are not listed here: once the [root] is held, folding the Control - * Plane yields the community's channels.) + * The plaintext document is wire-compatible with Soapbox Armada's `communityList.ts`: + * `{ "entries": [ { "community_id", "seed": JoinMaterial, "current": JoinMaterial, + * "added_at" } ], "tombstones": [ { "community_id", "removed_at" } ] }`, where + * [JoinMaterialWire] is the snake_case per-snapshot key bundle. Liveness is derived — + * an entry is dropped only when a later tombstone removes it — and each entry keeps a + * [CommunityListEntryWire.seed] (backfill anchor) plus [CommunityListEntryWire.current] + * (latest) snapshot; we hydrate from `current`, falling back to `seed`. + * + * (Channels are not listed here beyond their private keys: once the [root] is held, + * folding the Control Plane yields the community's channels.) */ object ConcordCommunityList { + // ---- wire DTOs (snake_case, Armada communityList.ts) ---------------------- + + @Serializable + private class WireChannel( + val id: String, + val key: String, + val epoch: Long, + val name: String = "", + ) + + @Serializable + private class WireHeldRoot( + val epoch: Long, + val key: String, + ) + + @Serializable + private class JoinMaterialWire( + @SerialName("community_id") val communityId: String, + val owner: String, + @SerialName("owner_salt") val ownerSalt: String, + @SerialName("community_root") val communityRoot: String, + @SerialName("root_epoch") val rootEpoch: Long, + val channels: List = emptyList(), + val relays: List = emptyList(), + val name: String = "", + @SerialName("held_roots") val heldRoots: List = emptyList(), + val refounder: String? = null, + ) + + @Serializable + private class CommunityListEntryWire( + @SerialName("community_id") val communityId: String, + val seed: JoinMaterialWire? = null, + val current: JoinMaterialWire? = null, + @SerialName("added_at") val addedAt: Long = 0, + ) + + @Serializable + private class CommunityTombstoneWire( + @SerialName("community_id") val communityId: String, + @SerialName("removed_at") val removedAt: Long = 0, + ) + + @Serializable + private class CommunityListDoc( + val entries: List = emptyList(), + val tombstones: List = emptyList(), + ) + + private fun ConcordCommunityListEntry.toJoinMaterial() = + JoinMaterialWire( + communityId = id, + owner = owner, + ownerSalt = ownerSalt, + communityRoot = root, + rootEpoch = rootEpoch, + channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name) }, + relays = relays, + name = name, + heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) }, + ) + + private fun JoinMaterialWire.toEntry(addedAt: Long) = + ConcordCommunityListEntry( + id = communityId, + owner = owner, + ownerSalt = ownerSalt, + root = communityRoot, + rootEpoch = rootEpoch, + heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) }, + privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) }, + relays = relays, + name = name, + addedAt = addedAt, + ) + + // ---- build / codec -------------------------------------------------------- + /** Builds the encrypted kind-13302 list event from [entries], signed by [signer]. */ suspend fun build( signer: NostrSigner, @@ -81,13 +171,43 @@ object ConcordCommunityList { return signer.sign(createdAt, ConcordCommunityListEvent.KIND, emptyArray(), content) } - /** Serializes [entries] to the plaintext JSON that gets NIP-44 self-encrypted. */ - fun encode(entries: List): String = ConcordJson.instance.encodeToString(ListSerializer(ConcordCommunityListEntry.serializer()), entries) + /** Serializes [entries] to the plaintext JSON document that gets NIP-44 self-encrypted. */ + fun encode(entries: List): String { + val doc = + CommunityListDoc( + entries = + entries.map { e -> + val jm = e.toJoinMaterial() + CommunityListEntryWire( + communityId = e.id, + seed = jm, + current = jm, + addedAt = e.addedAt, + ) + }, + tombstones = emptyList(), + ) + return ConcordJson.instance.encodeToString(CommunityListDoc.serializer(), doc) + } - /** Parses the decrypted plaintext JSON back into entries, or empty on failure. */ + /** + * Parses the decrypted plaintext JSON document back into live entries, or empty on + * failure. An entry is live unless a tombstone for the same community removed it + * strictly after it was added; hydration prefers `current`, falling back to `seed`. + */ fun decode(json: String): List = try { - ConcordJson.instance.decodeFromString(ListSerializer(ConcordCommunityListEntry.serializer()), json) + val doc = ConcordJson.instance.decodeFromString(CommunityListDoc.serializer(), json) + val latestRemoval = HashMap() + for (t in doc.tombstones) { + val prev = latestRemoval[t.communityId] + if (prev == null || t.removedAt > prev) latestRemoval[t.communityId] = t.removedAt + } + doc.entries.mapNotNull { e -> + val removedAt = latestRemoval[e.communityId] + if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null + (e.current ?: e.seed)?.toEntry(e.addedAt) + } } catch (_: Exception) { emptyList() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt index 9259e09583..9a9dea784d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt @@ -69,6 +69,70 @@ class ConcordCommunityListTest { assertTrue(ConcordCommunityList.parse(event, other).isEmpty()) // wrong key ⇒ nothing } + @Test + fun decodesArmadaWireDocument() { + // A document as Soapbox Armada writes it (communityList.ts): {entries:[{community_id, + // seed, current, added_at}], tombstones:[]} with snake_case JoinMaterial. + val json = + """ + { + "entries": [ + { + "community_id": "${"11".repeat(32)}", + "seed": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"bb".repeat(32)}", + "root_epoch": 0, + "channels": [], + "relays": ["wss://relay.ditto.pub"], + "name": "Soapbox" + }, + "current": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"cc".repeat(32)}", + "root_epoch": 2, + "channels": [ + { "id": "${"ee".repeat(32)}", "key": "${"dd".repeat(32)}", "epoch": 2, "name": "secret" } + ], + "relays": ["wss://relay.ditto.pub"], + "name": "Soapbox", + "held_roots": [ { "epoch": 1, "key": "${"bb".repeat(32)}" } ] + }, + "added_at": 1700000000000 + } + ], + "tombstones": [] + } + """.trimIndent() + + val entries = ConcordCommunityList.decode(json) + assertEquals(1, entries.size) + val e = entries[0] + assertEquals("11".repeat(32), e.id) + assertEquals("Soapbox", e.name) + assertEquals("cc".repeat(32), e.root) // hydrated from `current`, not `seed` + assertEquals(2L, e.rootEpoch) + assertEquals(1700000000000L, e.addedAt) + assertEquals(listOf("wss://relay.ditto.pub"), e.relays) + assertEquals(1, e.privateChannels.size) + assertEquals("ee".repeat(32), e.privateChannels[0].channelId) + assertEquals("secret", e.privateChannels[0].name) + assertEquals(1, e.heldRoots.size) + assertEquals(1L, e.heldRoots[0].epoch) + } + + @Test + fun tombstoneAfterAddDropsEntry() { + val jm = """{"community_id":"${"11".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"aa".repeat(32)}","community_root":"${"bb".repeat(32)}","root_epoch":0,"channels":[],"relays":[],"name":"Gone"}""" + val json = + """{"entries":[{"community_id":"${"11".repeat(32)}","seed":$jm,"current":$jm,"added_at":100}],"tombstones":[{"community_id":"${"11".repeat(32)}","removed_at":200}]}""" + assertTrue(ConcordCommunityList.decode(json).isEmpty()) // removed after add ⇒ not live + } + @Test fun mergeKeepsFreshestEpochPerCommunity() { val a = listOf(entry("11".repeat(32), "Old", epoch = 1))