diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt new file mode 100644 index 0000000000..daba4d65ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -0,0 +1,98 @@ +/* + * 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.cord02Community + +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.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity + +/** A channel id paired with its current folded definition. */ +class ConcordChannel( + val channelIdHex: String, + val definition: ChannelEntity, +) + +/** + * The current, folded state of a Concord community's Control Plane (CORD-02). + * + * Produced by [fold] from the set of decrypted, verified control editions plus + * the community's known [ownerPubKey]. It exposes the community [metadata], the + * live (non-deleted) [channels], the live [roles], the owner-rooted [authority] + * resolver, and whether the community has been [dissolved]. + * + * "Every member keeps the entire Control Plane in sync — it is small and must + * stay complete." Recompute this whenever the known editions change. + */ +class ConcordCommunityState( + val ownerPubKey: String, + val metadata: MetadataEntity?, + val channels: Map, + val roles: Map, + val authority: AuthorityResolver, + val dissolved: Boolean, +) { + companion object { + fun fold( + editions: Collection, + ownerPubKey: String, + ): ConcordCommunityState { + val heads = EditionFold.fold(editions).values + + val metadata = + heads + .filter { it.entityKind == ControlEntityKind.METADATA } + .maxByOrNull { it.version } // one metadata entity; guard against strays + ?.let { ConcordJson.decodeOrNull(it.content) } + + val channels = LinkedHashMap() + for (e in heads) { + if (e.entityKind != ControlEntityKind.CHANNEL) continue + val def = ConcordJson.decodeOrNull(e.content) ?: continue + if (def.deleted) continue + channels[e.entityIdHex] = ConcordChannel(e.entityIdHex, def) + } + + val roles = HashMap() + for (e in heads) { + if (e.entityKind != ControlEntityKind.ROLE) continue + val r = ConcordJson.decodeOrNull(e.content) ?: continue + if (r.deleted) continue + roles[e.entityIdHex] = r + } + + val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED } + + return ConcordCommunityState( + ownerPubKey = ownerPubKey.lowercase(), + metadata = metadata, + channels = channels, + roles = roles, + authority = AuthorityResolver.resolve(heads, ownerPubKey), + dissolved = dissolved, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt new file mode 100644 index 0000000000..37ce1dfd35 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt @@ -0,0 +1,126 @@ +/* + * 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.cord02Community + +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** A self-signed membership motion on the Guestbook Plane. */ +enum class GuestbookAction( + val wire: String, +) { + JOIN("join"), + LEAVE("leave"), + ; + + companion object { + fun of(wire: String) = entries.firstOrNull { it.wire == wire } + } +} + +/** A parsed Guestbook join/leave: who ([member]), what ([action]), and invite attribution. */ +class GuestbookEntry( + val member: HexKey, + val action: GuestbookAction, + val createdAt: Long, + val inviteCreator: HexKey?, + val inviteLabel: String?, +) + +/** + * The Guestbook Plane (CORD-02): self-signed Joins and Leaves plus authorized + * Kicks that track membership motion. It is **off-consensus** — nothing in the + * Control or Chat planes depends on it — so it is best-effort presence, not + * authority. + * + * Rumor builders here are unsigned; seal + wrap them onto the community's + * Guestbook plane with + * [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope]. + */ +object Guestbook { + const val TAG_MS = "ms" + const val TAG_INVITE = "invite" + const val TAG_P = "p" + + /** A self-signed join (kind 3306), optionally attributing the invite used. */ + fun join( + memberPubKey: HexKey, + createdAt: Long, + subMs: Int? = null, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ): Event = motion(memberPubKey, GuestbookAction.JOIN, createdAt, subMs, inviteCreator, inviteLabel) + + /** A self-signed leave (kind 3306). */ + fun leave( + memberPubKey: HexKey, + createdAt: Long, + subMs: Int? = null, + ): Event = motion(memberPubKey, GuestbookAction.LEAVE, createdAt, subMs, null, null) + + private fun motion( + memberPubKey: HexKey, + action: GuestbookAction, + createdAt: Long, + subMs: Int?, + inviteCreator: HexKey?, + inviteLabel: String?, + ): Event { + val tags = ArrayList>(2) + if (subMs != null) tags.add(arrayOf(TAG_MS, subMs.toString())) + if (inviteCreator != null) { + tags.add(if (inviteLabel != null) arrayOf(TAG_INVITE, inviteCreator, inviteLabel) else arrayOf(TAG_INVITE, inviteCreator)) + } + return RumorAssembler.assembleRumor(memberPubKey, createdAt, ConcordKinds.JOIN_LEAVE, tags.toTypedArray(), action.wire) + } + + /** + * An authorized Kick (kind 3309) directing [target] to leave. A Kick is only + * honored by clients when its signer holds [com.vitorpamplona.quartz.concord + * .cord04Roles.ConcordPermissions.KICK] and outranks the target — a Kick from + * a non-KICK holder is dropped (CORD-04 §The Three Removals). + */ + fun kick( + actorPubKey: HexKey, + target: HexKey, + createdAt: Long, + ): Event = RumorAssembler.assembleRumor(actorPubKey, createdAt, ConcordKinds.KICK, arrayOf(arrayOf(TAG_P, target)), "") + + /** Parses a Guestbook join/leave rumor, or null if it is not one. */ + fun parse(rumor: Event): GuestbookEntry? { + if (rumor.kind != ConcordKinds.JOIN_LEAVE) return null + val action = GuestbookAction.of(rumor.content) ?: return null + val invite = rumor.tags.firstOrNull { it.size >= 2 && it[0] == TAG_INVITE } + return GuestbookEntry( + member = rumor.pubKey, + action = action, + createdAt = rumor.createdAt, + inviteCreator = invite?.getOrNull(1), + inviteLabel = invite?.getOrNull(2), + ) + } + + /** The kick target's pubkey from a kind-3309 rumor, or null. */ + fun kickTarget(rumor: Event): HexKey? = if (rumor.kind == ConcordKinds.KICK) rumor.tags.firstTagValue(TAG_P) else null +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index 6e8c02a583..ba9ff93571 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -84,3 +84,24 @@ class GrantEntity( val member: String = "", @SerialName("role_ids") val roleIds: List = emptyList(), ) + +/** + * A Channel's content (CORD-03). The channel id is the edition entity id. + * [private] selects derived-key visibility; [voice] flags an audio channel. + * A [deleted] channel is terminal — its id is never reused. + */ +@Serializable +class ChannelEntity( + val name: String = "", + val private: Boolean = false, + val voice: Boolean = false, + val deleted: Boolean = false, +) + +/** A community's Metadata content (CORD-02): display name, icon, and description. */ +@Serializable +class MetadataEntity( + val name: String = "", + val icon: String? = null, + val description: String? = null, +) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt new file mode 100644 index 0000000000..501e44697e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt @@ -0,0 +1,83 @@ +/* + * 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.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordCommunityStateTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) + private val adminRole = "11".repeat(32) + + private fun edition( + kind: ControlEntityKind, + eid: String, + content: String, + author: String = owner, + ) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0) + + @Test + fun foldsMetadataChannelsRolesAndAuthority() { + val editions = + listOf( + edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"My Server","description":"hi"}"""), + edition(ControlEntityKind.CHANNEL, "c1".repeat(32), """{"name":"general","private":false}"""), + edition(ControlEntityKind.CHANNEL, "c2".repeat(32), """{"name":"voice-lounge","private":false,"voice":true}"""), + edition(ControlEntityKind.CHANNEL, "c3".repeat(32), """{"name":"old","deleted":true}"""), + edition(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), + edition(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$alice","role_ids":["$adminRole"]}"""), + ) + + val state = ConcordCommunityState.fold(editions, owner) + + assertEquals("My Server", state.metadata?.name) + assertEquals("hi", state.metadata?.description) + + // deleted channel excluded; general + voice channel kept + assertEquals(2, state.channels.size) + assertEquals("general", state.channels["c1".repeat(32)]?.definition?.name) + assertTrue(state.channels["c2".repeat(32)]?.definition?.voice == true) + assertNull(state.channels["c3".repeat(32)]) + + assertNotNull(state.roles[adminRole]) + assertEquals(1L, state.authority.rank(alice)) + assertFalse(state.dissolved) + } + + @Test + fun dissolutionTombstoneMarksCommunityDissolved() { + val editions = + listOf( + edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"Doomed"}"""), + edition(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""), + ) + val state = ConcordCommunityState.fold(editions, owner) + assertTrue(state.dissolved) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt new file mode 100644 index 0000000000..71968b1d04 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt @@ -0,0 +1,66 @@ +/* + * 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.cord02Community + +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GuestbookTest { + private val member = KeyPair().pubKey.toHexKey() + private val creator = KeyPair().pubKey.toHexKey() + private val target = KeyPair().pubKey.toHexKey() + + @Test + fun joinWithInviteAttributionRoundTrips() { + val rumor = Guestbook.join(member, createdAt = 1_700_000_000L, subMs = 128, inviteCreator = creator, inviteLabel = "Reddit") + assertEquals(ConcordKinds.JOIN_LEAVE, rumor.kind) + assertEquals("join", rumor.content) + + val entry = Guestbook.parse(rumor) + assertEquals(GuestbookAction.JOIN, entry?.action) + assertEquals(member, entry?.member) + assertEquals(creator, entry?.inviteCreator) + assertEquals("Reddit", entry?.inviteLabel) + } + + @Test + fun leaveParses() { + val entry = Guestbook.parse(Guestbook.leave(member, createdAt = 1L)) + assertEquals(GuestbookAction.LEAVE, entry?.action) + assertNull(entry?.inviteCreator) + } + + @Test + fun kickTargetsAMember() { + val rumor = Guestbook.kick(actorPubKey = creator, target = target, createdAt = 1L) + assertEquals(ConcordKinds.KICK, rumor.kind) + assertEquals(target, Guestbook.kickTarget(rumor)) + } + + @Test + fun parseIgnoresNonGuestbookRumors() { + assertNull(Guestbook.parse(Guestbook.kick(creator, target, 1L))) // kick is not a join/leave + } +}