feat(quartz): NIP-9A community rules parser + validator (kind:34551)
Adds CommunityRulesEvent and CommunityRulesValidator under quartz/.../nip72ModCommunities/rules/, mirroring the existing definition/ and approval/ subpackages. NIP-9A complements NIP-72: the freeform rules tag on kind:34550 keeps the human-readable text, this new addressable kind:34551 event carries the machine-readable companion. Rules cover per-kind allow-lists with optional max-bytes and per-author-per-day quotas, per-pubkey allow/deny overrides, an optional web-of-trust gate, a global event size cap, and an anti-rollback ratchet for stolen-key recovery. CommunityRulesValidator is pure logic (no I/O); WoT lookups are deferred to the caller via a WotResolver functional interface so consumers can plug in NIP-02 follow lists, NIP-85 trusted assertions, or any local source. 14 commonTest cases covering each violation path and the explicit-allow override over WoT gates. Spec: https://github.com/nostr-protocol/nips/pull/2331 This commit only adds the protocol layer. UI integration (composer validation, rules editor, feed filter) will land as separate PRs once the NIP draft has had review feedback and a kind number is finalised.
This commit is contained in:
+131
@@ -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<Array<String>>,
|
||||
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<KindRuleTag> = tags.mapNotNull(KindRuleTag::parse)
|
||||
|
||||
/** Distinct kinds whitelisted by this rules document. */
|
||||
fun allowedKinds(): Set<Int> = 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<PubkeyRuleTag> = 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<WotTag> = 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<KindRuleTag>,
|
||||
pubkeyRules: List<PubkeyRuleTag> = emptyList(),
|
||||
wotGates: List<WotTag> = emptyList(),
|
||||
maxEventSize: Int? = null,
|
||||
minRulesCreatedAt: Long? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CommunityRulesEvent>.() -> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -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
|
||||
}
|
||||
}
|
||||
+71
@@ -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", "<kind>", "<max-bytes?>", "<max-per-author-per-day?>"]`
|
||||
*
|
||||
* 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<String>): 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+44
@@ -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", "<bytes>"]`
|
||||
*/
|
||||
class MaxEventSizeTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "max_event_size"
|
||||
|
||||
fun parse(tag: Array<String>): 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())
|
||||
}
|
||||
}
|
||||
+47
@@ -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", "<unix-seconds>"]`
|
||||
*/
|
||||
class MinRulesCreatedAtTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "min_rules_created_at"
|
||||
|
||||
fun parse(tag: Array<String>): 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())
|
||||
}
|
||||
}
|
||||
+77
@@ -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", "<hex>", "<allow|deny>", "<role?>"]`
|
||||
*
|
||||
* `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<String>): 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+63
@@ -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", "<root-pubkey>", "<depth>"]`
|
||||
*
|
||||
* Posts are allowed only if the author is reachable from `<root-pubkey>` through
|
||||
* follow lists within `<depth>` 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<String>): 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())
|
||||
}
|
||||
}
|
||||
+196
@@ -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<KindRuleTag> = listOf(KindRuleTag(1111, 16384, null)),
|
||||
pubkeyRules: List<PubkeyRuleTag> = emptyList(),
|
||||
wotGates: List<WotTag> = 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<CommunityRulesValidator.Violation.KindNotAllowed>(v)
|
||||
assertEquals(30023, violation.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsOversizedPostByKindLimit() {
|
||||
val v = CommunityRulesValidator(rules()).validate(author, 1111, 20_000)
|
||||
val violation = assertIs<CommunityRulesValidator.Violation.KindSizeExceeded>(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<CommunityRulesValidator.Violation.MaxSizeExceeded>(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<CommunityRulesValidator.Violation.AuthorDenied>(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<CommunityRulesValidator.Violation.AuthorDenied>(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<CommunityRulesValidator.Violation.QuotaExceeded>(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<CommunityRulesValidator.Violation.WotGateFailed>(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<CommunityRulesValidator.Violation.StaleRules>(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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user