diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt new file mode 100644 index 0000000000..9edce5c798 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt @@ -0,0 +1,95 @@ +/* + * 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.cord04Roles + +/** + * A member's Concord permission set (CORD-04): a `u64` bitfield of frozen bit + * positions. On the wire it is encoded as a **decimal string** (not a JSON + * number) to prevent floating-point corruption of the high bits. + * + * Bit positions are frozen forever — new permissions claim the next free bit, + * retired ones are burned and never reused. Bit 7 is retired; bits 10–12 are + * reserved. + * + * "A member's effective permissions are the union of their Roles' bits." + */ +@JvmInline +value class ConcordPermissions( + val bits: ULong, +) { + fun has(bit: Int): Boolean = (bits and (1uL shl bit)) != 0uL + + /** True if every bit set in [other] is also set here (used for role-vs-actor checks). */ + fun hasAll(other: ConcordPermissions): Boolean = (bits and other.bits) == other.bits + + infix fun union(other: ConcordPermissions): ConcordPermissions = ConcordPermissions(bits or other.bits) + + fun with(bit: Int): ConcordPermissions = ConcordPermissions(bits or (1uL shl bit)) + + fun without(bit: Int): ConcordPermissions = ConcordPermissions(bits and (1uL shl bit).inv()) + + /** The wire encoding: an unsigned decimal string with no leading zeros. */ + fun toWire(): String = bits.toString() + + companion object { + val NONE = ConcordPermissions(0uL) + + // Frozen bit positions (CORD-04 §Permission Bits). + const val MANAGE_ROLES = 0 + const val MANAGE_CHANNELS = 1 + const val MANAGE_METADATA = 2 + const val KICK = 3 + const val BAN = 4 + const val MANAGE_MESSAGES = 5 + const val CREATE_INVITE = 6 + + // bit 7 retired — never reuse + + const val VIEW_AUDIT_LOG = 8 + const val MENTION_EVERYONE = 9 + + // bits 10-12 reserved + + fun of(vararg bits: Int): ConcordPermissions { + var acc = 0uL + for (b in bits) acc = acc or (1uL shl b) + return ConcordPermissions(acc) + } + + /** + * Parses the wire form — a decimal `u64` string. Returns [NONE] for a + * blank value; throws [NumberFormatException] for a malformed or + * out-of-range one so a corrupt edition is rejected rather than folded. + */ + fun fromWire(wire: String): ConcordPermissions { + if (wire.isBlank()) return NONE + return ConcordPermissions(wire.trim().toULong()) + } + + /** Non-throwing variant: returns null on a malformed value. */ + fun fromWireOrNull(wire: String): ConcordPermissions? = + try { + fromWire(wire) + } catch (_: NumberFormatException) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt new file mode 100644 index 0000000000..f399690a1e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.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.cord04Roles + +/** + * The Control Plane entity sub-kinds (the `vsk` tag on a kind-3308 edition), + * pinned to the Concord v2 reference client (`concord-v2/lib/kinds.ts`). + * + * On the wire the `vsk` value is a decimal string with no leading zeros + * ([wire]); [ControlEntityKind.of] maps that string back to the enum. + */ +enum class ControlEntityKind( + val wire: String, +) { + /** Community metadata (name, icon, description). */ + METADATA("0"), + + /** A Role definition (name, position, permissions, scope, color). */ + ROLE("1"), + + /** A Channel definition (name, private flag, voice flag). */ + CHANNEL("2"), + + /** A member→roles Grant. */ + GRANT("3"), + + /** The community-wide Banlist. */ + BANLIST("4"), + + /** A live invite-link registry entry (Public/Private source of truth). */ + INVITE_LIVE("6"), + + /** The aggregate invite registry. */ + INVITE_REGISTRY("8"), + + /** A revoked invite-link marker. */ + INVITE_REVOKED("9"), + + /** The dissolution tombstone (terminal). */ + DISSOLVED("10"), + ; + + companion object { + private val byWire = entries.associateBy { it.wire } + + fun of(wire: String): ControlEntityKind? = byWire[wire] + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt new file mode 100644 index 0000000000..801b755c1f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.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.events + +/** + * Every Nostr event kind used by the Concord protocol, pinned to the Concord v2 + * reference client (Soapbox Armada, `concord-v2/lib/kinds.ts`) for wire interop. + * + * Rumor kinds (9, 1111, 3302, 3303, 3306, 3308, 3309, 3310, 3312, 3313, 23311, + * 23313, 7, 5) never appear on the wire on their own — they are always sealed + * and wrapped by [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope]. + * Only the wrap (1059/21059) and the bare bookkeeping kinds (33301, 13302, 13303) + * are published directly. + */ +object ConcordKinds { + // Envelope (CORD-01) + const val WRAP = 1059 + const val WRAP_EPHEMERAL = 21059 + const val SEAL_ENCRYPTED = 20013 + const val SEAL_PLAINTEXT = 20014 + + // Chat Plane rumors (CORD-03) + const val MESSAGE = 9 + const val COMMENT = 1111 + const val REACTION = 7 + const val DELETE = 5 + const val EDIT = 3302 + const val WEBXDC = 3310 + const val TYPING = 23311 + const val VOICE_PRESENCE = 23313 + + // Guestbook Plane rumors (CORD-02) + const val JOIN_LEAVE = 3306 + const val KICK = 3309 + const val SNAPSHOT = 3312 + + // Person-addressed rumor (CORD-05) + const val DIRECT_INVITE = 3313 + + // Control / rekey rumors (CORD-02/04/06) + const val CONTROL = 3308 + const val REKEY = 3303 + + // Bare bookkeeping events + const val INVITE_BUNDLE = 33301 // addressable, CORD-05 + const val COMMUNITY_LIST = 13302 // private self-list, CORD-05 + const val INVITE_LIST = 13303 // private self-list, CORD-05 +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt new file mode 100644 index 0000000000..dbe9b16a18 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt @@ -0,0 +1,97 @@ +/* + * 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.cord04Roles + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.KICK +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.MANAGE_ROLES +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.MENTION_EVERYONE +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordPermissionsTest { + @Test + fun bitPositionsAreFrozen() { + assertEquals(0, MANAGE_ROLES) + assertEquals(3, KICK) + assertEquals(4, BAN) + assertEquals(9, MENTION_EVERYONE) + } + + @Test + fun hasChecksIndividualBits() { + val p = ConcordPermissions.of(KICK, BAN) + assertTrue(p.has(KICK)) + assertTrue(p.has(BAN)) + assertFalse(p.has(MANAGE_ROLES)) + } + + @Test + fun unionIsBitwiseOr() { + val a = ConcordPermissions.of(KICK) + val b = ConcordPermissions.of(BAN) + val u = a union b + assertTrue(u.has(KICK)) + assertTrue(u.has(BAN)) + // effective permissions are the union of a member's roles' bits + assertEquals(ConcordPermissions.of(KICK, BAN).bits, u.bits) + } + + @Test + fun wireEncodingIsDecimalString() { + // KICK(3) | BAN(4) = 0b11000 = 24 + assertEquals("24", ConcordPermissions.of(KICK, BAN).toWire()) + assertEquals(ConcordPermissions.of(KICK, BAN).bits, ConcordPermissions.fromWire("24").bits) + } + + @Test + fun highBitsSurviveDecimalRoundTripWithoutFloatingPointLoss() { + // Bit 63 set — a value that would be corrupted if parsed as a JSON double. + val hi = ConcordPermissions(1uL shl 63) + val wire = hi.toWire() + assertEquals("9223372036854775808", wire) + assertEquals(hi.bits, ConcordPermissions.fromWire(wire).bits) + } + + @Test + fun blankParsesToNoneAndGarbageIsRejected() { + assertEquals(ConcordPermissions.NONE.bits, ConcordPermissions.fromWire("").bits) + assertNull(ConcordPermissions.fromWireOrNull("not-a-number")) + assertNull(ConcordPermissions.fromWireOrNull("-1")) + } + + @Test + fun entityKindWireMappingMatchesReference() { + assertEquals(ControlEntityKind.METADATA, ControlEntityKind.of("0")) + assertEquals(ControlEntityKind.ROLE, ControlEntityKind.of("1")) + assertEquals(ControlEntityKind.CHANNEL, ControlEntityKind.of("2")) + assertEquals(ControlEntityKind.GRANT, ControlEntityKind.of("3")) + assertEquals(ControlEntityKind.BANLIST, ControlEntityKind.of("4")) + assertEquals(ControlEntityKind.INVITE_LIVE, ControlEntityKind.of("6")) + assertEquals(ControlEntityKind.INVITE_REGISTRY, ControlEntityKind.of("8")) + assertEquals(ControlEntityKind.INVITE_REVOKED, ControlEntityKind.of("9")) + assertEquals(ControlEntityKind.DISSOLVED, ControlEntityKind.of("10")) + assertNull(ControlEntityKind.of("7")) // retired/unknown + } +}