diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/CommunityRulesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/CommunityRulesEvent.kt new file mode 100644 index 000000000..dbf247e2a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/CommunityRulesEvent.kt @@ -0,0 +1,131 @@ +/* + * 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.nip72ModCommunities.rules + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.MaxEventSizeTag +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.MinRulesCreatedAtTag +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-9A Verifiable Community Rules. + * + * Addressable replaceable event (`kind:34551`) carrying a machine-readable, signed + * rules document for a community defined by NIP-72 (`kind:34550`). Clients fetch + * the latest rules event before publishing into a community and reject drafts + * locally if they would violate any rule, surfacing the violation to the user + * before send. + * + * Strictly additive to NIP-72: the freeform `rules` tag on `kind:34550` continues + * to carry human-readable text. This event carries the machine-readable companion. + * + * See `9A.md` in nostr-protocol/nips for the full specification. + */ +@Immutable +class CommunityRulesEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + /** All `k` rules. May contain duplicates; callers usually want [allowedKinds]. */ + fun kindRules(): List = tags.mapNotNull(KindRuleTag::parse) + + /** Distinct kinds whitelisted by this rules document. */ + fun allowedKinds(): Set = kindRules().map { it.kind }.toSet() + + /** Returns the rule for [kind], or null if the kind is not whitelisted. */ + fun ruleForKind(kind: Int): KindRuleTag? = kindRules().firstOrNull { it.kind == kind } + + fun pubkeyRules(): List = tags.mapNotNull(PubkeyRuleTag::parse) + + /** + * Returns the policy declared for [pubkey], or null if no `p` rule applies. + * `deny` overrides `allow`; if both exist for the same pubkey, deny wins. + */ + fun policyFor(pubkey: HexKey): PubkeyRuleTag.Policy? { + var allow: PubkeyRuleTag.Policy? = null + for (rule in pubkeyRules()) { + if (rule.pubkey != pubkey) continue + if (rule.policy == PubkeyRuleTag.Policy.DENY) return PubkeyRuleTag.Policy.DENY + allow = PubkeyRuleTag.Policy.ALLOW + } + return allow + } + + fun wotGates(): List = tags.mapNotNull(WotTag::parse) + + fun maxEventSize(): Int? = tags.firstNotNullOfOrNull(MaxEventSizeTag::parse) + + fun minRulesCreatedAt(): Long? = tags.firstNotNullOfOrNull(MinRulesCreatedAtTag::parse) + + /** Address (`a` tag) of the community this rules document governs. */ + fun communityAddress(): String? = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + companion object { + const val KIND = 34551 + const val KIND_STR = "34551" + const val ALT_DESCRIPTION = "Community rules" + + fun build( + dTag: String, + communityAddress: ATag, + kindRules: List, + pubkeyRules: List = emptyList(), + wotGates: List = emptyList(), + maxEventSize: Int? = null, + minRulesCreatedAt: Long? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + + dTag(dTag) + add(communityAddress.toATagArray()) + + kindRules.forEach { add(it.toTagArray()) } + pubkeyRules.forEach { add(it.toTagArray()) } + wotGates.forEach { add(it.toTagArray()) } + maxEventSize?.let { add(MaxEventSizeTag.assemble(it)) } + minRulesCreatedAt?.let { add(MinRulesCreatedAtTag.assemble(it)) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/CommunityRulesValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/CommunityRulesValidator.kt new file mode 100644 index 000000000..d94dbc039 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/CommunityRulesValidator.kt @@ -0,0 +1,155 @@ +/* + * 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.nip72ModCommunities.rules + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag + +/** + * Validates draft events against a [CommunityRulesEvent] (NIP-9A). + * + * Pure logic; no I/O. Web-of-trust gates are deferred to the caller via + * [WotResolver] because NIP-9A leaves the lookup mechanism unspecified + * (NIP-02 follow lists, NIP-85 trusted assertions, etc). + */ +class CommunityRulesValidator( + private val rules: CommunityRulesEvent, +) { + /** A reason a draft event would be rejected. */ + sealed interface Violation { + /** The rules document was signed before [minRulesCreatedAt] and must be ignored. */ + data class StaleRules( + val rulesCreatedAt: Long, + val minRulesCreatedAt: Long, + ) : Violation + + /** Author is on the deny-list. */ + data class AuthorDenied( + val author: HexKey, + ) : Violation + + /** No `k` rule whitelists this kind. */ + data class KindNotAllowed( + val kind: Int, + ) : Violation + + /** Event exceeds the kind-specific size limit. */ + data class KindSizeExceeded( + val kind: Int, + val sizeBytes: Int, + val maxBytes: Int, + ) : Violation + + /** Event exceeds the global `max_event_size`. */ + data class MaxSizeExceeded( + val sizeBytes: Int, + val maxBytes: Int, + ) : Violation + + /** Author exceeded the per-day quota for this kind. */ + data class QuotaExceeded( + val kind: Int, + val postsToday: Int, + val maxPerDay: Int, + ) : Violation + + /** Author failed every web-of-trust gate. */ + data class WotGateFailed( + val gateCount: Int, + ) : Violation + } + + /** Looks up whether [author] is reachable from [root] in the follow graph within [depth] hops. */ + fun interface WotResolver { + fun isReachable( + author: HexKey, + root: HexKey, + depth: Int, + ): Boolean + } + + /** + * Validates a draft event. Returns null on success, or the first violation found. + * + * @param author pubkey of the draft author + * @param kind kind of the draft event + * @param sizeBytes JSON-encoded event size in bytes + * @param postsTodayByKind callback returning the count of events of [kind] this author has + * already published today, or null if unknown (in which case quota checks are skipped) + * @param wot resolver for `wot` gates, or null to skip WoT checks + */ + fun validate( + author: HexKey, + kind: Int, + sizeBytes: Int, + postsTodayByKind: ((Int) -> Int?)? = null, + wot: WotResolver? = null, + ): Violation? { + // 1. Stale-rules ratchet: callers should pre-filter, but defend in depth. + rules.minRulesCreatedAt()?.let { minAt -> + if (rules.createdAt < minAt) { + return Violation.StaleRules(rules.createdAt, minAt) + } + } + + // 2. Author deny-list overrides everything else. + if (rules.policyFor(author) == PubkeyRuleTag.Policy.DENY) { + return Violation.AuthorDenied(author) + } + + // 3. Kind whitelist. + val kindRule = rules.ruleForKind(kind) ?: return Violation.KindNotAllowed(kind) + + // 4. Per-kind size limit. + kindRule.maxBytes?.let { maxBytes -> + if (sizeBytes > maxBytes) { + return Violation.KindSizeExceeded(kind, sizeBytes, maxBytes) + } + } + + // 5. Global size cap. + rules.maxEventSize()?.let { maxBytes -> + if (sizeBytes > maxBytes) { + return Violation.MaxSizeExceeded(sizeBytes, maxBytes) + } + } + + // 6. Per-day quota. + if (kindRule.maxPerAuthorPerDay != null && postsTodayByKind != null) { + val today = postsTodayByKind(kind) + if (today != null && today >= kindRule.maxPerAuthorPerDay) { + return Violation.QuotaExceeded(kind, today, kindRule.maxPerAuthorPerDay) + } + } + + // 7. WoT gates: pass if any one gate accepts the author. Allow-listed pubkeys + // bypass WoT (an explicit `allow` is a stronger signal than a graph traversal). + if (rules.policyFor(author) != PubkeyRuleTag.Policy.ALLOW) { + val gates = rules.wotGates() + if (gates.isNotEmpty() && wot != null) { + val anyPasses = gates.any { wot.isReachable(author, it.rootPubkey, it.depth) } + if (!anyPasses) return Violation.WotGateFailed(gates.size) + } + } + + return null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/KindRuleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/KindRuleTag.kt new file mode 100644 index 000000000..c2950ac5c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/KindRuleTag.kt @@ -0,0 +1,71 @@ +/* + * 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.nip72ModCommunities.rules.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-9A `k` tag: whitelists one event kind for the community, optionally with a + * max event size in bytes and a per-author quota per day. + * + * Form: `["k", "", "", ""]` + * + * Position 2 (kind) is required. Positions 3 and 4 are optional; an empty string + * is treated as "unset". + */ +@Immutable +data class KindRuleTag( + val kind: Int, + val maxBytes: Int?, + val maxPerAuthorPerDay: Int?, +) { + fun toTagArray() = assemble(kind, maxBytes, maxPerAuthorPerDay) + + companion object { + const val TAG_NAME = "k" + + fun parse(tag: Array): KindRuleTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val kind = tag[1].toIntOrNull() ?: return null + val maxBytes = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.toIntOrNull() + val maxPerDay = tag.getOrNull(3)?.takeIf { it.isNotEmpty() }?.toIntOrNull() + + return KindRuleTag(kind, maxBytes, maxPerDay) + } + + fun assemble( + kind: Int, + maxBytes: Int?, + maxPerAuthorPerDay: Int?, + ) = arrayOfNotNull( + TAG_NAME, + kind.toString(), + maxBytes?.toString(), + maxPerAuthorPerDay?.toString(), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/MaxEventSizeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/MaxEventSizeTag.kt new file mode 100644 index 000000000..514280c21 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/MaxEventSizeTag.kt @@ -0,0 +1,44 @@ +/* + * 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.nip72ModCommunities.rules.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-9A `max_event_size` tag: hard cap on the JSON-encoded event byte size, + * applied in addition to any per-kind size limit from `KindRuleTag`. + * + * Form: `["max_event_size", ""]` + */ +class MaxEventSizeTag { + companion object { + const val TAG_NAME = "max_event_size" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toIntOrNull()?.takeIf { it > 0 } + } + + fun assemble(maxBytes: Int) = arrayOf(TAG_NAME, maxBytes.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/MinRulesCreatedAtTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/MinRulesCreatedAtTag.kt new file mode 100644 index 000000000..81052f06a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/MinRulesCreatedAtTag.kt @@ -0,0 +1,47 @@ +/* + * 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.nip72ModCommunities.rules.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-9A `min_rules_created_at` tag: anti-rollback ratchet. + * + * Clients MUST refuse rules events whose `created_at` is below this value, even + * when signed by the legitimate owner. Limits the damage window from a stolen + * key replay. + * + * Form: `["min_rules_created_at", ""]` + */ +class MinRulesCreatedAtTag { + companion object { + const val TAG_NAME = "min_rules_created_at" + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toLongOrNull()?.takeIf { it >= 0 } + } + + fun assemble(minCreatedAt: Long) = arrayOf(TAG_NAME, minCreatedAt.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/PubkeyRuleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/PubkeyRuleTag.kt new file mode 100644 index 000000000..f73a5fcbe --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/PubkeyRuleTag.kt @@ -0,0 +1,77 @@ +/* + * 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.nip72ModCommunities.rules.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-9A `p` tag: per-pubkey allow/deny override with optional role. + * + * Form: `["p", "", "", ""]` + * + * `deny` overrides any `allow` and any other rule. + */ +@Immutable +data class PubkeyRuleTag( + val pubkey: HexKey, + val policy: Policy, + val role: String?, +) { + enum class Policy { ALLOW, DENY } + + fun toTagArray() = assemble(pubkey, policy, role) + + companion object { + const val TAG_NAME = "p" + const val ALLOW = "allow" + const val DENY = "deny" + + fun parse(tag: Array): PubkeyRuleTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val policy = + when (tag[2]) { + ALLOW -> Policy.ALLOW + DENY -> Policy.DENY + else -> return null + } + + return PubkeyRuleTag(tag[1], policy, tag.getOrNull(3)?.takeIf { it.isNotEmpty() }) + } + + fun assemble( + pubkey: HexKey, + policy: Policy, + role: String?, + ) = arrayOfNotNull( + TAG_NAME, + pubkey, + if (policy == Policy.ALLOW) ALLOW else DENY, + role, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/WotTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/WotTag.kt new file mode 100644 index 000000000..dec0c4a3e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/tags/WotTag.kt @@ -0,0 +1,63 @@ +/* + * 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.nip72ModCommunities.rules.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-9A `wot` tag: optional web-of-trust gate. + * + * Form: `["wot", "", ""]` + * + * Posts are allowed only if the author is reachable from `` through + * follow lists within `` hops. Multiple `wot` tags act as OR (any one + * passing is enough). The lookup mechanism is unspecified: clients MAY use NIP-02 + * follow lists, NIP-85 trusted assertions, or any other source. + */ +@Immutable +data class WotTag( + val rootPubkey: HexKey, + val depth: Int, +) { + fun toTagArray() = assemble(rootPubkey, depth) + + companion object { + const val TAG_NAME = "wot" + + fun parse(tag: Array): WotTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val depth = tag[2].toIntOrNull()?.takeIf { it > 0 } ?: return null + + return WotTag(tag[1], depth) + } + + fun assemble( + rootPubkey: HexKey, + depth: Int, + ) = arrayOf(TAG_NAME, rootPubkey, depth.toString()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/CommunityRulesValidatorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/CommunityRulesValidatorTest.kt new file mode 100644 index 000000000..b7dc490e3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/rules/CommunityRulesValidatorTest.kt @@ -0,0 +1,196 @@ +/* + * 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.nip72ModCommunities.rules + +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.MaxEventSizeTag +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.MinRulesCreatedAtTag +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag +import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CommunityRulesValidatorTest { + private val owner = "a".repeat(64) + private val author = "b".repeat(64) + private val villain = "c".repeat(64) + private val communityAddr = "34550:$owner:my-community" + private val rulesD = "my-community" + + private fun rules( + kindRules: List = listOf(KindRuleTag(1111, 16384, null)), + pubkeyRules: List = emptyList(), + wotGates: List = emptyList(), + maxSize: Int? = null, + minRulesCreatedAt: Long? = null, + createdAt: Long = 1_700_000_000L, + ): CommunityRulesEvent { + val tags = + buildList { + add(arrayOf("d", rulesD)) + add(arrayOf("a", communityAddr)) + kindRules.forEach { add(it.toTagArray()) } + pubkeyRules.forEach { add(it.toTagArray()) } + wotGates.forEach { add(it.toTagArray()) } + maxSize?.let { add(MaxEventSizeTag.assemble(it)) } + minRulesCreatedAt?.let { add(MinRulesCreatedAtTag.assemble(it)) } + }.toTypedArray() + return CommunityRulesEvent("id", owner, createdAt, tags, "", "sig") + } + + @Test + fun allowsConformantPost() { + val v = CommunityRulesValidator(rules()).validate(author, 1111, 1024) + assertNull(v) + } + + @Test + fun rejectsDisallowedKind() { + val v = CommunityRulesValidator(rules()).validate(author, 30023, 1024) + val violation = assertIs(v) + assertEquals(30023, violation.kind) + } + + @Test + fun rejectsOversizedPostByKindLimit() { + val v = CommunityRulesValidator(rules()).validate(author, 1111, 20_000) + val violation = assertIs(v) + assertEquals(1111, violation.kind) + assertEquals(20_000, violation.sizeBytes) + assertEquals(16384, violation.maxBytes) + } + + @Test + fun rejectsOversizedPostByGlobalCap() { + val r = rules(kindRules = listOf(KindRuleTag(1111, null, null)), maxSize = 8000) + val v = CommunityRulesValidator(r).validate(author, 1111, 9000) + val violation = assertIs(v) + assertEquals(8000, violation.maxBytes) + } + + @Test + fun rejectsDeniedAuthorRegardlessOfKind() { + val r = + rules( + pubkeyRules = listOf(PubkeyRuleTag(villain, PubkeyRuleTag.Policy.DENY, null)), + ) + val v = CommunityRulesValidator(r).validate(villain, 1111, 100) + val violation = assertIs(v) + assertEquals(villain, violation.author) + } + + @Test + fun denyOverridesAllowForSamePubkey() { + val r = + rules( + pubkeyRules = + listOf( + PubkeyRuleTag(villain, PubkeyRuleTag.Policy.ALLOW, null), + PubkeyRuleTag(villain, PubkeyRuleTag.Policy.DENY, null), + ), + ) + val v = CommunityRulesValidator(r).validate(villain, 1111, 100) + assertIs(v) + } + + @Test + fun enforcesPerKindDailyQuota() { + val r = rules(kindRules = listOf(KindRuleTag(1111, null, 5))) + val v = CommunityRulesValidator(r).validate(author, 1111, 100, postsTodayByKind = { 5 }) + val violation = assertIs(v) + assertEquals(5, violation.postsToday) + assertEquals(5, violation.maxPerDay) + } + + @Test + fun unknownQuotaPostsDoesNotBlock() { + val r = rules(kindRules = listOf(KindRuleTag(1111, null, 5))) + val v = CommunityRulesValidator(r).validate(author, 1111, 100, postsTodayByKind = { null }) + assertNull(v) + } + + @Test + fun wotGatePassesWhenReachable() { + val r = rules(wotGates = listOf(WotTag(owner, 2))) + val v = + CommunityRulesValidator(r).validate(author, 1111, 100) { _, _, _ -> true } + assertNull(v) + } + + @Test + fun wotGateFailsWhenUnreachable() { + val r = rules(wotGates = listOf(WotTag(owner, 2))) + val v = + CommunityRulesValidator(r).validate(author, 1111, 100) { _, _, _ -> false } + val violation = assertIs(v) + assertEquals(1, violation.gateCount) + } + + @Test + fun explicitAllowBypassesWotGate() { + val r = + rules( + pubkeyRules = listOf(PubkeyRuleTag(author, PubkeyRuleTag.Policy.ALLOW, "vip")), + wotGates = listOf(WotTag(owner, 2)), + ) + val v = + CommunityRulesValidator(r).validate(author, 1111, 100) { _, _, _ -> false } + assertNull(v) + } + + @Test + fun multipleWotGatesActAsOr() { + val r = + rules( + wotGates = + listOf( + WotTag(owner, 1), + WotTag(villain, 5), + ), + ) + // Only the second gate accepts. + val v = + CommunityRulesValidator(r).validate(author, 1111, 100) { _, root, _ -> + root == villain + } + assertNull(v) + } + + @Test + fun staleRulesAreRejected() { + val r = rules(minRulesCreatedAt = 1_800_000_000L, createdAt = 1_700_000_000L) + val v = CommunityRulesValidator(r).validate(author, 1111, 100) + val violation = assertIs(v) + assertEquals(1_700_000_000L, violation.rulesCreatedAt) + assertEquals(1_800_000_000L, violation.minRulesCreatedAt) + } + + @Test + fun parsesEventAccessors() { + val r = rules() + assertEquals(communityAddr, r.communityAddress()) + assertEquals(setOf(1111), r.allowedKinds()) + assertTrue(r.kindRules().isNotEmpty()) + } +}