mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
feat(core): rebuild governance core to the plan's decision model
The evaluator was a skeleton with invented thresholds (5/3/10/5) and two unimplemented branches. Rebuild it against the migration plan's spec and the behavior characterized from the pinned reference commit. Core: - four independent decision dimensions (ranking, visibility, interaction, transaction) with severity ladders and max-merge composition - stable reason identifiers replacing free-text strings - structured evidence, redacted unless a profile opts in - administrative-state reduction with capability filtering at merge time, so revoking a role drops that actor's entries immediately - protected actors stripped from every denial set - snapshot fingerprints for cache invalidation - generic policy engine: applications supply profiles and category thresholds; the core carries none of its own Characterization: - 34 generated fixtures covering every behavior the plan's Phase 0 requires - conformance suite that actually evaluates them, replacing a placeholder that asserted a list's length - reference thresholds (3/3/20/5, 60-day mute window) live in bitvid-compat as an application profile, not as engine defaults Fixes found while building: - reduceAdminState accepted malformed target identifiers, because getTargetKey formats anything it is given - community-user-deny fired for any contributor rather than only for community-curated sources 273 tests pass; typecheck and the reference-import guard are clean.
This commit is contained in:
1 parent
5a7c1a26de
commit
4f689184e2
60 files changed
+7812
-2103
No files matched your search
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-address-deny",
|
||||||
|
"description": "Denying an address hides it across revisions.",
|
||||||
|
"behavior": "Address denial is keyed by coordinate, so republishing does not clear it.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "address-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "address",
|
||||||
|
"kind": "30023",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||||
|
"identifier": "listing-1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "address",
|
||||||
|
"kind": "30023",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||||
|
"identifier": "listing-1"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"admin-address-deny"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"addressDenied": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-event-deny-applies",
|
||||||
|
"description": "The denied event itself is hidden.",
|
||||||
|
"behavior": "Exact-event denial hides the named event.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "event-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"admin-event-deny"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"eventDenied": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-event-deny",
|
||||||
|
"description": "An exact event denial hides only that event.",
|
||||||
|
"behavior": "Event denial applies to the named event and does not travel to the author.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "event-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-user-deny",
|
||||||
|
"description": "A moderator's user denial hides the creator.",
|
||||||
|
"behavior": "Administrative denial applies the profile's administrativeDeny effects.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"admin-user-deny"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"userDenied": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "author-deny-reaches-events",
|
||||||
|
"description": "Denying a creator hides that creator's events.",
|
||||||
|
"behavior": "Author-level denial reaches the author's content.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"admin-user-deny"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"userDenied": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "author-override",
|
||||||
|
"description": "An override on the author does not leak to the author's events.",
|
||||||
|
"behavior": "Overrides are keyed by exact target.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewer": {
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"key": "user:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||||
|
"visibility": "allow"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"admin-user-deny"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"name": "blocked-reporter-ignored",
|
||||||
|
"description": "Reports from accounts the viewer blocks do not count.",
|
||||||
|
"behavior": "Blocked reporters are skipped before aggregation.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"blocks": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedReportTotal": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "cache-fallback-effective",
|
||||||
|
"description": "State reduced from a cached snapshot decides identically to live state.",
|
||||||
|
"behavior": "Administrative state stays effective when relays are unreachable.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"fromCache": true,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"admin-user-deny"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "clean-target-allowed",
|
||||||
|
"description": "A target with no signals against it is fully allowed.",
|
||||||
|
"behavior": "Governance is opt-in per signal; absence of evidence is not suspicion.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "community-blacklist-merge",
|
||||||
|
"description": "A curator's denial is honored without moderator authority.",
|
||||||
|
"behavior": "Curators hold contribute-user-deny only; their entries merge as a union.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"source": "community-list-1",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"admin-user-deny",
|
||||||
|
"community-user-deny"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "curator-cannot-deny-events",
|
||||||
|
"description": "A curator's event denial is ignored.",
|
||||||
|
"behavior": "Contributions beyond an actor's capabilities are dropped at merge time.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3",
|
||||||
|
"kind": "event-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"name": "denied-reporter-ignored",
|
||||||
|
"description": "Reports from administratively denied accounts do not count.",
|
||||||
|
"behavior": "Denied accounts are excluded from aggregation, so a denied account cannot manufacture reports.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "0101010101010101010101010101010101010101010101010101010101010101"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "0202020202020202020202020202020202020202020202020202020202020202"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedReportTotal": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"name": "duplicate-muter-dedupe",
|
||||||
|
"description": "The same account muting repeatedly counts once.",
|
||||||
|
"behavior": "Muters are counted uniquely.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trustedMutes": {
|
||||||
|
"user:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4": [
|
||||||
|
{
|
||||||
|
"muter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"updatedAt": 1749827200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "downrank",
|
||||||
|
"weight": 1
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedMuteTotal": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"name": "duplicate-reporter-dedupe",
|
||||||
|
"description": "One account reporting the same category repeatedly counts once.",
|
||||||
|
"behavior": "Reports are deduplicated per reporter and category before counting.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "spam",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "spam",
|
||||||
|
"createdAt": 1749827200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "spam",
|
||||||
|
"createdAt": 1749740800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "spam",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"trusted-report"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"trustedReportTotal": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"name": "evidence-redacted-by-default",
|
||||||
|
"description": "Feed surfaces report counts but not who produced them.",
|
||||||
|
"behavior": "Reporter pubkeys are withheld unless the profile opts in.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "restrict",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "require-explicit-action"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedReportTotal": 3,
|
||||||
|
"trustedReporterPubkeysCount": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
{
|
||||||
|
"name": "expired-trusted-mute",
|
||||||
|
"description": "Mutes older than the 60-day window are ignored.",
|
||||||
|
"behavior": "Stale mute lists must not hold a target down indefinitely.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trustedMutes": {
|
||||||
|
"user:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4": [
|
||||||
|
{
|
||||||
|
"muter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"updatedAt": 1744729600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "downrank",
|
||||||
|
"weight": 1
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedMuteTotal": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
{
|
||||||
|
"name": "home-hide-bypass",
|
||||||
|
"description": "The home surface downranks instead of hiding.",
|
||||||
|
"behavior": "Home and Recent decline to hard-hide so one stale list cannot empty the feed.",
|
||||||
|
"profile": "home",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trustedMutes": {
|
||||||
|
"user:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4": [
|
||||||
|
{
|
||||||
|
"muter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "downrank",
|
||||||
|
"weight": 1
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "restrict",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"trusted-mute",
|
||||||
|
"trusted-mute-threshold",
|
||||||
|
"surface-policy-bypass"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"name": "moderator-trust-seed",
|
||||||
|
"description": "An anonymous viewer inherits trust seeds.",
|
||||||
|
"behavior": "Seeds are the fallback trust graph when the viewer follows nobody.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [],
|
||||||
|
"seeds": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "restrict",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "require-explicit-action"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedReportTotal": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
{
|
||||||
|
"name": "non-spam-reports-never-hide",
|
||||||
|
"description": "Eight trusted reports in a non-spam category restrict but never hide.",
|
||||||
|
"behavior": "Only spam has a report-driven hide threshold.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "misleading",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "misleading",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"category": "misleading",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"category": "misleading",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"category": "misleading",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"category": "misleading",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"category": "misleading",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"category": "misleading",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "restrict",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "require-explicit-action"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "personal-block-precedence",
|
||||||
|
"description": "A viewer's own block hides the target regardless of other state.",
|
||||||
|
"behavior": "Viewer blocks are viewer-local and always enforced.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"blocks": [
|
||||||
|
"d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"viewer-block"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"personalBlock": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "playback-enforces-hide",
|
||||||
|
"description": "Playback enforces the full decision with no surface exception.",
|
||||||
|
"behavior": "The hide bypass is a discovery-surface concession, not a global one.",
|
||||||
|
"profile": "playback",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "deny",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"admin-user-deny"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"name": "playback-exposes-evidence",
|
||||||
|
"description": "The playback surface exposes reporter pubkeys; discovery surfaces do not.",
|
||||||
|
"behavior": "exposeEvidence controls whether contributor pubkeys reach the consumer.",
|
||||||
|
"profile": "playback",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "restrict",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "require-explicit-action"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedReportTotal": 3,
|
||||||
|
"trustedReporterPubkeysCount": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "protected-target",
|
||||||
|
"description": "The root administrator cannot be denied by contributor lists.",
|
||||||
|
"behavior": "Protected actors are stripped from every denial set.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"protectedTarget": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "recent-hide-bypass",
|
||||||
|
"description": "The recent surface applies the same exception as home.",
|
||||||
|
"behavior": "Recent shares the home surface exception.",
|
||||||
|
"profile": "recent",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "restrict",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "deny"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"admin-user-deny",
|
||||||
|
"surface-policy-bypass"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "revoked-actor-contribution-dropped",
|
||||||
|
"description": "An actor with no roles cannot deny anyone.",
|
||||||
|
"behavior": "Revocation takes effect immediately because capabilities resolve at merge time.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"actor": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||||
|
"kind": "user-deny",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "seeds-yield-to-real-contacts",
|
||||||
|
"description": "Once the viewer has contacts, seeds stop contributing.",
|
||||||
|
"behavior": "Following nobody must not be equivalent to following the seed set.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101"
|
||||||
|
],
|
||||||
|
"seeds": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedReportTotal": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"name": "spam-report-hide-threshold",
|
||||||
|
"description": "Five trusted spam reports escalate to a hide.",
|
||||||
|
"behavior": "Spam carries its own higher hide threshold; other categories do not hide on reports alone.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "spam",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "spam",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"category": "spam",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"category": "spam",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"category": "spam",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "require-explicit-action"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"trusted-report",
|
||||||
|
"trusted-report-threshold"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"name": "trusted-mute-at-threshold",
|
||||||
|
"description": "Twenty trusted mutes escalate to a reversible hide.",
|
||||||
|
"behavior": "The hide threshold is 20 unique trusted muters.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trustedMutes": {
|
||||||
|
"user:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4": [
|
||||||
|
{
|
||||||
|
"muter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "downrank",
|
||||||
|
"weight": 1
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "hide",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedMuteTotal": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"name": "trusted-mute-below-threshold",
|
||||||
|
"description": "Nineteen trusted mutes downrank only.",
|
||||||
|
"behavior": "Below the hide threshold a trusted mute is a ranking signal: no blur, no playback block, no denial.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trustedMutes": {
|
||||||
|
"user:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4": [
|
||||||
|
{
|
||||||
|
"muter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "downrank",
|
||||||
|
"weight": 1
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"trusted-mute",
|
||||||
|
"trusted-mute-threshold"
|
||||||
|
],
|
||||||
|
"evidence": {
|
||||||
|
"trustedMuteTotal": 19
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"name": "trusted-report-below-threshold",
|
||||||
|
"description": "Two trusted reports are not enough to restrict.",
|
||||||
|
"behavior": "Counts below the blur threshold leave the target fully visible.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"trusted-report"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"name": "trusted-report-threshold-reached",
|
||||||
|
"description": "Three trusted reports restrict visibility and withhold autoplay.",
|
||||||
|
"behavior": "Blur threshold and autoplay-block threshold are both 3 trusted reports.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "restrict",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "require-explicit-action"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"trusted-report",
|
||||||
|
"trusted-report-threshold"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "untrusted-reporter-ignored",
|
||||||
|
"description": "Reports from outside the trust graph do not count.",
|
||||||
|
"behavior": "Only reports from the viewer's trusted graph are counted.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"event:1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b": [
|
||||||
|
{
|
||||||
|
"reporter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reporter": "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6",
|
||||||
|
"category": "nudity",
|
||||||
|
"createdAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "event",
|
||||||
|
"id": "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
|
||||||
|
"author": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"trustedReportTotal": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"name": "viewer-override-cannot-escalate",
|
||||||
|
"description": "An override asking for a harsher effect is ignored.",
|
||||||
|
"behavior": "Overrides only soften.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"key": "user:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||||
|
"visibility": "deny"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "normal",
|
||||||
|
"weight": 0
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "allow"
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
{
|
||||||
|
"name": "viewer-override-softens-hide",
|
||||||
|
"description": "A viewer override reveals hidden content behind an explicit action.",
|
||||||
|
"behavior": "Overrides may soften a decision and force explicit interaction; they never escalate.",
|
||||||
|
"profile": "feed",
|
||||||
|
"now": 1750000000,
|
||||||
|
"authority": {
|
||||||
|
"root": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
|
"actors": {
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2": [
|
||||||
|
"moderator"
|
||||||
|
],
|
||||||
|
"c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3": [
|
||||||
|
"curator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protectedActors": [
|
||||||
|
"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"contacts": [
|
||||||
|
"0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"1515151515151515151515151515151515151515151515151515151515151515",
|
||||||
|
"1616161616161616161616161616161616161616161616161616161616161616",
|
||||||
|
"1717171717171717171717171717171717171717171717171717171717171717",
|
||||||
|
"1818181818181818181818181818181818181818181818181818181818181818"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"key": "user:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||||
|
"visibility": "allow"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trustedMutes": {
|
||||||
|
"user:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4": [
|
||||||
|
{
|
||||||
|
"muter": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0404040404040404040404040404040404040404040404040404040404040404",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0505050505050505050505050505050505050505050505050505050505050505",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0606060606060606060606060606060606060606060606060606060606060606",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0808080808080808080808080808080808080808080808080808080808080808",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0909090909090909090909090909090909090909090909090909090909090909",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1010101010101010101010101010101010101010101010101010101010101010",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1212121212121212121212121212121212121212121212121212121212121212",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1313131313131313131313131313131313131313131313131313131313131313",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"muter": "1414141414141414141414141414141414141414141414141414141414141414",
|
||||||
|
"updatedAt": 1749913600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "user",
|
||||||
|
"pubkey": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"ranking": {
|
||||||
|
"effect": "downrank",
|
||||||
|
"weight": 1
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"effect": "allow",
|
||||||
|
"overridable": true
|
||||||
|
},
|
||||||
|
"interaction": {
|
||||||
|
"effect": "require-explicit-action"
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"trusted-mute",
|
||||||
|
"trusted-mute-threshold",
|
||||||
|
"viewer-override"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,36 @@
|
|||||||
[
|
[
|
||||||
"personal-block-precedence",
|
|
||||||
"user-deny",
|
|
||||||
"event-deny",
|
|
||||||
"address-deny",
|
|
||||||
"allowlist-miss",
|
|
||||||
"protected-target",
|
|
||||||
"trusted-report-threshold-reached",
|
"trusted-report-threshold-reached",
|
||||||
|
"trusted-report-below-threshold",
|
||||||
"duplicate-reporter-dedupe",
|
"duplicate-reporter-dedupe",
|
||||||
"blocked-reporter-ignored",
|
"blocked-reporter-ignored",
|
||||||
|
"denied-reporter-ignored",
|
||||||
|
"untrusted-reporter-ignored",
|
||||||
|
"spam-report-hide-threshold",
|
||||||
|
"non-spam-reports-never-hide",
|
||||||
"trusted-mute-below-threshold",
|
"trusted-mute-below-threshold",
|
||||||
"trusted-mute-at-threshold",
|
"trusted-mute-at-threshold",
|
||||||
"expired-trusted-mute",
|
"expired-trusted-mute",
|
||||||
"viewer-override",
|
"duplicate-muter-dedupe",
|
||||||
|
"personal-block-precedence",
|
||||||
|
"admin-user-deny",
|
||||||
|
"admin-event-deny",
|
||||||
|
"admin-event-deny-applies",
|
||||||
|
"author-deny-reaches-events",
|
||||||
|
"admin-address-deny",
|
||||||
|
"community-blacklist-merge",
|
||||||
|
"curator-cannot-deny-events",
|
||||||
|
"revoked-actor-contribution-dropped",
|
||||||
|
"protected-target",
|
||||||
|
"moderator-trust-seed",
|
||||||
|
"seeds-yield-to-real-contacts",
|
||||||
|
"viewer-override-softens-hide",
|
||||||
|
"viewer-override-cannot-escalate",
|
||||||
"author-override",
|
"author-override",
|
||||||
|
"home-hide-bypass",
|
||||||
|
"recent-hide-bypass",
|
||||||
|
"playback-enforces-hide",
|
||||||
|
"playback-exposes-evidence",
|
||||||
|
"evidence-redacted-by-default",
|
||||||
"cache-fallback-effective",
|
"cache-fallback-effective",
|
||||||
"report-category-thresholds",
|
"clean-target-allowed"
|
||||||
"mute-aggregation-rules",
|
|
||||||
"hide-vs-restrict-vs-downrank",
|
|
||||||
"moderation-decorator-fields"
|
|
||||||
]
|
]
|
||||||
Generated
+5
-1
@@ -1894,7 +1894,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/testing": {
|
"packages/testing": {
|
||||||
"name": "@nostr-governance/testing"
|
"name": "@nostr-governance/testing",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@nostr-governance/core": "*"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,10 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./dist/types/src/index.d.ts",
|
"types": "./dist/types/src/index.d.ts",
|
||||||
"import": "./src/index.js"
|
"import": "./src/index.js"
|
||||||
|
},
|
||||||
|
"./profile": {
|
||||||
|
"types": "./dist/types/src/profile.d.ts",
|
||||||
|
"import": "./src/profile.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Reference application profile for the extraction's characterization corpus.
|
||||||
|
//
|
||||||
|
// These values are one application's configuration, not governance defaults.
|
||||||
|
// They live outside @nostr-governance/core on purpose: the core engine must
|
||||||
|
// carry no application thresholds, and this file is what proves the generic
|
||||||
|
// engine can reproduce a real deployment's behavior.
|
||||||
|
//
|
||||||
|
// Values characterized from the pinned reference commit (see
|
||||||
|
// docs/reference-map.md). Changing a number here should break a conformance
|
||||||
|
// fixture — that is the point.
|
||||||
|
|
||||||
|
import { createPolicyDefinition } from "@nostr-governance/core";
|
||||||
|
|
||||||
|
/** Trusted reports needed before a thumbnail is blurred. */
|
||||||
|
export const BLUR_THRESHOLD = 3;
|
||||||
|
|
||||||
|
/** Trusted reports needed before automatic playback is withheld. */
|
||||||
|
export const AUTOPLAY_BLOCK_THRESHOLD = 3;
|
||||||
|
|
||||||
|
/** Unique trusted muters needed before a target is hidden. */
|
||||||
|
export const TRUSTED_MUTE_HIDE_THRESHOLD = 20;
|
||||||
|
|
||||||
|
/** Trusted spam reports needed before a target is hidden. */
|
||||||
|
export const TRUSTED_SPAM_HIDE_THRESHOLD = 5;
|
||||||
|
|
||||||
|
/** Trusted mutes older than this are ignored. */
|
||||||
|
export const TRUSTED_MUTE_WINDOW_DAYS = 60;
|
||||||
|
export const TRUSTED_MUTE_WINDOW_SECONDS = TRUSTED_MUTE_WINDOW_DAYS * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report thresholds shared by every surface.
|
||||||
|
*
|
||||||
|
* `restrict` at the blur threshold is the blur; `requireExplicitAction` at the
|
||||||
|
* autoplay threshold is the withheld autoplay. Spam escalates to a hide at its
|
||||||
|
* own higher threshold, while other categories never hide on reports alone.
|
||||||
|
*
|
||||||
|
* @type {Record<string, import('@nostr-governance/core').CategoryThresholds>}
|
||||||
|
*/
|
||||||
|
const REPORT_THRESHOLDS = {
|
||||||
|
default: {
|
||||||
|
restrict: BLUR_THRESHOLD,
|
||||||
|
requireExplicitAction: AUTOPLAY_BLOCK_THRESHOLD,
|
||||||
|
},
|
||||||
|
spam: {
|
||||||
|
restrict: BLUR_THRESHOLD,
|
||||||
|
requireExplicitAction: AUTOPLAY_BLOCK_THRESHOLD,
|
||||||
|
hide: TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted mutes downrank from the first muter and only hide at the threshold.
|
||||||
|
*
|
||||||
|
* Below the hide threshold a trusted mute is a ranking signal only: it must not
|
||||||
|
* blur, must not block playback, and must not read as an administrative denial.
|
||||||
|
*
|
||||||
|
* @type {Record<string, import('@nostr-governance/core').CategoryThresholds>}
|
||||||
|
*/
|
||||||
|
const MUTE_THRESHOLDS = {
|
||||||
|
default: {
|
||||||
|
downrank: 1,
|
||||||
|
hide: TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reference application's policy definition.
|
||||||
|
*
|
||||||
|
* Surfaces differ only in how they treat a hide and whether a viewer may
|
||||||
|
* override: discovery feeds decline to hard-hide so that a single stale list
|
||||||
|
* cannot empty a feed, while playback enforces the full decision.
|
||||||
|
*/
|
||||||
|
export const REFERENCE_POLICY = createPolicyDefinition({
|
||||||
|
id: "reference-video-app",
|
||||||
|
name: "Reference video application",
|
||||||
|
description: "Characterized from the pinned reference commit. Illustrative, not a default.",
|
||||||
|
version: "1.0.0",
|
||||||
|
defaultProfile: "feed",
|
||||||
|
profiles: {
|
||||||
|
feed: {
|
||||||
|
name: "feed",
|
||||||
|
administrativeDeny: { visibility: "hide", interaction: "deny" },
|
||||||
|
viewerBlock: { visibility: "hide", interaction: "deny" },
|
||||||
|
allowViewerOverride: true,
|
||||||
|
muteWindowSeconds: TRUSTED_MUTE_WINDOW_SECONDS,
|
||||||
|
reports: REPORT_THRESHOLDS,
|
||||||
|
mutes: MUTE_THRESHOLDS,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Home and Recent decline to hide: a target that would be hidden is
|
||||||
|
// downranked and restricted instead, preserving the surface exception.
|
||||||
|
home: {
|
||||||
|
name: "home",
|
||||||
|
administrativeDeny: { visibility: "hide", interaction: "deny" },
|
||||||
|
viewerBlock: { visibility: "hide", interaction: "deny" },
|
||||||
|
allowViewerOverride: true,
|
||||||
|
bypassHide: true,
|
||||||
|
bypassHideCeiling: "restrict",
|
||||||
|
muteWindowSeconds: TRUSTED_MUTE_WINDOW_SECONDS,
|
||||||
|
reports: REPORT_THRESHOLDS,
|
||||||
|
mutes: MUTE_THRESHOLDS,
|
||||||
|
},
|
||||||
|
|
||||||
|
recent: {
|
||||||
|
name: "recent",
|
||||||
|
administrativeDeny: { visibility: "hide", interaction: "deny" },
|
||||||
|
viewerBlock: { visibility: "hide", interaction: "deny" },
|
||||||
|
allowViewerOverride: true,
|
||||||
|
bypassHide: true,
|
||||||
|
bypassHideCeiling: "restrict",
|
||||||
|
muteWindowSeconds: TRUSTED_MUTE_WINDOW_SECONDS,
|
||||||
|
reports: REPORT_THRESHOLDS,
|
||||||
|
mutes: MUTE_THRESHOLDS,
|
||||||
|
},
|
||||||
|
|
||||||
|
playback: {
|
||||||
|
name: "playback",
|
||||||
|
administrativeDeny: { visibility: "deny", interaction: "deny" },
|
||||||
|
viewerBlock: { visibility: "hide", interaction: "deny" },
|
||||||
|
allowViewerOverride: true,
|
||||||
|
exposeEvidence: true,
|
||||||
|
muteWindowSeconds: TRUSTED_MUTE_WINDOW_SECONDS,
|
||||||
|
reports: REPORT_THRESHOLDS,
|
||||||
|
mutes: MUTE_THRESHOLDS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// Administrative state reduction for Nostr Governance
|
||||||
|
//
|
||||||
|
// Contributions arrive as per-actor lists signed under each actor's own key.
|
||||||
|
// Reduction resolves each contributor's capabilities against the current
|
||||||
|
// authority state and keeps only the entries that actor was allowed to publish.
|
||||||
|
// Because capabilities are resolved here rather than at ingest time, revoking a
|
||||||
|
// role immediately drops that actor's entries from effective state.
|
||||||
|
|
||||||
|
import { getTargetKey } from "./identifiers.js";
|
||||||
|
import { isValidTarget } from "./targets.js";
|
||||||
|
import { hasCapability, isProtectedActor } from "./authority.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
|
||||||
|
* @typedef {import('./authority.js').AuthorityState} AuthorityState
|
||||||
|
* @typedef {import('./authority.js').GovernanceCapability} GovernanceCapability
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Contribution
|
||||||
|
* @property {string} actor - Pubkey of the contributing actor
|
||||||
|
* @property {"user-allow"|"user-deny"|"event-deny"|"address-deny"|"trust-seed"} kind
|
||||||
|
* @property {GovernanceTarget[]} targets
|
||||||
|
* @property {number} [createdAt] - Unix seconds; used for replaceable selection
|
||||||
|
* @property {string} [source] - Opaque origin marker (e.g. a community list id)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AdminState
|
||||||
|
* @property {Set<string>} userAllow - Target keys explicitly allowed
|
||||||
|
* @property {Set<string>} userDeny - Target keys denied
|
||||||
|
* @property {Set<string>} eventDeny
|
||||||
|
* @property {Set<string>} addressDeny
|
||||||
|
* @property {Set<string>} trustSeeds - Pubkeys seeding the trust graph
|
||||||
|
* @property {Map<string, string[]>} contributors - Target key to contributing actor pubkeys
|
||||||
|
* @property {Map<string, string[]>} communitySources - Target key to community list ids that denied it
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Record<Contribution["kind"], GovernanceCapability>} */
|
||||||
|
const KIND_CAPABILITY = {
|
||||||
|
"user-allow": "contribute-user-allow",
|
||||||
|
"user-deny": "contribute-user-deny",
|
||||||
|
"event-deny": "contribute-event-deny",
|
||||||
|
"address-deny": "contribute-address-deny",
|
||||||
|
"trust-seed": "contribute-trust-seed",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {Record<Contribution["kind"], GovernanceTarget["type"]>} */
|
||||||
|
const KIND_TARGET_TYPE = {
|
||||||
|
"user-allow": "user",
|
||||||
|
"user-deny": "user",
|
||||||
|
"event-deny": "event",
|
||||||
|
"address-deny": "address",
|
||||||
|
"trust-seed": "user",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an empty administrative state.
|
||||||
|
* @returns {AdminState}
|
||||||
|
*/
|
||||||
|
export function createEmptyAdminState() {
|
||||||
|
return {
|
||||||
|
userAllow: new Set(),
|
||||||
|
userDeny: new Set(),
|
||||||
|
eventDeny: new Set(),
|
||||||
|
addressDeny: new Set(),
|
||||||
|
trustSeeds: new Set(),
|
||||||
|
contributors: new Map(),
|
||||||
|
communitySources: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce contributions into effective administrative state.
|
||||||
|
*
|
||||||
|
* Denial entries are a union across authorized contributors, matching the merge
|
||||||
|
* policy. Protected actors are removed from every denial set as the last step,
|
||||||
|
* so no contributor list can deny the root administrator regardless of how many
|
||||||
|
* curators list them.
|
||||||
|
*
|
||||||
|
* @param {Contribution[]} contributions
|
||||||
|
* @param {AuthorityState} authority
|
||||||
|
* @returns {AdminState}
|
||||||
|
*/
|
||||||
|
export function reduceAdminState(contributions, authority) {
|
||||||
|
const state = createEmptyAdminState();
|
||||||
|
|
||||||
|
if (!Array.isArray(contributions)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const contribution of contributions) {
|
||||||
|
if (!contribution || typeof contribution !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capability = KIND_CAPABILITY[contribution.kind];
|
||||||
|
if (!capability) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!hasCapability(contribution.actor, capability, authority)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedType = KIND_TARGET_TYPE[contribution.kind];
|
||||||
|
const targets = Array.isArray(contribution.targets) ? contribution.targets : [];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
// A malformed identifier must never reach a denial set: getTargetKey
|
||||||
|
// happily formats anything, so validation has to happen here.
|
||||||
|
if (!target || target.type !== expectedType || !isValidTarget(target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key;
|
||||||
|
try {
|
||||||
|
key = getTargetKey(target);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (contribution.kind) {
|
||||||
|
case "user-allow":
|
||||||
|
state.userAllow.add(key);
|
||||||
|
break;
|
||||||
|
case "user-deny":
|
||||||
|
state.userDeny.add(key);
|
||||||
|
break;
|
||||||
|
case "event-deny":
|
||||||
|
state.eventDeny.add(key);
|
||||||
|
break;
|
||||||
|
case "address-deny":
|
||||||
|
state.addressDeny.add(key);
|
||||||
|
break;
|
||||||
|
case "trust-seed":
|
||||||
|
state.trustSeeds.add(/** @type {{ pubkey: string }} */ (target).pubkey);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contribution.kind !== "trust-seed") {
|
||||||
|
const existing = state.contributors.get(key) ?? [];
|
||||||
|
if (!existing.includes(contribution.actor)) {
|
||||||
|
existing.push(contribution.actor);
|
||||||
|
}
|
||||||
|
state.contributors.set(key, existing);
|
||||||
|
|
||||||
|
// Only contributions carrying a source marker are community-curated.
|
||||||
|
// A moderator acting directly is an administrative denial, not a
|
||||||
|
// federated one, and the two must stay distinguishable to consumers.
|
||||||
|
if (typeof contribution.source === "string" && contribution.source.trim()) {
|
||||||
|
const sources = state.communitySources.get(key) ?? [];
|
||||||
|
if (!sources.includes(contribution.source)) {
|
||||||
|
sources.push(contribution.source);
|
||||||
|
}
|
||||||
|
state.communitySources.set(key, sources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protectActors(state, authority);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove protected actors from every denial set.
|
||||||
|
* @param {AdminState} state
|
||||||
|
* @param {AuthorityState} authority
|
||||||
|
* @returns {AdminState} The same state, mutated in place
|
||||||
|
*/
|
||||||
|
export function protectActors(state, authority) {
|
||||||
|
for (const pubkey of authority.protectedActors) {
|
||||||
|
const key = `user:${pubkey}`;
|
||||||
|
state.userDeny.delete(key);
|
||||||
|
state.contributors.delete(key);
|
||||||
|
state.communitySources.delete(key);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a community-curated denial list into administrative state.
|
||||||
|
*
|
||||||
|
* Community curators contribute denials without gaining moderator authority:
|
||||||
|
* the curator must hold `contribute-user-deny`, the merge is a union, and
|
||||||
|
* protected actors are stripped afterwards.
|
||||||
|
*
|
||||||
|
* @param {AdminState} state
|
||||||
|
* @param {Contribution} contribution
|
||||||
|
* @param {AuthorityState} authority
|
||||||
|
* @returns {AdminState} The same state, mutated in place
|
||||||
|
*/
|
||||||
|
export function mergeCommunitySource(state, contribution, authority) {
|
||||||
|
const merged = reduceAdminState([contribution], authority);
|
||||||
|
for (const key of merged.userDeny) {
|
||||||
|
state.userDeny.add(key);
|
||||||
|
|
||||||
|
const contributors = state.contributors.get(key) ?? [];
|
||||||
|
for (const actor of merged.contributors.get(key) ?? []) {
|
||||||
|
if (!contributors.includes(actor)) {
|
||||||
|
contributors.push(actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.contributors.set(key, contributors);
|
||||||
|
|
||||||
|
const sources = state.communitySources.get(key) ?? [];
|
||||||
|
for (const source of merged.communitySources.get(key) ?? []) {
|
||||||
|
if (!sources.includes(source)) {
|
||||||
|
sources.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sources.length) {
|
||||||
|
state.communitySources.set(key, sources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return protectActors(state, authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a target is denied by administrative state.
|
||||||
|
* @param {GovernanceTarget} target
|
||||||
|
* @param {AdminState} state
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isDenied(target, state) {
|
||||||
|
const key = getTargetKey(target);
|
||||||
|
switch (target.type) {
|
||||||
|
case "user":
|
||||||
|
return state.userDeny.has(key);
|
||||||
|
case "event":
|
||||||
|
return state.eventDeny.has(key);
|
||||||
|
case "address":
|
||||||
|
return state.addressDeny.has(key);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize administrative state into a plain, ordered object.
|
||||||
|
*
|
||||||
|
* Sets are sorted so that fingerprints and fixtures are stable across runs.
|
||||||
|
*
|
||||||
|
* @param {AdminState} state
|
||||||
|
* @returns {{ userAllow: string[], userDeny: string[], eventDeny: string[], addressDeny: string[], trustSeeds: string[] }}
|
||||||
|
*/
|
||||||
|
export function serializeAdminState(state) {
|
||||||
|
return {
|
||||||
|
userAllow: Array.from(state.userAllow).sort(),
|
||||||
|
userDeny: Array.from(state.userDeny).sort(),
|
||||||
|
eventDeny: Array.from(state.eventDeny).sort(),
|
||||||
|
addressDeny: Array.from(state.addressDeny).sort(),
|
||||||
|
trustSeeds: Array.from(state.trustSeeds).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
+209
-229
@@ -1,177 +1,193 @@
|
|||||||
// Authority handling for Nostr Governance
|
// Authority and delegation for Nostr Governance
|
||||||
//
|
//
|
||||||
// This module handles role resolution, capability checking, and authority
|
// Root-authorized contributor model: the root administrator publishes the role
|
||||||
// validation for the governance system.
|
// roster, each authorized actor publishes contributions under their own key,
|
||||||
|
// and the runtime accepts a contribution only when the signing actor holds the
|
||||||
|
// matching capability. Revoking a role stops accepting that actor's
|
||||||
|
// contributions immediately, because capabilities are resolved at merge time
|
||||||
|
// rather than baked into stored state.
|
||||||
|
|
||||||
|
import { normalizePubkey } from "./identifiers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} RoleDefinition
|
* @typedef {"manage-roles"
|
||||||
* @property {string} name - Role name
|
* | "manage-policy"
|
||||||
* @property {string[]} capabilities - Array of capability names this role has
|
* | "manage-community-sources"
|
||||||
|
* | "contribute-user-allow"
|
||||||
|
* | "contribute-user-deny"
|
||||||
|
* | "contribute-event-deny"
|
||||||
|
* | "contribute-address-deny"
|
||||||
|
* | "contribute-trust-seed"
|
||||||
|
* | "review-evidence"} GovernanceCapability
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** @type {readonly GovernanceCapability[]} */
|
||||||
|
export const GOVERNANCE_CAPABILITIES = Object.freeze([
|
||||||
|
"manage-roles",
|
||||||
|
"manage-policy",
|
||||||
|
"manage-community-sources",
|
||||||
|
"contribute-user-allow",
|
||||||
|
"contribute-user-deny",
|
||||||
|
"contribute-event-deny",
|
||||||
|
"contribute-address-deny",
|
||||||
|
"contribute-trust-seed",
|
||||||
|
"review-evidence",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CAPABILITY_SET = new Set(GOVERNANCE_CAPABILITIES);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Actor
|
* @param {GovernanceCapability[]} capabilities
|
||||||
* @property {string} pubkey - 64-character hex pubkey of the actor
|
* @returns {readonly GovernanceCapability[]}
|
||||||
* @property {string[]} roles - Array of role names assigned to this actor
|
|
||||||
*/
|
*/
|
||||||
|
const bundle = (capabilities) => Object.freeze(capabilities);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default role bundles. Roles are convenience only; capabilities are
|
||||||
|
* authoritative, and applications may define additional roles.
|
||||||
|
* @type {Record<string, readonly GovernanceCapability[]>}
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ROLE_CAPABILITIES = Object.freeze({
|
||||||
|
super_admin: bundle([
|
||||||
|
"manage-roles",
|
||||||
|
"manage-policy",
|
||||||
|
"manage-community-sources",
|
||||||
|
"contribute-user-allow",
|
||||||
|
"contribute-user-deny",
|
||||||
|
"contribute-event-deny",
|
||||||
|
"contribute-address-deny",
|
||||||
|
"contribute-trust-seed",
|
||||||
|
"review-evidence",
|
||||||
|
]),
|
||||||
|
moderator: bundle([
|
||||||
|
"contribute-user-deny",
|
||||||
|
"contribute-event-deny",
|
||||||
|
"contribute-address-deny",
|
||||||
|
"contribute-trust-seed",
|
||||||
|
"review-evidence",
|
||||||
|
]),
|
||||||
|
curator: bundle(["contribute-user-deny"]),
|
||||||
|
reviewer: bundle(["review-evidence"]),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} AuthorityState
|
* @typedef {Object} AuthorityState
|
||||||
* @property {Record<string, RoleDefinition>} roles - Map of role names to definitions
|
* @property {Record<string, readonly GovernanceCapability[]>} roles - Role name to capabilities
|
||||||
* @property {Record<string, Actor>} actors - Map of pubkeys to actors
|
* @property {Record<string, string[]>} actors - Actor pubkey to role names
|
||||||
|
* @property {string[]} protectedActors - Pubkeys that contributor lists cannot deny
|
||||||
|
* @property {string} [root] - Root administrator pubkey
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in role definitions
|
* Check whether a value is a known capability.
|
||||||
* @type {Record<string, RoleDefinition>}
|
* @param {unknown} value
|
||||||
|
* @returns {value is GovernanceCapability}
|
||||||
*/
|
*/
|
||||||
export const BUILTIN_ROLES = {
|
export function isGovernanceCapability(value) {
|
||||||
"root": {
|
return typeof value === "string" && CAPABILITY_SET.has(/** @type {GovernanceCapability} */ (value));
|
||||||
name: "root",
|
|
||||||
capabilities: [
|
|
||||||
"manage-roles",
|
|
||||||
"manage-capabilities",
|
|
||||||
"manage-actors",
|
|
||||||
"manage-policies",
|
|
||||||
"manage-admin-lists",
|
|
||||||
"manage-trust-graph",
|
|
||||||
"manage-overrides",
|
|
||||||
"view-reports",
|
|
||||||
"view-trust-graph",
|
|
||||||
"view-admin-lists",
|
|
||||||
"view-policies"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"administrator": {
|
|
||||||
name: "administrator",
|
|
||||||
capabilities: [
|
|
||||||
"manage-admin-lists",
|
|
||||||
"manage-trust-graph",
|
|
||||||
"view-reports",
|
|
||||||
"view-trust-graph",
|
|
||||||
"view-admin-lists"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"moderator": {
|
|
||||||
name: "moderator",
|
|
||||||
capabilities: [
|
|
||||||
"submit-reports",
|
|
||||||
"submit-trust-endorsements",
|
|
||||||
"view-reports"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"trusted-user": {
|
|
||||||
name: "trusted-user",
|
|
||||||
capabilities: [
|
|
||||||
"submit-reports",
|
|
||||||
"view-reports"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
name: "user",
|
|
||||||
capabilities: [
|
|
||||||
"submit-reports"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an authority state.
|
||||||
|
*
|
||||||
|
* The root administrator is always protected and always holds every
|
||||||
|
* capability, so a misconfigured role roster cannot lock governance out of its
|
||||||
|
* own instance.
|
||||||
|
*
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {Record<string, readonly GovernanceCapability[]>} [options.roles]
|
||||||
|
* @param {Record<string, string[]>} [options.actors]
|
||||||
|
* @param {string[]} [options.protectedActors]
|
||||||
|
* @param {string} [options.root]
|
||||||
|
* @returns {AuthorityState}
|
||||||
|
*/
|
||||||
|
export function createAuthorityState(options = {}) {
|
||||||
|
/** @type {Record<string, readonly GovernanceCapability[]>} */
|
||||||
|
const roles = {};
|
||||||
|
for (const [name, capabilities] of Object.entries(options.roles ?? DEFAULT_ROLE_CAPABILITIES)) {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
roles[trimmed] = Object.freeze(
|
||||||
|
(Array.isArray(capabilities) ? capabilities : []).filter(isGovernanceCapability),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, string[]>} */
|
||||||
|
const actors = {};
|
||||||
|
for (const [pubkey, roleNames] of Object.entries(options.actors ?? {})) {
|
||||||
|
const normalized = normalizePubkey(pubkey);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const names = (Array.isArray(roleNames) ? roleNames : [])
|
||||||
|
.filter((name) => typeof name === "string" && name.trim())
|
||||||
|
.map((name) => name.trim());
|
||||||
|
if (names.length) {
|
||||||
|
actors[normalized] = Array.from(new Set(names));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = options.root ? normalizePubkey(options.root) : "";
|
||||||
|
|
||||||
|
const protectedActors = new Set(
|
||||||
|
(options.protectedActors ?? [])
|
||||||
|
.map((pubkey) => normalizePubkey(pubkey))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
if (root) {
|
||||||
|
protectedActors.add(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {AuthorityState} */
|
||||||
|
const state = {
|
||||||
|
roles,
|
||||||
|
actors,
|
||||||
|
protectedActors: Array.from(protectedActors).sort(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (root) {
|
||||||
|
state.root = root;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get capabilities for a role
|
* Get the role names assigned to an actor.
|
||||||
* @param {string} roleName
|
* @param {string} pubkey
|
||||||
* @param {AuthorityState} [authorityState]
|
* @param {AuthorityState} authority
|
||||||
* @returns {string[]}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
export function getRoleCapabilities(roleName, authorityState) {
|
export function getActorRoles(pubkey, authority) {
|
||||||
// Check custom roles first
|
const normalized = normalizePubkey(pubkey);
|
||||||
if (authorityState && authorityState.roles[roleName]) {
|
if (!normalized) {
|
||||||
return authorityState.roles[roleName].capabilities;
|
return [];
|
||||||
|
}
|
||||||
|
return [...(authority.actors[normalized] ?? [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check built-in roles
|
/**
|
||||||
if (BUILTIN_ROLES[roleName]) {
|
* Get every capability an actor holds, resolved through their roles.
|
||||||
return BUILTIN_ROLES[roleName].capabilities;
|
* @param {string} pubkey
|
||||||
}
|
* @param {AuthorityState} authority
|
||||||
|
* @returns {GovernanceCapability[]}
|
||||||
// Unknown role has no capabilities
|
*/
|
||||||
|
export function getActorCapabilities(pubkey, authority) {
|
||||||
|
const normalized = normalizePubkey(pubkey);
|
||||||
|
if (!normalized) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (authority.root && normalized === authority.root) {
|
||||||
* Check if an actor has a specific role
|
return [...GOVERNANCE_CAPABILITIES];
|
||||||
* @param {string} pubkey - 64-character hex pubkey
|
|
||||||
* @param {string} roleName
|
|
||||||
* @param {AuthorityState} [authorityState]
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function hasRole(pubkey, roleName, authorityState) {
|
|
||||||
// Normalize pubkey
|
|
||||||
const normalizedPubkey = pubkey.toLowerCase();
|
|
||||||
|
|
||||||
// Check custom actors first
|
|
||||||
if (authorityState && authorityState.actors[normalizedPubkey]) {
|
|
||||||
return authorityState.actors[normalizedPubkey].roles.includes(roleName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No actor found means no roles
|
/** @type {Set<GovernanceCapability>} */
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all roles for an actor
|
|
||||||
* @param {string} pubkey - 64-character hex pubkey
|
|
||||||
* @param {AuthorityState} [authorityState]
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
export function getActorRoles(pubkey, authorityState) {
|
|
||||||
// Normalize pubkey
|
|
||||||
const normalizedPubkey = pubkey.toLowerCase();
|
|
||||||
|
|
||||||
// Check custom actors first
|
|
||||||
if (authorityState && authorityState.actors[normalizedPubkey]) {
|
|
||||||
return authorityState.actors[normalizedPubkey].roles;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No actor found means no roles
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an actor has a specific capability
|
|
||||||
* @param {string} pubkey - 64-character hex pubkey
|
|
||||||
* @param {string} capability
|
|
||||||
* @param {AuthorityState} [authorityState]
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function hasCapability(pubkey, capability, authorityState) {
|
|
||||||
// Get all roles for the actor
|
|
||||||
const roles = getActorRoles(pubkey, authorityState);
|
|
||||||
|
|
||||||
// Check if any role has the capability
|
|
||||||
for (const role of roles) {
|
|
||||||
const capabilities = getRoleCapabilities(role, authorityState);
|
|
||||||
if (capabilities.includes(capability)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all capabilities for an actor
|
|
||||||
* @param {string} pubkey - 64-character hex pubkey
|
|
||||||
* @param {AuthorityState} [authorityState]
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
export function getActorCapabilities(pubkey, authorityState) {
|
|
||||||
// Get all roles for the actor
|
|
||||||
const roles = getActorRoles(pubkey, authorityState);
|
|
||||||
|
|
||||||
// Collect all capabilities from all roles
|
|
||||||
const capabilities = new Set();
|
const capabilities = new Set();
|
||||||
for (const role of roles) {
|
for (const roleName of authority.actors[normalized] ?? []) {
|
||||||
const roleCapabilities = getRoleCapabilities(role, authorityState);
|
for (const capability of authority.roles[roleName] ?? []) {
|
||||||
for (const capability of roleCapabilities) {
|
|
||||||
capabilities.add(capability);
|
capabilities.add(capability);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,99 +196,63 @@ export function getActorCapabilities(pubkey, authorityState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new role definition
|
* Check whether an actor holds a capability.
|
||||||
* @param {string} name - Role name
|
* @param {string} pubkey
|
||||||
* @param {string[]} capabilities - Array of capability names
|
* @param {GovernanceCapability} capability
|
||||||
* @returns {RoleDefinition}
|
* @param {AuthorityState} authority
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function hasCapability(pubkey, capability, authority) {
|
||||||
|
if (!isGovernanceCapability(capability)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return getActorCapabilities(pubkey, authority).includes(capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an actor holds a role.
|
||||||
|
* @param {string} pubkey
|
||||||
|
* @param {string} roleName
|
||||||
|
* @param {AuthorityState} authority
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function hasRole(pubkey, roleName, authority) {
|
||||||
|
return getActorRoles(pubkey, authority).includes(roleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a pubkey is protected from contributor denial.
|
||||||
|
* @param {string} pubkey
|
||||||
|
* @param {AuthorityState} authority
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isProtectedActor(pubkey, authority) {
|
||||||
|
const normalized = normalizePubkey(pubkey);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return authority.protectedActors.includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a role's capability bundle, rejecting unknown capabilities.
|
||||||
|
* @param {string} name
|
||||||
|
* @param {GovernanceCapability[]} capabilities
|
||||||
|
* @returns {{ name: string, capabilities: GovernanceCapability[] }}
|
||||||
*/
|
*/
|
||||||
export function createRoleDefinition(name, capabilities) {
|
export function createRoleDefinition(name, capabilities) {
|
||||||
// Validate input
|
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
throw new Error("Role name must be a non-empty string");
|
throw new Error("Role name must be a non-empty string");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(capabilities)) {
|
if (!Array.isArray(capabilities)) {
|
||||||
throw new Error("Capabilities must be an array");
|
throw new Error("Role capabilities must be an array");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate each capability
|
|
||||||
for (const capability of capabilities) {
|
for (const capability of capabilities) {
|
||||||
if (typeof capability !== "string" || !capability.trim()) {
|
if (!isGovernanceCapability(capability)) {
|
||||||
throw new Error("All capabilities must be non-empty strings");
|
throw new Error(`Unknown governance capability: ${String(capability)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { name: name.trim(), capabilities: [...new Set(capabilities)] };
|
||||||
name: name.trim(),
|
|
||||||
capabilities: capabilities.map(c => c.trim())
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new actor
|
|
||||||
* @param {string} pubkey - 64-character hex pubkey
|
|
||||||
* @param {string[]} roles - Array of role names
|
|
||||||
* @returns {Actor}
|
|
||||||
*/
|
|
||||||
export function createActor(pubkey, roles) {
|
|
||||||
// Validate pubkey
|
|
||||||
if (typeof pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(pubkey)) {
|
|
||||||
throw new Error("Pubkey must be a 64-character hex string");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate roles
|
|
||||||
if (!Array.isArray(roles)) {
|
|
||||||
throw new Error("Roles must be an array");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each role
|
|
||||||
for (const role of roles) {
|
|
||||||
if (typeof role !== "string" || !role.trim()) {
|
|
||||||
throw new Error("All roles must be non-empty strings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pubkey: pubkey.toLowerCase(),
|
|
||||||
roles: roles.map(r => r.trim())
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new authority state
|
|
||||||
* @param {Record<string, RoleDefinition>} [roles]
|
|
||||||
* @param {Record<string, Actor>} [actors]
|
|
||||||
* @returns {AuthorityState}
|
|
||||||
*/
|
|
||||||
export function createAuthorityState(roles = {}, actors = {}) {
|
|
||||||
// Validate roles
|
|
||||||
for (const [name, role] of Object.entries(roles)) {
|
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
|
||||||
throw new Error("Role names must be non-empty strings");
|
|
||||||
}
|
|
||||||
if (!role || typeof role !== "object") {
|
|
||||||
throw new Error("Role definitions must be objects");
|
|
||||||
}
|
|
||||||
if (role.name !== name) {
|
|
||||||
throw new Error(`Role name mismatch: ${name} !== ${role.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate actors
|
|
||||||
for (const [pubkey, actor] of Object.entries(actors)) {
|
|
||||||
if (typeof pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(pubkey)) {
|
|
||||||
throw new Error("Actor pubkeys must be 64-character hex strings");
|
|
||||||
}
|
|
||||||
if (!actor || typeof actor !== "object") {
|
|
||||||
throw new Error("Actors must be objects");
|
|
||||||
}
|
|
||||||
if (actor.pubkey !== pubkey.toLowerCase()) {
|
|
||||||
throw new Error(`Actor pubkey mismatch: ${pubkey} !== ${actor.pubkey}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
roles,
|
|
||||||
actors
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
+515
-355
@@ -1,441 +1,601 @@
|
|||||||
// Evaluator for Nostr Governance
|
// Canonical evaluator for Nostr Governance
|
||||||
//
|
//
|
||||||
// This module brings together all the core components to evaluate governance
|
// This is the single place policy is decided. Applications collect targets and
|
||||||
// decisions for targets based on state and policies.
|
// apply the returned decision; they must not recompute thresholds themselves.
|
||||||
|
//
|
||||||
|
// Evaluation is pure: it performs no I/O, reads an injected clock, and never
|
||||||
|
// mutates its inputs. The same snapshot and context always produce the same
|
||||||
|
// decision, which is what makes the conformance corpus meaningful.
|
||||||
|
|
||||||
import { getTargetKey } from "./identifiers.js";
|
import { getTargetKey } from "./identifiers.js";
|
||||||
import { areTargetsEqual, isDescendantOf } from "./targets.js";
|
import { isValidTarget } from "./targets.js";
|
||||||
import { hasCapability } from "./authority.js";
|
import { isProtectedActor } from "./authority.js";
|
||||||
|
import { isDenied } from "./adminState.js";
|
||||||
|
import { createEmptyEvidence, freezeEvidence, redactEvidence } from "./evidence.js";
|
||||||
|
import { createReason, dedupeReasons } from "./reasons.js";
|
||||||
import {
|
import {
|
||||||
createGovernanceDecision,
|
NEUTRAL_POLICY,
|
||||||
getPolicyEffect,
|
applyEffects,
|
||||||
composeGovernanceDecisions
|
applyThresholds,
|
||||||
|
createNeutralDecision,
|
||||||
|
effectSeverity,
|
||||||
|
resolveProfile,
|
||||||
|
resolveThresholds,
|
||||||
} from "./policy.js";
|
} from "./policy.js";
|
||||||
|
import { snapshotFingerprint } from "./fingerprint.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
|
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
|
||||||
* @typedef {import('./authority.js').AuthorityState} AuthorityState
|
* @typedef {import('./authority.js').AuthorityState} AuthorityState
|
||||||
|
* @typedef {import('./adminState.js').AdminState} AdminState
|
||||||
* @typedef {import('./policy.js').PolicyContext} PolicyContext
|
* @typedef {import('./policy.js').PolicyContext} PolicyContext
|
||||||
|
* @typedef {import('./policy.js').PolicyProfile} PolicyProfile
|
||||||
* @typedef {import('./policy.js').GovernanceDecision} GovernanceDecision
|
* @typedef {import('./policy.js').GovernanceDecision} GovernanceDecision
|
||||||
* @typedef {import('./policy.js').PolicyDefinition} PolicyDefinition
|
* @typedef {import('./evidence.js').GovernanceEvidence} GovernanceEvidence
|
||||||
|
* @typedef {import('./reasons.js').GovernanceReason} GovernanceReason
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Report
|
* @typedef {Object} ReportRecord
|
||||||
* @property {string} reporter - Pubkey of the reporter
|
* @property {string} reporter - Reporter pubkey
|
||||||
* @property {GovernanceTarget} target - Target being reported
|
|
||||||
* @property {string} category - Report category
|
* @property {string} category - Report category
|
||||||
* @property {number} timestamp - Unix timestamp of the report
|
* @property {number} [createdAt] - Unix seconds
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} TrustedMute
|
* @typedef {Object} MuteRecord
|
||||||
* @property {GovernanceTarget} target - Target being muted
|
* @property {string} muter - Muting pubkey
|
||||||
* @property {number} count - Number of trusted mutes
|
* @property {string} [category] - Optional mute category
|
||||||
* @property {Record<string, number>} categories - Count of mutes per category
|
* @property {number} [updatedAt] - Unix seconds; compared against the mute window
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Override
|
* @typedef {Object} ViewerState
|
||||||
* @property {GovernanceTarget} target - Target being overridden
|
* @property {Set<string>} [blocks] - Pubkeys the viewer blocks outright
|
||||||
* @property {"allow"|"restrict"|"hide"|"deny"} visibility - Visibility override
|
* @property {Set<string>} [mutes] - Pubkeys the viewer mutes
|
||||||
* @property {string} [reason] - Reason for the override
|
* @property {Map<string, { visibility: string, reason?: string }>} [overrides] - Viewer overrides by target key
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} GovernanceState
|
* @typedef {Object} TrustState
|
||||||
* @property {AuthorityState} authority - Authority state
|
* @property {Set<string>} [contacts] - Pubkeys the viewer trusts directly
|
||||||
* @property {Record<string, Report[]>} reports - Reports by target key
|
* @property {Set<string>} [seeds] - Fallback trust seeds for anonymous viewers
|
||||||
* @property {Record<string, TrustedMute>} trustedMutes - Trusted mutes by target key
|
|
||||||
* @property {Record<string, Override>} overrides - Overrides by target key
|
|
||||||
* @property {Record<string, PolicyDefinition>} policies - Policy definitions by ID
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default thresholds for governance decisions
|
* @typedef {Object} GovernanceSnapshot
|
||||||
|
* @property {AuthorityState} authority
|
||||||
|
* @property {AdminState} admin
|
||||||
|
* @property {TrustState} [trust]
|
||||||
|
* @property {Map<string, ReportRecord[]>} [reports] - Reports by target key
|
||||||
|
* @property {Map<string, MuteRecord[]>} [trustedMutes] - Mutes by target key
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_THRESHOLDS = {
|
|
||||||
// Number of trusted reports needed to hide a target
|
|
||||||
trustedReportHideThreshold: 5,
|
|
||||||
// Number of trusted reports needed to restrict a target
|
|
||||||
trustedReportRestrictThreshold: 3,
|
|
||||||
// Number of trusted mutes needed to hide a target
|
|
||||||
trustedMuteHideThreshold: 10,
|
|
||||||
// Number of trusted mutes needed to restrict a target
|
|
||||||
trustedMuteRestrictThreshold: 5
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate a single target against the governance state
|
* Resolve the effective trust set for a viewer.
|
||||||
|
*
|
||||||
|
* Trust seeds are a fallback for anonymous or not-yet-hydrated viewers, not an
|
||||||
|
* addition to a real follow graph: once the viewer has contacts, seeds stop
|
||||||
|
* contributing so that following nobody is not equivalent to following the
|
||||||
|
* seed set.
|
||||||
|
*
|
||||||
|
* @param {TrustState} [trust]
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
export function resolveTrustSet(trust) {
|
||||||
|
const contacts = trust?.contacts;
|
||||||
|
if (contacts && contacts.size > 0) {
|
||||||
|
return contacts;
|
||||||
|
}
|
||||||
|
return trust?.seeds ?? new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a contributor's signal counts.
|
||||||
|
*
|
||||||
|
* Blocked and administratively denied accounts are excluded before any
|
||||||
|
* counting, so a denied account cannot manufacture reports.
|
||||||
|
*
|
||||||
|
* @param {string} pubkey
|
||||||
|
* @param {Set<string>} trustSet
|
||||||
|
* @param {GovernanceSnapshot} snapshot
|
||||||
|
* @param {ViewerState} viewer
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isCountableSignal(pubkey, trustSet, snapshot, viewer) {
|
||||||
|
if (typeof pubkey !== "string" || !pubkey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!trustSet.has(pubkey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (viewer.blocks?.has(pubkey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (snapshot.admin.userDeny.has(`user:${pubkey}`)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate trusted reports for a target.
|
||||||
|
*
|
||||||
|
* Reports are deduplicated per (reporter, category): one account reporting the
|
||||||
|
* same target for the same reason twice is one signal, but the same account may
|
||||||
|
* legitimately report distinct categories.
|
||||||
|
*
|
||||||
|
* @param {string} key
|
||||||
|
* @param {GovernanceSnapshot} snapshot
|
||||||
|
* @param {ViewerState} viewer
|
||||||
|
* @param {Set<string>} trustSet
|
||||||
|
* @returns {{ total: number, byCategory: Record<string, number>, reporters: string[] }}
|
||||||
|
*/
|
||||||
|
export function aggregateReports(key, snapshot, viewer, trustSet) {
|
||||||
|
const records = snapshot.reports?.get(key) ?? [];
|
||||||
|
/** @type {Map<string, Set<string>>} */
|
||||||
|
const byCategory = new Map();
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
const reporters = new Set();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (!record || !isCountableSignal(record.reporter, trustSet, snapshot, viewer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const category = typeof record.category === "string" ? record.category.trim().toLowerCase() : "";
|
||||||
|
if (!category) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!byCategory.has(category)) {
|
||||||
|
byCategory.set(category, new Set());
|
||||||
|
}
|
||||||
|
/** @type {Set<string>} */ (byCategory.get(category)).add(record.reporter);
|
||||||
|
reporters.add(record.reporter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const counts = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const [category, set] of byCategory.entries()) {
|
||||||
|
counts[category] = set.size;
|
||||||
|
total += set.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, byCategory: counts, reporters: Array.from(reporters) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate trusted mutes for a target.
|
||||||
|
*
|
||||||
|
* Muters are counted uniquely and mutes outside the profile's validity window
|
||||||
|
* are ignored, so a stale list cannot hold a target down indefinitely.
|
||||||
|
*
|
||||||
|
* @param {string} key
|
||||||
|
* @param {GovernanceSnapshot} snapshot
|
||||||
|
* @param {ViewerState} viewer
|
||||||
|
* @param {Set<string>} trustSet
|
||||||
|
* @param {number} now - Unix seconds
|
||||||
|
* @param {number} [windowSeconds] - 0 or undefined disables expiry
|
||||||
|
* @returns {{ total: number, byCategory: Record<string, number>, muters: string[] }}
|
||||||
|
*/
|
||||||
|
export function aggregateMutes(key, snapshot, viewer, trustSet, now, windowSeconds) {
|
||||||
|
const records = snapshot.trustedMutes?.get(key) ?? [];
|
||||||
|
const cutoff = Number.isFinite(windowSeconds) && /** @type {number} */ (windowSeconds) > 0
|
||||||
|
? now - /** @type {number} */ (windowSeconds)
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
const muters = new Set();
|
||||||
|
/** @type {Map<string, Set<string>>} */
|
||||||
|
const byCategory = new Map();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (!record || !isCountableSignal(record.muter, trustSet, snapshot, viewer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updatedAt = Number.isFinite(record.updatedAt) ? /** @type {number} */ (record.updatedAt) : 0;
|
||||||
|
if (updatedAt < cutoff) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
muters.add(record.muter);
|
||||||
|
|
||||||
|
const category = typeof record.category === "string" ? record.category.trim().toLowerCase() : "";
|
||||||
|
if (category) {
|
||||||
|
if (!byCategory.has(category)) {
|
||||||
|
byCategory.set(category, new Set());
|
||||||
|
}
|
||||||
|
/** @type {Set<string>} */ (byCategory.get(category)).add(record.muter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const counts = {};
|
||||||
|
for (const [category, set] of byCategory.entries()) {
|
||||||
|
counts[category] = set.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total: muters.size, byCategory: counts, muters: Array.from(muters) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect the pubkeys whose administrative denial should reach this target.
|
||||||
|
*
|
||||||
|
* An event or address inherits its author's denial: denying a seller must take
|
||||||
|
* down that seller's listings. An exact-event denial does not travel the other
|
||||||
|
* way — it applies only to the event named.
|
||||||
|
*
|
||||||
* @param {GovernanceTarget} target
|
* @param {GovernanceTarget} target
|
||||||
* @param {GovernanceState} state
|
* @returns {string[]}
|
||||||
* @param {PolicyContext} context
|
*/
|
||||||
* @param {string} [viewerPubkey] - Pubkey of the viewer (for personal overrides)
|
function authorChain(target) {
|
||||||
|
if (target.type === "user") {
|
||||||
|
return [target.pubkey];
|
||||||
|
}
|
||||||
|
if (target.type === "address") {
|
||||||
|
return [target.pubkey];
|
||||||
|
}
|
||||||
|
const author = /** @type {{ author?: string }} */ (target).author;
|
||||||
|
return typeof author === "string" && author ? [author] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate one target.
|
||||||
|
*
|
||||||
|
* @param {GovernanceTarget} target
|
||||||
|
* @param {GovernanceSnapshot} snapshot
|
||||||
|
* @param {PolicyContext} [context]
|
||||||
|
* @param {ViewerState} [viewerState]
|
||||||
* @returns {GovernanceDecision}
|
* @returns {GovernanceDecision}
|
||||||
*/
|
*/
|
||||||
export function evaluateTarget(target, state, context, viewerPubkey) {
|
export function evaluateTarget(target, snapshot, context = { surface: "default" }, viewerState = {}) {
|
||||||
const decisions = [];
|
// 1. Validate and normalize the target.
|
||||||
|
if (!isValidTarget(target)) {
|
||||||
// 1. Check for personal overrides first
|
throw new Error(`Invalid governance target: ${JSON.stringify(target)}`);
|
||||||
if (viewerPubkey) {
|
|
||||||
const overrideDecision = checkPersonalOverrides(target, state, viewerPubkey);
|
|
||||||
if (overrideDecision) {
|
|
||||||
decisions.push(overrideDecision);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check for explicit overrides
|
const key = getTargetKey(target);
|
||||||
const explicitOverrideDecision = checkExplicitOverrides(target, state, context);
|
const policy = context.policy ?? NEUTRAL_POLICY;
|
||||||
if (explicitOverrideDecision) {
|
const profile = resolveProfile(policy, context);
|
||||||
decisions.push(explicitOverrideDecision);
|
const now = Number.isFinite(context.now) ? /** @type {number} */ (context.now) : 0;
|
||||||
|
const viewer = viewerState ?? {};
|
||||||
|
|
||||||
|
const includeTransaction =
|
||||||
|
profile.administrativeDeny?.transaction !== undefined ||
|
||||||
|
hasTransactionGate(profile);
|
||||||
|
|
||||||
|
/** @type {GovernanceDecision} */
|
||||||
|
const decision = createNeutralDecision({ key, target, includeTransaction });
|
||||||
|
decision.policyProfile = profile.name;
|
||||||
|
decision.policyVersion = policy.version;
|
||||||
|
decision.evaluatedAt = now;
|
||||||
|
decision.snapshotFingerprint = snapshotFingerprint({
|
||||||
|
authority: snapshot.authority,
|
||||||
|
admin: snapshot.admin,
|
||||||
|
reports: snapshot.reports,
|
||||||
|
trustedMutes: snapshot.trustedMutes,
|
||||||
|
policy: { id: policy.id, version: policy.version, profile: profile.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
const evidence = createEmptyEvidence();
|
||||||
|
|
||||||
|
// A disabled surface reports why it decided nothing rather than silently
|
||||||
|
// returning a neutral decision that looks like a real evaluation.
|
||||||
|
if (profile.disabled) {
|
||||||
|
decision.reasons = [createReason("policy-disabled")];
|
||||||
|
decision.evidence = evidence;
|
||||||
|
return decision;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check reports
|
const authors = authorChain(target);
|
||||||
const reportDecision = checkReports(target, state, context);
|
|
||||||
if (reportDecision) {
|
|
||||||
decisions.push(reportDecision);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Check trusted mutes
|
// 2. Protected-target status. Checked before any denial path so that a
|
||||||
const muteDecision = checkTrustedMutes(target, state, context);
|
// protected actor cannot be denied even by a malformed admin snapshot.
|
||||||
if (muteDecision) {
|
const isProtected = authors.some((pubkey) => isProtectedActor(pubkey, snapshot.authority));
|
||||||
decisions.push(muteDecision);
|
evidence.protectedTarget = isProtected;
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Check if target is a descendant of a denied ancestor
|
// 3. Viewer personal block. Viewer-local and always enforced: a viewer's own
|
||||||
const ancestorDecision = checkAncestorDeny(target, state, context);
|
// block is never softened by an application profile.
|
||||||
if (ancestorDecision) {
|
const blockedAuthor = authors.find((pubkey) => viewer.blocks?.has(pubkey));
|
||||||
decisions.push(ancestorDecision);
|
if (blockedAuthor) {
|
||||||
}
|
evidence.personalBlock = true;
|
||||||
|
applyEffects(
|
||||||
// Compose all decisions into a final decision
|
decision,
|
||||||
if (decisions.length === 0) {
|
profile.viewerBlock ?? { visibility: "hide", interaction: "deny" },
|
||||||
// Default allow decision
|
[createReason("viewer-block")],
|
||||||
return createGovernanceDecision(
|
|
||||||
{ effect: /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow") },
|
|
||||||
{ effect: /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow") }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return composeGovernanceDecisions(decisions);
|
if (authors.some((pubkey) => viewer.mutes?.has(pubkey))) {
|
||||||
|
evidence.personalMute = true;
|
||||||
|
decision.reasons = dedupeReasons([...decision.reasons, createReason("viewer-mute")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 4-6. Administrative denial: the target itself, then its author.
|
||||||
* Check for personal overrides for a viewer
|
if (!isProtected) {
|
||||||
* @param {GovernanceTarget} target
|
const targetDenied = isDenied(target, snapshot.admin);
|
||||||
* @param {GovernanceState} state
|
const authorDenied =
|
||||||
* @param {string} viewerPubkey
|
target.type !== "user" && authors.some((pubkey) => snapshot.admin.userDeny.has(`user:${pubkey}`));
|
||||||
* @returns {GovernanceDecision|null}
|
|
||||||
*/
|
if (target.type === "user" && targetDenied) {
|
||||||
function checkPersonalOverrides(target, state, viewerPubkey) {
|
evidence.userDenied = true;
|
||||||
// This would check for personal allowlists, blocklists, etc.
|
}
|
||||||
// For now, we'll return null to indicate no personal override
|
if (target.type === "event" && targetDenied) {
|
||||||
return null;
|
evidence.eventDenied = true;
|
||||||
|
}
|
||||||
|
if (target.type === "address" && targetDenied) {
|
||||||
|
evidence.addressDenied = true;
|
||||||
|
}
|
||||||
|
if (authorDenied) {
|
||||||
|
evidence.userDenied = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (targetDenied || authorDenied) {
|
||||||
* Check for explicit overrides
|
const reasonId =
|
||||||
* @param {GovernanceTarget} target
|
target.type === "event" && targetDenied
|
||||||
* @param {GovernanceState} state
|
? "admin-event-deny"
|
||||||
* @param {PolicyContext} context
|
: target.type === "address" && targetDenied
|
||||||
* @returns {GovernanceDecision|null}
|
? "admin-address-deny"
|
||||||
*/
|
: "admin-user-deny";
|
||||||
function checkExplicitOverrides(target, state, context) {
|
|
||||||
const targetKey = getTargetKey(target);
|
|
||||||
const override = state.overrides[targetKey];
|
|
||||||
|
|
||||||
if (!override) {
|
const communitySources = snapshot.admin.communitySources.get(key) ?? [];
|
||||||
return null;
|
/** @type {GovernanceReason[]} */
|
||||||
|
const reasons = [createReason(reasonId)];
|
||||||
|
if (communitySources.length) {
|
||||||
|
reasons.push(createReason("community-user-deny", { source: communitySources[0] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the override should be applied based on context
|
applyEffects(
|
||||||
if (context.enforcement && context.enforcement.allowOverrides === false) {
|
decision,
|
||||||
return null;
|
profile.administrativeDeny ?? { visibility: "hide", interaction: "deny" },
|
||||||
}
|
|
||||||
|
|
||||||
const reasons = [];
|
|
||||||
if (override.reason) {
|
|
||||||
reasons.push(override.reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
return createGovernanceDecision(
|
|
||||||
{
|
|
||||||
effect: /** @type {import('./policy.js').PolicyEffect["effect"]} */ (override.visibility),
|
|
||||||
reason: override.reason
|
|
||||||
},
|
|
||||||
{
|
|
||||||
effect: /** @type {import('./policy.js').PolicyEffect["effect"]} */ (override.visibility),
|
|
||||||
reason: override.reason
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
reasons,
|
reasons,
|
||||||
[override]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (isDenied(target, snapshot.admin)) {
|
||||||
/**
|
// A protected actor appearing in a denial set is a policy no-op, but the
|
||||||
* Check reports against a target
|
// consumer should be able to see that it was ignored.
|
||||||
* @param {GovernanceTarget} target
|
decision.reasons = dedupeReasons([...decision.reasons, createReason("protected-target")]);
|
||||||
* @param {GovernanceState} state
|
|
||||||
* @param {PolicyContext} context
|
|
||||||
* @returns {GovernanceDecision|null}
|
|
||||||
*/
|
|
||||||
function checkReports(target, state, context) {
|
|
||||||
const targetKey = getTargetKey(target);
|
|
||||||
const targetReports = state.reports[targetKey] || [];
|
|
||||||
|
|
||||||
if (targetReports.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count trusted reports by category
|
// 7. Allowlist policy. Access control is separate from trust: being allowed
|
||||||
/** @type {Record<string, number>} */
|
// to publish grants no trust, and missing the allowlist is not a moral
|
||||||
const trustedReportCounts = {};
|
// judgement, so it is only enforced where the profile asks for it.
|
||||||
/** @type {any[]} */
|
if (profile.requireAllowlist && !isProtected) {
|
||||||
const evidence = [];
|
const allowed = authors.some((pubkey) => snapshot.admin.userAllow.has(`user:${pubkey}`));
|
||||||
|
evidence.userAllowed = allowed;
|
||||||
for (const report of targetReports) {
|
if (!allowed) {
|
||||||
// Check if the reporter is trusted
|
applyEffects(
|
||||||
if (hasCapability(report.reporter, "submit-reports", state.authority)) {
|
decision,
|
||||||
if (!trustedReportCounts[report.category]) {
|
profile.allowlistMiss ?? { visibility: "hide", interaction: "deny" },
|
||||||
trustedReportCounts[report.category] = 0;
|
[createReason("allowlist-miss")],
|
||||||
}
|
|
||||||
trustedReportCounts[report.category]++;
|
|
||||||
evidence.push(report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine effect based on trusted report counts
|
|
||||||
let visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow");
|
|
||||||
let interactionEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow");
|
|
||||||
/** @type {string[]} */
|
|
||||||
const reasons = [];
|
|
||||||
|
|
||||||
for (const [category, count] of Object.entries(trustedReportCounts)) {
|
|
||||||
if (count >= DEFAULT_THRESHOLDS.trustedReportHideThreshold) {
|
|
||||||
visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("hide");
|
|
||||||
interactionEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("deny");
|
|
||||||
reasons.push(`Hidden due to ${count} trusted reports in category ${category}`);
|
|
||||||
} else if (count >= DEFAULT_THRESHOLDS.trustedReportRestrictThreshold) {
|
|
||||||
visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("restrict");
|
|
||||||
reasons.push(`Restricted due to ${count} trusted reports in category ${category}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibilityEffect === "allow" && interactionEffect === "allow") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createGovernanceDecision(
|
|
||||||
{ effect: visibilityEffect, reason: reasons.join("; ") },
|
|
||||||
{ effect: interactionEffect, reason: reasons.join("; ") },
|
|
||||||
undefined,
|
|
||||||
reasons,
|
|
||||||
evidence
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
/**
|
evidence.userAllowed = authors.some((pubkey) => snapshot.admin.userAllow.has(`user:${pubkey}`));
|
||||||
* Check trusted mutes against a target
|
|
||||||
* @param {GovernanceTarget} target
|
|
||||||
* @param {GovernanceState} state
|
|
||||||
* @param {PolicyContext} context
|
|
||||||
* @returns {GovernanceDecision|null}
|
|
||||||
*/
|
|
||||||
function checkTrustedMutes(target, state, context) {
|
|
||||||
const targetKey = getTargetKey(target);
|
|
||||||
const trustedMute = state.trustedMutes[targetKey];
|
|
||||||
|
|
||||||
if (!trustedMute) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine effect based on trusted mute count
|
// 9-11. Trust signals. Aggregated after administrative state so that denied
|
||||||
let visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow");
|
// accounts are already excluded from counting.
|
||||||
let interactionEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow");
|
const trustSet = resolveTrustSet(snapshot.trust);
|
||||||
/** @type {string[]} */
|
|
||||||
const reasons = [];
|
|
||||||
const evidence = [trustedMute];
|
|
||||||
|
|
||||||
if (trustedMute.count >= DEFAULT_THRESHOLDS.trustedMuteHideThreshold) {
|
const mutes = aggregateMutes(key, snapshot, viewer, trustSet, now, profile.muteWindowSeconds);
|
||||||
visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("hide");
|
evidence.trustedMuteTotal = mutes.total;
|
||||||
interactionEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("deny");
|
evidence.trustedMutesByCategory = mutes.byCategory;
|
||||||
reasons.push(`Hidden due to ${trustedMute.count} trusted mutes`);
|
evidence.trustedMuterPubkeys = mutes.muters;
|
||||||
} else if (trustedMute.count >= DEFAULT_THRESHOLDS.trustedMuteRestrictThreshold) {
|
|
||||||
visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("restrict");
|
|
||||||
reasons.push(`Restricted due to ${trustedMute.count} trusted mutes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibilityEffect === "allow" && interactionEffect === "allow") {
|
if (mutes.total > 0) {
|
||||||
return null;
|
const thresholds = resolveThresholds(profile.mutes, undefined);
|
||||||
}
|
const { effects, firedGates } = applyThresholds(mutes.total, thresholds);
|
||||||
|
/** @type {GovernanceReason[]} */
|
||||||
return createGovernanceDecision(
|
const reasons = [createReason("trusted-mute", { count: mutes.total })];
|
||||||
{ effect: visibilityEffect, reason: reasons.join("; ") },
|
if (firedGates.length) {
|
||||||
{ effect: interactionEffect, reason: reasons.join("; ") },
|
reasons.push(
|
||||||
undefined,
|
createReason("trusted-mute-threshold", {
|
||||||
reasons,
|
count: mutes.total,
|
||||||
evidence
|
threshold: lowestFiredThreshold(thresholds, firedGates),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
applyEffects(decision, effects, reasons);
|
||||||
|
|
||||||
/**
|
for (const [category, count] of Object.entries(mutes.byCategory)) {
|
||||||
* Check if target is a descendant of a denied ancestor
|
const categoryThresholds = profile.mutes?.[category];
|
||||||
* @param {GovernanceTarget} target
|
if (!categoryThresholds) {
|
||||||
* @param {GovernanceState} state
|
continue;
|
||||||
* @param {PolicyContext} context
|
}
|
||||||
* @returns {GovernanceDecision|null}
|
const categoryResult = applyThresholds(count, categoryThresholds);
|
||||||
*/
|
if (categoryResult.firedGates.length) {
|
||||||
function checkAncestorDeny(target, state, context) {
|
applyEffects(decision, categoryResult.effects, [
|
||||||
// This would check if any ancestor of the target is denied
|
createReason("trusted-mute-threshold", {
|
||||||
// For now, we'll return null to indicate no ancestor deny
|
category,
|
||||||
return null;
|
count,
|
||||||
|
threshold: lowestFiredThreshold(categoryThresholds, categoryResult.firedGates),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reports = aggregateReports(key, snapshot, viewer, trustSet);
|
||||||
|
evidence.trustedReportTotal = reports.total;
|
||||||
|
evidence.trustedReportsByCategory = reports.byCategory;
|
||||||
|
evidence.trustedReporterPubkeys = reports.reporters;
|
||||||
|
|
||||||
|
if (reports.total > 0) {
|
||||||
|
decision.reasons = dedupeReasons([
|
||||||
|
...decision.reasons,
|
||||||
|
createReason("trusted-report", { count: reports.total }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const [category, count] of Object.entries(reports.byCategory)) {
|
||||||
|
const thresholds = resolveThresholds(profile.reports, category);
|
||||||
|
const { effects, firedGates } = applyThresholds(count, thresholds);
|
||||||
|
if (!firedGates.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
applyEffects(decision, effects, [
|
||||||
|
createReason("trusted-report-threshold", {
|
||||||
|
category,
|
||||||
|
count,
|
||||||
|
threshold: lowestFiredThreshold(thresholds, firedGates),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence.thresholds = summarizeThresholds(profile);
|
||||||
|
|
||||||
|
// 8. Viewer override, applied after evidence so the viewer is overriding a
|
||||||
|
// decision that actually exists. Overrides may only soften, never
|
||||||
|
// escalate, and never reach a non-overridable decision.
|
||||||
|
const override = viewer.overrides?.get(key);
|
||||||
|
const overridesAllowed =
|
||||||
|
context.enforcement?.allowOverrides !== false && profile.allowViewerOverride !== false;
|
||||||
|
|
||||||
|
if (override && overridesAllowed && decision.visibility.overridable) {
|
||||||
|
const requested = override.visibility;
|
||||||
|
if (effectSeverity("visibility", requested) >= 0) {
|
||||||
|
const isSoftening =
|
||||||
|
effectSeverity("visibility", requested) < effectSeverity("visibility", decision.visibility.effect);
|
||||||
|
if (isSoftening) {
|
||||||
|
decision.visibility.effect = /** @type {import('./policy.js').VisibilityEffect} */ (requested);
|
||||||
|
decision.interaction.effect = "require-explicit-action";
|
||||||
|
decision.reasons = dedupeReasons([...decision.reasons, createReason("viewer-override")]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Surface policy profile. A feed may decline to hide while still
|
||||||
|
// downranking, so the hide ceiling is applied last, over the composed
|
||||||
|
// decision, and records that it fired.
|
||||||
|
if (profile.bypassHide && effectSeverity("visibility", decision.visibility.effect) >= effectSeverity("visibility", "hide")) {
|
||||||
|
const ceiling = profile.bypassHideCeiling ?? "restrict";
|
||||||
|
decision.visibility.effect = /** @type {import('./policy.js').VisibilityEffect} */ (ceiling);
|
||||||
|
decision.reasons = dedupeReasons([...decision.reasons, createReason("surface-policy-bypass")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
decision.evidence = profile.exposeEvidence ? freezeEvidence(evidence) : redactEvidence(evidence);
|
||||||
|
decision.reasons = dedupeReasons(decision.reasons);
|
||||||
|
|
||||||
|
return decision;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate multiple targets against the governance state
|
* Whether any category threshold in a profile can affect the transaction
|
||||||
|
* dimension, which decides if the decision carries a transaction block at all.
|
||||||
|
* @param {PolicyProfile} profile
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function hasTransactionGate(profile) {
|
||||||
|
for (const table of [profile.reports, profile.mutes]) {
|
||||||
|
for (const thresholds of Object.values(table ?? {})) {
|
||||||
|
if (
|
||||||
|
Number.isFinite(thresholds.transactionDeny) ||
|
||||||
|
Number.isFinite(thresholds.transactionReview)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The smallest threshold value among the gates that fired, which is the one
|
||||||
|
* that explains why the decision changed.
|
||||||
|
* @param {import('./policy.js').CategoryThresholds} thresholds
|
||||||
|
* @param {string[]} firedGates
|
||||||
|
* @returns {number|undefined}
|
||||||
|
*/
|
||||||
|
function lowestFiredThreshold(thresholds, firedGates) {
|
||||||
|
let lowest;
|
||||||
|
for (const gate of firedGates) {
|
||||||
|
const value = /** @type {Record<string, number|undefined>} */ (thresholds)[gate];
|
||||||
|
if (Number.isFinite(value) && (lowest === undefined || /** @type {number} */ (value) < lowest)) {
|
||||||
|
lowest = /** @type {number} */ (value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lowest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize a profile's headline thresholds for evidence reporting.
|
||||||
|
* @param {PolicyProfile} profile
|
||||||
|
* @returns {import('./evidence.js').EvidenceThresholds}
|
||||||
|
*/
|
||||||
|
function summarizeThresholds(profile) {
|
||||||
|
const defaults = profile.reports?.default ?? {};
|
||||||
|
/** @type {import('./evidence.js').EvidenceThresholds} */
|
||||||
|
const summary = {};
|
||||||
|
for (const gate of /** @type {const} */ (["warn", "restrict", "hide", "deny"])) {
|
||||||
|
if (Number.isFinite(defaults[gate])) {
|
||||||
|
summary[gate] = /** @type {number} */ (defaults[gate]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate many targets against one snapshot.
|
||||||
|
*
|
||||||
|
* The snapshot fingerprint is computed once and reused, which is what keeps
|
||||||
|
* large feeds from re-hashing identical state per item.
|
||||||
|
*
|
||||||
* @param {GovernanceTarget[]} targets
|
* @param {GovernanceTarget[]} targets
|
||||||
* @param {GovernanceState} state
|
* @param {GovernanceSnapshot} snapshot
|
||||||
* @param {PolicyContext} context
|
* @param {PolicyContext} [context]
|
||||||
* @param {string} [viewerPubkey] - Pubkey of the viewer (for personal overrides)
|
* @param {ViewerState} [viewerState]
|
||||||
* @returns {Record<string, GovernanceDecision>}
|
* @returns {Map<string, GovernanceDecision>}
|
||||||
*/
|
*/
|
||||||
export function evaluateMany(targets, state, context, viewerPubkey) {
|
export function evaluateMany(targets, snapshot, context, viewerState) {
|
||||||
/** @type {Record<string, GovernanceDecision>} */
|
/** @type {Map<string, GovernanceDecision>} */
|
||||||
const results = {};
|
const results = new Map();
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const targetKey = getTargetKey(target);
|
if (!isValidTarget(target)) {
|
||||||
results[targetKey] = evaluateTarget(target, state, context, viewerPubkey);
|
continue;
|
||||||
|
}
|
||||||
|
const key = getTargetKey(target);
|
||||||
|
if (results.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.set(key, evaluateTarget(target, snapshot, context, viewerState));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a governance state
|
* Create a governance snapshot with empty defaults.
|
||||||
* @param {AuthorityState} [authority]
|
* @param {Partial<GovernanceSnapshot>} [parts]
|
||||||
* @param {Record<string, Report[]>} [reports]
|
* @returns {GovernanceSnapshot}
|
||||||
* @param {Record<string, TrustedMute>} [trustedMutes]
|
|
||||||
* @param {Record<string, Override>} [overrides]
|
|
||||||
* @param {Record<string, PolicyDefinition>} [policies]
|
|
||||||
* @returns {GovernanceState}
|
|
||||||
*/
|
*/
|
||||||
export function createGovernanceState(authority, reports, trustedMutes, overrides, policies) {
|
export function createSnapshot(parts = {}) {
|
||||||
return {
|
return {
|
||||||
authority: authority || { roles: {}, actors: {} },
|
authority: parts.authority ?? { roles: {}, actors: {}, protectedActors: [] },
|
||||||
reports: reports || {},
|
admin: parts.admin ?? {
|
||||||
trustedMutes: trustedMutes || {},
|
userAllow: new Set(),
|
||||||
overrides: overrides || {},
|
userDeny: new Set(),
|
||||||
policies: policies || {}
|
eventDeny: new Set(),
|
||||||
|
addressDeny: new Set(),
|
||||||
|
trustSeeds: new Set(),
|
||||||
|
contributors: new Map(),
|
||||||
|
communitySources: new Map(),
|
||||||
|
},
|
||||||
|
trust: parts.trust ?? { contacts: new Set(), seeds: new Set() },
|
||||||
|
reports: parts.reports ?? new Map(),
|
||||||
|
trustedMutes: parts.trustedMutes ?? new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a report
|
* Create a viewer state with empty defaults.
|
||||||
* @param {string} reporter
|
* @param {Partial<ViewerState>} [parts]
|
||||||
* @param {GovernanceTarget} target
|
* @returns {ViewerState}
|
||||||
* @param {string} category
|
|
||||||
* @param {number} timestamp
|
|
||||||
* @returns {Report}
|
|
||||||
*/
|
*/
|
||||||
export function createReport(reporter, target, category, timestamp) {
|
export function createViewerState(parts = {}) {
|
||||||
// Validate reporter pubkey
|
|
||||||
if (typeof reporter !== "string" || !/^[0-9a-f]{64}$/i.test(reporter)) {
|
|
||||||
throw new Error("Reporter must be a 64-character hex pubkey");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate target
|
|
||||||
if (!target || typeof target !== "object") {
|
|
||||||
throw new Error("Target must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate category
|
|
||||||
if (typeof category !== "string" || !category.trim()) {
|
|
||||||
throw new Error("Category must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate timestamp
|
|
||||||
if (typeof timestamp !== "number" || timestamp <= 0) {
|
|
||||||
throw new Error("Timestamp must be a positive number");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reporter: reporter.toLowerCase(),
|
blocks: parts.blocks ?? new Set(),
|
||||||
target,
|
mutes: parts.mutes ?? new Set(),
|
||||||
category: category.trim(),
|
overrides: parts.overrides ?? new Map(),
|
||||||
timestamp
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a trusted mute
|
|
||||||
* @param {GovernanceTarget} target
|
|
||||||
* @param {number} count
|
|
||||||
* @param {Record<string, number>} categories
|
|
||||||
* @returns {TrustedMute}
|
|
||||||
*/
|
|
||||||
export function createTrustedMute(target, count, categories) {
|
|
||||||
// Validate target
|
|
||||||
if (!target || typeof target !== "object") {
|
|
||||||
throw new Error("Target must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate count
|
|
||||||
if (typeof count !== "number" || count < 0) {
|
|
||||||
throw new Error("Count must be a non-negative number");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate categories
|
|
||||||
if (!categories || typeof categories !== "object") {
|
|
||||||
throw new Error("Categories must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each category count
|
|
||||||
for (const [category, categoryCount] of Object.entries(categories)) {
|
|
||||||
if (typeof category !== "string" || !category.trim()) {
|
|
||||||
throw new Error("Category names must be non-empty strings");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof categoryCount !== "number" || categoryCount < 0) {
|
|
||||||
throw new Error("Category counts must be non-negative numbers");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
target,
|
|
||||||
count,
|
|
||||||
categories
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an override
|
|
||||||
* @param {GovernanceTarget} target
|
|
||||||
* @param {"allow"|"restrict"|"hide"|"deny"} visibility
|
|
||||||
* @param {string} [reason]
|
|
||||||
* @returns {Override}
|
|
||||||
*/
|
|
||||||
export function createOverride(target, visibility, reason) {
|
|
||||||
// Validate target
|
|
||||||
if (!target || typeof target !== "object") {
|
|
||||||
throw new Error("Target must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate visibility
|
|
||||||
if (!["allow", "restrict", "hide", "deny"].includes(visibility)) {
|
|
||||||
throw new Error("Invalid visibility effect");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {Override} */
|
|
||||||
const override = {
|
|
||||||
target,
|
|
||||||
visibility
|
|
||||||
};
|
|
||||||
|
|
||||||
if (reason) {
|
|
||||||
override.reason = reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
return override;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Governance evidence for Nostr Governance
|
||||||
|
//
|
||||||
|
// Evidence explains a decision without leaking anything the application has not
|
||||||
|
// already resolved. Pubkeys are included because callers need them to render
|
||||||
|
// "muted by people you follow" affordances; display names and profile data are
|
||||||
|
// never resolved here.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} EvidenceThresholds
|
||||||
|
* @property {number} [warn]
|
||||||
|
* @property {number} [restrict]
|
||||||
|
* @property {number} [hide]
|
||||||
|
* @property {number} [deny]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} GovernanceEvidence
|
||||||
|
* @property {number} trustedReportTotal
|
||||||
|
* @property {Record<string, number>} trustedReportsByCategory
|
||||||
|
* @property {string[]} trustedReporterPubkeys
|
||||||
|
* @property {number} trustedMuteTotal
|
||||||
|
* @property {Record<string, number>} trustedMutesByCategory
|
||||||
|
* @property {string[]} trustedMuterPubkeys
|
||||||
|
* @property {boolean} personalBlock
|
||||||
|
* @property {boolean} personalMute
|
||||||
|
* @property {boolean} userDenied
|
||||||
|
* @property {boolean} eventDenied
|
||||||
|
* @property {boolean} addressDenied
|
||||||
|
* @property {boolean} userAllowed
|
||||||
|
* @property {boolean} protectedTarget
|
||||||
|
* @property {EvidenceThresholds} thresholds
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an empty evidence record.
|
||||||
|
* @returns {GovernanceEvidence}
|
||||||
|
*/
|
||||||
|
export function createEmptyEvidence() {
|
||||||
|
return {
|
||||||
|
trustedReportTotal: 0,
|
||||||
|
trustedReportsByCategory: {},
|
||||||
|
trustedReporterPubkeys: [],
|
||||||
|
trustedMuteTotal: 0,
|
||||||
|
trustedMutesByCategory: {},
|
||||||
|
trustedMuterPubkeys: [],
|
||||||
|
personalBlock: false,
|
||||||
|
personalMute: false,
|
||||||
|
userDenied: false,
|
||||||
|
eventDenied: false,
|
||||||
|
addressDenied: false,
|
||||||
|
userAllowed: false,
|
||||||
|
protectedTarget: false,
|
||||||
|
thresholds: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce a stable, structurally-cloned copy of an evidence record.
|
||||||
|
*
|
||||||
|
* Pubkey lists and category maps are sorted so that two evaluations over the
|
||||||
|
* same snapshot serialize identically, which conformance fixtures depend on.
|
||||||
|
*
|
||||||
|
* @param {GovernanceEvidence} evidence
|
||||||
|
* @returns {GovernanceEvidence}
|
||||||
|
*/
|
||||||
|
export function freezeEvidence(evidence) {
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const reportsByCategory = {};
|
||||||
|
for (const key of Object.keys(evidence.trustedReportsByCategory).sort()) {
|
||||||
|
reportsByCategory[key] = evidence.trustedReportsByCategory[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const mutesByCategory = {};
|
||||||
|
for (const key of Object.keys(evidence.trustedMutesByCategory).sort()) {
|
||||||
|
mutesByCategory[key] = evidence.trustedMutesByCategory[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...evidence,
|
||||||
|
trustedReportsByCategory: reportsByCategory,
|
||||||
|
trustedMutesByCategory: mutesByCategory,
|
||||||
|
trustedReporterPubkeys: [...evidence.trustedReporterPubkeys].sort(),
|
||||||
|
trustedMuterPubkeys: [...evidence.trustedMuterPubkeys].sort(),
|
||||||
|
thresholds: { ...evidence.thresholds },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip evidence down to counts, dropping the pubkey lists.
|
||||||
|
*
|
||||||
|
* Used when a policy profile does not set `exposeEvidence`: the consumer still
|
||||||
|
* learns how strong the signal was, but not who produced it.
|
||||||
|
*
|
||||||
|
* @param {GovernanceEvidence} evidence
|
||||||
|
* @returns {GovernanceEvidence}
|
||||||
|
*/
|
||||||
|
export function redactEvidence(evidence) {
|
||||||
|
return {
|
||||||
|
...freezeEvidence(evidence),
|
||||||
|
trustedReporterPubkeys: [],
|
||||||
|
trustedMuterPubkeys: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Snapshot fingerprints for Nostr Governance
|
||||||
|
//
|
||||||
|
// Fingerprints answer one question: "is this the same input as last time?"
|
||||||
|
// They drive cache invalidation and let a mismatch record point at which
|
||||||
|
// snapshot produced a decision. They are not a security primitive — a
|
||||||
|
// fingerprint collision misses a cache invalidation, it does not grant
|
||||||
|
// authority — so a fast pure-JS hash is used instead of WebCrypto, which would
|
||||||
|
// force the whole evaluation path to become async.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical JSON: object keys sorted at every depth, so two structurally equal
|
||||||
|
* values always serialize to the same string.
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function canonicalStringify(value) {
|
||||||
|
if (value === null || typeof value !== "object") {
|
||||||
|
return JSON.stringify(value) ?? "null";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[${value.map((entry) => canonicalStringify(entry)).join(",")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Set) {
|
||||||
|
return `[${Array.from(value).map(String).sort().map((entry) => JSON.stringify(entry)).join(",")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Map) {
|
||||||
|
const entries = Array.from(value.entries())
|
||||||
|
.map(([key, entry]) => [String(key), entry])
|
||||||
|
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||||
|
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalStringify(entry)}`).join(",")}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(/** @type {Record<string, unknown>} */ (value)).sort();
|
||||||
|
const parts = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const entry = /** @type {Record<string, unknown>} */ (value)[key];
|
||||||
|
if (entry === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parts.push(`${JSON.stringify(key)}:${canonicalStringify(entry)}`);
|
||||||
|
}
|
||||||
|
return `{${parts.join(",")}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNV-1a over 32 bits, run twice with different offsets to widen the digest to
|
||||||
|
* 64 bits and keep accidental collisions unlikely for realistic snapshot counts.
|
||||||
|
* @param {string} input
|
||||||
|
* @returns {string} 16-character lowercase hex digest
|
||||||
|
*/
|
||||||
|
export function hashString(input) {
|
||||||
|
let high = 0x811c9dc5;
|
||||||
|
let low = 0x01000193;
|
||||||
|
|
||||||
|
for (let index = 0; index < input.length; index += 1) {
|
||||||
|
const code = input.charCodeAt(index);
|
||||||
|
high ^= code;
|
||||||
|
high = Math.imul(high, 0x01000193) >>> 0;
|
||||||
|
low ^= code + index;
|
||||||
|
low = Math.imul(low, 0x85ebca6b) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return high.toString(16).padStart(8, "0") + low.toString(16).padStart(8, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint any value.
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function fingerprint(value) {
|
||||||
|
return hashString(canonicalStringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint the parts of a governance snapshot that can change a decision.
|
||||||
|
*
|
||||||
|
* Deliberately excludes evaluation time and viewer identity: those belong to
|
||||||
|
* the evaluation, not the snapshot, and including them would defeat caching.
|
||||||
|
*
|
||||||
|
* @param {Object} snapshot
|
||||||
|
* @param {unknown} [snapshot.authority]
|
||||||
|
* @param {unknown} [snapshot.admin]
|
||||||
|
* @param {unknown} [snapshot.reports]
|
||||||
|
* @param {unknown} [snapshot.trustedMutes]
|
||||||
|
* @param {unknown} [snapshot.overrides]
|
||||||
|
* @param {unknown} [snapshot.policy]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function snapshotFingerprint(snapshot) {
|
||||||
|
return fingerprint({
|
||||||
|
authority: snapshot.authority ?? null,
|
||||||
|
admin: snapshot.admin ?? null,
|
||||||
|
reports: snapshot.reports ?? null,
|
||||||
|
trustedMutes: snapshot.trustedMutes ?? null,
|
||||||
|
overrides: snapshot.overrides ?? null,
|
||||||
|
policy: snapshot.policy ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
* @typedef {Object} EventTarget
|
* @typedef {Object} EventTarget
|
||||||
* @property {"event"} type
|
* @property {"event"} type
|
||||||
* @property {string} id - 64-character hex event ID
|
* @property {string} id - 64-character hex event ID
|
||||||
|
* @property {string} [author] - Author pubkey, so author-level denial can reach the event
|
||||||
|
* @property {number} [kind] - Event kind, carried for application routing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,16 +136,30 @@ export function createUserTarget(pubkey) {
|
|||||||
/**
|
/**
|
||||||
* Create an event target
|
* Create an event target
|
||||||
* @param {string} id - Event ID in hex or note/nevent format
|
* @param {string} id - Event ID in hex or note/nevent format
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {string} [options.author] - Author pubkey; ignored when it is not valid hex
|
||||||
|
* @param {number} [options.kind] - Event kind
|
||||||
* @returns {EventTarget|null} Event target or null if invalid
|
* @returns {EventTarget|null} Event target or null if invalid
|
||||||
*/
|
*/
|
||||||
export function createEventTarget(id) {
|
export function createEventTarget(id, options = {}) {
|
||||||
const normalizedId = normalizeEventId(id);
|
const normalizedId = normalizeEventId(id);
|
||||||
if (!normalizedId) return null;
|
if (!normalizedId) return null;
|
||||||
|
|
||||||
return {
|
/** @type {EventTarget} */
|
||||||
|
const target = {
|
||||||
type: "event",
|
type: "event",
|
||||||
id: normalizedId
|
id: normalizedId
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const author = normalizePubkey(options.author ?? "");
|
||||||
|
if (author) {
|
||||||
|
target.author = author;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(options.kind)) {
|
||||||
|
target.kind = /** @type {number} */ (options.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+118
-2
@@ -1,2 +1,118 @@
|
|||||||
// Placeholder for core module exports
|
// @nostr-governance/core
|
||||||
export {};
|
//
|
||||||
|
// Pure, headless governance primitives. No I/O, no browser globals, no
|
||||||
|
// application thresholds. Applications supply a policy definition and a
|
||||||
|
// snapshot; the evaluator returns a decision they apply.
|
||||||
|
|
||||||
|
export {
|
||||||
|
normalizePubkey,
|
||||||
|
normalizeEventId,
|
||||||
|
normalizeAddress,
|
||||||
|
createUserTarget,
|
||||||
|
createEventTarget,
|
||||||
|
getTargetKey,
|
||||||
|
} from "./identifiers.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
areTargetsEqual,
|
||||||
|
isValidTarget,
|
||||||
|
createTarget,
|
||||||
|
getParentTarget,
|
||||||
|
isDescendantOf,
|
||||||
|
} from "./targets.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
GOVERNANCE_CAPABILITIES,
|
||||||
|
DEFAULT_ROLE_CAPABILITIES,
|
||||||
|
isGovernanceCapability,
|
||||||
|
createAuthorityState,
|
||||||
|
getActorRoles,
|
||||||
|
getActorCapabilities,
|
||||||
|
hasCapability,
|
||||||
|
hasRole,
|
||||||
|
isProtectedActor,
|
||||||
|
createRoleDefinition,
|
||||||
|
} from "./authority.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
createEmptyAdminState,
|
||||||
|
reduceAdminState,
|
||||||
|
protectActors,
|
||||||
|
mergeCommunitySource,
|
||||||
|
isDenied,
|
||||||
|
serializeAdminState,
|
||||||
|
} from "./adminState.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
REASON_IDS,
|
||||||
|
isReasonId,
|
||||||
|
createReason,
|
||||||
|
dedupeReasons,
|
||||||
|
} from "./reasons.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
createEmptyEvidence,
|
||||||
|
freezeEvidence,
|
||||||
|
redactEvidence,
|
||||||
|
} from "./evidence.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
RANKING_LADDER,
|
||||||
|
VISIBILITY_LADDER,
|
||||||
|
INTERACTION_LADDER,
|
||||||
|
TRANSACTION_LADDER,
|
||||||
|
NEUTRAL_POLICY,
|
||||||
|
effectSeverity,
|
||||||
|
maxEffect,
|
||||||
|
createNeutralDecision,
|
||||||
|
normalizeDimensionEffects,
|
||||||
|
normalizeCategoryThresholds,
|
||||||
|
normalizePolicyProfile,
|
||||||
|
normalizePolicyDefinition,
|
||||||
|
createPolicyDefinition,
|
||||||
|
resolveProfile,
|
||||||
|
resolveThresholds,
|
||||||
|
applyThresholds,
|
||||||
|
applyEffects,
|
||||||
|
composeDecisions,
|
||||||
|
} from "./policy.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
canonicalStringify,
|
||||||
|
hashString,
|
||||||
|
fingerprint,
|
||||||
|
snapshotFingerprint,
|
||||||
|
} from "./fingerprint.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
evaluateTarget,
|
||||||
|
evaluateMany,
|
||||||
|
createSnapshot,
|
||||||
|
createViewerState,
|
||||||
|
resolveTrustSet,
|
||||||
|
aggregateReports,
|
||||||
|
aggregateMutes,
|
||||||
|
} from "./evaluator.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
|
||||||
|
* @typedef {import('./identifiers.js').UserTarget} UserTarget
|
||||||
|
* @typedef {import('./identifiers.js').EventTarget} EventTarget
|
||||||
|
* @typedef {import('./identifiers.js').AddressTarget} AddressTarget
|
||||||
|
* @typedef {import('./authority.js').AuthorityState} AuthorityState
|
||||||
|
* @typedef {import('./authority.js').GovernanceCapability} GovernanceCapability
|
||||||
|
* @typedef {import('./adminState.js').AdminState} AdminState
|
||||||
|
* @typedef {import('./adminState.js').Contribution} Contribution
|
||||||
|
* @typedef {import('./policy.js').PolicyDefinition} PolicyDefinition
|
||||||
|
* @typedef {import('./policy.js').PolicyProfile} PolicyProfile
|
||||||
|
* @typedef {import('./policy.js').PolicyContext} PolicyContext
|
||||||
|
* @typedef {import('./policy.js').GovernanceDecision} GovernanceDecision
|
||||||
|
* @typedef {import('./policy.js').CategoryThresholds} CategoryThresholds
|
||||||
|
* @typedef {import('./evidence.js').GovernanceEvidence} GovernanceEvidence
|
||||||
|
* @typedef {import('./reasons.js').GovernanceReason} GovernanceReason
|
||||||
|
* @typedef {import('./reasons.js').ReasonId} ReasonId
|
||||||
|
* @typedef {import('./evaluator.js').GovernanceSnapshot} GovernanceSnapshot
|
||||||
|
* @typedef {import('./evaluator.js').ViewerState} ViewerState
|
||||||
|
* @typedef {import('./evaluator.js').ReportRecord} ReportRecord
|
||||||
|
* @typedef {import('./evaluator.js').MuteRecord} MuteRecord
|
||||||
|
*/
|
||||||
+529
-320
@@ -1,380 +1,589 @@
|
|||||||
// Policy handling for Nostr Governance
|
// Policy engine for Nostr Governance
|
||||||
//
|
//
|
||||||
// This module handles policy definitions, decision making, and effect calculations
|
// The engine is deliberately generic: it carries no application thresholds of
|
||||||
// for the governance system.
|
// its own. Applications supply a policy definition made of named profiles, and
|
||||||
|
// each profile decides how evidence maps onto the four decision dimensions.
|
||||||
|
//
|
||||||
|
// Decision dimensions are independent ladders. A product can stay visible and
|
||||||
|
// inspectable while being downranked and blocked from checkout, without
|
||||||
|
// collapsing those states into a single "hidden" boolean.
|
||||||
|
|
||||||
|
import { createReason, dedupeReasons } from "./reasons.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} PolicyEffect
|
* @typedef {import('./reasons.js').GovernanceReason} GovernanceReason
|
||||||
* @property {"allow"|"restrict"|"hide"|"deny"} effect - The base effect
|
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
|
||||||
* @property {string} [reason] - Reason for the effect
|
* @typedef {import('./evidence.js').GovernanceEvidence} GovernanceEvidence
|
||||||
* @property {any} [details] - Additional details about the effect
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {"normal"|"downrank"} RankingEffect
|
||||||
|
* @typedef {"allow"|"warn"|"restrict"|"hide"|"deny"} VisibilityEffect
|
||||||
|
* @typedef {"allow"|"require-explicit-action"|"deny"} InteractionEffect
|
||||||
|
* @typedef {"allow"|"require-review"|"deny"} TransactionEffect
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Severity ladders. Composition always keeps the most severe effect seen, so
|
||||||
|
* these arrays define the entire merge semantics for each dimension.
|
||||||
|
*/
|
||||||
|
export const RANKING_LADDER = Object.freeze(["normal", "downrank"]);
|
||||||
|
export const VISIBILITY_LADDER = Object.freeze(["allow", "warn", "restrict", "hide", "deny"]);
|
||||||
|
export const INTERACTION_LADDER = Object.freeze(["allow", "require-explicit-action", "deny"]);
|
||||||
|
export const TRANSACTION_LADDER = Object.freeze(["allow", "require-review", "deny"]);
|
||||||
|
|
||||||
|
/** @type {Record<string, readonly string[]>} */
|
||||||
|
const LADDERS = {
|
||||||
|
ranking: RANKING_LADDER,
|
||||||
|
visibility: VISIBILITY_LADDER,
|
||||||
|
interaction: INTERACTION_LADDER,
|
||||||
|
transaction: TRANSACTION_LADDER,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank an effect within its dimension's ladder.
|
||||||
|
* @param {"ranking"|"visibility"|"interaction"|"transaction"} dimension
|
||||||
|
* @param {string} effect
|
||||||
|
* @returns {number} Ladder index, or -1 when the effect is not valid
|
||||||
|
*/
|
||||||
|
export function effectSeverity(dimension, effect) {
|
||||||
|
const ladder = LADDERS[dimension];
|
||||||
|
if (!ladder) {
|
||||||
|
throw new Error(`Unknown decision dimension: ${dimension}`);
|
||||||
|
}
|
||||||
|
return ladder.indexOf(effect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the more severe of two effects in the same dimension.
|
||||||
|
* @param {"ranking"|"visibility"|"interaction"|"transaction"} dimension
|
||||||
|
* @param {string} a
|
||||||
|
* @param {string} b
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function maxEffect(dimension, a, b) {
|
||||||
|
const severityA = effectSeverity(dimension, a);
|
||||||
|
const severityB = effectSeverity(dimension, b);
|
||||||
|
if (severityA < 0) return b;
|
||||||
|
if (severityB < 0) return a;
|
||||||
|
return severityA >= severityB ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CategoryThresholds
|
||||||
|
* @property {number} [downrank] - Count at which the target is downranked
|
||||||
|
* @property {number} [warn] - Count at which the viewer is warned
|
||||||
|
* @property {number} [restrict] - Count at which visibility is restricted
|
||||||
|
* @property {number} [hide] - Count at which the target is hidden
|
||||||
|
* @property {number} [deny] - Count at which the target is denied outright
|
||||||
|
* @property {number} [interactionDeny] - Count at which interaction is denied
|
||||||
|
* @property {number} [requireExplicitAction] - Count at which interaction needs explicit intent
|
||||||
|
* @property {number} [transactionReview] - Count at which a transaction needs review
|
||||||
|
* @property {number} [transactionDeny] - Count at which a transaction is denied
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DimensionEffects
|
||||||
|
* @property {RankingEffect} [ranking]
|
||||||
|
* @property {VisibilityEffect} [visibility]
|
||||||
|
* @property {InteractionEffect} [interaction]
|
||||||
|
* @property {TransactionEffect} [transaction]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} PolicyProfile
|
* @typedef {Object} PolicyProfile
|
||||||
* @property {string} name - Profile name
|
* @property {string} name
|
||||||
* @property {Record<string, PolicyEffect>} effects - Map of effect types to policy effects
|
* @property {DimensionEffects} [administrativeDeny] - Applied on admin/community denial
|
||||||
|
* @property {DimensionEffects} [viewerBlock] - Applied when the viewer personally blocks
|
||||||
|
* @property {DimensionEffects} [allowlistMiss] - Applied when allowlist mode is on and the target misses
|
||||||
|
* @property {boolean} [requireAllowlist] - Whether allowlist misses are enforced on this surface
|
||||||
|
* @property {boolean} [allowViewerOverride] - Whether viewer overrides may soften a decision
|
||||||
|
* @property {boolean} [exposeEvidence] - Whether the consumer may surface evidence detail
|
||||||
|
* @property {boolean} [bypassHide] - Surface exception: cap visibility below `hide`
|
||||||
|
* @property {VisibilityEffect} [bypassHideCeiling] - Effect used when `bypassHide` caps a hide
|
||||||
|
* @property {boolean} [disabled] - Disable governance entirely for this surface
|
||||||
|
* @property {Record<string, CategoryThresholds>} [reports] - Report thresholds by category ("default" applies to unlisted categories)
|
||||||
|
* @property {Record<string, CategoryThresholds>} [mutes] - Trusted-mute thresholds by category ("default" applies to totals)
|
||||||
|
* @property {number} [muteWindowSeconds] - Trusted mutes older than this are ignored (0 disables expiry)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} PolicyDefinition
|
* @typedef {Object} PolicyDefinition
|
||||||
* @property {string} id - Unique policy identifier
|
* @property {string} id
|
||||||
* @property {string} name - Human-readable name
|
* @property {string} [name] - Defaults to the id when omitted
|
||||||
* @property {string} description - Description of what the policy does
|
* @property {string} [description]
|
||||||
* @property {Record<string, PolicyProfile>} profiles - Map of profile names to policy profiles
|
* @property {string} version - Policy version, reported on every decision
|
||||||
|
* @property {Record<string, PolicyProfile>} profiles
|
||||||
|
* @property {string} [defaultProfile]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} PolicyContext
|
* @typedef {Object} PolicyContext
|
||||||
* @property {string} surface - The surface where the policy is being applied (e.g., "feed", "search", "checkout")
|
* @property {string} surface - Surface being rendered (e.g. "feed", "checkout")
|
||||||
* @property {string} policyProfile - The policy profile to use
|
* @property {string} [policyProfile] - Profile name; defaults to the policy's default profile
|
||||||
* @property {Object} [enforcement] - Enforcement options
|
* @property {PolicyDefinition} [policy] - Policy definition to evaluate against
|
||||||
* @property {boolean} [enforcement.hardHide] - Whether to hard hide denied items
|
* @property {number} [now] - Evaluation time in unix seconds (injected clock)
|
||||||
* @property {boolean} [enforcement.allowOverrides] - Whether to allow viewer overrides
|
* @property {Object} [enforcement]
|
||||||
|
* @property {boolean} [enforcement.allowOverrides] - Hard switch for viewer overrides
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DecisionDimensions
|
||||||
|
* @property {{ effect: RankingEffect, weight: number }} ranking
|
||||||
|
* @property {{ effect: VisibilityEffect, overridable: boolean }} visibility
|
||||||
|
* @property {{ effect: InteractionEffect }} interaction
|
||||||
|
* @property {{ effect: TransactionEffect }} [transaction]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} GovernanceDecision
|
* @typedef {Object} GovernanceDecision
|
||||||
* @property {PolicyEffect} visibility - Visibility effect
|
* @property {GovernanceTarget} [target]
|
||||||
* @property {PolicyEffect} interaction - Interaction effect
|
* @property {string} key
|
||||||
* @property {PolicyEffect} [transaction] - Transaction effect (for commerce)
|
* @property {{ effect: RankingEffect, weight: number }} ranking
|
||||||
* @property {string[]} reasons - Array of reasons for the decision
|
* @property {{ effect: VisibilityEffect, overridable: boolean }} visibility
|
||||||
* @property {any[]} evidence - Array of evidence supporting the decision
|
* @property {{ effect: InteractionEffect }} interaction
|
||||||
* @property {Object} [metadata] - Additional metadata about the decision
|
* @property {{ effect: TransactionEffect }} [transaction]
|
||||||
|
* @property {GovernanceReason[]} reasons
|
||||||
|
* @property {GovernanceEvidence} [evidence]
|
||||||
|
* @property {string} policyProfile
|
||||||
|
* @property {string} policyVersion
|
||||||
|
* @property {string} snapshotFingerprint
|
||||||
|
* @property {number} evaluatedAt
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in policy profiles
|
* A decision with every dimension at its most permissive value.
|
||||||
* @type {Record<string, PolicyProfile>}
|
* @param {Object} [options]
|
||||||
*/
|
* @param {string} [options.key]
|
||||||
export const BUILTIN_POLICY_PROFILES = {
|
* @param {GovernanceTarget} [options.target]
|
||||||
"default": {
|
* @param {boolean} [options.includeTransaction]
|
||||||
name: "default",
|
|
||||||
effects: {
|
|
||||||
"visibility": { effect: "allow" },
|
|
||||||
"interaction": { effect: "allow" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"moderate": {
|
|
||||||
name: "moderate",
|
|
||||||
effects: {
|
|
||||||
"visibility": { effect: "restrict" },
|
|
||||||
"interaction": { effect: "allow" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"strict": {
|
|
||||||
name: "strict",
|
|
||||||
effects: {
|
|
||||||
"visibility": { effect: "hide" },
|
|
||||||
"interaction": { effect: "deny" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"commerce-default": {
|
|
||||||
name: "commerce-default",
|
|
||||||
effects: {
|
|
||||||
"visibility": { effect: "allow" },
|
|
||||||
"interaction": { effect: "allow" },
|
|
||||||
"transaction": { effect: "allow" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"commerce-transaction": {
|
|
||||||
name: "commerce-transaction",
|
|
||||||
effects: {
|
|
||||||
"visibility": { effect: "allow" },
|
|
||||||
"interaction": { effect: "allow" },
|
|
||||||
"transaction": { effect: "deny" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a policy effect
|
|
||||||
* @param {"allow"|"restrict"|"hide"|"deny"} effect
|
|
||||||
* @param {string} [reason]
|
|
||||||
* @param {any} [details]
|
|
||||||
* @returns {PolicyEffect}
|
|
||||||
*/
|
|
||||||
export function createPolicyEffect(effect, reason, details) {
|
|
||||||
if (!["allow", "restrict", "hide", "deny"].includes(effect)) {
|
|
||||||
throw new Error("Invalid policy effect: " + effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
const policyEffect = { effect };
|
|
||||||
|
|
||||||
if (reason) {
|
|
||||||
policyEffect.reason = reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details) {
|
|
||||||
policyEffect.details = details;
|
|
||||||
}
|
|
||||||
|
|
||||||
return policyEffect;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a policy profile
|
|
||||||
* @param {string} name
|
|
||||||
* @param {Record<string, PolicyEffect>} effects
|
|
||||||
* @returns {PolicyProfile}
|
|
||||||
*/
|
|
||||||
export function createPolicyProfile(name, effects) {
|
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
|
||||||
throw new Error("Policy profile name must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!effects || typeof effects !== "object") {
|
|
||||||
throw new Error("Policy profile effects must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each effect
|
|
||||||
for (const [effectType, effect] of Object.entries(effects)) {
|
|
||||||
if (typeof effectType !== "string" || !effectType.trim()) {
|
|
||||||
throw new Error("Effect types must be non-empty strings");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!effect || typeof effect !== "object") {
|
|
||||||
throw new Error("Effects must be objects");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["allow", "restrict", "hide", "deny"].includes(effect.effect)) {
|
|
||||||
throw new Error("Invalid policy effect: " + effect.effect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: name.trim(),
|
|
||||||
effects
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a policy definition
|
|
||||||
* @param {string} id
|
|
||||||
* @param {string} name
|
|
||||||
* @param {string} description
|
|
||||||
* @param {Record<string, PolicyProfile>} profiles
|
|
||||||
* @returns {PolicyDefinition}
|
|
||||||
*/
|
|
||||||
export function createPolicyDefinition(id, name, description, profiles) {
|
|
||||||
if (typeof id !== "string" || !id.trim()) {
|
|
||||||
throw new Error("Policy ID must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
|
||||||
throw new Error("Policy name must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof description !== "string") {
|
|
||||||
throw new Error("Policy description must be a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!profiles || typeof profiles !== "object") {
|
|
||||||
throw new Error("Policy profiles must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each profile
|
|
||||||
for (const [profileName, profile] of Object.entries(profiles)) {
|
|
||||||
if (typeof profileName !== "string" || !profileName.trim()) {
|
|
||||||
throw new Error("Profile names must be non-empty strings");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!profile || typeof profile !== "object") {
|
|
||||||
throw new Error("Profiles must be objects");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.name !== profileName) {
|
|
||||||
throw new Error(`Profile name mismatch: ${profileName} !== ${profile.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: id.trim(),
|
|
||||||
name: name.trim(),
|
|
||||||
description,
|
|
||||||
profiles
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a policy context
|
|
||||||
* @param {string} surface
|
|
||||||
* @param {string} policyProfile
|
|
||||||
* @param {Object} [enforcement]
|
|
||||||
* @returns {PolicyContext}
|
|
||||||
*/
|
|
||||||
export function createPolicyContext(surface, policyProfile, enforcement) {
|
|
||||||
if (typeof surface !== "string" || !surface.trim()) {
|
|
||||||
throw new Error("Surface must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof policyProfile !== "string" || !policyProfile.trim()) {
|
|
||||||
throw new Error("Policy profile must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = {
|
|
||||||
surface: surface.trim(),
|
|
||||||
policyProfile: policyProfile.trim()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (enforcement) {
|
|
||||||
context.enforcement = {};
|
|
||||||
|
|
||||||
if (typeof enforcement.hardHide === "boolean") {
|
|
||||||
context.enforcement.hardHide = enforcement.hardHide;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof enforcement.allowOverrides === "boolean") {
|
|
||||||
context.enforcement.allowOverrides = enforcement.allowOverrides;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a governance decision
|
|
||||||
* @param {PolicyEffect} visibility
|
|
||||||
* @param {PolicyEffect} interaction
|
|
||||||
* @param {PolicyEffect} [transaction]
|
|
||||||
* @param {string[]} [reasons]
|
|
||||||
* @param {any[]} [evidence]
|
|
||||||
* @param {Object} [metadata]
|
|
||||||
* @returns {GovernanceDecision}
|
* @returns {GovernanceDecision}
|
||||||
*/
|
*/
|
||||||
export function createGovernanceDecision(visibility, interaction, transaction, reasons, evidence, metadata) {
|
export function createNeutralDecision(options = {}) {
|
||||||
// Validate visibility effect
|
/** @type {GovernanceDecision} */
|
||||||
if (!visibility || typeof visibility !== "object" ||
|
|
||||||
!["allow", "restrict", "hide", "deny"].includes(visibility.effect)) {
|
|
||||||
throw new Error("Invalid visibility effect");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate interaction effect
|
|
||||||
if (!interaction || typeof interaction !== "object" ||
|
|
||||||
!["allow", "restrict", "hide", "deny"].includes(interaction.effect)) {
|
|
||||||
throw new Error("Invalid interaction effect");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate transaction effect if provided
|
|
||||||
if (transaction && (typeof transaction !== "object" ||
|
|
||||||
!["allow", "restrict", "hide", "deny"].includes(transaction.effect))) {
|
|
||||||
throw new Error("Invalid transaction effect");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate reasons
|
|
||||||
if (reasons && !Array.isArray(reasons)) {
|
|
||||||
throw new Error("Reasons must be an array");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate evidence
|
|
||||||
if (evidence && !Array.isArray(evidence)) {
|
|
||||||
throw new Error("Evidence must be an array");
|
|
||||||
}
|
|
||||||
|
|
||||||
const decision = {
|
const decision = {
|
||||||
visibility,
|
key: options.key ?? "",
|
||||||
interaction,
|
ranking: { effect: "normal", weight: 0 },
|
||||||
reasons: reasons || [],
|
visibility: { effect: "allow", overridable: true },
|
||||||
evidence: evidence || []
|
interaction: { effect: "allow" },
|
||||||
|
reasons: [],
|
||||||
|
policyProfile: "",
|
||||||
|
policyVersion: "",
|
||||||
|
snapshotFingerprint: "",
|
||||||
|
evaluatedAt: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (transaction) {
|
if (options.target) {
|
||||||
decision.transaction = transaction;
|
decision.target = options.target;
|
||||||
}
|
}
|
||||||
|
if (options.includeTransaction) {
|
||||||
if (metadata) {
|
decision.transaction = { effect: "allow" };
|
||||||
decision.metadata = metadata;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return decision;
|
return decision;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get effect for a policy profile
|
* Validate a set of dimension effects supplied by an application profile.
|
||||||
* @param {string} profileName
|
* @param {DimensionEffects} [effects]
|
||||||
* @param {string} effectType
|
* @param {string} location - Human-readable location for error messages
|
||||||
* @param {Record<string, PolicyDefinition>} [customPolicies]
|
* @returns {DimensionEffects}
|
||||||
* @returns {PolicyEffect}
|
|
||||||
*/
|
*/
|
||||||
export function getPolicyEffect(profileName, effectType, customPolicies) {
|
export function normalizeDimensionEffects(effects, location = "profile") {
|
||||||
// Check custom policies first
|
if (!effects || typeof effects !== "object") {
|
||||||
if (customPolicies) {
|
return {};
|
||||||
for (const policy of Object.values(customPolicies)) {
|
|
||||||
if (policy.profiles[profileName] && policy.profiles[profileName].effects[effectType]) {
|
|
||||||
return policy.profiles[profileName].effects[effectType];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check built-in profiles
|
/** @type {DimensionEffects} */
|
||||||
if (BUILTIN_POLICY_PROFILES[profileName] &&
|
const normalized = {};
|
||||||
BUILTIN_POLICY_PROFILES[profileName].effects[effectType]) {
|
|
||||||
return BUILTIN_POLICY_PROFILES[profileName].effects[effectType];
|
for (const dimension of /** @type {const} */ ([
|
||||||
|
"ranking",
|
||||||
|
"visibility",
|
||||||
|
"interaction",
|
||||||
|
"transaction",
|
||||||
|
])) {
|
||||||
|
const value = effects[dimension];
|
||||||
|
if (value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (effectSeverity(dimension, value) < 0) {
|
||||||
|
throw new Error(`${location}: invalid ${dimension} effect "${String(value)}"`);
|
||||||
|
}
|
||||||
|
// @ts-expect-error -- the ladder check above narrows the value for us
|
||||||
|
normalized[dimension] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to allow
|
return normalized;
|
||||||
return { effect: "allow" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose multiple governance decisions into a single decision
|
* Normalize category thresholds, dropping non-numeric entries.
|
||||||
* @param {GovernanceDecision[]} decisions
|
* @param {Record<string, CategoryThresholds>} [thresholds]
|
||||||
* @returns {GovernanceDecision}
|
* @param {string} [location]
|
||||||
|
* @returns {Record<string, CategoryThresholds>}
|
||||||
*/
|
*/
|
||||||
export function composeGovernanceDecisions(decisions) {
|
export function normalizeCategoryThresholds(thresholds, location = "profile") {
|
||||||
if (!Array.isArray(decisions) || decisions.length === 0) {
|
if (!thresholds || typeof thresholds !== "object") {
|
||||||
// Return a default allow decision
|
return {};
|
||||||
return createGovernanceDecision(
|
}
|
||||||
{ effect: "allow" },
|
|
||||||
{ effect: "allow" },
|
/** @type {Record<string, CategoryThresholds>} */
|
||||||
undefined,
|
const normalized = {};
|
||||||
[],
|
|
||||||
[]
|
for (const [category, config] of Object.entries(thresholds)) {
|
||||||
|
if (!config || typeof config !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmed = category.trim().toLowerCase();
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {CategoryThresholds} */
|
||||||
|
const entry = {};
|
||||||
|
for (const [gate, value] of Object.entries(config)) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/** @type {number} */ (value) < 0) {
|
||||||
|
throw new Error(`${location}: threshold "${gate}" for "${category}" must be >= 0`);
|
||||||
|
}
|
||||||
|
// Gate names are validated where thresholds are applied; unknown keys are
|
||||||
|
// carried through harmlessly rather than rejected here.
|
||||||
|
/** @type {Record<string, number>} */ (entry)[gate] = Math.floor(
|
||||||
|
/** @type {number} */ (value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
normalized[trimmed] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a policy profile.
|
||||||
|
* @param {PolicyProfile} profile
|
||||||
|
* @returns {PolicyProfile}
|
||||||
|
*/
|
||||||
|
export function normalizePolicyProfile(profile) {
|
||||||
|
if (!profile || typeof profile !== "object") {
|
||||||
|
throw new Error("Policy profile must be an object");
|
||||||
|
}
|
||||||
|
if (typeof profile.name !== "string" || !profile.name.trim()) {
|
||||||
|
throw new Error("Policy profile requires a non-empty name");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = profile.name.trim();
|
||||||
|
const location = `profile "${name}"`;
|
||||||
|
|
||||||
|
/** @type {PolicyProfile} */
|
||||||
|
const normalized = {
|
||||||
|
name,
|
||||||
|
administrativeDeny: normalizeDimensionEffects(profile.administrativeDeny, location),
|
||||||
|
viewerBlock: normalizeDimensionEffects(profile.viewerBlock, location),
|
||||||
|
allowlistMiss: normalizeDimensionEffects(profile.allowlistMiss, location),
|
||||||
|
requireAllowlist: profile.requireAllowlist === true,
|
||||||
|
allowViewerOverride: profile.allowViewerOverride !== false,
|
||||||
|
exposeEvidence: profile.exposeEvidence === true,
|
||||||
|
bypassHide: profile.bypassHide === true,
|
||||||
|
disabled: profile.disabled === true,
|
||||||
|
reports: normalizeCategoryThresholds(profile.reports, location),
|
||||||
|
mutes: normalizeCategoryThresholds(profile.mutes, location),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Number.isFinite(profile.muteWindowSeconds)) {
|
||||||
|
const window = /** @type {number} */ (profile.muteWindowSeconds);
|
||||||
|
if (window < 0) {
|
||||||
|
throw new Error(`${location}: muteWindowSeconds must be >= 0`);
|
||||||
|
}
|
||||||
|
normalized.muteWindowSeconds = Math.floor(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.bypassHideCeiling !== undefined) {
|
||||||
|
if (effectSeverity("visibility", profile.bypassHideCeiling) < 0) {
|
||||||
|
throw new Error(`${location}: invalid bypassHideCeiling "${String(profile.bypassHideCeiling)}"`);
|
||||||
|
}
|
||||||
|
normalized.bypassHideCeiling = profile.bypassHideCeiling;
|
||||||
|
} else if (normalized.bypassHide) {
|
||||||
|
normalized.bypassHideCeiling = "restrict";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a full policy definition.
|
||||||
|
* @param {PolicyDefinition} policy
|
||||||
|
* @returns {PolicyDefinition}
|
||||||
|
*/
|
||||||
|
export function normalizePolicyDefinition(policy) {
|
||||||
|
if (!policy || typeof policy !== "object") {
|
||||||
|
throw new Error("Policy definition must be an object");
|
||||||
|
}
|
||||||
|
if (typeof policy.id !== "string" || !policy.id.trim()) {
|
||||||
|
throw new Error("Policy definition requires a non-empty id");
|
||||||
|
}
|
||||||
|
if (typeof policy.version !== "string" || !policy.version.trim()) {
|
||||||
|
throw new Error("Policy definition requires a version string");
|
||||||
|
}
|
||||||
|
if (!policy.profiles || typeof policy.profiles !== "object") {
|
||||||
|
throw new Error("Policy definition requires a profiles map");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, PolicyProfile>} */
|
||||||
|
const profiles = {};
|
||||||
|
for (const [key, profile] of Object.entries(policy.profiles)) {
|
||||||
|
const normalizedProfile = normalizePolicyProfile({ ...profile, name: profile?.name ?? key });
|
||||||
|
profiles[key.trim()] = normalizedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileNames = Object.keys(profiles);
|
||||||
|
if (profileNames.length === 0) {
|
||||||
|
throw new Error("Policy definition requires at least one profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProfile = policy.defaultProfile?.trim() || profileNames[0];
|
||||||
|
if (!profiles[defaultProfile]) {
|
||||||
|
throw new Error(`Policy default profile "${defaultProfile}" is not defined`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: policy.id.trim(),
|
||||||
|
name: typeof policy.name === "string" && policy.name.trim() ? policy.name.trim() : policy.id.trim(),
|
||||||
|
description: typeof policy.description === "string" ? policy.description : "",
|
||||||
|
version: policy.version.trim(),
|
||||||
|
profiles,
|
||||||
|
defaultProfile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the profile a context selects.
|
||||||
|
* @param {PolicyDefinition} policy
|
||||||
|
* @param {PolicyContext} [context]
|
||||||
|
* @returns {PolicyProfile}
|
||||||
|
*/
|
||||||
|
export function resolveProfile(policy, context) {
|
||||||
|
const requested = context?.policyProfile?.trim();
|
||||||
|
if (requested) {
|
||||||
|
const profile = policy.profiles[requested];
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(`Unknown policy profile: ${requested}`);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
const fallback = policy.defaultProfile ?? Object.keys(policy.profiles)[0];
|
||||||
|
return policy.profiles[fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve thresholds for a category, falling back to the profile "default" entry.
|
||||||
|
* @param {Record<string, CategoryThresholds>} [table]
|
||||||
|
* @param {string} [category]
|
||||||
|
* @returns {CategoryThresholds}
|
||||||
|
*/
|
||||||
|
export function resolveThresholds(table, category) {
|
||||||
|
if (!table) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const normalizedCategory = typeof category === "string" ? category.trim().toLowerCase() : "";
|
||||||
|
if (normalizedCategory && table[normalizedCategory]) {
|
||||||
|
return table[normalizedCategory];
|
||||||
|
}
|
||||||
|
return table.default ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a count against category thresholds into dimension effects.
|
||||||
|
*
|
||||||
|
* A gate fires when `count >= threshold`. A threshold of 0 is treated as
|
||||||
|
* "never fires" so that an application can disable a gate by zeroing it rather
|
||||||
|
* than having it trigger on every target.
|
||||||
|
*
|
||||||
|
* @param {number} count
|
||||||
|
* @param {CategoryThresholds} thresholds
|
||||||
|
* @returns {{ effects: DimensionEffects, firedGates: string[] }}
|
||||||
|
*/
|
||||||
|
export function applyThresholds(count, thresholds) {
|
||||||
|
/** @type {DimensionEffects} */
|
||||||
|
const effects = {};
|
||||||
|
/** @type {string[]} */
|
||||||
|
const firedGates = [];
|
||||||
|
|
||||||
|
if (!Number.isFinite(count) || count <= 0) {
|
||||||
|
return { effects, firedGates };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Array<[keyof CategoryThresholds, "ranking"|"visibility"|"interaction"|"transaction", string]>} */
|
||||||
|
const gates = [
|
||||||
|
["downrank", "ranking", "downrank"],
|
||||||
|
["warn", "visibility", "warn"],
|
||||||
|
["restrict", "visibility", "restrict"],
|
||||||
|
["hide", "visibility", "hide"],
|
||||||
|
["deny", "visibility", "deny"],
|
||||||
|
["requireExplicitAction", "interaction", "require-explicit-action"],
|
||||||
|
["interactionDeny", "interaction", "deny"],
|
||||||
|
["transactionReview", "transaction", "require-review"],
|
||||||
|
["transactionDeny", "transaction", "deny"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [gate, dimension, effect] of gates) {
|
||||||
|
const threshold = thresholds[gate];
|
||||||
|
if (!Number.isFinite(threshold) || /** @type {number} */ (threshold) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count < /** @type {number} */ (threshold)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const current = effects[dimension];
|
||||||
|
// @ts-expect-error -- effect strings are drawn from the matching ladder
|
||||||
|
effects[dimension] = current ? maxEffect(dimension, current, effect) : effect;
|
||||||
|
firedGates.push(String(gate));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { effects, firedGates };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply dimension effects onto a decision, keeping the most severe value.
|
||||||
|
* @param {GovernanceDecision} decision
|
||||||
|
* @param {DimensionEffects} effects
|
||||||
|
* @param {GovernanceReason[]} [reasons]
|
||||||
|
* @returns {GovernanceDecision} The same decision, mutated in place
|
||||||
|
*/
|
||||||
|
export function applyEffects(decision, effects, reasons = []) {
|
||||||
|
if (effects.ranking) {
|
||||||
|
const next = maxEffect("ranking", decision.ranking.effect, effects.ranking);
|
||||||
|
if (next !== decision.ranking.effect) {
|
||||||
|
decision.ranking.effect = /** @type {RankingEffect} */ (next);
|
||||||
|
}
|
||||||
|
if (effects.ranking === "downrank") {
|
||||||
|
decision.ranking.weight += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effects.visibility) {
|
||||||
|
decision.visibility.effect = /** @type {VisibilityEffect} */ (
|
||||||
|
maxEffect("visibility", decision.visibility.effect, effects.visibility)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start with the first decision
|
if (effects.interaction) {
|
||||||
const composed = { ...decisions[0] };
|
decision.interaction.effect = /** @type {InteractionEffect} */ (
|
||||||
|
maxEffect("interaction", decision.interaction.effect, effects.interaction)
|
||||||
// For each subsequent decision, apply the most restrictive effects
|
);
|
||||||
for (let i = 1; i < decisions.length; i++) {
|
|
||||||
const decision = decisions[i];
|
|
||||||
|
|
||||||
// Compose visibility effect (hide > restrict > deny > allow)
|
|
||||||
const visibilityOrder = { "allow": 0, "restrict": 1, "hide": 2, "deny": 3 };
|
|
||||||
if (visibilityOrder[decision.visibility.effect] > visibilityOrder[composed.visibility.effect]) {
|
|
||||||
composed.visibility = decision.visibility;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose interaction effect (deny > hide > restrict > allow)
|
if (effects.transaction) {
|
||||||
const interactionOrder = { "allow": 0, "restrict": 1, "hide": 2, "deny": 3 };
|
if (!decision.transaction) {
|
||||||
if (interactionOrder[decision.interaction.effect] > interactionOrder[composed.interaction.effect]) {
|
decision.transaction = { effect: "allow" };
|
||||||
composed.interaction = decision.interaction;
|
}
|
||||||
|
decision.transaction.effect = /** @type {TransactionEffect} */ (
|
||||||
|
maxEffect("transaction", decision.transaction.effect, effects.transaction)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose transaction effect if present
|
if (reasons.length) {
|
||||||
|
decision.reasons = dedupeReasons([...decision.reasons, ...reasons]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose several decisions into one, keeping the most severe effect per
|
||||||
|
* dimension and the union of reasons.
|
||||||
|
*
|
||||||
|
* Composition is commutative and associative: every dimension merges by ladder
|
||||||
|
* maximum, so the order decisions arrive in cannot change the result.
|
||||||
|
*
|
||||||
|
* @param {GovernanceDecision[]} decisions
|
||||||
|
* @returns {GovernanceDecision}
|
||||||
|
*/
|
||||||
|
export function composeDecisions(decisions) {
|
||||||
|
if (!Array.isArray(decisions) || decisions.length === 0) {
|
||||||
|
return createNeutralDecision();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first, ...rest] = decisions;
|
||||||
|
/** @type {GovernanceDecision} */
|
||||||
|
const composed = {
|
||||||
|
...first,
|
||||||
|
ranking: { ...first.ranking },
|
||||||
|
visibility: { ...first.visibility },
|
||||||
|
interaction: { ...first.interaction },
|
||||||
|
reasons: [...first.reasons],
|
||||||
|
};
|
||||||
|
if (first.transaction) {
|
||||||
|
composed.transaction = { ...first.transaction };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const decision of rest) {
|
||||||
|
composed.ranking.effect = /** @type {RankingEffect} */ (
|
||||||
|
maxEffect("ranking", composed.ranking.effect, decision.ranking.effect)
|
||||||
|
);
|
||||||
|
composed.ranking.weight += decision.ranking.weight;
|
||||||
|
|
||||||
|
composed.visibility.effect = /** @type {VisibilityEffect} */ (
|
||||||
|
maxEffect("visibility", composed.visibility.effect, decision.visibility.effect)
|
||||||
|
);
|
||||||
|
composed.visibility.overridable = composed.visibility.overridable && decision.visibility.overridable;
|
||||||
|
|
||||||
|
composed.interaction.effect = /** @type {InteractionEffect} */ (
|
||||||
|
maxEffect("interaction", composed.interaction.effect, decision.interaction.effect)
|
||||||
|
);
|
||||||
|
|
||||||
if (decision.transaction) {
|
if (decision.transaction) {
|
||||||
if (!composed.transaction) {
|
if (!composed.transaction) {
|
||||||
composed.transaction = decision.transaction;
|
composed.transaction = { ...decision.transaction };
|
||||||
} else {
|
} else {
|
||||||
const transactionOrder = { "allow": 0, "restrict": 1, "hide": 2, "deny": 3 };
|
composed.transaction.effect = /** @type {TransactionEffect} */ (
|
||||||
if (transactionOrder[decision.transaction.effect] > transactionOrder[composed.transaction.effect]) {
|
maxEffect("transaction", composed.transaction.effect, decision.transaction.effect)
|
||||||
composed.transaction = decision.transaction;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine reasons and evidence
|
|
||||||
for (const reason of decision.reasons) {
|
|
||||||
if (!composed.reasons.includes(reason)) {
|
|
||||||
composed.reasons.push(reason);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const evidence of decision.evidence) {
|
composed.reasons = [...composed.reasons, ...decision.reasons];
|
||||||
composed.evidence.push(evidence);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composed.reasons = dedupeReasons(composed.reasons);
|
||||||
return composed;
|
return composed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a policy definition, validating and normalizing it.
|
||||||
|
* @param {PolicyDefinition} policy
|
||||||
|
* @returns {PolicyDefinition}
|
||||||
|
*/
|
||||||
|
export function createPolicyDefinition(policy) {
|
||||||
|
return normalizePolicyDefinition(policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A permissive policy used when an application supplies none. It enforces
|
||||||
|
* administrative denials but applies no trust thresholds of its own, matching
|
||||||
|
* the rule that the generic engine never hides content merely because one
|
||||||
|
* trusted account acted against it.
|
||||||
|
* @type {PolicyDefinition}
|
||||||
|
*/
|
||||||
|
export const NEUTRAL_POLICY = normalizePolicyDefinition({
|
||||||
|
id: "neutral",
|
||||||
|
name: "Neutral",
|
||||||
|
description: "Enforces administrative denials only. Applications should supply their own policy.",
|
||||||
|
version: "1.0.0",
|
||||||
|
defaultProfile: "default",
|
||||||
|
profiles: {
|
||||||
|
default: {
|
||||||
|
name: "default",
|
||||||
|
administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" },
|
||||||
|
viewerBlock: { visibility: "hide", interaction: "deny" },
|
||||||
|
allowlistMiss: { visibility: "hide", interaction: "deny" },
|
||||||
|
allowViewerOverride: true,
|
||||||
|
reports: {},
|
||||||
|
mutes: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { createReason };
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Stable reason identifiers for Nostr Governance
|
||||||
|
//
|
||||||
|
// Reason identifiers are part of the public contract. Applications map these
|
||||||
|
// identifiers to their own wording; the engine never emits user-facing prose.
|
||||||
|
// Adding a new identifier is a minor change. Renaming or removing one is a
|
||||||
|
// breaking change.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {"viewer-block"
|
||||||
|
* | "viewer-mute"
|
||||||
|
* | "viewer-override"
|
||||||
|
* | "admin-user-deny"
|
||||||
|
* | "admin-event-deny"
|
||||||
|
* | "admin-address-deny"
|
||||||
|
* | "community-user-deny"
|
||||||
|
* | "trusted-report"
|
||||||
|
* | "trusted-report-threshold"
|
||||||
|
* | "trusted-mute"
|
||||||
|
* | "trusted-mute-threshold"
|
||||||
|
* | "allowlist-miss"
|
||||||
|
* | "protected-target"
|
||||||
|
* | "surface-policy-bypass"
|
||||||
|
* | "policy-disabled"} ReasonId
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every stable reason identifier the evaluator may emit.
|
||||||
|
* @type {readonly ReasonId[]}
|
||||||
|
*/
|
||||||
|
export const REASON_IDS = Object.freeze([
|
||||||
|
"viewer-block",
|
||||||
|
"viewer-mute",
|
||||||
|
"viewer-override",
|
||||||
|
"admin-user-deny",
|
||||||
|
"admin-event-deny",
|
||||||
|
"admin-address-deny",
|
||||||
|
"community-user-deny",
|
||||||
|
"trusted-report",
|
||||||
|
"trusted-report-threshold",
|
||||||
|
"trusted-mute",
|
||||||
|
"trusted-mute-threshold",
|
||||||
|
"allowlist-miss",
|
||||||
|
"protected-target",
|
||||||
|
"surface-policy-bypass",
|
||||||
|
"policy-disabled",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REASON_ID_SET = new Set(REASON_IDS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} GovernanceReason
|
||||||
|
* @property {ReasonId} id - Stable identifier
|
||||||
|
* @property {string} [category] - Report or mute category the reason relates to
|
||||||
|
* @property {number} [count] - Observed count that produced the reason
|
||||||
|
* @property {number} [threshold] - Threshold the count was compared against
|
||||||
|
* @property {string} [source] - Opaque origin marker (e.g. a curator pubkey)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a value is a known reason identifier.
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {value is ReasonId}
|
||||||
|
*/
|
||||||
|
export function isReasonId(value) {
|
||||||
|
return typeof value === "string" && REASON_ID_SET.has(/** @type {ReasonId} */ (value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a governance reason.
|
||||||
|
* @param {ReasonId} id
|
||||||
|
* @param {Object} [detail]
|
||||||
|
* @param {string} [detail.category]
|
||||||
|
* @param {number} [detail.count]
|
||||||
|
* @param {number} [detail.threshold]
|
||||||
|
* @param {string} [detail.source]
|
||||||
|
* @returns {GovernanceReason}
|
||||||
|
*/
|
||||||
|
export function createReason(id, detail = {}) {
|
||||||
|
if (!isReasonId(id)) {
|
||||||
|
throw new Error(`Unknown reason identifier: ${String(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {GovernanceReason} */
|
||||||
|
const reason = { id };
|
||||||
|
|
||||||
|
if (typeof detail.category === "string" && detail.category.trim()) {
|
||||||
|
reason.category = detail.category.trim();
|
||||||
|
}
|
||||||
|
if (Number.isFinite(detail.count)) {
|
||||||
|
reason.count = /** @type {number} */ (detail.count);
|
||||||
|
}
|
||||||
|
if (Number.isFinite(detail.threshold)) {
|
||||||
|
reason.threshold = /** @type {number} */ (detail.threshold);
|
||||||
|
}
|
||||||
|
if (typeof detail.source === "string" && detail.source.trim()) {
|
||||||
|
reason.source = detail.source.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate reasons, preserving first-seen order.
|
||||||
|
*
|
||||||
|
* Two reasons are the same when their identifier, category, and threshold all
|
||||||
|
* match; counts are allowed to differ and the highest is kept so that a merged
|
||||||
|
* decision reports the strongest observed evidence.
|
||||||
|
*
|
||||||
|
* @param {GovernanceReason[]} reasons
|
||||||
|
* @returns {GovernanceReason[]}
|
||||||
|
*/
|
||||||
|
export function dedupeReasons(reasons) {
|
||||||
|
/** @type {Map<string, GovernanceReason>} */
|
||||||
|
const byKey = new Map();
|
||||||
|
|
||||||
|
for (const reason of reasons) {
|
||||||
|
if (!reason || !isReasonId(reason.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = `${reason.id}|${reason.category ?? ""}|${reason.threshold ?? ""}`;
|
||||||
|
const existing = byKey.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
byKey.set(key, { ...reason });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(reason.count)) {
|
||||||
|
const nextCount = /** @type {number} */ (reason.count);
|
||||||
|
if (!Number.isFinite(existing.count) || nextCount > /** @type {number} */ (existing.count)) {
|
||||||
|
existing.count = nextCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byKey.values());
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ export function isValidTarget(target) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a target from various input formats
|
* Create a target from various input formats
|
||||||
* @param {string|{type: string, pubkey?: string, id?: string, kind?: string|number, identifier?: string}|null|undefined} input
|
* @param {string|{type: string, pubkey?: string, id?: string, author?: string, kind?: string|number, identifier?: string}|null|undefined} input
|
||||||
* @returns {GovernanceTarget|null}
|
* @returns {GovernanceTarget|null}
|
||||||
*/
|
*/
|
||||||
export function createTarget(input) {
|
export function createTarget(input) {
|
||||||
@@ -116,7 +116,10 @@ export function createTarget(input) {
|
|||||||
case "user":
|
case "user":
|
||||||
return createUserTarget(/** @type {string} */ (input.pubkey));
|
return createUserTarget(/** @type {string} */ (input.pubkey));
|
||||||
case "event":
|
case "event":
|
||||||
return createEventTarget(/** @type {string} */ (input.id));
|
return createEventTarget(/** @type {string} */ (input.id), {
|
||||||
|
author: /** @type {string|undefined} */ (input.author),
|
||||||
|
kind: typeof input.kind === "number" ? input.kind : undefined,
|
||||||
|
});
|
||||||
case "address":
|
case "address":
|
||||||
return normalizeAddress(
|
return normalizeAddress(
|
||||||
/** @type {string|number} */ (input.kind),
|
/** @type {string|number} */ (input.kind),
|
||||||
@@ -145,12 +148,15 @@ export function getParentTarget(target, authorPubkey) {
|
|||||||
case "user":
|
case "user":
|
||||||
// User targets have no parent
|
// User targets have no parent
|
||||||
return null;
|
return null;
|
||||||
case "event":
|
case "event": {
|
||||||
// Event targets have the author as parent, but we need the author pubkey
|
// Event targets have the author as parent. The author may be carried on
|
||||||
if (!authorPubkey) {
|
// the target itself; the explicit argument stays supported and wins.
|
||||||
|
const author = authorPubkey ?? target.author;
|
||||||
|
if (!author) {
|
||||||
throw new Error("authorPubkey is required for event targets");
|
throw new Error("authorPubkey is required for event targets");
|
||||||
}
|
}
|
||||||
return createUserTarget(authorPubkey);
|
return createUserTarget(author);
|
||||||
|
}
|
||||||
case "address":
|
case "address":
|
||||||
// Address targets have the author as parent
|
// Address targets have the author as parent
|
||||||
return createUserTarget(target.pubkey);
|
return createUserTarget(target.pubkey);
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createAuthorityState } from "../src/authority.js";
|
||||||
|
import {
|
||||||
|
createEmptyAdminState,
|
||||||
|
isDenied,
|
||||||
|
mergeCommunitySource,
|
||||||
|
reduceAdminState,
|
||||||
|
serializeAdminState,
|
||||||
|
} from "../src/adminState.js";
|
||||||
|
|
||||||
|
const ROOT = "a1".repeat(32);
|
||||||
|
const MODERATOR = "b2".repeat(32);
|
||||||
|
const CURATOR = "c3".repeat(32);
|
||||||
|
const CREATOR = "d4".repeat(32);
|
||||||
|
const STRANGER = "e5".repeat(32);
|
||||||
|
const EVENT_ID = "1b".repeat(32);
|
||||||
|
|
||||||
|
const authority = () =>
|
||||||
|
createAuthorityState({
|
||||||
|
root: ROOT,
|
||||||
|
actors: {
|
||||||
|
[ROOT]: ["super_admin"],
|
||||||
|
[MODERATOR]: ["moderator"],
|
||||||
|
[CURATOR]: ["curator"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @param {string} pubkey @returns {import("../src/identifiers.js").UserTarget} */
|
||||||
|
const user = (pubkey) => ({ type: "user", pubkey });
|
||||||
|
/** @param {string} id @returns {import("../src/identifiers.js").EventTarget} */
|
||||||
|
const event = (id) => ({ type: "event", id });
|
||||||
|
/**
|
||||||
|
* @param {string} kind
|
||||||
|
* @param {string} pubkey
|
||||||
|
* @param {string} identifier
|
||||||
|
* @returns {import("../src/identifiers.js").AddressTarget}
|
||||||
|
*/
|
||||||
|
const address = (kind, pubkey, identifier) => ({ type: "address", kind, pubkey, identifier });
|
||||||
|
|
||||||
|
describe("reduceAdminState", () => {
|
||||||
|
it("accepts a contribution the actor is authorized to make", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.userDeny.has(`user:${CREATOR}`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a contribution beyond the actor's capabilities", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[{ actor: CURATOR, kind: "event-deny", targets: [event(EVENT_ID)] }],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.eventDeny.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops contributions from actors with no roles", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[{ actor: STRANGER, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.userDeny.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an actor's entries as soon as the role is revoked", () => {
|
||||||
|
/** @type {import("../src/adminState.js").Contribution[]} */
|
||||||
|
const contributions = [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }];
|
||||||
|
const withRole = reduceAdminState(contributions, authority());
|
||||||
|
expect(withRole.userDeny.size).toBe(1);
|
||||||
|
|
||||||
|
const revoked = createAuthorityState({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
|
||||||
|
expect(reduceAdminState(contributions, revoked).userDeny.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unions denials from several contributors", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[
|
||||||
|
{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] },
|
||||||
|
{ actor: CURATOR, kind: "user-deny", targets: [user(CREATOR)] },
|
||||||
|
],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.contributors.get(`user:${CREATOR}`)).toEqual([MODERATOR, CURATOR]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores targets of the wrong type for the contribution kind", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[{ actor: MODERATOR, kind: "event-deny", targets: [user(CREATOR)] }],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.eventDeny.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed contributions and targets", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
/** @type {any[]} */ ([
|
||||||
|
null,
|
||||||
|
{ actor: MODERATOR, kind: "not-a-kind", targets: [user(CREATOR)] },
|
||||||
|
{ actor: MODERATOR, kind: "user-deny", targets: [/** @type {any} */ ({ type: "user", pubkey: "short" })] },
|
||||||
|
{ actor: MODERATOR, kind: "user-deny" },
|
||||||
|
]),
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.userDeny.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects trust seeds as pubkeys", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[{ actor: MODERATOR, kind: "trust-seed", targets: [user(CREATOR)] }],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.trustSeeds.has(CREATOR)).toBe(true);
|
||||||
|
expect(state.contributors.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records address denials by coordinate", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[{ actor: MODERATOR, kind: "address-deny", targets: [address("30023", CREATOR, "listing")] }],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.addressDeny.has(`address:30023:${CREATOR}:listing`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty state for non-array input", () => {
|
||||||
|
expect(reduceAdminState(/** @type {any} */ (null), authority()).userDeny.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("protected actors", () => {
|
||||||
|
it("cannot be denied through a contributor list", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[{ actor: MODERATOR, kind: "user-deny", targets: [user(ROOT)] }],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.userDeny.has(`user:${ROOT}`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be denied even by many contributors", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[
|
||||||
|
{ actor: MODERATOR, kind: "user-deny", targets: [user(ROOT)] },
|
||||||
|
{ actor: CURATOR, kind: "user-deny", targets: [user(ROOT)] },
|
||||||
|
],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.userDeny.has(`user:${ROOT}`)).toBe(false);
|
||||||
|
expect(state.contributors.has(`user:${ROOT}`)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("community sources", () => {
|
||||||
|
it("marks only contributions carrying a source", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[
|
||||||
|
{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] },
|
||||||
|
{ actor: CURATOR, kind: "user-deny", source: "list-a", targets: [user(STRANGER)] },
|
||||||
|
],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.communitySources.has(`user:${CREATOR}`)).toBe(false);
|
||||||
|
expect(state.communitySources.get(`user:${STRANGER}`)).toEqual(["list-a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges a community list into existing state", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
mergeCommunitySource(
|
||||||
|
state,
|
||||||
|
{ actor: CURATOR, kind: "user-deny", source: "list-b", targets: [user(STRANGER)] },
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.userDeny.has(`user:${CREATOR}`)).toBe(true);
|
||||||
|
expect(state.userDeny.has(`user:${STRANGER}`)).toBe(true);
|
||||||
|
expect(state.communitySources.get(`user:${STRANGER}`)).toEqual(["list-b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still protects the root when merging a community list", () => {
|
||||||
|
const state = createEmptyAdminState();
|
||||||
|
mergeCommunitySource(
|
||||||
|
state,
|
||||||
|
{ actor: CURATOR, kind: "user-deny", source: "hostile", targets: [user(ROOT)] },
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.userDeny.has(`user:${ROOT}`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a community list from an unauthorized curator", () => {
|
||||||
|
const state = createEmptyAdminState();
|
||||||
|
mergeCommunitySource(
|
||||||
|
state,
|
||||||
|
{ actor: STRANGER, kind: "user-deny", source: "list-c", targets: [user(CREATOR)] },
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
expect(state.userDeny.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isDenied", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[
|
||||||
|
{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] },
|
||||||
|
{ actor: MODERATOR, kind: "event-deny", targets: [event(EVENT_ID)] },
|
||||||
|
{ actor: MODERATOR, kind: "address-deny", targets: [address("30023", CREATOR, "listing")] },
|
||||||
|
],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("detects denial per target type", () => {
|
||||||
|
expect(isDenied(user(CREATOR), state)).toBe(true);
|
||||||
|
expect(isDenied(event(EVENT_ID), state)).toBe(true);
|
||||||
|
expect(isDenied(address("30023", CREATOR, "listing"), state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak denial across target types", () => {
|
||||||
|
expect(isDenied(user(STRANGER), state)).toBe(false);
|
||||||
|
expect(isDenied(address("30023", CREATOR, "other"), state)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serializeAdminState", () => {
|
||||||
|
it("produces sorted, stable output", () => {
|
||||||
|
const state = reduceAdminState(
|
||||||
|
[{ actor: MODERATOR, kind: "user-deny", targets: [user(STRANGER), user(CREATOR)] }],
|
||||||
|
authority(),
|
||||||
|
);
|
||||||
|
const serialized = serializeAdminState(state);
|
||||||
|
expect(serialized.userDeny).toEqual([...serialized.userDeny].sort());
|
||||||
|
expect(serializeAdminState(state)).toEqual(serialized);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,392 +1,187 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BUILTIN_ROLES,
|
DEFAULT_ROLE_CAPABILITIES,
|
||||||
getRoleCapabilities,
|
GOVERNANCE_CAPABILITIES,
|
||||||
hasRole,
|
createAuthorityState,
|
||||||
|
createRoleDefinition,
|
||||||
|
getActorCapabilities,
|
||||||
getActorRoles,
|
getActorRoles,
|
||||||
hasCapability,
|
hasCapability,
|
||||||
getActorCapabilities,
|
hasRole,
|
||||||
createRoleDefinition,
|
isGovernanceCapability,
|
||||||
createActor,
|
isProtectedActor,
|
||||||
createAuthorityState
|
|
||||||
} from "../src/authority.js";
|
} from "../src/authority.js";
|
||||||
|
|
||||||
describe("authority", () => {
|
const ROOT = "a1".repeat(32);
|
||||||
describe("BUILTIN_ROLES", () => {
|
const MODERATOR = "b2".repeat(32);
|
||||||
it("should define built-in roles with capabilities", () => {
|
const CURATOR = "c3".repeat(32);
|
||||||
expect(BUILTIN_ROLES).toHaveProperty("root");
|
const STRANGER = "d4".repeat(32);
|
||||||
expect(BUILTIN_ROLES).toHaveProperty("administrator");
|
|
||||||
expect(BUILTIN_ROLES).toHaveProperty("moderator");
|
|
||||||
expect(BUILTIN_ROLES).toHaveProperty("trusted-user");
|
|
||||||
expect(BUILTIN_ROLES).toHaveProperty("user");
|
|
||||||
|
|
||||||
// Check that each role has a name and capabilities
|
const baseState = () =>
|
||||||
for (const [name, role] of Object.entries(BUILTIN_ROLES)) {
|
createAuthorityState({
|
||||||
expect(role.name).toBe(name);
|
root: ROOT,
|
||||||
expect(Array.isArray(role.capabilities)).toBe(true);
|
actors: {
|
||||||
expect(role.capabilities.length).toBeGreaterThan(0);
|
[ROOT]: ["super_admin"],
|
||||||
}
|
[MODERATOR]: ["moderator"],
|
||||||
});
|
[CURATOR]: ["curator"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getRoleCapabilities", () => {
|
describe("capabilities", () => {
|
||||||
it("should return capabilities for built-in roles", () => {
|
it("recognizes every documented capability", () => {
|
||||||
const capabilities = getRoleCapabilities("root");
|
for (const capability of GOVERNANCE_CAPABILITIES) {
|
||||||
expect(capabilities).toEqual(BUILTIN_ROLES.root.capabilities);
|
expect(isGovernanceCapability(capability)).toBe(true);
|
||||||
});
|
|
||||||
|
|
||||||
it("should return capabilities for custom roles", () => {
|
|
||||||
const authorityState = createAuthorityState({
|
|
||||||
"custom-role": {
|
|
||||||
name: "custom-role",
|
|
||||||
capabilities: ["custom-capability"]
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const capabilities = getRoleCapabilities("custom-role", authorityState);
|
it("rejects unknown capabilities", () => {
|
||||||
expect(capabilities).toEqual(["custom-capability"]);
|
expect(isGovernanceCapability("delete-everything")).toBe(false);
|
||||||
|
expect(isGovernanceCapability("")).toBe(false);
|
||||||
|
expect(isGovernanceCapability(null)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prefer custom roles over built-in roles", () => {
|
it("gives super_admin every capability", () => {
|
||||||
const authorityState = createAuthorityState({
|
expect([...DEFAULT_ROLE_CAPABILITIES.super_admin].sort()).toEqual(
|
||||||
"root": {
|
[...GOVERNANCE_CAPABILITIES].sort(),
|
||||||
name: "root",
|
);
|
||||||
capabilities: ["custom-root-capability"]
|
});
|
||||||
|
|
||||||
|
it("keeps curators strictly weaker than moderators", () => {
|
||||||
|
const curator = new Set(DEFAULT_ROLE_CAPABILITIES.curator);
|
||||||
|
const moderator = new Set(DEFAULT_ROLE_CAPABILITIES.moderator);
|
||||||
|
for (const capability of curator) {
|
||||||
|
expect(moderator.has(capability)).toBe(true);
|
||||||
}
|
}
|
||||||
|
expect(moderator.size).toBeGreaterThan(curator.size);
|
||||||
});
|
});
|
||||||
|
|
||||||
const capabilities = getRoleCapabilities("root", authorityState);
|
it("does not let a curator deny events or addresses", () => {
|
||||||
expect(capabilities).toEqual(["custom-root-capability"]);
|
expect(DEFAULT_ROLE_CAPABILITIES.curator).not.toContain("contribute-event-deny");
|
||||||
|
expect(DEFAULT_ROLE_CAPABILITIES.curator).not.toContain("contribute-address-deny");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty array for unknown roles", () => {
|
it("does not let a moderator manage roles or policy", () => {
|
||||||
const capabilities = getRoleCapabilities("unknown-role");
|
expect(DEFAULT_ROLE_CAPABILITIES.moderator).not.toContain("manage-roles");
|
||||||
expect(capabilities).toEqual([]);
|
expect(DEFAULT_ROLE_CAPABILITIES.moderator).not.toContain("manage-policy");
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasRole", () => {
|
|
||||||
it("should return true for actors with the specified role", () => {
|
|
||||||
const authorityState = createAuthorityState({}, {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
|
||||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
roles: ["moderator"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = hasRole(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
"moderator",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for actors without the specified role", () => {
|
|
||||||
const authorityState = createAuthorityState({}, {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
|
||||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
roles: ["user"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = hasRole(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
"moderator",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for unknown actors", () => {
|
|
||||||
const authorityState = createAuthorityState();
|
|
||||||
const result = hasRole(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
"moderator",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should normalize pubkeys to lowercase", () => {
|
|
||||||
const authorityState = createAuthorityState({}, {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
|
||||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
roles: ["moderator"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = hasRole(
|
|
||||||
"ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789",
|
|
||||||
"moderator",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getActorRoles", () => {
|
|
||||||
it("should return roles for known actors", () => {
|
|
||||||
const authorityState = createAuthorityState({}, {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
|
||||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
roles: ["user", "trusted-user"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const roles = getActorRoles(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(roles).toEqual(["user", "trusted-user"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return empty array for unknown actors", () => {
|
|
||||||
const authorityState = createAuthorityState();
|
|
||||||
const roles = getActorRoles(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(roles).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasCapability", () => {
|
|
||||||
it("should return true for actors with the specified capability", () => {
|
|
||||||
const authorityState = createAuthorityState({}, {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
|
||||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
roles: ["moderator"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = hasCapability(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
"submit-reports",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for actors without the specified capability", () => {
|
|
||||||
const authorityState = createAuthorityState({}, {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
|
||||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
roles: ["user"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = hasCapability(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
"manage-admin-lists",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for unknown actors", () => {
|
|
||||||
const authorityState = createAuthorityState();
|
|
||||||
const result = hasCapability(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
"submit-reports",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getActorCapabilities", () => {
|
|
||||||
it("should return all capabilities for an actor's roles", () => {
|
|
||||||
const authorityState = createAuthorityState({}, {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
|
||||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
roles: ["moderator", "trusted-user"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const capabilities = getActorCapabilities(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should have capabilities from both roles, with duplicates removed
|
|
||||||
expect(capabilities).toContain("submit-reports");
|
|
||||||
expect(capabilities).toContain("view-reports");
|
|
||||||
expect(capabilities).toContain("submit-trust-endorsements");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return empty array for unknown actors", () => {
|
|
||||||
const authorityState = createAuthorityState();
|
|
||||||
const capabilities = getActorCapabilities(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
authorityState
|
|
||||||
);
|
|
||||||
expect(capabilities).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createRoleDefinition", () => {
|
|
||||||
it("should create a valid role definition", () => {
|
|
||||||
const role = createRoleDefinition("test-role", ["capability1", "capability2"]);
|
|
||||||
expect(role).toEqual({
|
|
||||||
name: "test-role",
|
|
||||||
capabilities: ["capability1", "capability2"]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should trim whitespace from role name and capabilities", () => {
|
|
||||||
const role = createRoleDefinition(" test-role ", [" capability1 ", " capability2 "]);
|
|
||||||
expect(role).toEqual({
|
|
||||||
name: "test-role",
|
|
||||||
capabilities: ["capability1", "capability2"]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid role name", () => {
|
|
||||||
expect(() => createRoleDefinition("", ["capability"])).toThrow();
|
|
||||||
expect(() => createRoleDefinition(/** @type {any} */ (123), ["capability"])).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid capabilities", () => {
|
|
||||||
expect(() => createRoleDefinition("test-role", /** @type {any} */ ("not-an-array"))).toThrow();
|
|
||||||
expect(() => createRoleDefinition("test-role", [/** @type {any} */ (123)])).toThrow();
|
|
||||||
expect(() => createRoleDefinition("test-role", [""])).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createActor", () => {
|
|
||||||
it("should create a valid actor", () => {
|
|
||||||
const actor = createActor(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
["role1", "role2"]
|
|
||||||
);
|
|
||||||
expect(actor).toEqual({
|
|
||||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
roles: ["role1", "role2"]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should normalize pubkey to lowercase", () => {
|
|
||||||
const actor = createActor(
|
|
||||||
"ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789",
|
|
||||||
["role1"]
|
|
||||||
);
|
|
||||||
expect(actor.pubkey).toBe("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should trim whitespace from roles", () => {
|
|
||||||
const actor = createActor(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
[" role1 ", " role2 "]
|
|
||||||
);
|
|
||||||
expect(actor.roles).toEqual(["role1", "role2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid pubkey", () => {
|
|
||||||
expect(() => createActor("invalid-pubkey", ["role"])).toThrow();
|
|
||||||
expect(() => createActor(/** @type {any} */ (123), ["role"])).toThrow();
|
|
||||||
expect(() => createActor("", ["role"])).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid roles", () => {
|
|
||||||
expect(() => createActor(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
/** @type {any} */ ("not-an-array")
|
|
||||||
)).toThrow();
|
|
||||||
expect(() => createActor(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
[/** @type {any} */ (123)]
|
|
||||||
)).toThrow();
|
|
||||||
expect(() => createActor(
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
[""]
|
|
||||||
)).toThrow();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createAuthorityState", () => {
|
describe("createAuthorityState", () => {
|
||||||
it("should create a valid authority state", () => {
|
it("normalizes actor pubkeys and drops invalid ones", () => {
|
||||||
const roles = {
|
const state = createAuthorityState({
|
||||||
"test-role": {
|
actors: { [MODERATOR.toUpperCase()]: ["moderator"], "not-a-key": ["moderator"] },
|
||||||
name: "test-role",
|
});
|
||||||
capabilities: ["test-capability"]
|
expect(state.actors[MODERATOR]).toEqual(["moderator"]);
|
||||||
}
|
expect(Object.keys(state.actors)).toHaveLength(1);
|
||||||
};
|
|
||||||
|
|
||||||
const actors = {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
|
||||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
||||||
roles: ["test-role"]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = createAuthorityState(roles, actors);
|
|
||||||
expect(state).toEqual({ roles, actors });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create empty authority state with no arguments", () => {
|
it("drops actors with no roles", () => {
|
||||||
const state = createAuthorityState();
|
const state = createAuthorityState({ actors: { [MODERATOR]: [] } });
|
||||||
expect(state).toEqual({ roles: {}, actors: {} });
|
expect(state.actors[MODERATOR]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should validate role names", () => {
|
it("deduplicates repeated roles", () => {
|
||||||
const roles = {
|
const state = createAuthorityState({ actors: { [MODERATOR]: ["moderator", "moderator"] } });
|
||||||
"": {
|
expect(state.actors[MODERATOR]).toEqual(["moderator"]);
|
||||||
name: "",
|
|
||||||
capabilities: ["test-capability"]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => createAuthorityState(roles)).toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should validate role definitions", () => {
|
it("always protects the root administrator", () => {
|
||||||
const roles = {
|
const state = createAuthorityState({ root: ROOT });
|
||||||
"test-role": /** @type {any} */ (null)
|
expect(state.protectedActors).toContain(ROOT);
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => createAuthorityState(roles)).toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should validate role name consistency", () => {
|
it("keeps explicitly protected actors alongside root", () => {
|
||||||
const roles = {
|
const state = createAuthorityState({ root: ROOT, protectedActors: [MODERATOR] });
|
||||||
"test-role": {
|
expect(state.protectedActors).toContain(ROOT);
|
||||||
name: "different-name",
|
expect(state.protectedActors).toContain(MODERATOR);
|
||||||
capabilities: ["test-capability"]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => createAuthorityState(roles)).toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should validate actor pubkeys", () => {
|
it("filters unknown capabilities out of custom roles", () => {
|
||||||
const actors = {
|
const state = createAuthorityState({
|
||||||
"invalid-pubkey": {
|
roles: { odd: /** @type {any} */ (["manage-policy", "not-a-capability"]) },
|
||||||
pubkey: "invalid-pubkey",
|
|
||||||
roles: ["test-role"]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => createAuthorityState({}, actors)).toThrow();
|
|
||||||
});
|
});
|
||||||
|
expect(state.roles.odd).toEqual(["manage-policy"]);
|
||||||
it("should validate actors", () => {
|
|
||||||
const actors = {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": /** @type {any} */ (null)
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => createAuthorityState({}, actors)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate actor pubkey consistency", () => {
|
|
||||||
const actors = {
|
|
||||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
|
||||||
pubkey: "different-pubkey",
|
|
||||||
roles: ["test-role"]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => createAuthorityState({}, actors)).toThrow();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("capability resolution", () => {
|
||||||
|
it("resolves capabilities through roles", () => {
|
||||||
|
const state = baseState();
|
||||||
|
expect(hasCapability(MODERATOR, "contribute-event-deny", state)).toBe(true);
|
||||||
|
expect(hasCapability(CURATOR, "contribute-user-deny", state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies capabilities a role does not carry", () => {
|
||||||
|
const state = baseState();
|
||||||
|
expect(hasCapability(CURATOR, "contribute-event-deny", state)).toBe(false);
|
||||||
|
expect(hasCapability(MODERATOR, "manage-roles", state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the root every capability even without a role entry", () => {
|
||||||
|
const state = createAuthorityState({ root: ROOT });
|
||||||
|
expect(hasCapability(ROOT, "manage-roles", state)).toBe(true);
|
||||||
|
expect(getActorCapabilities(ROOT, state).sort()).toEqual([...GOVERNANCE_CAPABILITIES].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for unknown actors", () => {
|
||||||
|
const state = baseState();
|
||||||
|
expect(getActorCapabilities(STRANGER, state)).toEqual([]);
|
||||||
|
expect(getActorRoles(STRANGER, state)).toEqual([]);
|
||||||
|
expect(hasCapability(STRANGER, "contribute-user-deny", state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops granting capabilities the moment a role is revoked", () => {
|
||||||
|
const before = baseState();
|
||||||
|
expect(hasCapability(MODERATOR, "contribute-user-deny", before)).toBe(true);
|
||||||
|
|
||||||
|
const after = createAuthorityState({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
|
||||||
|
expect(hasCapability(MODERATOR, "contribute-user-deny", after)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes pubkey case when resolving", () => {
|
||||||
|
const state = baseState();
|
||||||
|
expect(hasRole(MODERATOR.toUpperCase(), "moderator", state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown capability names outright", () => {
|
||||||
|
const state = baseState();
|
||||||
|
expect(hasCapability(ROOT, /** @type {any} */ ("nope"), state)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("protected actors", () => {
|
||||||
|
it("reports protection for the root", () => {
|
||||||
|
expect(isProtectedActor(ROOT, baseState())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not protect ordinary actors", () => {
|
||||||
|
expect(isProtectedActor(MODERATOR, baseState())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles invalid input", () => {
|
||||||
|
expect(isProtectedActor("", baseState())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRoleDefinition", () => {
|
||||||
|
it("creates a role from valid capabilities", () => {
|
||||||
|
const role = createRoleDefinition("listing_moderator", ["contribute-address-deny"]);
|
||||||
|
expect(role).toEqual({ name: "listing_moderator", capabilities: ["contribute-address-deny"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates capabilities", () => {
|
||||||
|
const role = createRoleDefinition("r", ["review-evidence", "review-evidence"]);
|
||||||
|
expect(role.capabilities).toEqual(["review-evidence"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown capability rather than silently dropping it", () => {
|
||||||
|
expect(() => createRoleDefinition("r", [/** @type {any} */ ("invent-capability")])).toThrow(
|
||||||
|
/Unknown governance capability/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", () => {
|
||||||
|
expect(() => createRoleDefinition(" ", [])).toThrow(/non-empty/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,369 +1,581 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createAuthorityState } from "../src/authority.js";
|
||||||
|
import { reduceAdminState } from "../src/adminState.js";
|
||||||
|
import { createPolicyDefinition } from "../src/policy.js";
|
||||||
import {
|
import {
|
||||||
evaluateTarget,
|
aggregateMutes,
|
||||||
|
aggregateReports,
|
||||||
|
createSnapshot,
|
||||||
|
createViewerState,
|
||||||
evaluateMany,
|
evaluateMany,
|
||||||
createGovernanceState,
|
evaluateTarget,
|
||||||
createReport,
|
resolveTrustSet,
|
||||||
createTrustedMute,
|
|
||||||
createOverride,
|
|
||||||
DEFAULT_THRESHOLDS
|
|
||||||
} from "../src/evaluator.js";
|
} from "../src/evaluator.js";
|
||||||
import {
|
|
||||||
createUserTarget,
|
|
||||||
createEventTarget
|
|
||||||
} from "../src/identifiers.js";
|
|
||||||
import {
|
|
||||||
createAuthorityState,
|
|
||||||
createActor
|
|
||||||
} from "../src/authority.js";
|
|
||||||
import {
|
|
||||||
createPolicyContext
|
|
||||||
} from "../src/policy.js";
|
|
||||||
|
|
||||||
describe("evaluator", () => {
|
const ROOT = "a1".repeat(32);
|
||||||
|
const MODERATOR = "b2".repeat(32);
|
||||||
|
const CREATOR = "d4".repeat(32);
|
||||||
|
const STRANGER = "f6".repeat(32);
|
||||||
|
const EVENT_ID = "1b".repeat(32);
|
||||||
|
const NOW = 1_750_000_000;
|
||||||
|
const DAY = 86_400;
|
||||||
|
|
||||||
|
const trusted = (index) => index.toString(16).padStart(2, "0").repeat(32);
|
||||||
|
const TRUSTED = Array.from({ length: 24 }, (_, index) => trusted(index + 1));
|
||||||
|
|
||||||
|
/** @param {string} pubkey @returns {import("../src/identifiers.js").UserTarget} */
|
||||||
|
const user = (pubkey) => ({ type: "user", pubkey });
|
||||||
|
/** @param {string} id @param {string} author @returns {import("../src/identifiers.js").EventTarget} */
|
||||||
|
const event = (id, author) => ({ type: "event", id, author });
|
||||||
|
|
||||||
|
const POLICY = createPolicyDefinition({
|
||||||
|
id: "test",
|
||||||
|
version: "2.1.0",
|
||||||
|
defaultProfile: "feed",
|
||||||
|
profiles: {
|
||||||
|
feed: {
|
||||||
|
name: "feed",
|
||||||
|
administrativeDeny: { visibility: "hide", interaction: "deny" },
|
||||||
|
viewerBlock: { visibility: "hide", interaction: "deny" },
|
||||||
|
allowViewerOverride: true,
|
||||||
|
muteWindowSeconds: 60 * DAY,
|
||||||
|
reports: { default: { restrict: 3 }, spam: { hide: 5 } },
|
||||||
|
mutes: { default: { downrank: 1, hide: 20 } },
|
||||||
|
},
|
||||||
|
strict: {
|
||||||
|
name: "strict",
|
||||||
|
administrativeDeny: { visibility: "deny", interaction: "deny" },
|
||||||
|
allowViewerOverride: false,
|
||||||
|
requireAllowlist: true,
|
||||||
|
allowlistMiss: { visibility: "hide", interaction: "deny" },
|
||||||
|
exposeEvidence: true,
|
||||||
|
reports: {},
|
||||||
|
mutes: {},
|
||||||
|
},
|
||||||
|
off: { name: "off", disabled: true },
|
||||||
|
commerce: {
|
||||||
|
name: "commerce",
|
||||||
|
administrativeDeny: { visibility: "allow", transaction: "deny" },
|
||||||
|
reports: { scam: { transactionReview: 2, transactionDeny: 4 } },
|
||||||
|
mutes: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const authority = () =>
|
||||||
|
createAuthorityState({
|
||||||
|
root: ROOT,
|
||||||
|
actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} [parts]
|
||||||
|
* @param {import("../src/adminState.js").Contribution[]} [parts.contributions]
|
||||||
|
* @param {{ contacts?: Set<string>, seeds?: Set<string> }} [parts.trust]
|
||||||
|
* @param {Map<string, import("../src/evaluator.js").ReportRecord[]>} [parts.reports]
|
||||||
|
* @param {Map<string, import("../src/evaluator.js").MuteRecord[]>} [parts.trustedMutes]
|
||||||
|
*/
|
||||||
|
function snapshotWith({ contributions = [], trust, reports, trustedMutes } = {}) {
|
||||||
|
const auth = authority();
|
||||||
|
return createSnapshot({
|
||||||
|
authority: auth,
|
||||||
|
admin: reduceAdminState(contributions, auth),
|
||||||
|
trust: trust ?? { contacts: new Set(TRUSTED) },
|
||||||
|
reports: reports ?? new Map(),
|
||||||
|
trustedMutes: trustedMutes ?? new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = (profile = "feed") => ({
|
||||||
|
surface: profile,
|
||||||
|
policyProfile: profile,
|
||||||
|
policy: POLICY,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportsFrom = (count, category) =>
|
||||||
|
TRUSTED.slice(0, count).map((reporter) => ({ reporter, category, createdAt: NOW - DAY }));
|
||||||
|
|
||||||
|
const mutesFrom = (count, updatedAt = NOW - DAY) =>
|
||||||
|
TRUSTED.slice(0, count).map((muter) => ({ muter, updatedAt }));
|
||||||
|
|
||||||
describe("evaluateTarget", () => {
|
describe("evaluateTarget", () => {
|
||||||
it("should return default allow decision for target with no reports or mutes", () => {
|
it("allows a target with no signals", () => {
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
const decision = evaluateTarget(user(CREATOR), snapshotWith(), context());
|
||||||
const state = createGovernanceState();
|
|
||||||
const context = createPolicyContext("feed", "default");
|
|
||||||
|
|
||||||
const decision = evaluateTarget(target, state, context);
|
|
||||||
expect(decision).toEqual({
|
|
||||||
visibility: { effect: "allow" },
|
|
||||||
interaction: { effect: "allow" },
|
|
||||||
reasons: [],
|
|
||||||
evidence: []
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should hide target with sufficient trusted reports", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
const reporter = "1234567890123456789012345678901234567890123456789012345678901234";
|
|
||||||
|
|
||||||
// Create authority state with reporter as moderator (can submit reports)
|
|
||||||
const authorityState = createAuthorityState({}, {
|
|
||||||
[reporter]: createActor(reporter, ["moderator"])
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = createGovernanceState(
|
|
||||||
authorityState,
|
|
||||||
{
|
|
||||||
"user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": [
|
|
||||||
createReport(reporter, target, "spam", Date.now())
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const context = createPolicyContext("feed", "default");
|
|
||||||
|
|
||||||
// Add enough reports to exceed the hide threshold
|
|
||||||
for (let i = 0; i < DEFAULT_THRESHOLDS.trustedReportHideThreshold; i++) {
|
|
||||||
state.reports["user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"].push(
|
|
||||||
createReport(reporter, target, "spam", Date.now() + i)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decision = evaluateTarget(target, state, context);
|
|
||||||
expect(decision.visibility.effect).toBe("hide");
|
|
||||||
expect(decision.interaction.effect).toBe("deny");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should restrict target with moderate number of trusted reports", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
const reporter = "1234567890123456789012345678901234567890123456789012345678901234";
|
|
||||||
|
|
||||||
// Create authority state with reporter as moderator (can submit reports)
|
|
||||||
const authorityState = createAuthorityState({}, {
|
|
||||||
[reporter]: createActor(reporter, ["moderator"])
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = createGovernanceState(
|
|
||||||
authorityState,
|
|
||||||
{
|
|
||||||
"user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": [
|
|
||||||
createReport(reporter, target, "spam", Date.now())
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const context = createPolicyContext("feed", "default");
|
|
||||||
|
|
||||||
// Add enough reports to exceed the restrict threshold but not the hide threshold
|
|
||||||
for (let i = 0; i < DEFAULT_THRESHOLDS.trustedReportRestrictThreshold; i++) {
|
|
||||||
state.reports["user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"].push(
|
|
||||||
createReport(reporter, target, "spam", Date.now() + i)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decision = evaluateTarget(target, state, context);
|
|
||||||
expect(decision.visibility.effect).toBe("restrict");
|
|
||||||
expect(decision.interaction.effect).toBe("allow");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should hide target with sufficient trusted mutes", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
|
|
||||||
const state = createGovernanceState(
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
"user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789":
|
|
||||||
createTrustedMute(target, DEFAULT_THRESHOLDS.trustedMuteHideThreshold, {
|
|
||||||
"mute": DEFAULT_THRESHOLDS.trustedMuteHideThreshold
|
|
||||||
})
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const context = createPolicyContext("feed", "default");
|
|
||||||
|
|
||||||
const decision = evaluateTarget(target, state, context);
|
|
||||||
expect(decision.visibility.effect).toBe("hide");
|
|
||||||
expect(decision.interaction.effect).toBe("deny");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should restrict target with moderate number of trusted mutes", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
|
|
||||||
const state = createGovernanceState(
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
"user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789":
|
|
||||||
createTrustedMute(target, DEFAULT_THRESHOLDS.trustedMuteRestrictThreshold, {
|
|
||||||
"mute": DEFAULT_THRESHOLDS.trustedMuteRestrictThreshold
|
|
||||||
})
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const context = createPolicyContext("feed", "default");
|
|
||||||
|
|
||||||
const decision = evaluateTarget(target, state, context);
|
|
||||||
expect(decision.visibility.effect).toBe("restrict");
|
|
||||||
expect(decision.interaction.effect).toBe("allow");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should apply explicit overrides", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
|
|
||||||
const state = createGovernanceState(
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
"user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789":
|
|
||||||
createOverride(target, "hide", "Administrative override")
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const context = createPolicyContext("feed", "default");
|
|
||||||
|
|
||||||
const decision = evaluateTarget(target, state, context);
|
|
||||||
expect(decision.visibility.effect).toBe("hide");
|
|
||||||
expect(decision.interaction.effect).toBe("hide");
|
|
||||||
expect(decision.reasons).toContain("Administrative override");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should ignore overrides when enforcement disallows them", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
|
|
||||||
const state = createGovernanceState(
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
"user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789":
|
|
||||||
createOverride(target, "hide", "Administrative override")
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const context = createPolicyContext("feed", "default", {
|
|
||||||
allowOverrides: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const decision = evaluateTarget(target, state, context);
|
|
||||||
expect(decision.visibility.effect).toBe("allow");
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
expect(decision.interaction.effect).toBe("allow");
|
expect(decision.interaction.effect).toBe("allow");
|
||||||
|
expect(decision.ranking.effect).toBe("normal");
|
||||||
|
expect(decision.reasons).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid target", () => {
|
||||||
|
expect(() => evaluateTarget(/** @type {any} */ ({ type: "user", pubkey: "nope" }), snapshotWith(), context())).toThrow(
|
||||||
|
/Invalid governance target/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps policy identity and a snapshot fingerprint on the decision", () => {
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshotWith(), context());
|
||||||
|
expect(decision.policyProfile).toBe("feed");
|
||||||
|
expect(decision.policyVersion).toBe("2.1.0");
|
||||||
|
expect(decision.evaluatedAt).toBe(NOW);
|
||||||
|
expect(decision.snapshotFingerprint).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
expect(decision.key).toBe(`user:${CREATOR}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a disabled surface instead of pretending to evaluate", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context("off"));
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
expect(decision.reasons.map((r) => r.id)).toEqual(["policy-disabled"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("administrative denial", () => {
|
||||||
|
it("applies the profile's denial effects", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context());
|
||||||
|
expect(decision.visibility.effect).toBe("hide");
|
||||||
|
expect(decision.interaction.effect).toBe("deny");
|
||||||
|
expect(decision.reasons.map((r) => r.id)).toContain("admin-user-deny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("varies by surface", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
});
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshot, context("strict")).visibility.effect).toBe("deny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches an author's events", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
});
|
||||||
|
expect(evaluateTarget(event(EVENT_ID, CREATOR), snapshot, context()).visibility.effect).toBe("hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reach events of other authors", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
});
|
||||||
|
expect(evaluateTarget(event(EVENT_ID, STRANGER), snapshot, context()).visibility.effect).toBe("allow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a protected actor untouched", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(ROOT)] }],
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(ROOT), snapshot, context());
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
expect(decision.evidence?.protectedTarget).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a transaction while leaving a product visible", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context("commerce"));
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
expect(decision.transaction?.effect).toBe("deny");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("allowlist", () => {
|
||||||
|
it("hides a target that misses the allowlist when the profile requires one", () => {
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshotWith(), context("strict"));
|
||||||
|
expect(decision.visibility.effect).toBe("hide");
|
||||||
|
expect(decision.reasons.map((r) => r.id)).toContain("allowlist-miss");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permits an allowlisted target", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: ROOT, kind: "user-allow", targets: [user(CREATOR)] }],
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context("strict"));
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
expect(decision.evidence?.userAllowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not enforce the allowlist on surfaces that do not require it", () => {
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshotWith(), context()).visibility.effect).toBe("allow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants no trust from being allowlisted", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: ROOT, kind: "user-allow", targets: [user(CREATOR)] }],
|
||||||
|
trust: { contacts: new Set() },
|
||||||
|
reports: new Map([[`user:${STRANGER}`, [{ reporter: CREATOR, category: "spam", createdAt: NOW }]]]),
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(STRANGER), snapshot, context());
|
||||||
|
expect(decision.evidence?.trustedReportTotal).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("viewer state", () => {
|
||||||
|
it("hides a blocked author", () => {
|
||||||
|
const viewer = createViewerState({ blocks: new Set([CREATOR]) });
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshotWith(), context(), viewer);
|
||||||
|
expect(decision.visibility.effect).toBe("hide");
|
||||||
|
expect(decision.evidence?.personalBlock).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a block through to the author's events", () => {
|
||||||
|
const viewer = createViewerState({ blocks: new Set([CREATOR]) });
|
||||||
|
expect(evaluateTarget(event(EVENT_ID, CREATOR), snapshotWith(), context(), viewer).visibility.effect).toBe(
|
||||||
|
"hide",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a viewer mute without changing effects", () => {
|
||||||
|
const viewer = createViewerState({ mutes: new Set([CREATOR]) });
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshotWith(), context(), viewer);
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
expect(decision.evidence?.personalMute).toBe(true);
|
||||||
|
expect(decision.reasons.map((r) => r.id)).toContain("viewer-mute");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("softens a decision through an override and requires explicit action", () => {
|
||||||
|
const viewer = createViewerState({
|
||||||
|
overrides: new Map([[`user:${CREATOR}`, { visibility: "allow" }]]),
|
||||||
|
});
|
||||||
|
const snapshot = snapshotWith({ trustedMutes: new Map([[`user:${CREATOR}`, mutesFrom(20)]]) });
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context(), viewer);
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
expect(decision.interaction.effect).toBe("require-explicit-action");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets an override escalate", () => {
|
||||||
|
const viewer = createViewerState({
|
||||||
|
overrides: new Map([[`user:${CREATOR}`, { visibility: "deny" }]]),
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshotWith(), context(), viewer);
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
expect(decision.reasons.map((r) => r.id)).not.toContain("viewer-override");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores overrides on a profile that forbids them", () => {
|
||||||
|
const viewer = createViewerState({
|
||||||
|
overrides: new Map([[`user:${CREATOR}`, { visibility: "allow" }]]),
|
||||||
|
});
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshotWith(), context("strict"), viewer).visibility.effect).toBe(
|
||||||
|
"hide",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores overrides when enforcement disables them", () => {
|
||||||
|
const viewer = createViewerState({
|
||||||
|
overrides: new Map([[`user:${CREATOR}`, { visibility: "allow" }]]),
|
||||||
|
});
|
||||||
|
const snapshot = snapshotWith({ trustedMutes: new Map([[`user:${CREATOR}`, mutesFrom(20)]]) });
|
||||||
|
const decision = evaluateTarget(
|
||||||
|
user(CREATOR),
|
||||||
|
snapshot,
|
||||||
|
{ ...context(), enforcement: { allowOverrides: false } },
|
||||||
|
viewer,
|
||||||
|
);
|
||||||
|
expect(decision.visibility.effect).toBe("hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an override naming an unknown effect", () => {
|
||||||
|
const viewer = createViewerState({
|
||||||
|
overrides: new Map([[`user:${CREATOR}`, /** @type {any} */ ({ visibility: "sideways" })]]),
|
||||||
|
});
|
||||||
|
const snapshot = snapshotWith({ trustedMutes: new Map([[`user:${CREATOR}`, mutesFrom(20)]]) });
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshot, context(), viewer).visibility.effect).toBe("hide");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trust resolution", () => {
|
||||||
|
it("prefers real contacts over seeds", () => {
|
||||||
|
const set = resolveTrustSet({ contacts: new Set([CREATOR]), seeds: new Set([STRANGER]) });
|
||||||
|
expect(set.has(CREATOR)).toBe(true);
|
||||||
|
expect(set.has(STRANGER)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to seeds when the viewer follows nobody", () => {
|
||||||
|
const set = resolveTrustSet({ contacts: new Set(), seeds: new Set([STRANGER]) });
|
||||||
|
expect(set.has(STRANGER)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty set when neither is present", () => {
|
||||||
|
expect(resolveTrustSet(undefined).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("report aggregation", () => {
|
||||||
|
it("restricts at the threshold", () => {
|
||||||
|
const snapshot = snapshotWith({ reports: new Map([[`user:${CREATOR}`, reportsFrom(3, "nudity")]]) });
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshot, context()).visibility.effect).toBe("restrict");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays permissive below the threshold", () => {
|
||||||
|
const snapshot = snapshotWith({ reports: new Map([[`user:${CREATOR}`, reportsFrom(2, "nudity")]]) });
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshot, context()).visibility.effect).toBe("allow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates a reporter within a category", () => {
|
||||||
|
const records = [
|
||||||
|
{ reporter: TRUSTED[0], category: "spam", createdAt: NOW },
|
||||||
|
{ reporter: TRUSTED[0], category: "spam", createdAt: NOW - 1 },
|
||||||
|
];
|
||||||
|
const result = aggregateReports(
|
||||||
|
"k",
|
||||||
|
{ ...snapshotWith(), reports: new Map([["k", records]]) },
|
||||||
|
{},
|
||||||
|
new Set(TRUSTED),
|
||||||
|
);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts distinct categories from the same reporter separately", () => {
|
||||||
|
const records = [
|
||||||
|
{ reporter: TRUSTED[0], category: "spam", createdAt: NOW },
|
||||||
|
{ reporter: TRUSTED[0], category: "nudity", createdAt: NOW },
|
||||||
|
];
|
||||||
|
const result = aggregateReports(
|
||||||
|
"k",
|
||||||
|
{ ...snapshotWith(), reports: new Map([["k", records]]) },
|
||||||
|
{},
|
||||||
|
new Set(TRUSTED),
|
||||||
|
);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
expect(result.byCategory).toEqual({ spam: 1, nudity: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips untrusted, blocked, and denied reporters", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(TRUSTED[1])] }],
|
||||||
|
reports: new Map([
|
||||||
|
[
|
||||||
|
`user:${CREATOR}`,
|
||||||
|
[
|
||||||
|
{ reporter: TRUSTED[0], category: "spam", createdAt: NOW },
|
||||||
|
{ reporter: TRUSTED[1], category: "spam", createdAt: NOW },
|
||||||
|
{ reporter: TRUSTED[2], category: "spam", createdAt: NOW },
|
||||||
|
{ reporter: STRANGER, category: "spam", createdAt: NOW },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const viewer = createViewerState({ blocks: new Set([TRUSTED[2]]) });
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context(), viewer);
|
||||||
|
expect(decision.evidence?.trustedReportTotal).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores reports with no category", () => {
|
||||||
|
const result = aggregateReports(
|
||||||
|
"k",
|
||||||
|
{ ...snapshotWith(), reports: new Map([["k", [{ reporter: TRUSTED[0], category: " " }]]]) },
|
||||||
|
{},
|
||||||
|
new Set(TRUSTED),
|
||||||
|
);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies category-specific thresholds", () => {
|
||||||
|
const snapshot = snapshotWith({ reports: new Map([[`user:${CREATOR}`, reportsFrom(5, "spam")]]) });
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshot, context()).visibility.effect).toBe("hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives the transaction dimension on a commerce surface", () => {
|
||||||
|
const snapshot = snapshotWith({ reports: new Map([[`user:${CREATOR}`, reportsFrom(2, "scam")]]) });
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshot, context("commerce")).transaction?.effect).toBe(
|
||||||
|
"require-review",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mute aggregation", () => {
|
||||||
|
it("downranks below the hide threshold without hiding", () => {
|
||||||
|
const snapshot = snapshotWith({ trustedMutes: new Map([[`user:${CREATOR}`, mutesFrom(19)]]) });
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context());
|
||||||
|
expect(decision.ranking.effect).toBe("downrank");
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
expect(decision.interaction.effect).toBe("allow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides at the threshold", () => {
|
||||||
|
const snapshot = snapshotWith({ trustedMutes: new Map([[`user:${CREATOR}`, mutesFrom(20)]]) });
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshot, context()).visibility.effect).toBe("hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts muters uniquely", () => {
|
||||||
|
const records = [
|
||||||
|
{ muter: TRUSTED[0], updatedAt: NOW },
|
||||||
|
{ muter: TRUSTED[0], updatedAt: NOW - 1 },
|
||||||
|
{ muter: TRUSTED[1], updatedAt: NOW },
|
||||||
|
];
|
||||||
|
const result = aggregateMutes(
|
||||||
|
"k",
|
||||||
|
{ ...snapshotWith(), trustedMutes: new Map([["k", records]]) },
|
||||||
|
{},
|
||||||
|
new Set(TRUSTED),
|
||||||
|
NOW,
|
||||||
|
60 * DAY,
|
||||||
|
);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores mutes outside the validity window", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
trustedMutes: new Map([[`user:${CREATOR}`, mutesFrom(20, NOW - 61 * DAY)]]),
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context());
|
||||||
|
expect(decision.evidence?.trustedMuteTotal).toBe(0);
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every mute when the window is disabled", () => {
|
||||||
|
const result = aggregateMutes(
|
||||||
|
"k",
|
||||||
|
{ ...snapshotWith(), trustedMutes: new Map([["k", mutesFrom(3, NOW - 4000 * DAY)]]) },
|
||||||
|
{},
|
||||||
|
new Set(TRUSTED),
|
||||||
|
NOW,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(result.total).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("surface exceptions", () => {
|
||||||
|
const bypassPolicy = createPolicyDefinition({
|
||||||
|
id: "bypass",
|
||||||
|
version: "1.0.0",
|
||||||
|
profiles: {
|
||||||
|
home: {
|
||||||
|
name: "home",
|
||||||
|
administrativeDeny: { visibility: "hide", interaction: "deny" },
|
||||||
|
bypassHide: true,
|
||||||
|
bypassHideCeiling: "restrict",
|
||||||
|
reports: {},
|
||||||
|
mutes: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a hide at the configured ceiling", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }],
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, {
|
||||||
|
surface: "home",
|
||||||
|
policyProfile: "home",
|
||||||
|
policy: bypassPolicy,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(decision.visibility.effect).toBe("restrict");
|
||||||
|
expect(decision.reasons.map((r) => r.id)).toContain("surface-policy-bypass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a decision below the ceiling untouched", () => {
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshotWith(), {
|
||||||
|
surface: "home",
|
||||||
|
policyProfile: "home",
|
||||||
|
policy: bypassPolicy,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
expect(decision.reasons.map((r) => r.id)).not.toContain("surface-policy-bypass");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("evidence exposure", () => {
|
||||||
|
it("withholds contributor pubkeys by default", () => {
|
||||||
|
const snapshot = snapshotWith({ reports: new Map([[`user:${CREATOR}`, reportsFrom(3, "nudity")]]) });
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context());
|
||||||
|
expect(decision.evidence?.trustedReportTotal).toBe(3);
|
||||||
|
expect(decision.evidence?.trustedReporterPubkeys).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes them when the profile opts in", () => {
|
||||||
|
const snapshot = snapshotWith({
|
||||||
|
contributions: [{ actor: ROOT, kind: "user-allow", targets: [user(CREATOR)] }],
|
||||||
|
reports: new Map([[`user:${CREATOR}`, reportsFrom(3, "nudity")]]),
|
||||||
|
});
|
||||||
|
const decision = evaluateTarget(user(CREATOR), snapshot, context("strict"));
|
||||||
|
expect(decision.evidence?.trustedReporterPubkeys).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("purity and determinism", () => {
|
||||||
|
it("produces identical decisions for identical inputs", () => {
|
||||||
|
const snapshot = snapshotWith({ reports: new Map([[`user:${CREATOR}`, reportsFrom(3, "spam")]]) });
|
||||||
|
expect(evaluateTarget(user(CREATOR), snapshot, context())).toEqual(
|
||||||
|
evaluateTarget(user(CREATOR), snapshot, context()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the snapshot", () => {
|
||||||
|
const snapshot = snapshotWith({ reports: new Map([[`user:${CREATOR}`, reportsFrom(3, "spam")]]) });
|
||||||
|
const serialize = () =>
|
||||||
|
JSON.stringify({
|
||||||
|
deny: [...snapshot.admin.userDeny],
|
||||||
|
reports: [...(snapshot.reports ?? new Map()).entries()],
|
||||||
|
});
|
||||||
|
const before = serialize();
|
||||||
|
evaluateTarget(user(CREATOR), snapshot, context());
|
||||||
|
expect(serialize()).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the target", () => {
|
||||||
|
const target = user(CREATOR);
|
||||||
|
evaluateTarget(target, snapshotWith(), context());
|
||||||
|
expect(target).toEqual({ type: "user", pubkey: CREATOR });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes the fingerprint when the snapshot changes", () => {
|
||||||
|
const clean = evaluateTarget(user(CREATOR), snapshotWith(), context());
|
||||||
|
const denied = evaluateTarget(
|
||||||
|
user(CREATOR),
|
||||||
|
snapshotWith({ contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }] }),
|
||||||
|
context(),
|
||||||
|
);
|
||||||
|
expect(denied.snapshotFingerprint).not.toBe(clean.snapshotFingerprint);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the fingerprint stable across viewers", () => {
|
||||||
|
const snapshot = snapshotWith();
|
||||||
|
const a = evaluateTarget(user(CREATOR), snapshot, context(), createViewerState({ blocks: new Set() }));
|
||||||
|
const b = evaluateTarget(
|
||||||
|
user(CREATOR),
|
||||||
|
snapshot,
|
||||||
|
context(),
|
||||||
|
createViewerState({ blocks: new Set([CREATOR]) }),
|
||||||
|
);
|
||||||
|
expect(a.snapshotFingerprint).toBe(b.snapshotFingerprint);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("evaluateMany", () => {
|
describe("evaluateMany", () => {
|
||||||
it("should evaluate multiple targets", () => {
|
it("evaluates every target and keys results", () => {
|
||||||
const target1 = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
const results = evaluateMany([user(CREATOR), user(STRANGER)], snapshotWith(), context());
|
||||||
const target2 = createEventTarget("1234567890123456789012345678901234567890123456789012345678901234");
|
expect(results.size).toBe(2);
|
||||||
|
expect(results.get(`user:${CREATOR}`)?.visibility.effect).toBe("allow");
|
||||||
const state = createGovernanceState();
|
|
||||||
const context = createPolicyContext("feed", "default");
|
|
||||||
|
|
||||||
const results = evaluateMany([target1, target2], state, context);
|
|
||||||
|
|
||||||
expect(results).toHaveProperty("user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
expect(results).toHaveProperty("event:1234567890123456789012345678901234567890123456789012345678901234");
|
|
||||||
|
|
||||||
expect(results["user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"]).toEqual({
|
|
||||||
visibility: { effect: "allow" },
|
|
||||||
interaction: { effect: "allow" },
|
|
||||||
reasons: [],
|
|
||||||
evidence: []
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(results["event:1234567890123456789012345678901234567890123456789012345678901234"]).toEqual({
|
it("skips invalid targets rather than throwing", () => {
|
||||||
visibility: { effect: "allow" },
|
const results = evaluateMany(
|
||||||
interaction: { effect: "allow" },
|
[user(CREATOR), /** @type {any} */ ({ type: "user", pubkey: "bad" })],
|
||||||
reasons: [],
|
snapshotWith(),
|
||||||
evidence: []
|
context(),
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createGovernanceState", () => {
|
|
||||||
it("should create a valid governance state", () => {
|
|
||||||
const authority = createAuthorityState();
|
|
||||||
const reports = {};
|
|
||||||
const trustedMutes = {};
|
|
||||||
const overrides = {};
|
|
||||||
const policies = {};
|
|
||||||
|
|
||||||
const state = createGovernanceState(authority, reports, trustedMutes, overrides, policies);
|
|
||||||
expect(state).toEqual({
|
|
||||||
authority,
|
|
||||||
reports,
|
|
||||||
trustedMutes,
|
|
||||||
overrides,
|
|
||||||
policies
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a governance state with default empty objects", () => {
|
|
||||||
const state = createGovernanceState();
|
|
||||||
expect(state).toEqual({
|
|
||||||
authority: { roles: {}, actors: {} },
|
|
||||||
reports: {},
|
|
||||||
trustedMutes: {},
|
|
||||||
overrides: {},
|
|
||||||
policies: {}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createReport", () => {
|
|
||||||
it("should create a valid report", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
const report = createReport(
|
|
||||||
"1234567890123456789012345678901234567890123456789012345678901234",
|
|
||||||
target,
|
|
||||||
"spam",
|
|
||||||
Date.now()
|
|
||||||
);
|
);
|
||||||
|
expect(results.size).toBe(1);
|
||||||
expect(report).toEqual({
|
|
||||||
reporter: "1234567890123456789012345678901234567890123456789012345678901234",
|
|
||||||
target,
|
|
||||||
category: "spam",
|
|
||||||
timestamp: expect.any(Number)
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error for invalid reporter", () => {
|
it("deduplicates repeated targets", () => {
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
const results = evaluateMany([user(CREATOR), user(CREATOR)], snapshotWith(), context());
|
||||||
expect(() => createReport("invalid", target, "spam", Date.now())).toThrow();
|
expect(results.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error for invalid target", () => {
|
it("agrees with evaluateTarget", () => {
|
||||||
expect(() => createReport(
|
const snapshot = snapshotWith({ reports: new Map([[`user:${CREATOR}`, reportsFrom(3, "spam")]]) });
|
||||||
"1234567890123456789012345678901234567890123456789012345678901234",
|
const many = evaluateMany([user(CREATOR)], snapshot, context()).get(`user:${CREATOR}`);
|
||||||
null,
|
expect(many).toEqual(evaluateTarget(user(CREATOR), snapshot, context()));
|
||||||
"spam",
|
|
||||||
Date.now()
|
|
||||||
)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid category", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
expect(() => createReport(
|
|
||||||
"1234567890123456789012345678901234567890123456789012345678901234",
|
|
||||||
target,
|
|
||||||
"",
|
|
||||||
Date.now()
|
|
||||||
)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid timestamp", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
expect(() => createReport(
|
|
||||||
"1234567890123456789012345678901234567890123456789012345678901234",
|
|
||||||
target,
|
|
||||||
"spam",
|
|
||||||
-1
|
|
||||||
)).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createTrustedMute", () => {
|
|
||||||
it("should create a valid trusted mute", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
const trustedMute = createTrustedMute(target, 5, { "mute": 5 });
|
|
||||||
|
|
||||||
expect(trustedMute).toEqual({
|
|
||||||
target,
|
|
||||||
count: 5,
|
|
||||||
categories: { "mute": 5 }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid target", () => {
|
|
||||||
expect(() => createTrustedMute(null, 5, { "mute": 5 })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid count", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
expect(() => createTrustedMute(target, -1, { "mute": 5 })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid categories", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
expect(() => createTrustedMute(target, 5, null)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid category names", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
expect(() => createTrustedMute(target, 5, { "": 5 })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid category counts", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
expect(() => createTrustedMute(target, 5, { "mute": -1 })).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createOverride", () => {
|
|
||||||
it("should create a valid override", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
const override = createOverride(target, "hide", "Administrative override");
|
|
||||||
|
|
||||||
expect(override).toEqual({
|
|
||||||
target,
|
|
||||||
visibility: "hide",
|
|
||||||
reason: "Administrative override"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create an override without reason", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
const override = createOverride(target, "hide");
|
|
||||||
|
|
||||||
expect(override).toEqual({
|
|
||||||
target,
|
|
||||||
visibility: "hide"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid target", () => {
|
|
||||||
expect(() => createOverride(null, "hide")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid visibility", () => {
|
|
||||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
|
||||||
expect(() => createOverride(target, /** @type {any} */ ("invalid"))).toThrow();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createEmptyEvidence, freezeEvidence, redactEvidence } from "../src/evidence.js";
|
||||||
|
|
||||||
|
const populated = () => ({
|
||||||
|
...createEmptyEvidence(),
|
||||||
|
trustedReportTotal: 3,
|
||||||
|
trustedReportsByCategory: { spam: 2, nudity: 1 },
|
||||||
|
trustedReporterPubkeys: ["cc", "aa", "bb"],
|
||||||
|
trustedMuteTotal: 2,
|
||||||
|
trustedMutesByCategory: { other: 2 },
|
||||||
|
trustedMuterPubkeys: ["zz", "yy"],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createEmptyEvidence", () => {
|
||||||
|
it("starts at zero with no flags set", () => {
|
||||||
|
const evidence = createEmptyEvidence();
|
||||||
|
expect(evidence.trustedReportTotal).toBe(0);
|
||||||
|
expect(evidence.personalBlock).toBe(false);
|
||||||
|
expect(evidence.protectedTarget).toBe(false);
|
||||||
|
expect(evidence.trustedReporterPubkeys).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a fresh object each call", () => {
|
||||||
|
const first = createEmptyEvidence();
|
||||||
|
first.trustedReportTotal = 9;
|
||||||
|
expect(createEmptyEvidence().trustedReportTotal).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("freezeEvidence", () => {
|
||||||
|
it("sorts pubkey lists for stable serialization", () => {
|
||||||
|
expect(freezeEvidence(populated()).trustedReporterPubkeys).toEqual(["aa", "bb", "cc"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts category keys", () => {
|
||||||
|
expect(Object.keys(freezeEvidence(populated()).trustedReportsByCategory)).toEqual([
|
||||||
|
"nudity",
|
||||||
|
"spam",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves counts", () => {
|
||||||
|
const frozen = freezeEvidence(populated());
|
||||||
|
expect(frozen.trustedReportTotal).toBe(3);
|
||||||
|
expect(frozen.trustedReportsByCategory.spam).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate its input", () => {
|
||||||
|
const evidence = populated();
|
||||||
|
freezeEvidence(evidence);
|
||||||
|
expect(evidence.trustedReporterPubkeys).toEqual(["cc", "aa", "bb"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is stable across repeated calls", () => {
|
||||||
|
expect(freezeEvidence(populated())).toEqual(freezeEvidence(populated()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redactEvidence", () => {
|
||||||
|
it("drops contributor pubkeys", () => {
|
||||||
|
const redacted = redactEvidence(populated());
|
||||||
|
expect(redacted.trustedReporterPubkeys).toEqual([]);
|
||||||
|
expect(redacted.trustedMuterPubkeys).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the counts that explain the decision", () => {
|
||||||
|
const redacted = redactEvidence(populated());
|
||||||
|
expect(redacted.trustedReportTotal).toBe(3);
|
||||||
|
expect(redacted.trustedMuteTotal).toBe(2);
|
||||||
|
expect(redacted.trustedReportsByCategory).toEqual({ nudity: 1, spam: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
canonicalStringify,
|
||||||
|
fingerprint,
|
||||||
|
hashString,
|
||||||
|
snapshotFingerprint,
|
||||||
|
} from "../src/fingerprint.js";
|
||||||
|
|
||||||
|
describe("canonicalStringify", () => {
|
||||||
|
it("sorts object keys at every depth", () => {
|
||||||
|
expect(canonicalStringify({ b: 1, a: { d: 2, c: 3 } })).toBe(
|
||||||
|
canonicalStringify({ a: { c: 3, d: 2 }, b: 1 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves array order", () => {
|
||||||
|
expect(canonicalStringify([1, 2])).not.toBe(canonicalStringify([2, 1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes Sets order-independently", () => {
|
||||||
|
expect(canonicalStringify(new Set(["b", "a"]))).toBe(canonicalStringify(new Set(["a", "b"])));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes Maps order-independently", () => {
|
||||||
|
const a = new Map([["x", 1], ["y", 2]]);
|
||||||
|
const b = new Map([["y", 2], ["x", 1]]);
|
||||||
|
expect(canonicalStringify(a)).toBe(canonicalStringify(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips undefined values", () => {
|
||||||
|
expect(canonicalStringify({ a: 1, b: undefined })).toBe(canonicalStringify({ a: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles primitives and null", () => {
|
||||||
|
expect(canonicalStringify(null)).toBe("null");
|
||||||
|
expect(canonicalStringify(5)).toBe("5");
|
||||||
|
expect(canonicalStringify("x")).toBe('"x"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hashString", () => {
|
||||||
|
it("returns a 16-character hex digest", () => {
|
||||||
|
expect(hashString("anything")).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is stable across calls", () => {
|
||||||
|
expect(hashString("stable")).toBe(hashString("stable"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates similar inputs", () => {
|
||||||
|
expect(hashString("a")).not.toBe(hashString("b"));
|
||||||
|
expect(hashString("ab")).not.toBe(hashString("ba"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the empty string", () => {
|
||||||
|
expect(hashString("")).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fingerprint", () => {
|
||||||
|
it("matches for structurally equal values", () => {
|
||||||
|
expect(fingerprint({ a: 1, b: 2 })).toBe(fingerprint({ b: 2, a: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("differs when a value changes", () => {
|
||||||
|
expect(fingerprint({ a: 1 })).not.toBe(fingerprint({ a: 2 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("snapshotFingerprint", () => {
|
||||||
|
it("ignores fields outside the snapshot", () => {
|
||||||
|
const base = { authority: { root: "x" }, admin: { userDeny: new Set(["a"]) } };
|
||||||
|
expect(snapshotFingerprint(base)).toBe(
|
||||||
|
snapshotFingerprint(/** @type {any} */ ({ ...base, viewer: "someone" })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when administrative state changes", () => {
|
||||||
|
const before = snapshotFingerprint({ admin: { userDeny: new Set() } });
|
||||||
|
const after = snapshotFingerprint({ admin: { userDeny: new Set(["user:a"]) } });
|
||||||
|
expect(before).not.toBe(after);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when the policy changes", () => {
|
||||||
|
expect(snapshotFingerprint({ policy: { version: "1" } })).not.toBe(
|
||||||
|
snapshotFingerprint({ policy: { version: "2" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates a fully empty snapshot", () => {
|
||||||
|
expect(snapshotFingerprint({})).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+275
-385
@@ -1,428 +1,318 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BUILTIN_POLICY_PROFILES,
|
INTERACTION_LADDER,
|
||||||
createPolicyEffect,
|
NEUTRAL_POLICY,
|
||||||
createPolicyProfile,
|
RANKING_LADDER,
|
||||||
|
TRANSACTION_LADDER,
|
||||||
|
VISIBILITY_LADDER,
|
||||||
|
applyEffects,
|
||||||
|
applyThresholds,
|
||||||
|
composeDecisions,
|
||||||
|
createNeutralDecision,
|
||||||
createPolicyDefinition,
|
createPolicyDefinition,
|
||||||
createPolicyContext,
|
effectSeverity,
|
||||||
createGovernanceDecision,
|
maxEffect,
|
||||||
getPolicyEffect,
|
normalizeCategoryThresholds,
|
||||||
composeGovernanceDecisions
|
normalizeDimensionEffects,
|
||||||
|
normalizePolicyDefinition,
|
||||||
|
resolveProfile,
|
||||||
|
resolveThresholds,
|
||||||
} from "../src/policy.js";
|
} from "../src/policy.js";
|
||||||
|
import { createReason } from "../src/reasons.js";
|
||||||
|
|
||||||
describe("policy", () => {
|
describe("effect ladders", () => {
|
||||||
describe("BUILTIN_POLICY_PROFILES", () => {
|
it("orders visibility from permissive to severe", () => {
|
||||||
it("should define built-in policy profiles with effects", () => {
|
expect(VISIBILITY_LADDER).toEqual(["allow", "warn", "restrict", "hide", "deny"]);
|
||||||
expect(BUILTIN_POLICY_PROFILES).toHaveProperty("default");
|
});
|
||||||
expect(BUILTIN_POLICY_PROFILES).toHaveProperty("moderate");
|
|
||||||
expect(BUILTIN_POLICY_PROFILES).toHaveProperty("strict");
|
|
||||||
expect(BUILTIN_POLICY_PROFILES).toHaveProperty("commerce-default");
|
|
||||||
expect(BUILTIN_POLICY_PROFILES).toHaveProperty("commerce-transaction");
|
|
||||||
|
|
||||||
// Check that each profile has a name and effects
|
it("orders the remaining dimensions", () => {
|
||||||
for (const [name, profile] of Object.entries(BUILTIN_POLICY_PROFILES)) {
|
expect(RANKING_LADDER).toEqual(["normal", "downrank"]);
|
||||||
expect(profile.name).toBe(name);
|
expect(INTERACTION_LADDER).toEqual(["allow", "require-explicit-action", "deny"]);
|
||||||
expect(profile.effects).toBeDefined();
|
expect(TRANSACTION_LADDER).toEqual(["allow", "require-review", "deny"]);
|
||||||
expect(typeof profile.effects).toBe("object");
|
});
|
||||||
}
|
|
||||||
|
it("ranks effects by severity", () => {
|
||||||
|
expect(effectSeverity("visibility", "allow")).toBeLessThan(effectSeverity("visibility", "hide"));
|
||||||
|
expect(effectSeverity("visibility", "hide")).toBeLessThan(effectSeverity("visibility", "deny"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 for effects outside the ladder", () => {
|
||||||
|
expect(effectSeverity("visibility", "banish")).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for an unknown dimension", () => {
|
||||||
|
expect(() => effectSeverity(/** @type {any} */ ("vibes"), "allow")).toThrow(/Unknown decision dimension/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the more severe effect", () => {
|
||||||
|
expect(maxEffect("visibility", "allow", "hide")).toBe("hide");
|
||||||
|
expect(maxEffect("visibility", "deny", "warn")).toBe("deny");
|
||||||
|
expect(maxEffect("interaction", "allow", "require-explicit-action")).toBe("require-explicit-action");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the valid effect when one side is unknown", () => {
|
||||||
|
expect(maxEffect("visibility", "nonsense", "warn")).toBe("warn");
|
||||||
|
expect(maxEffect("visibility", "warn", "nonsense")).toBe("warn");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createPolicyEffect", () => {
|
describe("normalizeDimensionEffects", () => {
|
||||||
it("should create a valid policy effect", () => {
|
it("passes valid effects through", () => {
|
||||||
const effect = createPolicyEffect("hide", "User is blocked", { details: "more info" });
|
expect(normalizeDimensionEffects({ visibility: "hide", interaction: "deny" })).toEqual({
|
||||||
expect(effect).toEqual({
|
visibility: "hide",
|
||||||
effect: "hide",
|
interaction: "deny",
|
||||||
reason: "User is blocked",
|
|
||||||
details: { details: "more info" }
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create a policy effect without optional fields", () => {
|
it("rejects an effect from the wrong ladder", () => {
|
||||||
const effect = createPolicyEffect("allow");
|
expect(() => normalizeDimensionEffects({ interaction: /** @type {any} */ ("hide") })).toThrow(
|
||||||
expect(effect).toEqual({ effect: "allow" });
|
/invalid interaction effect/,
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid effect", () => {
|
|
||||||
expect(() => createPolicyEffect(/** @type {any} */ ("invalid"))).toThrow();
|
|
||||||
expect(() => createPolicyEffect(/** @type {any} */ (123))).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createPolicyProfile", () => {
|
|
||||||
it("should create a valid policy profile", () => {
|
|
||||||
const profile = createPolicyProfile("test-profile", {
|
|
||||||
"visibility": { effect: "hide", reason: "Hidden" },
|
|
||||||
"interaction": { effect: "deny", reason: "Denied" }
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(profile).toEqual({
|
|
||||||
name: "test-profile",
|
|
||||||
effects: {
|
|
||||||
"visibility": { effect: "hide", reason: "Hidden" },
|
|
||||||
"interaction": { effect: "deny", reason: "Denied" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid profile name", () => {
|
|
||||||
expect(() => createPolicyProfile("", {})).toThrow();
|
|
||||||
expect(() => createPolicyProfile(/** @type {any} */ (123), {})).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid effects", () => {
|
|
||||||
expect(() => createPolicyProfile("test-profile", /** @type {any} */ (null))).toThrow();
|
|
||||||
expect(() => createPolicyProfile("test-profile", /** @type {any} */ ("not-an-object"))).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid effect types", () => {
|
|
||||||
expect(() => createPolicyProfile("test-profile", {
|
|
||||||
"": { effect: "allow" }
|
|
||||||
})).toThrow();
|
|
||||||
|
|
||||||
expect(() => createPolicyProfile("test-profile", {
|
|
||||||
"visibility": /** @type {any} */ (null)
|
|
||||||
})).toThrow();
|
|
||||||
|
|
||||||
expect(() => createPolicyProfile("test-profile", {
|
|
||||||
"visibility": { effect: /** @type {any} */ ("invalid") }
|
|
||||||
})).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createPolicyDefinition", () => {
|
|
||||||
it("should create a valid policy definition", () => {
|
|
||||||
const definition = createPolicyDefinition(
|
|
||||||
"test-policy",
|
|
||||||
"Test Policy",
|
|
||||||
"A test policy",
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
name: "default",
|
|
||||||
effects: {
|
|
||||||
"visibility": { effect: "allow" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(definition).toEqual({
|
it("returns an empty object for missing input", () => {
|
||||||
id: "test-policy",
|
expect(normalizeDimensionEffects(/** @type {any} */ (undefined))).toEqual({});
|
||||||
name: "Test Policy",
|
|
||||||
description: "A test policy",
|
|
||||||
profiles: {
|
|
||||||
"default": {
|
|
||||||
name: "default",
|
|
||||||
effects: {
|
|
||||||
"visibility": { effect: "allow" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error for invalid ID", () => {
|
describe("normalizeCategoryThresholds", () => {
|
||||||
expect(() => createPolicyDefinition("", "Test Policy", "A test policy", {})).toThrow();
|
it("lowercases categories and floors values", () => {
|
||||||
expect(() => createPolicyDefinition(/** @type {any} */ (123), "Test Policy", "A test policy", {})).toThrow();
|
expect(normalizeCategoryThresholds({ SPAM: { hide: 5.7 } })).toEqual({ spam: { hide: 5 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw error for invalid name", () => {
|
it("drops non-numeric gates", () => {
|
||||||
expect(() => createPolicyDefinition("test-policy", "", "A test policy", {})).toThrow();
|
expect(normalizeCategoryThresholds({ spam: { hide: /** @type {any} */ ("many") } })).toEqual({
|
||||||
expect(() => createPolicyDefinition("test-policy", /** @type {any} */ (123), "A test policy", {})).toThrow();
|
spam: {},
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid description", () => {
|
|
||||||
expect(() => createPolicyDefinition("test-policy", "Test Policy", /** @type {any} */ (123), {})).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid profiles", () => {
|
|
||||||
expect(() => createPolicyDefinition("test-policy", "Test Policy", "A test policy", /** @type {any} */ (null))).toThrow();
|
|
||||||
expect(() => createPolicyDefinition("test-policy", "Test Policy", "A test policy", /** @type {any} */ ("not-an-object"))).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid profile names", () => {
|
|
||||||
expect(() => createPolicyDefinition(
|
|
||||||
"test-policy",
|
|
||||||
"Test Policy",
|
|
||||||
"A test policy",
|
|
||||||
{
|
|
||||||
"": {
|
|
||||||
name: "",
|
|
||||||
effects: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for profile name mismatch", () => {
|
|
||||||
expect(() => createPolicyDefinition(
|
|
||||||
"test-policy",
|
|
||||||
"Test Policy",
|
|
||||||
"A test policy",
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
name: "different-name",
|
|
||||||
effects: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)).toThrow();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createPolicyContext", () => {
|
it("rejects negative thresholds", () => {
|
||||||
it("should create a valid policy context", () => {
|
expect(() => normalizeCategoryThresholds({ spam: { hide: -1 } })).toThrow(/must be >= 0/);
|
||||||
const context = createPolicyContext("feed", "default");
|
|
||||||
expect(context).toEqual({
|
|
||||||
surface: "feed",
|
|
||||||
policyProfile: "default"
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create a policy context with enforcement options", () => {
|
describe("normalizePolicyDefinition", () => {
|
||||||
const context = createPolicyContext("feed", "default", {
|
const valid = {
|
||||||
hardHide: true,
|
id: "app",
|
||||||
allowOverrides: false
|
version: "1.0.0",
|
||||||
});
|
profiles: { feed: { name: "feed" } },
|
||||||
|
|
||||||
expect(context).toEqual({
|
|
||||||
surface: "feed",
|
|
||||||
policyProfile: "default",
|
|
||||||
enforcement: {
|
|
||||||
hardHide: true,
|
|
||||||
allowOverrides: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid surface", () => {
|
|
||||||
expect(() => createPolicyContext("", "default")).toThrow();
|
|
||||||
expect(() => createPolicyContext(/** @type {any} */ (123), "default")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid policy profile", () => {
|
|
||||||
expect(() => createPolicyContext("feed", "")).toThrow();
|
|
||||||
expect(() => createPolicyContext("feed", /** @type {any} */ (123))).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createGovernanceDecision", () => {
|
|
||||||
it("should create a valid governance decision", () => {
|
|
||||||
const decision = createGovernanceDecision(
|
|
||||||
{ effect: "hide", reason: "Hidden" },
|
|
||||||
{ effect: "deny", reason: "Denied" },
|
|
||||||
{ effect: "deny", reason: "Transaction denied" },
|
|
||||||
["reason1", "reason2"],
|
|
||||||
[{ evidence: "evidence1" }, { evidence: "evidence2" }],
|
|
||||||
{ metadata: "data" }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(decision).toEqual({
|
|
||||||
visibility: { effect: "hide", reason: "Hidden" },
|
|
||||||
interaction: { effect: "deny", reason: "Denied" },
|
|
||||||
transaction: { effect: "deny", reason: "Transaction denied" },
|
|
||||||
reasons: ["reason1", "reason2"],
|
|
||||||
evidence: [{ evidence: "evidence1" }, { evidence: "evidence2" }],
|
|
||||||
metadata: { metadata: "data" }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a governance decision without optional fields", () => {
|
|
||||||
const decision = createGovernanceDecision(
|
|
||||||
{ effect: "allow" },
|
|
||||||
{ effect: "allow" }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(decision).toEqual({
|
|
||||||
visibility: { effect: "allow" },
|
|
||||||
interaction: { effect: "allow" },
|
|
||||||
reasons: [],
|
|
||||||
evidence: []
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid visibility effect", () => {
|
|
||||||
expect(() => createGovernanceDecision(
|
|
||||||
/** @type {any} */ ({ effect: "invalid" }),
|
|
||||||
{ effect: "allow" }
|
|
||||||
)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid interaction effect", () => {
|
|
||||||
expect(() => createGovernanceDecision(
|
|
||||||
{ effect: "allow" },
|
|
||||||
/** @type {any} */ ({ effect: "invalid" })
|
|
||||||
)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid transaction effect", () => {
|
|
||||||
expect(() => createGovernanceDecision(
|
|
||||||
{ effect: "allow" },
|
|
||||||
{ effect: "allow" },
|
|
||||||
/** @type {any} */ ({ effect: "invalid" })
|
|
||||||
)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid reasons", () => {
|
|
||||||
expect(() => createGovernanceDecision(
|
|
||||||
{ effect: "allow" },
|
|
||||||
{ effect: "allow" },
|
|
||||||
undefined,
|
|
||||||
/** @type {any} */ ("not-an-array")
|
|
||||||
)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for invalid evidence", () => {
|
|
||||||
expect(() => createGovernanceDecision(
|
|
||||||
{ effect: "allow" },
|
|
||||||
{ effect: "allow" },
|
|
||||||
undefined,
|
|
||||||
[],
|
|
||||||
/** @type {any} */ ("not-an-array")
|
|
||||||
)).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getPolicyEffect", () => {
|
|
||||||
it("should return effect for built-in profiles", () => {
|
|
||||||
const effect = getPolicyEffect("strict", "visibility");
|
|
||||||
expect(effect).toEqual({ effect: "hide" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return effect for custom policies", () => {
|
|
||||||
const customPolicies = {
|
|
||||||
"custom-policy": {
|
|
||||||
id: "custom-policy",
|
|
||||||
name: "Custom Policy",
|
|
||||||
description: "A custom policy",
|
|
||||||
profiles: {
|
|
||||||
"custom-profile": {
|
|
||||||
name: "custom-profile",
|
|
||||||
effects: {
|
|
||||||
"visibility": { effect: "hide", reason: "Custom hidden" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const effect = getPolicyEffect("custom-profile", "visibility", /** @type {any} */ (customPolicies));
|
it("normalizes a minimal policy", () => {
|
||||||
expect(effect).toEqual({ effect: "hide", reason: "Custom hidden" });
|
const policy = normalizePolicyDefinition(valid);
|
||||||
|
expect(policy.id).toBe("app");
|
||||||
|
expect(policy.defaultProfile).toBe("feed");
|
||||||
|
expect(policy.profiles.feed.allowViewerOverride).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prefer custom policies over built-in profiles", () => {
|
it("requires an id and a version", () => {
|
||||||
const customPolicies = {
|
expect(() => normalizePolicyDefinition({ ...valid, id: "" })).toThrow(/non-empty id/);
|
||||||
"custom-policy": {
|
expect(() => normalizePolicyDefinition({ ...valid, version: "" })).toThrow(/version string/);
|
||||||
id: "custom-policy",
|
});
|
||||||
name: "Custom Policy",
|
|
||||||
description: "A custom policy",
|
it("requires at least one profile", () => {
|
||||||
profiles: {
|
expect(() => normalizePolicyDefinition({ ...valid, profiles: {} })).toThrow(/at least one profile/);
|
||||||
"default": {
|
});
|
||||||
name: "default",
|
|
||||||
effects: {
|
it("rejects a default profile that does not exist", () => {
|
||||||
"visibility": { effect: "hide", reason: "Custom default" }
|
expect(() => normalizePolicyDefinition({ ...valid, defaultProfile: "nope" })).toThrow(
|
||||||
}
|
/is not defined/,
|
||||||
}
|
);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
it("names a profile from its map key when the profile omits a name", () => {
|
||||||
|
const policy = normalizePolicyDefinition({
|
||||||
|
...valid,
|
||||||
|
profiles: { checkout: /** @type {any} */ ({}) },
|
||||||
|
});
|
||||||
|
expect(policy.profiles.checkout.name).toBe("checkout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the hide ceiling when bypassHide is set", () => {
|
||||||
|
const policy = normalizePolicyDefinition({
|
||||||
|
...valid,
|
||||||
|
profiles: { feed: { name: "feed", bypassHide: true } },
|
||||||
|
});
|
||||||
|
expect(policy.profiles.feed.bypassHideCeiling).toBe("restrict");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a negative mute window", () => {
|
||||||
|
expect(() =>
|
||||||
|
normalizePolicyDefinition({
|
||||||
|
...valid,
|
||||||
|
profiles: { feed: { name: "feed", muteWindowSeconds: -5 } },
|
||||||
|
}),
|
||||||
|
).toThrow(/muteWindowSeconds/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is exposed as createPolicyDefinition", () => {
|
||||||
|
expect(createPolicyDefinition(valid).id).toBe("app");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveProfile", () => {
|
||||||
|
const policy = createPolicyDefinition({
|
||||||
|
id: "app",
|
||||||
|
version: "1.0.0",
|
||||||
|
defaultProfile: "feed",
|
||||||
|
profiles: { feed: { name: "feed" }, checkout: { name: "checkout" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the requested profile", () => {
|
||||||
|
expect(resolveProfile(policy, { surface: "x", policyProfile: "checkout" }).name).toBe("checkout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default profile", () => {
|
||||||
|
expect(resolveProfile(policy, { surface: "x" }).name).toBe("feed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for an unknown profile rather than silently falling back", () => {
|
||||||
|
expect(() => resolveProfile(policy, { surface: "x", policyProfile: "ghost" })).toThrow(
|
||||||
|
/Unknown policy profile/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveThresholds", () => {
|
||||||
|
const table = { spam: { hide: 5 }, default: { restrict: 3 } };
|
||||||
|
|
||||||
|
it("prefers an exact category match", () => {
|
||||||
|
expect(resolveThresholds(table, "spam")).toEqual({ hide: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default entry", () => {
|
||||||
|
expect(resolveThresholds(table, "nudity")).toEqual({ restrict: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty object when nothing matches", () => {
|
||||||
|
expect(resolveThresholds({ spam: { hide: 5 } }, "nudity")).toEqual({});
|
||||||
|
expect(resolveThresholds(undefined, "spam")).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyThresholds", () => {
|
||||||
|
it("fires gates at or above the threshold", () => {
|
||||||
|
const { effects } = applyThresholds(3, { restrict: 3 });
|
||||||
|
expect(effects.visibility).toBe("restrict");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire below the threshold", () => {
|
||||||
|
const { effects, firedGates } = applyThresholds(2, { restrict: 3 });
|
||||||
|
expect(effects).toEqual({});
|
||||||
|
expect(firedGates).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the most severe gate when several fire", () => {
|
||||||
|
const { effects } = applyThresholds(10, { warn: 1, restrict: 3, hide: 5 });
|
||||||
|
expect(effects.visibility).toBe("hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a zero threshold as disabled rather than always-on", () => {
|
||||||
|
const { effects } = applyThresholds(5, { hide: 0 });
|
||||||
|
expect(effects).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-positive counts", () => {
|
||||||
|
expect(applyThresholds(0, { warn: 1 }).effects).toEqual({});
|
||||||
|
expect(applyThresholds(Number.NaN, { warn: 1 }).effects).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps gates onto their own dimensions", () => {
|
||||||
|
const { effects } = applyThresholds(5, {
|
||||||
|
downrank: 1,
|
||||||
|
restrict: 2,
|
||||||
|
requireExplicitAction: 3,
|
||||||
|
transactionDeny: 4,
|
||||||
|
});
|
||||||
|
expect(effects).toEqual({
|
||||||
|
ranking: "downrank",
|
||||||
|
visibility: "restrict",
|
||||||
|
interaction: "require-explicit-action",
|
||||||
|
transaction: "deny",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyEffects", () => {
|
||||||
|
it("escalates but never softens", () => {
|
||||||
|
const decision = createNeutralDecision();
|
||||||
|
applyEffects(decision, { visibility: "hide" });
|
||||||
|
applyEffects(decision, { visibility: "warn" });
|
||||||
|
expect(decision.visibility.effect).toBe("hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates downrank weight", () => {
|
||||||
|
const decision = createNeutralDecision();
|
||||||
|
applyEffects(decision, { ranking: "downrank" });
|
||||||
|
applyEffects(decision, { ranking: "downrank" });
|
||||||
|
expect(decision.ranking.weight).toBe(2);
|
||||||
|
expect(decision.ranking.effect).toBe("downrank");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the transaction dimension on demand", () => {
|
||||||
|
const decision = createNeutralDecision();
|
||||||
|
expect(decision.transaction).toBeUndefined();
|
||||||
|
applyEffects(decision, { transaction: "deny" });
|
||||||
|
expect(decision.transaction).toEqual({ effect: "deny" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates reasons", () => {
|
||||||
|
const decision = createNeutralDecision();
|
||||||
|
applyEffects(decision, {}, [createReason("viewer-block"), createReason("viewer-block")]);
|
||||||
|
expect(decision.reasons).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("composeDecisions", () => {
|
||||||
|
const withVisibility = (effect, weight = 0) => {
|
||||||
|
const decision = createNeutralDecision();
|
||||||
|
decision.visibility.effect = effect;
|
||||||
|
decision.ranking.weight = weight;
|
||||||
|
return decision;
|
||||||
};
|
};
|
||||||
|
|
||||||
const effect = getPolicyEffect("default", "visibility", /** @type {any} */ (customPolicies));
|
it("returns a neutral decision for an empty list", () => {
|
||||||
expect(effect).toEqual({ effect: "hide", reason: "Custom default" });
|
const composed = composeDecisions([]);
|
||||||
|
expect(composed.visibility.effect).toBe("allow");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return default allow effect for unknown profiles/effects", () => {
|
it("keeps the most severe effect", () => {
|
||||||
const effect = getPolicyEffect("unknown-profile", "unknown-effect");
|
expect(composeDecisions([withVisibility("warn"), withVisibility("hide")]).visibility.effect).toBe(
|
||||||
expect(effect).toEqual({ effect: "allow" });
|
"hide",
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("composeGovernanceDecisions", () => {
|
|
||||||
it("should return default allow decision for empty array", () => {
|
|
||||||
const decision = composeGovernanceDecisions([]);
|
|
||||||
expect(decision).toEqual({
|
|
||||||
visibility: { effect: "allow" },
|
|
||||||
interaction: { effect: "allow" },
|
|
||||||
reasons: [],
|
|
||||||
evidence: []
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return the single decision for array with one element", () => {
|
|
||||||
const inputDecision = createGovernanceDecision(
|
|
||||||
{ effect: "hide", reason: "Hidden" },
|
|
||||||
{ effect: "deny", reason: "Denied" },
|
|
||||||
undefined,
|
|
||||||
["reason1"],
|
|
||||||
[{ evidence: "evidence1" }]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const decision = composeGovernanceDecisions([inputDecision]);
|
|
||||||
expect(decision).toEqual(inputDecision);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should compose multiple decisions with most restrictive effects", () => {
|
it("is order-independent", () => {
|
||||||
const decisions = [
|
const a = composeDecisions([withVisibility("warn"), withVisibility("hide")]);
|
||||||
createGovernanceDecision(
|
const b = composeDecisions([withVisibility("hide"), withVisibility("warn")]);
|
||||||
{ effect: "allow" },
|
expect(a.visibility.effect).toBe(b.visibility.effect);
|
||||||
{ effect: "allow" }
|
|
||||||
),
|
|
||||||
createGovernanceDecision(
|
|
||||||
{ effect: "hide", reason: "Hidden" },
|
|
||||||
{ effect: "deny", reason: "Denied" }
|
|
||||||
),
|
|
||||||
createGovernanceDecision(
|
|
||||||
{ effect: "restrict", reason: "Restricted" },
|
|
||||||
{ effect: "allow" }
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
const decision = composeGovernanceDecisions(decisions);
|
|
||||||
expect(decision.visibility).toEqual({ effect: "hide", reason: "Hidden" });
|
|
||||||
expect(decision.interaction).toEqual({ effect: "deny", reason: "Denied" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should combine reasons and evidence from all decisions", () => {
|
it("sums ranking weight", () => {
|
||||||
const decisions = [
|
expect(composeDecisions([withVisibility("allow", 2), withVisibility("allow", 3)]).ranking.weight).toBe(5);
|
||||||
createGovernanceDecision(
|
|
||||||
{ effect: "allow" },
|
|
||||||
{ effect: "allow" },
|
|
||||||
undefined,
|
|
||||||
["reason1", "reason2"],
|
|
||||||
[{ evidence: "evidence1" }]
|
|
||||||
),
|
|
||||||
createGovernanceDecision(
|
|
||||||
{ effect: "hide" },
|
|
||||||
{ effect: "deny" },
|
|
||||||
undefined,
|
|
||||||
["reason2", "reason3"],
|
|
||||||
[{ evidence: "evidence2" }]
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
const decision = composeGovernanceDecisions(decisions);
|
|
||||||
expect(decision.reasons).toEqual(["reason1", "reason2", "reason3"]);
|
|
||||||
expect(decision.evidence).toEqual([
|
|
||||||
{ evidence: "evidence1" },
|
|
||||||
{ evidence: "evidence2" }
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should compose transaction effects when present", () => {
|
it("makes a decision non-overridable if any input was", () => {
|
||||||
const decisions = [
|
const locked = createNeutralDecision();
|
||||||
createGovernanceDecision(
|
locked.visibility.overridable = false;
|
||||||
{ effect: "allow" },
|
expect(composeDecisions([createNeutralDecision(), locked]).visibility.overridable).toBe(false);
|
||||||
{ effect: "allow" },
|
});
|
||||||
{ effect: "allow" }
|
|
||||||
),
|
|
||||||
createGovernanceDecision(
|
|
||||||
{ effect: "hide" },
|
|
||||||
{ effect: "deny" },
|
|
||||||
{ effect: "deny", reason: "Transaction denied" }
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
const decision = composeGovernanceDecisions(decisions);
|
it("does not mutate its inputs", () => {
|
||||||
expect(decision.transaction).toEqual({ effect: "deny", reason: "Transaction denied" });
|
const first = withVisibility("warn");
|
||||||
|
composeDecisions([first, withVisibility("deny")]);
|
||||||
|
expect(first.visibility.effect).toBe("warn");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NEUTRAL_POLICY", () => {
|
||||||
|
it("enforces administrative denial", () => {
|
||||||
|
expect(NEUTRAL_POLICY.profiles.default.administrativeDeny?.visibility).toBe("hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies no trust thresholds of its own", () => {
|
||||||
|
expect(NEUTRAL_POLICY.profiles.default.reports).toEqual({});
|
||||||
|
expect(NEUTRAL_POLICY.profiles.default.mutes).toEqual({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { REASON_IDS, createReason, dedupeReasons, isReasonId } from "../src/reasons.js";
|
||||||
|
|
||||||
|
describe("reason identifiers", () => {
|
||||||
|
it("exposes the documented set", () => {
|
||||||
|
expect(REASON_IDS).toContain("viewer-block");
|
||||||
|
expect(REASON_IDS).toContain("trusted-mute-threshold");
|
||||||
|
expect(REASON_IDS).toContain("surface-policy-bypass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no duplicates", () => {
|
||||||
|
expect(new Set(REASON_IDS).size).toBe(REASON_IDS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes known ids only", () => {
|
||||||
|
expect(isReasonId("viewer-block")).toBe(true);
|
||||||
|
expect(isReasonId("made-up")).toBe(false);
|
||||||
|
expect(isReasonId(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createReason", () => {
|
||||||
|
it("creates a bare reason", () => {
|
||||||
|
expect(createReason("viewer-block")).toEqual({ id: "viewer-block" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches detail fields", () => {
|
||||||
|
expect(
|
||||||
|
createReason("trusted-report-threshold", { category: " Spam ", count: 4, threshold: 3 }),
|
||||||
|
).toEqual({ id: "trusted-report-threshold", category: "Spam", count: 4, threshold: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits blank and non-numeric detail", () => {
|
||||||
|
expect(createReason("trusted-report", { category: " ", count: Number.NaN })).toEqual({
|
||||||
|
id: "trusted-report",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown identifier", () => {
|
||||||
|
expect(() => createReason(/** @type {any} */ ("invented"))).toThrow(/Unknown reason identifier/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dedupeReasons", () => {
|
||||||
|
it("collapses identical reasons", () => {
|
||||||
|
expect(dedupeReasons([createReason("viewer-block"), createReason("viewer-block")])).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps reasons that differ by category", () => {
|
||||||
|
const reasons = dedupeReasons([
|
||||||
|
createReason("trusted-report", { category: "spam" }),
|
||||||
|
createReason("trusted-report", { category: "nudity" }),
|
||||||
|
]);
|
||||||
|
expect(reasons).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the highest count when merging", () => {
|
||||||
|
const reasons = dedupeReasons([
|
||||||
|
createReason("trusted-report", { count: 2 }),
|
||||||
|
createReason("trusted-report", { count: 7 }),
|
||||||
|
]);
|
||||||
|
expect(reasons[0].count).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves first-seen order", () => {
|
||||||
|
const reasons = dedupeReasons([createReason("viewer-mute"), createReason("viewer-block")]);
|
||||||
|
expect(reasons.map((r) => r.id)).toEqual(["viewer-mute", "viewer-block"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops malformed entries", () => {
|
||||||
|
expect(
|
||||||
|
dedupeReasons(/** @type {any[]} */ ([null, { id: "not-real" }, createReason("viewer-block")])),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "@nostr-governance/testing",
|
"name": "@nostr-governance/testing",
|
||||||
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"main": "src/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./src/index.js"
|
||||||
|
},
|
||||||
|
"./conformance": {
|
||||||
|
"import": "./src/conformance.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nostr-governance/core": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,197 @@
|
|||||||
// Shared test utilities for conformance harness
|
// Conformance harness for @nostr-governance
|
||||||
export function createFixture(name, state, target, expectation) {
|
//
|
||||||
return {
|
// Turns a JSON fixture into a snapshot, evaluates it, and compares the decision
|
||||||
name,
|
// against the fixture's expectation. Expectations are partial by design: a
|
||||||
state: {
|
// fixture asserts the dimensions it characterizes and stays silent about the
|
||||||
adminState: state.adminState || {},
|
// rest, so adding a new decision field does not require touching every case.
|
||||||
trust: state.trust || {},
|
|
||||||
reports: state.reports || [],
|
import {
|
||||||
trustedMutes: state.trustedMutes || [],
|
createAuthorityState,
|
||||||
overrides: state.overrides || {},
|
createSnapshot,
|
||||||
},
|
createViewerState,
|
||||||
target,
|
evaluateTarget,
|
||||||
expectation,
|
reduceAdminState,
|
||||||
};
|
} from "@nostr-governance/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} FixtureExpectation
|
||||||
|
* @property {{ effect: string, weight?: number }} [ranking]
|
||||||
|
* @property {{ effect: string, overridable?: boolean }} [visibility]
|
||||||
|
* @property {{ effect: string }} [interaction]
|
||||||
|
* @property {{ effect: string }} [transaction]
|
||||||
|
* @property {string[]} [reasons] - Exact set of reason ids, order-insensitive
|
||||||
|
* @property {Record<string, number|boolean>} [evidence] - Subset of evidence fields
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a governance snapshot from a fixture.
|
||||||
|
* @param {Object} fixture
|
||||||
|
* @returns {{ snapshot: ReturnType<typeof createSnapshot>, viewer: ReturnType<typeof createViewerState> }}
|
||||||
|
*/
|
||||||
|
export function buildSnapshot(fixture) {
|
||||||
|
const authority = createAuthorityState({
|
||||||
|
root: fixture.authority?.root,
|
||||||
|
actors: fixture.authority?.actors,
|
||||||
|
protectedActors: fixture.authority?.protectedActors,
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = reduceAdminState(fixture.contributions ?? [], authority);
|
||||||
|
|
||||||
|
/** @type {Map<string, Array<{ reporter: string, category: string, createdAt?: number }>>} */
|
||||||
|
const reports = new Map();
|
||||||
|
for (const [key, records] of Object.entries(fixture.reports ?? {})) {
|
||||||
|
reports.set(key, records);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runConformanceTest(fixture, evaluator) {
|
/** @type {Map<string, Array<{ muter: string, category?: string, updatedAt?: number }>>} */
|
||||||
const decision = evaluator(fixture.state, fixture.target);
|
const trustedMutes = new Map();
|
||||||
return {
|
for (const [key, records] of Object.entries(fixture.trustedMutes ?? {})) {
|
||||||
passed: JSON.stringify(decision) === JSON.stringify(fixture.expectation),
|
trustedMutes.set(key, records);
|
||||||
actual: decision,
|
}
|
||||||
expected: fixture.expectation,
|
|
||||||
};
|
const snapshot = createSnapshot({
|
||||||
|
authority,
|
||||||
|
admin,
|
||||||
|
trust: {
|
||||||
|
contacts: new Set(fixture.trust?.contacts ?? []),
|
||||||
|
seeds: new Set(fixture.trust?.seeds ?? []),
|
||||||
|
},
|
||||||
|
reports,
|
||||||
|
trustedMutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @type {Map<string, { visibility: string, reason?: string }>} */
|
||||||
|
const overrides = new Map();
|
||||||
|
for (const entry of fixture.viewer?.overrides ?? []) {
|
||||||
|
overrides.set(entry.key, { visibility: entry.visibility, reason: entry.reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewer = createViewerState({
|
||||||
|
blocks: new Set(fixture.viewer?.blocks ?? []),
|
||||||
|
mutes: new Set(fixture.viewer?.mutes ?? []),
|
||||||
|
overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { snapshot, viewer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a fixture against a policy definition.
|
||||||
|
* @param {Object} fixture
|
||||||
|
* @param {import('@nostr-governance/core').PolicyDefinition} policy
|
||||||
|
* @returns {import('@nostr-governance/core').GovernanceDecision}
|
||||||
|
*/
|
||||||
|
export function evaluateFixture(fixture, policy) {
|
||||||
|
const { snapshot, viewer } = buildSnapshot(fixture);
|
||||||
|
return evaluateTarget(
|
||||||
|
fixture.target,
|
||||||
|
snapshot,
|
||||||
|
{ surface: fixture.profile, policyProfile: fixture.profile, policy, now: fixture.now },
|
||||||
|
viewer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare a decision against a fixture expectation.
|
||||||
|
* @param {import('@nostr-governance/core').GovernanceDecision} decision
|
||||||
|
* @param {FixtureExpectation} expectation
|
||||||
|
* @returns {string[]} Human-readable mismatches; empty when the fixture passes
|
||||||
|
*/
|
||||||
|
export function diffExpectation(decision, expectation) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const mismatches = [];
|
||||||
|
|
||||||
|
if (expectation.ranking) {
|
||||||
|
if (decision.ranking.effect !== expectation.ranking.effect) {
|
||||||
|
mismatches.push(
|
||||||
|
`ranking.effect: expected "${expectation.ranking.effect}", got "${decision.ranking.effect}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
expectation.ranking.weight !== undefined &&
|
||||||
|
decision.ranking.weight !== expectation.ranking.weight
|
||||||
|
) {
|
||||||
|
mismatches.push(
|
||||||
|
`ranking.weight: expected ${expectation.ranking.weight}, got ${decision.ranking.weight}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.visibility) {
|
||||||
|
if (decision.visibility.effect !== expectation.visibility.effect) {
|
||||||
|
mismatches.push(
|
||||||
|
`visibility.effect: expected "${expectation.visibility.effect}", got "${decision.visibility.effect}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
expectation.visibility.overridable !== undefined &&
|
||||||
|
decision.visibility.overridable !== expectation.visibility.overridable
|
||||||
|
) {
|
||||||
|
mismatches.push(
|
||||||
|
`visibility.overridable: expected ${expectation.visibility.overridable}, got ${decision.visibility.overridable}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.interaction && decision.interaction.effect !== expectation.interaction.effect) {
|
||||||
|
mismatches.push(
|
||||||
|
`interaction.effect: expected "${expectation.interaction.effect}", got "${decision.interaction.effect}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.transaction) {
|
||||||
|
const actual = decision.transaction?.effect ?? "(absent)";
|
||||||
|
if (actual !== expectation.transaction.effect) {
|
||||||
|
mismatches.push(
|
||||||
|
`transaction.effect: expected "${expectation.transaction.effect}", got "${actual}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.reasons) {
|
||||||
|
const actual = [...new Set(decision.reasons.map((reason) => reason.id))].sort();
|
||||||
|
const expected = [...new Set(expectation.reasons)].sort();
|
||||||
|
if (actual.join(",") !== expected.join(",")) {
|
||||||
|
mismatches.push(`reasons: expected [${expected.join(", ")}], got [${actual.join(", ")}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectation.evidence) {
|
||||||
|
const evidence = decision.evidence;
|
||||||
|
if (!evidence) {
|
||||||
|
mismatches.push("evidence: expected evidence on the decision, got none");
|
||||||
|
} else {
|
||||||
|
for (const [field, expected] of Object.entries(expectation.evidence)) {
|
||||||
|
// Pubkey lists are asserted by length: identity is covered elsewhere and
|
||||||
|
// spelling out 20 keys per fixture would obscure what the case is about.
|
||||||
|
if (field.endsWith("Count")) {
|
||||||
|
const listField = field.slice(0, -"Count".length);
|
||||||
|
const actual = /** @type {Record<string, unknown>} */ (evidence)[listField];
|
||||||
|
const length = Array.isArray(actual) ? actual.length : -1;
|
||||||
|
if (length !== expected) {
|
||||||
|
mismatches.push(`evidence.${listField}.length: expected ${expected}, got ${length}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const actual = /** @type {Record<string, unknown>} */ (evidence)[field];
|
||||||
|
if (actual !== expected) {
|
||||||
|
mismatches.push(`evidence.${field}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mismatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one fixture end to end.
|
||||||
|
* @param {Object} fixture
|
||||||
|
* @param {import('@nostr-governance/core').PolicyDefinition} policy
|
||||||
|
* @returns {{ passed: boolean, mismatches: string[], decision: import('@nostr-governance/core').GovernanceDecision }}
|
||||||
|
*/
|
||||||
|
export function runConformanceCase(fixture, policy) {
|
||||||
|
const decision = evaluateFixture(fixture, policy);
|
||||||
|
const mismatches = diffExpectation(decision, fixture.expect ?? {});
|
||||||
|
return { passed: mismatches.length === 0, mismatches, decision };
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,154 @@
|
|||||||
// Test the conformance utilities
|
import { describe, expect, it } from "vitest";
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { createFixture, runConformanceTest } from "../src/conformance.js";
|
|
||||||
|
|
||||||
describe("conformance utilities", () => {
|
import { createPolicyDefinition } from "@nostr-governance/core";
|
||||||
it("creates valid fixture objects", () => {
|
|
||||||
const fixture = createFixture(
|
import { buildSnapshot, diffExpectation, evaluateFixture, runConformanceCase } from "../src/conformance.js";
|
||||||
"test-fixture",
|
|
||||||
{
|
const ROOT = "a1".repeat(32);
|
||||||
adminState: { editors: [] },
|
const MODERATOR = "b2".repeat(32);
|
||||||
trust: {},
|
const CREATOR = "d4".repeat(32);
|
||||||
reports: [],
|
const TRUSTED = "01".repeat(32);
|
||||||
trustedMutes: [],
|
|
||||||
overrides: {},
|
const POLICY = createPolicyDefinition({
|
||||||
|
id: "harness",
|
||||||
|
version: "1.0.0",
|
||||||
|
defaultProfile: "feed",
|
||||||
|
profiles: {
|
||||||
|
feed: {
|
||||||
|
name: "feed",
|
||||||
|
administrativeDeny: { visibility: "hide", interaction: "deny" },
|
||||||
|
reports: { default: { restrict: 1 } },
|
||||||
|
mutes: {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ type: "user", pubkey: "test" },
|
|
||||||
{ visibility: "allow" }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fixture.name).toBe("test-fixture");
|
|
||||||
expect(fixture.state.adminState.editors).toEqual([]);
|
|
||||||
expect(fixture.target).toEqual({ type: "user", pubkey: "test" });
|
|
||||||
expect(fixture.expectation).toEqual({ visibility: "allow" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs conformance tests", () => {
|
const baseFixture = () => ({
|
||||||
const fixture = createFixture(
|
name: "sample",
|
||||||
"test-fixture",
|
profile: "feed",
|
||||||
{},
|
now: 1_750_000_000,
|
||||||
{ type: "user", pubkey: "test" },
|
authority: {
|
||||||
{ visibility: "allow" }
|
root: ROOT,
|
||||||
);
|
actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] },
|
||||||
|
protectedActors: [ROOT],
|
||||||
|
},
|
||||||
|
target: { type: "user", pubkey: CREATOR },
|
||||||
|
expect: {
|
||||||
|
visibility: { effect: "allow" },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Mock evaluator that always returns the expectation
|
describe("buildSnapshot", () => {
|
||||||
const mockEvaluator = () => ({ visibility: "allow" });
|
it("reduces contributions through the authority state", () => {
|
||||||
|
const fixture = {
|
||||||
|
...baseFixture(),
|
||||||
|
contributions: [
|
||||||
|
{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { snapshot } = buildSnapshot(fixture);
|
||||||
|
expect(snapshot.admin.userDeny.has(`user:${CREATOR}`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
const result = runConformanceTest(fixture, mockEvaluator);
|
it("converts viewer arrays into sets and maps", () => {
|
||||||
expect(result.passed).toBe(true);
|
const fixture = {
|
||||||
expect(result.actual).toEqual({ visibility: "allow" });
|
...baseFixture(),
|
||||||
expect(result.expected).toEqual({ visibility: "allow" });
|
viewer: {
|
||||||
|
blocks: [CREATOR],
|
||||||
|
mutes: [MODERATOR],
|
||||||
|
overrides: [{ key: `user:${CREATOR}`, visibility: "allow" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { viewer } = buildSnapshot(fixture);
|
||||||
|
expect(viewer.blocks?.has(CREATOR)).toBe(true);
|
||||||
|
expect(viewer.mutes?.has(MODERATOR)).toBe(true);
|
||||||
|
expect(viewer.overrides?.get(`user:${CREATOR}`)).toEqual({
|
||||||
|
visibility: "allow",
|
||||||
|
reason: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults every optional section", () => {
|
||||||
|
const { snapshot, viewer } = buildSnapshot(baseFixture());
|
||||||
|
expect(snapshot.reports?.size).toBe(0);
|
||||||
|
expect(snapshot.trustedMutes?.size).toBe(0);
|
||||||
|
expect(viewer.blocks?.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("evaluateFixture", () => {
|
||||||
|
it("evaluates against the fixture's profile", () => {
|
||||||
|
const decision = evaluateFixture(baseFixture(), POLICY);
|
||||||
|
expect(decision.policyProfile).toBe("feed");
|
||||||
|
expect(decision.visibility.effect).toBe("allow");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("diffExpectation", () => {
|
||||||
|
const decision = () => evaluateFixture(baseFixture(), POLICY);
|
||||||
|
|
||||||
|
it("reports no mismatches when the expectation holds", () => {
|
||||||
|
expect(diffExpectation(decision(), { visibility: { effect: "allow" } })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a visibility mismatch", () => {
|
||||||
|
const [mismatch] = diffExpectation(decision(), { visibility: { effect: "hide" } });
|
||||||
|
expect(mismatch).toMatch(/visibility.effect: expected "hide", got "allow"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a ranking weight mismatch", () => {
|
||||||
|
const [mismatch] = diffExpectation(decision(), { ranking: { effect: "normal", weight: 3 } });
|
||||||
|
expect(mismatch).toMatch(/ranking.weight/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares reasons order-insensitively", () => {
|
||||||
|
const fixture = {
|
||||||
|
...baseFixture(),
|
||||||
|
contributions: [
|
||||||
|
{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = evaluateFixture(fixture, POLICY);
|
||||||
|
expect(diffExpectation(result, { reasons: ["admin-user-deny"] })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing transaction dimension", () => {
|
||||||
|
const [mismatch] = diffExpectation(decision(), { transaction: { effect: "deny" } });
|
||||||
|
expect(mismatch).toMatch(/\(absent\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares evidence fields", () => {
|
||||||
|
const fixture = {
|
||||||
|
...baseFixture(),
|
||||||
|
trust: { contacts: [TRUSTED] },
|
||||||
|
reports: {
|
||||||
|
[`user:${CREATOR}`]: [{ reporter: TRUSTED, category: "spam", createdAt: 1_749_000_000 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = evaluateFixture(fixture, POLICY);
|
||||||
|
expect(diffExpectation(result, { evidence: { trustedReportTotal: 1 } })).toEqual([]);
|
||||||
|
expect(diffExpectation(result, { evidence: { trustedReportTotal: 5 } })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares pubkey lists by length via the Count suffix", () => {
|
||||||
|
const result = decision();
|
||||||
|
expect(diffExpectation(result, { evidence: { trustedReporterPubkeysCount: 0 } })).toEqual([]);
|
||||||
|
expect(diffExpectation(result, { evidence: { trustedReporterPubkeysCount: 2 } })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runConformanceCase", () => {
|
||||||
|
it("passes a fixture whose expectation holds", () => {
|
||||||
|
const { passed, mismatches } = runConformanceCase(baseFixture(), POLICY);
|
||||||
|
expect(passed).toBe(true);
|
||||||
|
expect(mismatches).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a fixture whose expectation does not hold", () => {
|
||||||
|
const fixture = { ...baseFixture(), expect: { visibility: { effect: "deny" } } };
|
||||||
|
const { passed, mismatches } = runConformanceCase(fixture, POLICY);
|
||||||
|
expect(passed).toBe(false);
|
||||||
|
expect(mismatches).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,800 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Generate the characterization corpus.
|
||||||
|
*
|
||||||
|
* Each case encodes one behavior read from the pinned reference commit (see
|
||||||
|
* docs/reference-map.md) as a snapshot, a target, and an expected decision.
|
||||||
|
* The corpus is generated rather than hand-written so that pubkeys stay
|
||||||
|
* consistent across cases and a behavior change shows up as a reviewable diff.
|
||||||
|
*
|
||||||
|
* Provenance: these fixtures are authored from the reference source and its
|
||||||
|
* documented configuration, not captured from a live relay. They pin the
|
||||||
|
* behavior the extraction must reproduce; they are not a recording of
|
||||||
|
* production traffic.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/build-characterization-corpus.mjs
|
||||||
|
*/
|
||||||
|
import { mkdir, writeFile, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const CASES_DIR = join(ROOT, "fixtures", "bitvid", "cases");
|
||||||
|
|
||||||
|
/** @param {string} seed */
|
||||||
|
const key = (seed) => seed.repeat(64).slice(0, 64);
|
||||||
|
|
||||||
|
const PUBKEYS = {
|
||||||
|
root: key("a1"),
|
||||||
|
moderator: key("b2"),
|
||||||
|
curator: key("c3"),
|
||||||
|
creator: key("d4"),
|
||||||
|
viewer: key("e5"),
|
||||||
|
stranger: key("f6"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted accounts used to reach report and mute thresholds.
|
||||||
|
* Each index gets its own two-hex-digit seed so that counting cases which need
|
||||||
|
* twenty distinct muters actually get twenty distinct pubkeys.
|
||||||
|
*/
|
||||||
|
const trusted = (index) => key(index.toString(16).padStart(2, "0"));
|
||||||
|
const TRUSTED = Array.from({ length: 24 }, (_, index) => trusted(index + 1));
|
||||||
|
|
||||||
|
const EVENT_ID = key("1b");
|
||||||
|
const OTHER_EVENT_ID = key("2c");
|
||||||
|
|
||||||
|
const DAY = 24 * 60 * 60;
|
||||||
|
const NOW = 1_750_000_000;
|
||||||
|
|
||||||
|
const userTarget = (pubkey) => ({ type: "user", pubkey });
|
||||||
|
const eventTarget = (id, author) => ({ type: "event", id, author });
|
||||||
|
const addressTarget = (kind, pubkey, identifier) => ({ type: "address", kind, pubkey, identifier });
|
||||||
|
|
||||||
|
/** Authority roster shared by every case. */
|
||||||
|
const AUTHORITY = {
|
||||||
|
root: PUBKEYS.root,
|
||||||
|
actors: {
|
||||||
|
[PUBKEYS.root]: ["super_admin"],
|
||||||
|
[PUBKEYS.moderator]: ["moderator"],
|
||||||
|
[PUBKEYS.curator]: ["curator"],
|
||||||
|
},
|
||||||
|
protectedActors: [PUBKEYS.root],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reports from N distinct trusted accounts in one category. */
|
||||||
|
const reportsFrom = (count, category, createdAt = NOW - DAY) =>
|
||||||
|
TRUSTED.slice(0, count).map((reporter) => ({ reporter, category, createdAt }));
|
||||||
|
|
||||||
|
/** Mutes from N distinct trusted accounts. */
|
||||||
|
const mutesFrom = (count, updatedAt = NOW - DAY) =>
|
||||||
|
TRUSTED.slice(0, count).map((muter) => ({ muter, updatedAt }));
|
||||||
|
|
||||||
|
/** Every trusted account follows the creator's critics. */
|
||||||
|
const trustAll = { contacts: TRUSTED };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} FixtureCase
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string} behavior - What the reference implementation does
|
||||||
|
* @property {string} profile
|
||||||
|
* @property {number} now
|
||||||
|
* @property {Object} authority
|
||||||
|
* @property {Array} [contributions]
|
||||||
|
* @property {Object} [trust]
|
||||||
|
* @property {Object} [viewer]
|
||||||
|
* @property {Object} [reports]
|
||||||
|
* @property {Object} [trustedMutes]
|
||||||
|
* @property {Object} target
|
||||||
|
* @property {Object} expect
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {FixtureCase[]} */
|
||||||
|
const CASES = [
|
||||||
|
{
|
||||||
|
name: "trusted-report-threshold-reached",
|
||||||
|
description: "Three trusted reports restrict visibility and withhold autoplay.",
|
||||||
|
behavior: "Blur threshold and autoplay-block threshold are both 3 trusted reports.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(3, "nudity") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "restrict", overridable: true },
|
||||||
|
interaction: { effect: "require-explicit-action" },
|
||||||
|
reasons: ["trusted-report", "trusted-report-threshold"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "trusted-report-below-threshold",
|
||||||
|
description: "Two trusted reports are not enough to restrict.",
|
||||||
|
behavior: "Counts below the blur threshold leave the target fully visible.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(2, "nudity") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
reasons: ["trusted-report"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "duplicate-reporter-dedupe",
|
||||||
|
description: "One account reporting the same category repeatedly counts once.",
|
||||||
|
behavior: "Reports are deduplicated per reporter and category before counting.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
reports: {
|
||||||
|
[`event:${EVENT_ID}`]: [
|
||||||
|
{ reporter: TRUSTED[0], category: "spam", createdAt: NOW - DAY },
|
||||||
|
{ reporter: TRUSTED[0], category: "spam", createdAt: NOW - 2 * DAY },
|
||||||
|
{ reporter: TRUSTED[0], category: "spam", createdAt: NOW - 3 * DAY },
|
||||||
|
{ reporter: TRUSTED[1], category: "spam", createdAt: NOW - DAY },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
reasons: ["trusted-report"],
|
||||||
|
evidence: { trustedReportTotal: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "blocked-reporter-ignored",
|
||||||
|
description: "Reports from accounts the viewer blocks do not count.",
|
||||||
|
behavior: "Blocked reporters are skipped before aggregation.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
viewer: { blocks: [TRUSTED[0], TRUSTED[1]] },
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(3, "nudity") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
evidence: { trustedReportTotal: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "denied-reporter-ignored",
|
||||||
|
description: "Reports from administratively denied accounts do not count.",
|
||||||
|
behavior: "Denied accounts are excluded from aggregation, so a denied account cannot manufacture reports.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "user-deny", targets: [userTarget(TRUSTED[0]), userTarget(TRUSTED[1])] },
|
||||||
|
],
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(3, "nudity") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
evidence: { trustedReportTotal: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "untrusted-reporter-ignored",
|
||||||
|
description: "Reports from outside the trust graph do not count.",
|
||||||
|
behavior: "Only reports from the viewer's trusted graph are counted.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: { contacts: [TRUSTED[0]] },
|
||||||
|
reports: {
|
||||||
|
[`event:${EVENT_ID}`]: [
|
||||||
|
{ reporter: TRUSTED[0], category: "nudity", createdAt: NOW - DAY },
|
||||||
|
{ reporter: PUBKEYS.stranger, category: "nudity", createdAt: NOW - DAY },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
evidence: { trustedReportTotal: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "spam-report-hide-threshold",
|
||||||
|
description: "Five trusted spam reports escalate to a hide.",
|
||||||
|
behavior: "Spam carries its own higher hide threshold; other categories do not hide on reports alone.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(5, "spam") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "require-explicit-action" },
|
||||||
|
reasons: ["trusted-report", "trusted-report-threshold"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "non-spam-reports-never-hide",
|
||||||
|
description: "Eight trusted reports in a non-spam category restrict but never hide.",
|
||||||
|
behavior: "Only spam has a report-driven hide threshold.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(8, "misleading") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "restrict", overridable: true },
|
||||||
|
interaction: { effect: "require-explicit-action" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "trusted-mute-below-threshold",
|
||||||
|
description: "Nineteen trusted mutes downrank only.",
|
||||||
|
behavior: "Below the hide threshold a trusted mute is a ranking signal: no blur, no playback block, no denial.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
trustedMutes: { [`user:${PUBKEYS.creator}`]: mutesFrom(19) },
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "downrank", weight: 1 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
reasons: ["trusted-mute", "trusted-mute-threshold"],
|
||||||
|
evidence: { trustedMuteTotal: 19 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "trusted-mute-at-threshold",
|
||||||
|
description: "Twenty trusted mutes escalate to a reversible hide.",
|
||||||
|
behavior: "The hide threshold is 20 unique trusted muters.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
trustedMutes: { [`user:${PUBKEYS.creator}`]: mutesFrom(20) },
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "downrank", weight: 1 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
evidence: { trustedMuteTotal: 20 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "expired-trusted-mute",
|
||||||
|
description: "Mutes older than the 60-day window are ignored.",
|
||||||
|
behavior: "Stale mute lists must not hold a target down indefinitely.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
trustedMutes: {
|
||||||
|
[`user:${PUBKEYS.creator}`]: [
|
||||||
|
...mutesFrom(20, NOW - 61 * DAY),
|
||||||
|
...mutesFrom(2, NOW - DAY).map((entry, index) => ({ ...entry, muter: TRUSTED[20 + index] })),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "downrank", weight: 1 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
evidence: { trustedMuteTotal: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "duplicate-muter-dedupe",
|
||||||
|
description: "The same account muting repeatedly counts once.",
|
||||||
|
behavior: "Muters are counted uniquely.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
trustedMutes: {
|
||||||
|
[`user:${PUBKEYS.creator}`]: [
|
||||||
|
{ muter: TRUSTED[0], updatedAt: NOW - DAY },
|
||||||
|
{ muter: TRUSTED[0], updatedAt: NOW - 2 * DAY },
|
||||||
|
{ muter: TRUSTED[1], updatedAt: NOW - DAY },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "downrank", weight: 1 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
evidence: { trustedMuteTotal: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "personal-block-precedence",
|
||||||
|
description: "A viewer's own block hides the target regardless of other state.",
|
||||||
|
behavior: "Viewer blocks are viewer-local and always enforced.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
viewer: { blocks: [PUBKEYS.creator] },
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["viewer-block"],
|
||||||
|
evidence: { personalBlock: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "admin-user-deny",
|
||||||
|
description: "A moderator's user denial hides the creator.",
|
||||||
|
behavior: "Administrative denial applies the profile's administrativeDeny effects.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "user-deny", targets: [userTarget(PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["admin-user-deny"],
|
||||||
|
evidence: { userDenied: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "admin-event-deny",
|
||||||
|
description: "An exact event denial hides only that event.",
|
||||||
|
behavior: "Event denial applies to the named event and does not travel to the author.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "event-deny", targets: [eventTarget(EVENT_ID, PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
target: eventTarget(OTHER_EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
reasons: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "admin-event-deny-applies",
|
||||||
|
description: "The denied event itself is hidden.",
|
||||||
|
behavior: "Exact-event denial hides the named event.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "event-deny", targets: [eventTarget(EVENT_ID, PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["admin-event-deny"],
|
||||||
|
evidence: { eventDenied: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "author-deny-reaches-events",
|
||||||
|
description: "Denying a creator hides that creator's events.",
|
||||||
|
behavior: "Author-level denial reaches the author's content.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "user-deny", targets: [userTarget(PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["admin-user-deny"],
|
||||||
|
evidence: { userDenied: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "admin-address-deny",
|
||||||
|
description: "Denying an address hides it across revisions.",
|
||||||
|
behavior: "Address denial is keyed by coordinate, so republishing does not clear it.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
actor: PUBKEYS.moderator,
|
||||||
|
kind: "address-deny",
|
||||||
|
targets: [addressTarget("30023", PUBKEYS.creator, "listing-1")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
target: addressTarget("30023", PUBKEYS.creator, "listing-1"),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["admin-address-deny"],
|
||||||
|
evidence: { addressDenied: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "community-blacklist-merge",
|
||||||
|
description: "A curator's denial is honored without moderator authority.",
|
||||||
|
behavior: "Curators hold contribute-user-deny only; their entries merge as a union.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
actor: PUBKEYS.curator,
|
||||||
|
kind: "user-deny",
|
||||||
|
source: "community-list-1",
|
||||||
|
targets: [userTarget(PUBKEYS.creator)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["admin-user-deny", "community-user-deny"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "curator-cannot-deny-events",
|
||||||
|
description: "A curator's event denial is ignored.",
|
||||||
|
behavior: "Contributions beyond an actor's capabilities are dropped at merge time.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.curator, kind: "event-deny", targets: [eventTarget(EVENT_ID, PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
reasons: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "revoked-actor-contribution-dropped",
|
||||||
|
description: "An actor with no roles cannot deny anyone.",
|
||||||
|
behavior: "Revocation takes effect immediately because capabilities resolve at merge time.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: { root: PUBKEYS.root, actors: { [PUBKEYS.root]: ["super_admin"] }, protectedActors: [PUBKEYS.root] },
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "user-deny", targets: [userTarget(PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
reasons: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "protected-target",
|
||||||
|
description: "The root administrator cannot be denied by contributor lists.",
|
||||||
|
behavior: "Protected actors are stripped from every denial set.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "user-deny", targets: [userTarget(PUBKEYS.root)] },
|
||||||
|
],
|
||||||
|
target: userTarget(PUBKEYS.root),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
evidence: { protectedTarget: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "moderator-trust-seed",
|
||||||
|
description: "An anonymous viewer inherits trust seeds.",
|
||||||
|
behavior: "Seeds are the fallback trust graph when the viewer follows nobody.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: { contacts: [], seeds: TRUSTED.slice(0, 3) },
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(3, "nudity") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "restrict", overridable: true },
|
||||||
|
interaction: { effect: "require-explicit-action" },
|
||||||
|
evidence: { trustedReportTotal: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "seeds-yield-to-real-contacts",
|
||||||
|
description: "Once the viewer has contacts, seeds stop contributing.",
|
||||||
|
behavior: "Following nobody must not be equivalent to following the seed set.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: { contacts: [TRUSTED[0]], seeds: TRUSTED.slice(0, 3) },
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(3, "nudity") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
evidence: { trustedReportTotal: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "viewer-override-softens-hide",
|
||||||
|
description: "A viewer override reveals hidden content behind an explicit action.",
|
||||||
|
behavior: "Overrides may soften a decision and force explicit interaction; they never escalate.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
viewer: { overrides: [{ key: `user:${PUBKEYS.creator}`, visibility: "allow" }] },
|
||||||
|
trustedMutes: { [`user:${PUBKEYS.creator}`]: mutesFrom(20) },
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "downrank", weight: 1 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "require-explicit-action" },
|
||||||
|
reasons: ["trusted-mute", "trusted-mute-threshold", "viewer-override"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "viewer-override-cannot-escalate",
|
||||||
|
description: "An override asking for a harsher effect is ignored.",
|
||||||
|
behavior: "Overrides only soften.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
viewer: { overrides: [{ key: `user:${PUBKEYS.creator}`, visibility: "deny" }] },
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
reasons: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "author-override",
|
||||||
|
description: "An override on the author does not leak to the author's events.",
|
||||||
|
behavior: "Overrides are keyed by exact target.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "user-deny", targets: [userTarget(PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
viewer: { overrides: [{ key: `user:${PUBKEYS.creator}`, visibility: "allow" }] },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["admin-user-deny"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "home-hide-bypass",
|
||||||
|
description: "The home surface downranks instead of hiding.",
|
||||||
|
behavior: "Home and Recent decline to hard-hide so one stale list cannot empty the feed.",
|
||||||
|
profile: "home",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
trustedMutes: { [`user:${PUBKEYS.creator}`]: mutesFrom(20) },
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "downrank", weight: 1 },
|
||||||
|
visibility: { effect: "restrict", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
reasons: ["trusted-mute", "trusted-mute-threshold", "surface-policy-bypass"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "recent-hide-bypass",
|
||||||
|
description: "The recent surface applies the same exception as home.",
|
||||||
|
behavior: "Recent shares the home surface exception.",
|
||||||
|
profile: "recent",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "user-deny", targets: [userTarget(PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "restrict", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["admin-user-deny", "surface-policy-bypass"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "playback-enforces-hide",
|
||||||
|
description: "Playback enforces the full decision with no surface exception.",
|
||||||
|
behavior: "The hide bypass is a discovery-surface concession, not a global one.",
|
||||||
|
profile: "playback",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "user-deny", targets: [userTarget(PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "deny", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["admin-user-deny"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "playback-exposes-evidence",
|
||||||
|
description: "The playback surface exposes reporter pubkeys; discovery surfaces do not.",
|
||||||
|
behavior: "exposeEvidence controls whether contributor pubkeys reach the consumer.",
|
||||||
|
profile: "playback",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(3, "nudity") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "restrict", overridable: true },
|
||||||
|
interaction: { effect: "require-explicit-action" },
|
||||||
|
evidence: { trustedReportTotal: 3, trustedReporterPubkeysCount: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "evidence-redacted-by-default",
|
||||||
|
description: "Feed surfaces report counts but not who produced them.",
|
||||||
|
behavior: "Reporter pubkeys are withheld unless the profile opts in.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
reports: { [`event:${EVENT_ID}`]: reportsFrom(3, "nudity") },
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "restrict", overridable: true },
|
||||||
|
interaction: { effect: "require-explicit-action" },
|
||||||
|
evidence: { trustedReportTotal: 3, trustedReporterPubkeysCount: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "cache-fallback-effective",
|
||||||
|
description: "State reduced from a cached snapshot decides identically to live state.",
|
||||||
|
behavior: "Administrative state stays effective when relays are unreachable.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
fromCache: true,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
contributions: [
|
||||||
|
{ actor: PUBKEYS.moderator, kind: "user-deny", targets: [userTarget(PUBKEYS.creator)] },
|
||||||
|
],
|
||||||
|
target: userTarget(PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "hide", overridable: true },
|
||||||
|
interaction: { effect: "deny" },
|
||||||
|
reasons: ["admin-user-deny"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "clean-target-allowed",
|
||||||
|
description: "A target with no signals against it is fully allowed.",
|
||||||
|
behavior: "Governance is opt-in per signal; absence of evidence is not suspicion.",
|
||||||
|
profile: "feed",
|
||||||
|
now: NOW,
|
||||||
|
authority: AUTHORITY,
|
||||||
|
trust: trustAll,
|
||||||
|
target: eventTarget(EVENT_ID, PUBKEYS.creator),
|
||||||
|
expect: {
|
||||||
|
ranking: { effect: "normal", weight: 0 },
|
||||||
|
visibility: { effect: "allow", overridable: true },
|
||||||
|
interaction: { effect: "allow" },
|
||||||
|
reasons: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(CASES_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Drop stale cases so a renamed fixture cannot linger and pass silently.
|
||||||
|
for (const entry of await readdir(CASES_DIR).catch(() => [])) {
|
||||||
|
if (entry.endsWith(".json")) {
|
||||||
|
await unlink(join(CASES_DIR, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = new Set();
|
||||||
|
for (const testCase of CASES) {
|
||||||
|
if (names.has(testCase.name)) {
|
||||||
|
throw new Error(`Duplicate fixture name: ${testCase.name}`);
|
||||||
|
}
|
||||||
|
names.add(testCase.name);
|
||||||
|
await writeFile(
|
||||||
|
join(CASES_DIR, `${testCase.name}.json`),
|
||||||
|
`${JSON.stringify(testCase, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
join(ROOT, "fixtures", "bitvid", "characterization-cases.json"),
|
||||||
|
`${JSON.stringify(CASES.map((entry) => entry.name), null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✓ Wrote ${CASES.length} characterization cases to fixtures/bitvid/cases`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("build-characterization-corpus failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,47 +1,84 @@
|
|||||||
// Test harness for BitVid parity cases
|
// Conformance suite for the characterization corpus.
|
||||||
import { describe, it, expect } from "vitest";
|
//
|
||||||
|
// Every fixture under fixtures/bitvid/cases is evaluated against the reference
|
||||||
|
// application profile. A change to a threshold, a precedence rule, or an effect
|
||||||
|
// ladder must show up here as a failing case.
|
||||||
|
|
||||||
// BitVid parity characterization cases
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
const BITVID_CHARACTERIZATION_CASES = [
|
import { dirname, join } from "node:path";
|
||||||
"personal-block-precedence",
|
import { fileURLToPath } from "node:url";
|
||||||
"user-deny",
|
import { describe, expect, it } from "vitest";
|
||||||
"event-deny",
|
|
||||||
"address-deny",
|
import { runConformanceCase } from "@nostr-governance/testing/conformance";
|
||||||
"allowlist-miss",
|
import { REFERENCE_POLICY } from "@nostr-governance/bitvid-compat/profile";
|
||||||
"protected-target",
|
|
||||||
|
const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
||||||
|
const CASES_DIR = join(ROOT, "fixtures", "bitvid", "cases");
|
||||||
|
const CASE_INDEX = join(ROOT, "fixtures", "bitvid", "characterization-cases.json");
|
||||||
|
|
||||||
|
const caseFiles = readdirSync(CASES_DIR).filter((name) => name.endsWith(".json")).sort();
|
||||||
|
const fixtures = caseFiles.map((name) => JSON.parse(readFileSync(join(CASES_DIR, name), "utf8")));
|
||||||
|
|
||||||
|
describe("characterization corpus", () => {
|
||||||
|
it("has fixtures on disk", () => {
|
||||||
|
expect(fixtures.length).toBeGreaterThanOrEqual(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("index matches the files on disk", () => {
|
||||||
|
const index = JSON.parse(readFileSync(CASE_INDEX, "utf8"));
|
||||||
|
expect([...index].sort()).toEqual(fixtures.map((fixture) => fixture.name).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers every behavior the migration plan requires", () => {
|
||||||
|
const names = new Set(fixtures.map((fixture) => fixture.name));
|
||||||
|
const required = [
|
||||||
"trusted-report-threshold-reached",
|
"trusted-report-threshold-reached",
|
||||||
"duplicate-reporter-dedupe",
|
"duplicate-reporter-dedupe",
|
||||||
"blocked-reporter-ignored",
|
"blocked-reporter-ignored",
|
||||||
"trusted-mute-below-threshold",
|
"trusted-mute-below-threshold",
|
||||||
"trusted-mute-at-threshold",
|
"trusted-mute-at-threshold",
|
||||||
"expired-trusted-mute",
|
"expired-trusted-mute",
|
||||||
"viewer-override",
|
"personal-block-precedence",
|
||||||
|
"admin-user-deny",
|
||||||
|
"admin-event-deny",
|
||||||
|
"community-blacklist-merge",
|
||||||
|
"moderator-trust-seed",
|
||||||
|
"viewer-override-softens-hide",
|
||||||
"author-override",
|
"author-override",
|
||||||
|
"home-hide-bypass",
|
||||||
"cache-fallback-effective",
|
"cache-fallback-effective",
|
||||||
"report-category-thresholds",
|
|
||||||
"mute-aggregation-rules",
|
|
||||||
"hide-vs-restrict-vs-downrank",
|
|
||||||
"moderation-decorator-fields"
|
|
||||||
];
|
];
|
||||||
|
for (const name of required) {
|
||||||
describe("BitVid parity characterization", () => {
|
expect(names, `missing required characterization case: ${name}`).toContain(name);
|
||||||
// This suite will grow as we add real fixtures
|
}
|
||||||
// For now, just ensure the harness loads correctly
|
});
|
||||||
|
|
||||||
it("has a list of characterization cases", () => {
|
|
||||||
expect(Array.isArray(BITVID_CHARACTERIZATION_CASES)).toBe(true);
|
|
||||||
expect(BITVID_CHARACTERIZATION_CASES.length).toBe(19);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Placeholder for when we have real fixtures
|
describe.each(fixtures.map((fixture) => [fixture.name, fixture]))("%s", (_name, fixture) => {
|
||||||
describe.skip("trusted-report-threshold-reached", () => {
|
it(fixture.description, () => {
|
||||||
it("should hide targets with sufficient trusted reports", async () => {
|
const { passed, mismatches, decision } = runConformanceCase(fixture, REFERENCE_POLICY);
|
||||||
// Load fixture
|
expect(passed, `${fixture.behavior}\n ${mismatches.join("\n ")}`).toBe(true);
|
||||||
// const fixture = await import("../../fixtures/bitvid/trusted-report-threshold.fixture.js");
|
expect(decision.policyProfile).toBe(fixture.profile);
|
||||||
// Run through evaluator
|
expect(decision.policyVersion).toBe(REFERENCE_POLICY.version);
|
||||||
// const decision = evaluate(fixture.state, fixture.target);
|
expect(decision.snapshotFingerprint).toMatch(/^[0-9a-f]{16}$/);
|
||||||
// Compare to expectation
|
|
||||||
// expect(decision).toEqual(fixture.expectation);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("evaluation determinism", () => {
|
||||||
|
it("produces identical decisions for identical snapshots", () => {
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
const first = runConformanceCase(fixture, REFERENCE_POLICY).decision;
|
||||||
|
const second = runConformanceCase(fixture, REFERENCE_POLICY).decision;
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(second.snapshotFingerprint).toBe(first.snapshotFingerprint);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate fixture inputs", () => {
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
const before = JSON.stringify(fixture);
|
||||||
|
runConformanceCase(fixture, REFERENCE_POLICY);
|
||||||
|
expect(JSON.stringify(fixture)).toBe(before);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user