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-below-threshold",
|
||||
"duplicate-reporter-dedupe",
|
||||
"blocked-reporter-ignored",
|
||||
"denied-reporter-ignored",
|
||||
"untrusted-reporter-ignored",
|
||||
"spam-report-hide-threshold",
|
||||
"non-spam-reports-never-hide",
|
||||
"trusted-mute-below-threshold",
|
||||
"trusted-mute-at-threshold",
|
||||
"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",
|
||||
"home-hide-bypass",
|
||||
"recent-hide-bypass",
|
||||
"playback-enforces-hide",
|
||||
"playback-exposes-evidence",
|
||||
"evidence-redacted-by-default",
|
||||
"cache-fallback-effective",
|
||||
"report-category-thresholds",
|
||||
"mute-aggregation-rules",
|
||||
"hide-vs-restrict-vs-downrank",
|
||||
"moderation-decorator-fields"
|
||||
"clean-target-allowed"
|
||||
]
|
||||
Generated
+5
-1
@@ -1894,7 +1894,11 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"import": "./src/index.js"
|
||||
},
|
||||
"./profile": {
|
||||
"types": "./dist/types/src/profile.d.ts",
|
||||
"import": "./src/profile.js"
|
||||
}
|
||||
},
|
||||
"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
|
||||
// validation for the governance system.
|
||||
// Root-authorized contributor model: the root administrator publishes the role
|
||||
// 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
|
||||
* @property {string} name - Role name
|
||||
* @property {string[]} capabilities - Array of capability names this role has
|
||||
* @typedef {"manage-roles"
|
||||
* | "manage-policy"
|
||||
* | "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
|
||||
* @property {string} pubkey - 64-character hex pubkey of the actor
|
||||
* @property {string[]} roles - Array of role names assigned to this actor
|
||||
* @param {GovernanceCapability[]} capabilities
|
||||
* @returns {readonly GovernanceCapability[]}
|
||||
*/
|
||||
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
|
||||
* @property {Record<string, RoleDefinition>} roles - Map of role names to definitions
|
||||
* @property {Record<string, Actor>} actors - Map of pubkeys to actors
|
||||
* @property {Record<string, readonly GovernanceCapability[]>} roles - Role name to capabilities
|
||||
* @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
|
||||
* @type {Record<string, RoleDefinition>}
|
||||
* Check whether a value is a known capability.
|
||||
* @param {unknown} value
|
||||
* @returns {value is GovernanceCapability}
|
||||
*/
|
||||
export const BUILTIN_ROLES = {
|
||||
"root": {
|
||||
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"
|
||||
]
|
||||
export function isGovernanceCapability(value) {
|
||||
return typeof value === "string" && CAPABILITY_SET.has(/** @type {GovernanceCapability} */ (value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {string} roleName
|
||||
* @param {AuthorityState} [authorityState]
|
||||
* Get the role names assigned to an actor.
|
||||
* @param {string} pubkey
|
||||
* @param {AuthorityState} authority
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getRoleCapabilities(roleName, authorityState) {
|
||||
// Check custom roles first
|
||||
if (authorityState && authorityState.roles[roleName]) {
|
||||
return authorityState.roles[roleName].capabilities;
|
||||
export function getActorRoles(pubkey, authority) {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
return [...(authority.actors[normalized] ?? [])];
|
||||
}
|
||||
|
||||
// Check built-in roles
|
||||
if (BUILTIN_ROLES[roleName]) {
|
||||
return BUILTIN_ROLES[roleName].capabilities;
|
||||
}
|
||||
|
||||
// Unknown role has no capabilities
|
||||
/**
|
||||
* Get every capability an actor holds, resolved through their roles.
|
||||
* @param {string} pubkey
|
||||
* @param {AuthorityState} authority
|
||||
* @returns {GovernanceCapability[]}
|
||||
*/
|
||||
export function getActorCapabilities(pubkey, authority) {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an actor has a specific role
|
||||
* @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);
|
||||
if (authority.root && normalized === authority.root) {
|
||||
return [...GOVERNANCE_CAPABILITIES];
|
||||
}
|
||||
|
||||
// No actor found means no roles
|
||||
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
|
||||
/** @type {Set<GovernanceCapability>} */
|
||||
const capabilities = new Set();
|
||||
for (const role of roles) {
|
||||
const roleCapabilities = getRoleCapabilities(role, authorityState);
|
||||
for (const capability of roleCapabilities) {
|
||||
for (const roleName of authority.actors[normalized] ?? []) {
|
||||
for (const capability of authority.roles[roleName] ?? []) {
|
||||
capabilities.add(capability);
|
||||
}
|
||||
}
|
||||
@@ -180,99 +196,63 @@ export function getActorCapabilities(pubkey, authorityState) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new role definition
|
||||
* @param {string} name - Role name
|
||||
* @param {string[]} capabilities - Array of capability names
|
||||
* @returns {RoleDefinition}
|
||||
* Check whether an actor holds a capability.
|
||||
* @param {string} pubkey
|
||||
* @param {GovernanceCapability} capability
|
||||
* @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) {
|
||||
// Validate input
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
throw new Error("Role name must be a non-empty string");
|
||||
}
|
||||
|
||||
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) {
|
||||
if (typeof capability !== "string" || !capability.trim()) {
|
||||
throw new Error("All capabilities must be non-empty strings");
|
||||
if (!isGovernanceCapability(capability)) {
|
||||
throw new Error(`Unknown governance capability: ${String(capability)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
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
|
||||
};
|
||||
return { name: name.trim(), capabilities: [...new Set(capabilities)] };
|
||||
}
|
||||
+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
|
||||
// decisions for targets based on state and policies.
|
||||
// This is the single place policy is decided. Applications collect targets and
|
||||
// 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 { areTargetsEqual, isDescendantOf } from "./targets.js";
|
||||
import { hasCapability } from "./authority.js";
|
||||
import { isValidTarget } from "./targets.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 {
|
||||
createGovernanceDecision,
|
||||
getPolicyEffect,
|
||||
composeGovernanceDecisions
|
||||
NEUTRAL_POLICY,
|
||||
applyEffects,
|
||||
applyThresholds,
|
||||
createNeutralDecision,
|
||||
effectSeverity,
|
||||
resolveProfile,
|
||||
resolveThresholds,
|
||||
} from "./policy.js";
|
||||
import { snapshotFingerprint } from "./fingerprint.js";
|
||||
|
||||
/**
|
||||
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
|
||||
* @typedef {import('./authority.js').AuthorityState} AuthorityState
|
||||
* @typedef {import('./adminState.js').AdminState} AdminState
|
||||
* @typedef {import('./policy.js').PolicyContext} PolicyContext
|
||||
* @typedef {import('./policy.js').PolicyProfile} PolicyProfile
|
||||
* @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
|
||||
* @property {string} reporter - Pubkey of the reporter
|
||||
* @property {GovernanceTarget} target - Target being reported
|
||||
* @typedef {Object} ReportRecord
|
||||
* @property {string} reporter - Reporter pubkey
|
||||
* @property {string} category - Report category
|
||||
* @property {number} timestamp - Unix timestamp of the report
|
||||
* @property {number} [createdAt] - Unix seconds
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TrustedMute
|
||||
* @property {GovernanceTarget} target - Target being muted
|
||||
* @property {number} count - Number of trusted mutes
|
||||
* @property {Record<string, number>} categories - Count of mutes per category
|
||||
* @typedef {Object} MuteRecord
|
||||
* @property {string} muter - Muting pubkey
|
||||
* @property {string} [category] - Optional mute category
|
||||
* @property {number} [updatedAt] - Unix seconds; compared against the mute window
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Override
|
||||
* @property {GovernanceTarget} target - Target being overridden
|
||||
* @property {"allow"|"restrict"|"hide"|"deny"} visibility - Visibility override
|
||||
* @property {string} [reason] - Reason for the override
|
||||
* @typedef {Object} ViewerState
|
||||
* @property {Set<string>} [blocks] - Pubkeys the viewer blocks outright
|
||||
* @property {Set<string>} [mutes] - Pubkeys the viewer mutes
|
||||
* @property {Map<string, { visibility: string, reason?: string }>} [overrides] - Viewer overrides by target key
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} GovernanceState
|
||||
* @property {AuthorityState} authority - Authority state
|
||||
* @property {Record<string, Report[]>} reports - Reports by target key
|
||||
* @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
|
||||
* @typedef {Object} TrustState
|
||||
* @property {Set<string>} [contacts] - Pubkeys the viewer trusts directly
|
||||
* @property {Set<string>} [seeds] - Fallback trust seeds for anonymous viewers
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 {GovernanceState} state
|
||||
* @param {PolicyContext} context
|
||||
* @param {string} [viewerPubkey] - Pubkey of the viewer (for personal overrides)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
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}
|
||||
*/
|
||||
export function evaluateTarget(target, state, context, viewerPubkey) {
|
||||
const decisions = [];
|
||||
|
||||
// 1. Check for personal overrides first
|
||||
if (viewerPubkey) {
|
||||
const overrideDecision = checkPersonalOverrides(target, state, viewerPubkey);
|
||||
if (overrideDecision) {
|
||||
decisions.push(overrideDecision);
|
||||
}
|
||||
export function evaluateTarget(target, snapshot, context = { surface: "default" }, viewerState = {}) {
|
||||
// 1. Validate and normalize the target.
|
||||
if (!isValidTarget(target)) {
|
||||
throw new Error(`Invalid governance target: ${JSON.stringify(target)}`);
|
||||
}
|
||||
|
||||
// 2. Check for explicit overrides
|
||||
const explicitOverrideDecision = checkExplicitOverrides(target, state, context);
|
||||
if (explicitOverrideDecision) {
|
||||
decisions.push(explicitOverrideDecision);
|
||||
const key = getTargetKey(target);
|
||||
const policy = context.policy ?? NEUTRAL_POLICY;
|
||||
const profile = resolveProfile(policy, context);
|
||||
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 reportDecision = checkReports(target, state, context);
|
||||
if (reportDecision) {
|
||||
decisions.push(reportDecision);
|
||||
}
|
||||
const authors = authorChain(target);
|
||||
|
||||
// 4. Check trusted mutes
|
||||
const muteDecision = checkTrustedMutes(target, state, context);
|
||||
if (muteDecision) {
|
||||
decisions.push(muteDecision);
|
||||
}
|
||||
// 2. Protected-target status. Checked before any denial path so that a
|
||||
// protected actor cannot be denied even by a malformed admin snapshot.
|
||||
const isProtected = authors.some((pubkey) => isProtectedActor(pubkey, snapshot.authority));
|
||||
evidence.protectedTarget = isProtected;
|
||||
|
||||
// 5. Check if target is a descendant of a denied ancestor
|
||||
const ancestorDecision = checkAncestorDeny(target, state, context);
|
||||
if (ancestorDecision) {
|
||||
decisions.push(ancestorDecision);
|
||||
}
|
||||
|
||||
// Compose all decisions into a final decision
|
||||
if (decisions.length === 0) {
|
||||
// Default allow decision
|
||||
return createGovernanceDecision(
|
||||
{ effect: /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow") },
|
||||
{ effect: /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow") }
|
||||
// 3. Viewer personal block. Viewer-local and always enforced: a viewer's own
|
||||
// block is never softened by an application profile.
|
||||
const blockedAuthor = authors.find((pubkey) => viewer.blocks?.has(pubkey));
|
||||
if (blockedAuthor) {
|
||||
evidence.personalBlock = true;
|
||||
applyEffects(
|
||||
decision,
|
||||
profile.viewerBlock ?? { visibility: "hide", interaction: "deny" },
|
||||
[createReason("viewer-block")],
|
||||
);
|
||||
}
|
||||
|
||||
return composeGovernanceDecisions(decisions);
|
||||
if (authors.some((pubkey) => viewer.mutes?.has(pubkey))) {
|
||||
evidence.personalMute = true;
|
||||
decision.reasons = dedupeReasons([...decision.reasons, createReason("viewer-mute")]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for personal overrides for a viewer
|
||||
* @param {GovernanceTarget} target
|
||||
* @param {GovernanceState} state
|
||||
* @param {string} viewerPubkey
|
||||
* @returns {GovernanceDecision|null}
|
||||
*/
|
||||
function checkPersonalOverrides(target, state, viewerPubkey) {
|
||||
// This would check for personal allowlists, blocklists, etc.
|
||||
// For now, we'll return null to indicate no personal override
|
||||
return null;
|
||||
// 4-6. Administrative denial: the target itself, then its author.
|
||||
if (!isProtected) {
|
||||
const targetDenied = isDenied(target, snapshot.admin);
|
||||
const authorDenied =
|
||||
target.type !== "user" && authors.some((pubkey) => snapshot.admin.userDeny.has(`user:${pubkey}`));
|
||||
|
||||
if (target.type === "user" && targetDenied) {
|
||||
evidence.userDenied = true;
|
||||
}
|
||||
if (target.type === "event" && targetDenied) {
|
||||
evidence.eventDenied = true;
|
||||
}
|
||||
if (target.type === "address" && targetDenied) {
|
||||
evidence.addressDenied = true;
|
||||
}
|
||||
if (authorDenied) {
|
||||
evidence.userDenied = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for explicit overrides
|
||||
* @param {GovernanceTarget} target
|
||||
* @param {GovernanceState} state
|
||||
* @param {PolicyContext} context
|
||||
* @returns {GovernanceDecision|null}
|
||||
*/
|
||||
function checkExplicitOverrides(target, state, context) {
|
||||
const targetKey = getTargetKey(target);
|
||||
const override = state.overrides[targetKey];
|
||||
if (targetDenied || authorDenied) {
|
||||
const reasonId =
|
||||
target.type === "event" && targetDenied
|
||||
? "admin-event-deny"
|
||||
: target.type === "address" && targetDenied
|
||||
? "admin-address-deny"
|
||||
: "admin-user-deny";
|
||||
|
||||
if (!override) {
|
||||
return null;
|
||||
const communitySources = snapshot.admin.communitySources.get(key) ?? [];
|
||||
/** @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
|
||||
if (context.enforcement && context.enforcement.allowOverrides === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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,
|
||||
applyEffects(
|
||||
decision,
|
||||
profile.administrativeDeny ?? { visibility: "hide", interaction: "deny" },
|
||||
reasons,
|
||||
[override]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check reports against a target
|
||||
* @param {GovernanceTarget} 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;
|
||||
} else if (isDenied(target, snapshot.admin)) {
|
||||
// A protected actor appearing in a denial set is a policy no-op, but the
|
||||
// consumer should be able to see that it was ignored.
|
||||
decision.reasons = dedupeReasons([...decision.reasons, createReason("protected-target")]);
|
||||
}
|
||||
|
||||
// Count trusted reports by category
|
||||
/** @type {Record<string, number>} */
|
||||
const trustedReportCounts = {};
|
||||
/** @type {any[]} */
|
||||
const evidence = [];
|
||||
|
||||
for (const report of targetReports) {
|
||||
// Check if the reporter is trusted
|
||||
if (hasCapability(report.reporter, "submit-reports", state.authority)) {
|
||||
if (!trustedReportCounts[report.category]) {
|
||||
trustedReportCounts[report.category] = 0;
|
||||
}
|
||||
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
|
||||
// 7. Allowlist policy. Access control is separate from trust: being allowed
|
||||
// to publish grants no trust, and missing the allowlist is not a moral
|
||||
// judgement, so it is only enforced where the profile asks for it.
|
||||
if (profile.requireAllowlist && !isProtected) {
|
||||
const allowed = authors.some((pubkey) => snapshot.admin.userAllow.has(`user:${pubkey}`));
|
||||
evidence.userAllowed = allowed;
|
||||
if (!allowed) {
|
||||
applyEffects(
|
||||
decision,
|
||||
profile.allowlistMiss ?? { visibility: "hide", interaction: "deny" },
|
||||
[createReason("allowlist-miss")],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
} else {
|
||||
evidence.userAllowed = authors.some((pubkey) => snapshot.admin.userAllow.has(`user:${pubkey}`));
|
||||
}
|
||||
|
||||
// Determine effect based on trusted mute count
|
||||
let visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow");
|
||||
let interactionEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow");
|
||||
/** @type {string[]} */
|
||||
const reasons = [];
|
||||
const evidence = [trustedMute];
|
||||
// 9-11. Trust signals. Aggregated after administrative state so that denied
|
||||
// accounts are already excluded from counting.
|
||||
const trustSet = resolveTrustSet(snapshot.trust);
|
||||
|
||||
if (trustedMute.count >= DEFAULT_THRESHOLDS.trustedMuteHideThreshold) {
|
||||
visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("hide");
|
||||
interactionEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("deny");
|
||||
reasons.push(`Hidden due to ${trustedMute.count} trusted mutes`);
|
||||
} else if (trustedMute.count >= DEFAULT_THRESHOLDS.trustedMuteRestrictThreshold) {
|
||||
visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("restrict");
|
||||
reasons.push(`Restricted due to ${trustedMute.count} trusted mutes`);
|
||||
}
|
||||
const mutes = aggregateMutes(key, snapshot, viewer, trustSet, now, profile.muteWindowSeconds);
|
||||
evidence.trustedMuteTotal = mutes.total;
|
||||
evidence.trustedMutesByCategory = mutes.byCategory;
|
||||
evidence.trustedMuterPubkeys = mutes.muters;
|
||||
|
||||
if (visibilityEffect === "allow" && interactionEffect === "allow") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createGovernanceDecision(
|
||||
{ effect: visibilityEffect, reason: reasons.join("; ") },
|
||||
{ effect: interactionEffect, reason: reasons.join("; ") },
|
||||
undefined,
|
||||
reasons,
|
||||
evidence
|
||||
if (mutes.total > 0) {
|
||||
const thresholds = resolveThresholds(profile.mutes, undefined);
|
||||
const { effects, firedGates } = applyThresholds(mutes.total, thresholds);
|
||||
/** @type {GovernanceReason[]} */
|
||||
const reasons = [createReason("trusted-mute", { count: mutes.total })];
|
||||
if (firedGates.length) {
|
||||
reasons.push(
|
||||
createReason("trusted-mute-threshold", {
|
||||
count: mutes.total,
|
||||
threshold: lowestFiredThreshold(thresholds, firedGates),
|
||||
}),
|
||||
);
|
||||
}
|
||||
applyEffects(decision, effects, reasons);
|
||||
|
||||
/**
|
||||
* Check if target is a descendant of a denied ancestor
|
||||
* @param {GovernanceTarget} target
|
||||
* @param {GovernanceState} state
|
||||
* @param {PolicyContext} context
|
||||
* @returns {GovernanceDecision|null}
|
||||
*/
|
||||
function checkAncestorDeny(target, state, context) {
|
||||
// This would check if any ancestor of the target is denied
|
||||
// For now, we'll return null to indicate no ancestor deny
|
||||
return null;
|
||||
for (const [category, count] of Object.entries(mutes.byCategory)) {
|
||||
const categoryThresholds = profile.mutes?.[category];
|
||||
if (!categoryThresholds) {
|
||||
continue;
|
||||
}
|
||||
const categoryResult = applyThresholds(count, categoryThresholds);
|
||||
if (categoryResult.firedGates.length) {
|
||||
applyEffects(decision, categoryResult.effects, [
|
||||
createReason("trusted-mute-threshold", {
|
||||
category,
|
||||
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 {GovernanceState} state
|
||||
* @param {PolicyContext} context
|
||||
* @param {string} [viewerPubkey] - Pubkey of the viewer (for personal overrides)
|
||||
* @returns {Record<string, GovernanceDecision>}
|
||||
* @param {GovernanceSnapshot} snapshot
|
||||
* @param {PolicyContext} [context]
|
||||
* @param {ViewerState} [viewerState]
|
||||
* @returns {Map<string, GovernanceDecision>}
|
||||
*/
|
||||
export function evaluateMany(targets, state, context, viewerPubkey) {
|
||||
/** @type {Record<string, GovernanceDecision>} */
|
||||
const results = {};
|
||||
export function evaluateMany(targets, snapshot, context, viewerState) {
|
||||
/** @type {Map<string, GovernanceDecision>} */
|
||||
const results = new Map();
|
||||
|
||||
for (const target of targets) {
|
||||
const targetKey = getTargetKey(target);
|
||||
results[targetKey] = evaluateTarget(target, state, context, viewerPubkey);
|
||||
if (!isValidTarget(target)) {
|
||||
continue;
|
||||
}
|
||||
const key = getTargetKey(target);
|
||||
if (results.has(key)) {
|
||||
continue;
|
||||
}
|
||||
results.set(key, evaluateTarget(target, snapshot, context, viewerState));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a governance state
|
||||
* @param {AuthorityState} [authority]
|
||||
* @param {Record<string, Report[]>} [reports]
|
||||
* @param {Record<string, TrustedMute>} [trustedMutes]
|
||||
* @param {Record<string, Override>} [overrides]
|
||||
* @param {Record<string, PolicyDefinition>} [policies]
|
||||
* @returns {GovernanceState}
|
||||
* Create a governance snapshot with empty defaults.
|
||||
* @param {Partial<GovernanceSnapshot>} [parts]
|
||||
* @returns {GovernanceSnapshot}
|
||||
*/
|
||||
export function createGovernanceState(authority, reports, trustedMutes, overrides, policies) {
|
||||
export function createSnapshot(parts = {}) {
|
||||
return {
|
||||
authority: authority || { roles: {}, actors: {} },
|
||||
reports: reports || {},
|
||||
trustedMutes: trustedMutes || {},
|
||||
overrides: overrides || {},
|
||||
policies: policies || {}
|
||||
authority: parts.authority ?? { roles: {}, actors: {}, protectedActors: [] },
|
||||
admin: parts.admin ?? {
|
||||
userAllow: new Set(),
|
||||
userDeny: new Set(),
|
||||
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
|
||||
* @param {string} reporter
|
||||
* @param {GovernanceTarget} target
|
||||
* @param {string} category
|
||||
* @param {number} timestamp
|
||||
* @returns {Report}
|
||||
* Create a viewer state with empty defaults.
|
||||
* @param {Partial<ViewerState>} [parts]
|
||||
* @returns {ViewerState}
|
||||
*/
|
||||
export function createReport(reporter, target, category, timestamp) {
|
||||
// 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");
|
||||
}
|
||||
|
||||
export function createViewerState(parts = {}) {
|
||||
return {
|
||||
reporter: reporter.toLowerCase(),
|
||||
target,
|
||||
category: category.trim(),
|
||||
timestamp
|
||||
blocks: parts.blocks ?? new Set(),
|
||||
mutes: parts.mutes ?? new Set(),
|
||||
overrides: parts.overrides ?? new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @property {"event"} type
|
||||
* @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
|
||||
* @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
|
||||
*/
|
||||
export function createEventTarget(id) {
|
||||
export function createEventTarget(id, options = {}) {
|
||||
const normalizedId = normalizeEventId(id);
|
||||
if (!normalizedId) return null;
|
||||
|
||||
return {
|
||||
/** @type {EventTarget} */
|
||||
const target = {
|
||||
type: "event",
|
||||
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
|
||||
export {};
|
||||
// @nostr-governance/core
|
||||
//
|
||||
// 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
|
||||
// for the governance system.
|
||||
// The engine is deliberately generic: it carries no application thresholds of
|
||||
// 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
|
||||
* @property {"allow"|"restrict"|"hide"|"deny"} effect - The base effect
|
||||
* @property {string} [reason] - Reason for the effect
|
||||
* @property {any} [details] - Additional details about the effect
|
||||
* @typedef {import('./reasons.js').GovernanceReason} GovernanceReason
|
||||
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
|
||||
* @typedef {import('./evidence.js').GovernanceEvidence} GovernanceEvidence
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @property {string} name - Profile name
|
||||
* @property {Record<string, PolicyEffect>} effects - Map of effect types to policy effects
|
||||
* @property {string} name
|
||||
* @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
|
||||
* @property {string} id - Unique policy identifier
|
||||
* @property {string} name - Human-readable name
|
||||
* @property {string} description - Description of what the policy does
|
||||
* @property {Record<string, PolicyProfile>} profiles - Map of profile names to policy profiles
|
||||
* @property {string} id
|
||||
* @property {string} [name] - Defaults to the id when omitted
|
||||
* @property {string} [description]
|
||||
* @property {string} version - Policy version, reported on every decision
|
||||
* @property {Record<string, PolicyProfile>} profiles
|
||||
* @property {string} [defaultProfile]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PolicyContext
|
||||
* @property {string} surface - The surface where the policy is being applied (e.g., "feed", "search", "checkout")
|
||||
* @property {string} policyProfile - The policy profile to use
|
||||
* @property {Object} [enforcement] - Enforcement options
|
||||
* @property {boolean} [enforcement.hardHide] - Whether to hard hide denied items
|
||||
* @property {boolean} [enforcement.allowOverrides] - Whether to allow viewer overrides
|
||||
* @property {string} surface - Surface being rendered (e.g. "feed", "checkout")
|
||||
* @property {string} [policyProfile] - Profile name; defaults to the policy's default profile
|
||||
* @property {PolicyDefinition} [policy] - Policy definition to evaluate against
|
||||
* @property {number} [now] - Evaluation time in unix seconds (injected clock)
|
||||
* @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
|
||||
* @property {PolicyEffect} visibility - Visibility effect
|
||||
* @property {PolicyEffect} interaction - Interaction effect
|
||||
* @property {PolicyEffect} [transaction] - Transaction effect (for commerce)
|
||||
* @property {string[]} reasons - Array of reasons for the decision
|
||||
* @property {any[]} evidence - Array of evidence supporting the decision
|
||||
* @property {Object} [metadata] - Additional metadata about the decision
|
||||
* @property {GovernanceTarget} [target]
|
||||
* @property {string} key
|
||||
* @property {{ effect: RankingEffect, weight: number }} ranking
|
||||
* @property {{ effect: VisibilityEffect, overridable: boolean }} visibility
|
||||
* @property {{ effect: InteractionEffect }} interaction
|
||||
* @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
|
||||
* @type {Record<string, PolicyProfile>}
|
||||
*/
|
||||
export const BUILTIN_POLICY_PROFILES = {
|
||||
"default": {
|
||||
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]
|
||||
* A decision with every dimension at its most permissive value.
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.key]
|
||||
* @param {GovernanceTarget} [options.target]
|
||||
* @param {boolean} [options.includeTransaction]
|
||||
* @returns {GovernanceDecision}
|
||||
*/
|
||||
export function createGovernanceDecision(visibility, interaction, transaction, reasons, evidence, metadata) {
|
||||
// Validate visibility effect
|
||||
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");
|
||||
}
|
||||
|
||||
export function createNeutralDecision(options = {}) {
|
||||
/** @type {GovernanceDecision} */
|
||||
const decision = {
|
||||
visibility,
|
||||
interaction,
|
||||
reasons: reasons || [],
|
||||
evidence: evidence || []
|
||||
key: options.key ?? "",
|
||||
ranking: { effect: "normal", weight: 0 },
|
||||
visibility: { effect: "allow", overridable: true },
|
||||
interaction: { effect: "allow" },
|
||||
reasons: [],
|
||||
policyProfile: "",
|
||||
policyVersion: "",
|
||||
snapshotFingerprint: "",
|
||||
evaluatedAt: 0,
|
||||
};
|
||||
|
||||
if (transaction) {
|
||||
decision.transaction = transaction;
|
||||
if (options.target) {
|
||||
decision.target = options.target;
|
||||
}
|
||||
|
||||
if (metadata) {
|
||||
decision.metadata = metadata;
|
||||
if (options.includeTransaction) {
|
||||
decision.transaction = { effect: "allow" };
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effect for a policy profile
|
||||
* @param {string} profileName
|
||||
* @param {string} effectType
|
||||
* @param {Record<string, PolicyDefinition>} [customPolicies]
|
||||
* @returns {PolicyEffect}
|
||||
* Validate a set of dimension effects supplied by an application profile.
|
||||
* @param {DimensionEffects} [effects]
|
||||
* @param {string} location - Human-readable location for error messages
|
||||
* @returns {DimensionEffects}
|
||||
*/
|
||||
export function getPolicyEffect(profileName, effectType, customPolicies) {
|
||||
// Check custom policies first
|
||||
if (customPolicies) {
|
||||
for (const policy of Object.values(customPolicies)) {
|
||||
if (policy.profiles[profileName] && policy.profiles[profileName].effects[effectType]) {
|
||||
return policy.profiles[profileName].effects[effectType];
|
||||
}
|
||||
}
|
||||
export function normalizeDimensionEffects(effects, location = "profile") {
|
||||
if (!effects || typeof effects !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Check built-in profiles
|
||||
if (BUILTIN_POLICY_PROFILES[profileName] &&
|
||||
BUILTIN_POLICY_PROFILES[profileName].effects[effectType]) {
|
||||
return BUILTIN_POLICY_PROFILES[profileName].effects[effectType];
|
||||
/** @type {DimensionEffects} */
|
||||
const normalized = {};
|
||||
|
||||
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 { effect: "allow" };
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose multiple governance decisions into a single decision
|
||||
* @param {GovernanceDecision[]} decisions
|
||||
* @returns {GovernanceDecision}
|
||||
* Normalize category thresholds, dropping non-numeric entries.
|
||||
* @param {Record<string, CategoryThresholds>} [thresholds]
|
||||
* @param {string} [location]
|
||||
* @returns {Record<string, CategoryThresholds>}
|
||||
*/
|
||||
export function composeGovernanceDecisions(decisions) {
|
||||
if (!Array.isArray(decisions) || decisions.length === 0) {
|
||||
// Return a default allow decision
|
||||
return createGovernanceDecision(
|
||||
{ effect: "allow" },
|
||||
{ effect: "allow" },
|
||||
undefined,
|
||||
[],
|
||||
[]
|
||||
export function normalizeCategoryThresholds(thresholds, location = "profile") {
|
||||
if (!thresholds || typeof thresholds !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** @type {Record<string, CategoryThresholds>} */
|
||||
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
|
||||
const composed = { ...decisions[0] };
|
||||
|
||||
// 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;
|
||||
if (effects.interaction) {
|
||||
decision.interaction.effect = /** @type {InteractionEffect} */ (
|
||||
maxEffect("interaction", decision.interaction.effect, effects.interaction)
|
||||
);
|
||||
}
|
||||
|
||||
// Compose interaction effect (deny > hide > restrict > allow)
|
||||
const interactionOrder = { "allow": 0, "restrict": 1, "hide": 2, "deny": 3 };
|
||||
if (interactionOrder[decision.interaction.effect] > interactionOrder[composed.interaction.effect]) {
|
||||
composed.interaction = decision.interaction;
|
||||
if (effects.transaction) {
|
||||
if (!decision.transaction) {
|
||||
decision.transaction = { effect: "allow" };
|
||||
}
|
||||
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 (!composed.transaction) {
|
||||
composed.transaction = decision.transaction;
|
||||
composed.transaction = { ...decision.transaction };
|
||||
} else {
|
||||
const transactionOrder = { "allow": 0, "restrict": 1, "hide": 2, "deny": 3 };
|
||||
if (transactionOrder[decision.transaction.effect] > transactionOrder[composed.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);
|
||||
composed.transaction.effect = /** @type {TransactionEffect} */ (
|
||||
maxEffect("transaction", composed.transaction.effect, decision.transaction.effect)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const evidence of decision.evidence) {
|
||||
composed.evidence.push(evidence);
|
||||
}
|
||||
composed.reasons = [...composed.reasons, ...decision.reasons];
|
||||
}
|
||||
|
||||
composed.reasons = dedupeReasons(composed.reasons);
|
||||
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
|
||||
* @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}
|
||||
*/
|
||||
export function createTarget(input) {
|
||||
@@ -116,7 +116,10 @@ export function createTarget(input) {
|
||||
case "user":
|
||||
return createUserTarget(/** @type {string} */ (input.pubkey));
|
||||
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":
|
||||
return normalizeAddress(
|
||||
/** @type {string|number} */ (input.kind),
|
||||
@@ -145,12 +148,15 @@ export function getParentTarget(target, authorPubkey) {
|
||||
case "user":
|
||||
// User targets have no parent
|
||||
return null;
|
||||
case "event":
|
||||
// Event targets have the author as parent, but we need the author pubkey
|
||||
if (!authorPubkey) {
|
||||
case "event": {
|
||||
// Event targets have the author as parent. The author may be carried on
|
||||
// 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");
|
||||
}
|
||||
return createUserTarget(authorPubkey);
|
||||
return createUserTarget(author);
|
||||
}
|
||||
case "address":
|
||||
// Address targets have the author as parent
|
||||
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 {
|
||||
BUILTIN_ROLES,
|
||||
getRoleCapabilities,
|
||||
hasRole,
|
||||
DEFAULT_ROLE_CAPABILITIES,
|
||||
GOVERNANCE_CAPABILITIES,
|
||||
createAuthorityState,
|
||||
createRoleDefinition,
|
||||
getActorCapabilities,
|
||||
getActorRoles,
|
||||
hasCapability,
|
||||
getActorCapabilities,
|
||||
createRoleDefinition,
|
||||
createActor,
|
||||
createAuthorityState
|
||||
hasRole,
|
||||
isGovernanceCapability,
|
||||
isProtectedActor,
|
||||
} from "../src/authority.js";
|
||||
|
||||
describe("authority", () => {
|
||||
describe("BUILTIN_ROLES", () => {
|
||||
it("should define built-in roles with capabilities", () => {
|
||||
expect(BUILTIN_ROLES).toHaveProperty("root");
|
||||
expect(BUILTIN_ROLES).toHaveProperty("administrator");
|
||||
expect(BUILTIN_ROLES).toHaveProperty("moderator");
|
||||
expect(BUILTIN_ROLES).toHaveProperty("trusted-user");
|
||||
expect(BUILTIN_ROLES).toHaveProperty("user");
|
||||
const ROOT = "a1".repeat(32);
|
||||
const MODERATOR = "b2".repeat(32);
|
||||
const CURATOR = "c3".repeat(32);
|
||||
const STRANGER = "d4".repeat(32);
|
||||
|
||||
// Check that each role has a name and capabilities
|
||||
for (const [name, role] of Object.entries(BUILTIN_ROLES)) {
|
||||
expect(role.name).toBe(name);
|
||||
expect(Array.isArray(role.capabilities)).toBe(true);
|
||||
expect(role.capabilities.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
const baseState = () =>
|
||||
createAuthorityState({
|
||||
root: ROOT,
|
||||
actors: {
|
||||
[ROOT]: ["super_admin"],
|
||||
[MODERATOR]: ["moderator"],
|
||||
[CURATOR]: ["curator"],
|
||||
},
|
||||
});
|
||||
|
||||
describe("getRoleCapabilities", () => {
|
||||
it("should return capabilities for built-in roles", () => {
|
||||
const capabilities = getRoleCapabilities("root");
|
||||
expect(capabilities).toEqual(BUILTIN_ROLES.root.capabilities);
|
||||
});
|
||||
|
||||
it("should return capabilities for custom roles", () => {
|
||||
const authorityState = createAuthorityState({
|
||||
"custom-role": {
|
||||
name: "custom-role",
|
||||
capabilities: ["custom-capability"]
|
||||
describe("capabilities", () => {
|
||||
it("recognizes every documented capability", () => {
|
||||
for (const capability of GOVERNANCE_CAPABILITIES) {
|
||||
expect(isGovernanceCapability(capability)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
const capabilities = getRoleCapabilities("custom-role", authorityState);
|
||||
expect(capabilities).toEqual(["custom-capability"]);
|
||||
it("rejects unknown capabilities", () => {
|
||||
expect(isGovernanceCapability("delete-everything")).toBe(false);
|
||||
expect(isGovernanceCapability("")).toBe(false);
|
||||
expect(isGovernanceCapability(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should prefer custom roles over built-in roles", () => {
|
||||
const authorityState = createAuthorityState({
|
||||
"root": {
|
||||
name: "root",
|
||||
capabilities: ["custom-root-capability"]
|
||||
it("gives super_admin every capability", () => {
|
||||
expect([...DEFAULT_ROLE_CAPABILITIES.super_admin].sort()).toEqual(
|
||||
[...GOVERNANCE_CAPABILITIES].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(capabilities).toEqual(["custom-root-capability"]);
|
||||
it("does not let a curator deny events or addresses", () => {
|
||||
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", () => {
|
||||
const capabilities = getRoleCapabilities("unknown-role");
|
||||
expect(capabilities).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
it("does not let a moderator manage roles or policy", () => {
|
||||
expect(DEFAULT_ROLE_CAPABILITIES.moderator).not.toContain("manage-roles");
|
||||
expect(DEFAULT_ROLE_CAPABILITIES.moderator).not.toContain("manage-policy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAuthorityState", () => {
|
||||
it("should create a valid authority state", () => {
|
||||
const roles = {
|
||||
"test-role": {
|
||||
name: "test-role",
|
||||
capabilities: ["test-capability"]
|
||||
}
|
||||
};
|
||||
|
||||
const actors = {
|
||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": {
|
||||
pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
roles: ["test-role"]
|
||||
}
|
||||
};
|
||||
|
||||
const state = createAuthorityState(roles, actors);
|
||||
expect(state).toEqual({ roles, actors });
|
||||
it("normalizes actor pubkeys and drops invalid ones", () => {
|
||||
const state = createAuthorityState({
|
||||
actors: { [MODERATOR.toUpperCase()]: ["moderator"], "not-a-key": ["moderator"] },
|
||||
});
|
||||
expect(state.actors[MODERATOR]).toEqual(["moderator"]);
|
||||
expect(Object.keys(state.actors)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should create empty authority state with no arguments", () => {
|
||||
const state = createAuthorityState();
|
||||
expect(state).toEqual({ roles: {}, actors: {} });
|
||||
it("drops actors with no roles", () => {
|
||||
const state = createAuthorityState({ actors: { [MODERATOR]: [] } });
|
||||
expect(state.actors[MODERATOR]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should validate role names", () => {
|
||||
const roles = {
|
||||
"": {
|
||||
name: "",
|
||||
capabilities: ["test-capability"]
|
||||
}
|
||||
};
|
||||
|
||||
expect(() => createAuthorityState(roles)).toThrow();
|
||||
it("deduplicates repeated roles", () => {
|
||||
const state = createAuthorityState({ actors: { [MODERATOR]: ["moderator", "moderator"] } });
|
||||
expect(state.actors[MODERATOR]).toEqual(["moderator"]);
|
||||
});
|
||||
|
||||
it("should validate role definitions", () => {
|
||||
const roles = {
|
||||
"test-role": /** @type {any} */ (null)
|
||||
};
|
||||
|
||||
expect(() => createAuthorityState(roles)).toThrow();
|
||||
it("always protects the root administrator", () => {
|
||||
const state = createAuthorityState({ root: ROOT });
|
||||
expect(state.protectedActors).toContain(ROOT);
|
||||
});
|
||||
|
||||
it("should validate role name consistency", () => {
|
||||
const roles = {
|
||||
"test-role": {
|
||||
name: "different-name",
|
||||
capabilities: ["test-capability"]
|
||||
}
|
||||
};
|
||||
|
||||
expect(() => createAuthorityState(roles)).toThrow();
|
||||
it("keeps explicitly protected actors alongside root", () => {
|
||||
const state = createAuthorityState({ root: ROOT, protectedActors: [MODERATOR] });
|
||||
expect(state.protectedActors).toContain(ROOT);
|
||||
expect(state.protectedActors).toContain(MODERATOR);
|
||||
});
|
||||
|
||||
it("should validate actor pubkeys", () => {
|
||||
const actors = {
|
||||
"invalid-pubkey": {
|
||||
pubkey: "invalid-pubkey",
|
||||
roles: ["test-role"]
|
||||
}
|
||||
};
|
||||
|
||||
expect(() => createAuthorityState({}, actors)).toThrow();
|
||||
it("filters unknown capabilities out of custom roles", () => {
|
||||
const state = createAuthorityState({
|
||||
roles: { odd: /** @type {any} */ (["manage-policy", "not-a-capability"]) },
|
||||
});
|
||||
|
||||
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();
|
||||
expect(state.roles.odd).toEqual(["manage-policy"]);
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
evaluateTarget,
|
||||
aggregateMutes,
|
||||
aggregateReports,
|
||||
createSnapshot,
|
||||
createViewerState,
|
||||
evaluateMany,
|
||||
createGovernanceState,
|
||||
createReport,
|
||||
createTrustedMute,
|
||||
createOverride,
|
||||
DEFAULT_THRESHOLDS
|
||||
evaluateTarget,
|
||||
resolveTrustSet,
|
||||
} 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", () => {
|
||||
it("should return default allow decision for target with no reports or mutes", () => {
|
||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
||||
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);
|
||||
it("allows a target with no signals", () => {
|
||||
const decision = evaluateTarget(user(CREATOR), snapshotWith(), context());
|
||||
expect(decision.visibility.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", () => {
|
||||
it("should evaluate multiple targets", () => {
|
||||
const target1 = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
||||
const target2 = createEventTarget("1234567890123456789012345678901234567890123456789012345678901234");
|
||||
|
||||
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: []
|
||||
it("evaluates every target and keys results", () => {
|
||||
const results = evaluateMany([user(CREATOR), user(STRANGER)], snapshotWith(), context());
|
||||
expect(results.size).toBe(2);
|
||||
expect(results.get(`user:${CREATOR}`)?.visibility.effect).toBe("allow");
|
||||
});
|
||||
|
||||
expect(results["event:1234567890123456789012345678901234567890123456789012345678901234"]).toEqual({
|
||||
visibility: { effect: "allow" },
|
||||
interaction: { effect: "allow" },
|
||||
reasons: [],
|
||||
evidence: []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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()
|
||||
it("skips invalid targets rather than throwing", () => {
|
||||
const results = evaluateMany(
|
||||
[user(CREATOR), /** @type {any} */ ({ type: "user", pubkey: "bad" })],
|
||||
snapshotWith(),
|
||||
context(),
|
||||
);
|
||||
|
||||
expect(report).toEqual({
|
||||
reporter: "1234567890123456789012345678901234567890123456789012345678901234",
|
||||
target,
|
||||
category: "spam",
|
||||
timestamp: expect.any(Number)
|
||||
});
|
||||
expect(results.size).toBe(1);
|
||||
});
|
||||
|
||||
it("should throw error for invalid reporter", () => {
|
||||
const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
||||
expect(() => createReport("invalid", target, "spam", Date.now())).toThrow();
|
||||
it("deduplicates repeated targets", () => {
|
||||
const results = evaluateMany([user(CREATOR), user(CREATOR)], snapshotWith(), context());
|
||||
expect(results.size).toBe(1);
|
||||
});
|
||||
|
||||
it("should throw error for invalid target", () => {
|
||||
expect(() => createReport(
|
||||
"1234567890123456789012345678901234567890123456789012345678901234",
|
||||
null,
|
||||
"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();
|
||||
});
|
||||
it("agrees with evaluateTarget", () => {
|
||||
const snapshot = snapshotWith({ reports: new Map([[`user:${CREATOR}`, reportsFrom(3, "spam")]]) });
|
||||
const many = evaluateMany([user(CREATOR)], snapshot, context()).get(`user:${CREATOR}`);
|
||||
expect(many).toEqual(evaluateTarget(user(CREATOR), snapshot, context()));
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
BUILTIN_POLICY_PROFILES,
|
||||
createPolicyEffect,
|
||||
createPolicyProfile,
|
||||
INTERACTION_LADDER,
|
||||
NEUTRAL_POLICY,
|
||||
RANKING_LADDER,
|
||||
TRANSACTION_LADDER,
|
||||
VISIBILITY_LADDER,
|
||||
applyEffects,
|
||||
applyThresholds,
|
||||
composeDecisions,
|
||||
createNeutralDecision,
|
||||
createPolicyDefinition,
|
||||
createPolicyContext,
|
||||
createGovernanceDecision,
|
||||
getPolicyEffect,
|
||||
composeGovernanceDecisions
|
||||
effectSeverity,
|
||||
maxEffect,
|
||||
normalizeCategoryThresholds,
|
||||
normalizeDimensionEffects,
|
||||
normalizePolicyDefinition,
|
||||
resolveProfile,
|
||||
resolveThresholds,
|
||||
} from "../src/policy.js";
|
||||
import { createReason } from "../src/reasons.js";
|
||||
|
||||
describe("policy", () => {
|
||||
describe("BUILTIN_POLICY_PROFILES", () => {
|
||||
it("should define built-in policy profiles with effects", () => {
|
||||
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");
|
||||
describe("effect ladders", () => {
|
||||
it("orders visibility from permissive to severe", () => {
|
||||
expect(VISIBILITY_LADDER).toEqual(["allow", "warn", "restrict", "hide", "deny"]);
|
||||
});
|
||||
|
||||
// Check that each profile has a name and effects
|
||||
for (const [name, profile] of Object.entries(BUILTIN_POLICY_PROFILES)) {
|
||||
expect(profile.name).toBe(name);
|
||||
expect(profile.effects).toBeDefined();
|
||||
expect(typeof profile.effects).toBe("object");
|
||||
}
|
||||
it("orders the remaining dimensions", () => {
|
||||
expect(RANKING_LADDER).toEqual(["normal", "downrank"]);
|
||||
expect(INTERACTION_LADDER).toEqual(["allow", "require-explicit-action", "deny"]);
|
||||
expect(TRANSACTION_LADDER).toEqual(["allow", "require-review", "deny"]);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("should create a valid policy effect", () => {
|
||||
const effect = createPolicyEffect("hide", "User is blocked", { details: "more info" });
|
||||
expect(effect).toEqual({
|
||||
effect: "hide",
|
||||
reason: "User is blocked",
|
||||
details: { details: "more info" }
|
||||
describe("normalizeDimensionEffects", () => {
|
||||
it("passes valid effects through", () => {
|
||||
expect(normalizeDimensionEffects({ visibility: "hide", interaction: "deny" })).toEqual({
|
||||
visibility: "hide",
|
||||
interaction: "deny",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a policy effect without optional fields", () => {
|
||||
const effect = createPolicyEffect("allow");
|
||||
expect(effect).toEqual({ effect: "allow" });
|
||||
});
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
it("rejects an effect from the wrong ladder", () => {
|
||||
expect(() => normalizeDimensionEffects({ interaction: /** @type {any} */ ("hide") })).toThrow(
|
||||
/invalid interaction effect/,
|
||||
);
|
||||
});
|
||||
|
||||
expect(definition).toEqual({
|
||||
id: "test-policy",
|
||||
name: "Test Policy",
|
||||
description: "A test policy",
|
||||
profiles: {
|
||||
"default": {
|
||||
name: "default",
|
||||
effects: {
|
||||
"visibility": { effect: "allow" }
|
||||
}
|
||||
}
|
||||
}
|
||||
it("returns an empty object for missing input", () => {
|
||||
expect(normalizeDimensionEffects(/** @type {any} */ (undefined))).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error for invalid ID", () => {
|
||||
expect(() => createPolicyDefinition("", "Test Policy", "A test policy", {})).toThrow();
|
||||
expect(() => createPolicyDefinition(/** @type {any} */ (123), "Test Policy", "A test policy", {})).toThrow();
|
||||
describe("normalizeCategoryThresholds", () => {
|
||||
it("lowercases categories and floors values", () => {
|
||||
expect(normalizeCategoryThresholds({ SPAM: { hide: 5.7 } })).toEqual({ spam: { hide: 5 } });
|
||||
});
|
||||
|
||||
it("should throw error for invalid name", () => {
|
||||
expect(() => createPolicyDefinition("test-policy", "", "A test policy", {})).toThrow();
|
||||
expect(() => createPolicyDefinition("test-policy", /** @type {any} */ (123), "A test policy", {})).toThrow();
|
||||
});
|
||||
|
||||
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();
|
||||
it("drops non-numeric gates", () => {
|
||||
expect(normalizeCategoryThresholds({ spam: { hide: /** @type {any} */ ("many") } })).toEqual({
|
||||
spam: {},
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPolicyContext", () => {
|
||||
it("should create a valid policy context", () => {
|
||||
const context = createPolicyContext("feed", "default");
|
||||
expect(context).toEqual({
|
||||
surface: "feed",
|
||||
policyProfile: "default"
|
||||
it("rejects negative thresholds", () => {
|
||||
expect(() => normalizeCategoryThresholds({ spam: { hide: -1 } })).toThrow(/must be >= 0/);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a policy context with enforcement options", () => {
|
||||
const context = createPolicyContext("feed", "default", {
|
||||
hardHide: true,
|
||||
allowOverrides: false
|
||||
});
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
describe("normalizePolicyDefinition", () => {
|
||||
const valid = {
|
||||
id: "app",
|
||||
version: "1.0.0",
|
||||
profiles: { feed: { name: "feed" } },
|
||||
};
|
||||
|
||||
const effect = getPolicyEffect("custom-profile", "visibility", /** @type {any} */ (customPolicies));
|
||||
expect(effect).toEqual({ effect: "hide", reason: "Custom hidden" });
|
||||
it("normalizes a minimal policy", () => {
|
||||
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", () => {
|
||||
const customPolicies = {
|
||||
"custom-policy": {
|
||||
id: "custom-policy",
|
||||
name: "Custom Policy",
|
||||
description: "A custom policy",
|
||||
profiles: {
|
||||
"default": {
|
||||
name: "default",
|
||||
effects: {
|
||||
"visibility": { effect: "hide", reason: "Custom default" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
it("requires an id and a version", () => {
|
||||
expect(() => normalizePolicyDefinition({ ...valid, id: "" })).toThrow(/non-empty id/);
|
||||
expect(() => normalizePolicyDefinition({ ...valid, version: "" })).toThrow(/version string/);
|
||||
});
|
||||
|
||||
it("requires at least one profile", () => {
|
||||
expect(() => normalizePolicyDefinition({ ...valid, profiles: {} })).toThrow(/at least one profile/);
|
||||
});
|
||||
|
||||
it("rejects a default profile that does not exist", () => {
|
||||
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));
|
||||
expect(effect).toEqual({ effect: "hide", reason: "Custom default" });
|
||||
it("returns a neutral decision for an empty list", () => {
|
||||
const composed = composeDecisions([]);
|
||||
expect(composed.visibility.effect).toBe("allow");
|
||||
});
|
||||
|
||||
it("should return default allow effect for unknown profiles/effects", () => {
|
||||
const effect = getPolicyEffect("unknown-profile", "unknown-effect");
|
||||
expect(effect).toEqual({ effect: "allow" });
|
||||
});
|
||||
});
|
||||
|
||||
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" }]
|
||||
it("keeps the most severe effect", () => {
|
||||
expect(composeDecisions([withVisibility("warn"), withVisibility("hide")]).visibility.effect).toBe(
|
||||
"hide",
|
||||
);
|
||||
|
||||
const decision = composeGovernanceDecisions([inputDecision]);
|
||||
expect(decision).toEqual(inputDecision);
|
||||
});
|
||||
|
||||
it("should compose multiple decisions with most restrictive effects", () => {
|
||||
const decisions = [
|
||||
createGovernanceDecision(
|
||||
{ effect: "allow" },
|
||||
{ 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("is order-independent", () => {
|
||||
const a = composeDecisions([withVisibility("warn"), withVisibility("hide")]);
|
||||
const b = composeDecisions([withVisibility("hide"), withVisibility("warn")]);
|
||||
expect(a.visibility.effect).toBe(b.visibility.effect);
|
||||
});
|
||||
|
||||
it("should combine reasons and evidence from all decisions", () => {
|
||||
const decisions = [
|
||||
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("sums ranking weight", () => {
|
||||
expect(composeDecisions([withVisibility("allow", 2), withVisibility("allow", 3)]).ranking.weight).toBe(5);
|
||||
});
|
||||
|
||||
it("should compose transaction effects when present", () => {
|
||||
const decisions = [
|
||||
createGovernanceDecision(
|
||||
{ effect: "allow" },
|
||||
{ effect: "allow" },
|
||||
{ effect: "allow" }
|
||||
),
|
||||
createGovernanceDecision(
|
||||
{ effect: "hide" },
|
||||
{ effect: "deny" },
|
||||
{ effect: "deny", reason: "Transaction denied" }
|
||||
)
|
||||
];
|
||||
it("makes a decision non-overridable if any input was", () => {
|
||||
const locked = createNeutralDecision();
|
||||
locked.visibility.overridable = false;
|
||||
expect(composeDecisions([createNeutralDecision(), locked]).visibility.overridable).toBe(false);
|
||||
});
|
||||
|
||||
const decision = composeGovernanceDecisions(decisions);
|
||||
expect(decision.transaction).toEqual({ effect: "deny", reason: "Transaction denied" });
|
||||
it("does not mutate its inputs", () => {
|
||||
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",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "src/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.js"
|
||||
},
|
||||
"./conformance": {
|
||||
"import": "./src/conformance.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nostr-governance/core": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,197 @@
|
||||
// Shared test utilities for conformance harness
|
||||
export function createFixture(name, state, target, expectation) {
|
||||
return {
|
||||
name,
|
||||
state: {
|
||||
adminState: state.adminState || {},
|
||||
trust: state.trust || {},
|
||||
reports: state.reports || [],
|
||||
trustedMutes: state.trustedMutes || [],
|
||||
overrides: state.overrides || {},
|
||||
},
|
||||
target,
|
||||
expectation,
|
||||
};
|
||||
// Conformance harness for @nostr-governance
|
||||
//
|
||||
// Turns a JSON fixture into a snapshot, evaluates it, and compares the decision
|
||||
// against the fixture's expectation. Expectations are partial by design: a
|
||||
// fixture asserts the dimensions it characterizes and stays silent about the
|
||||
// rest, so adding a new decision field does not require touching every case.
|
||||
|
||||
import {
|
||||
createAuthorityState,
|
||||
createSnapshot,
|
||||
createViewerState,
|
||||
evaluateTarget,
|
||||
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) {
|
||||
const decision = evaluator(fixture.state, fixture.target);
|
||||
return {
|
||||
passed: JSON.stringify(decision) === JSON.stringify(fixture.expectation),
|
||||
actual: decision,
|
||||
expected: fixture.expectation,
|
||||
};
|
||||
/** @type {Map<string, Array<{ muter: string, category?: string, updatedAt?: number }>>} */
|
||||
const trustedMutes = new Map();
|
||||
for (const [key, records] of Object.entries(fixture.trustedMutes ?? {})) {
|
||||
trustedMutes.set(key, records);
|
||||
}
|
||||
|
||||
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, it, expect } from "vitest";
|
||||
import { createFixture, runConformanceTest } from "../src/conformance.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("conformance utilities", () => {
|
||||
it("creates valid fixture objects", () => {
|
||||
const fixture = createFixture(
|
||||
"test-fixture",
|
||||
{
|
||||
adminState: { editors: [] },
|
||||
trust: {},
|
||||
reports: [],
|
||||
trustedMutes: [],
|
||||
overrides: {},
|
||||
import { createPolicyDefinition } from "@nostr-governance/core";
|
||||
|
||||
import { buildSnapshot, diffExpectation, evaluateFixture, runConformanceCase } from "../src/conformance.js";
|
||||
|
||||
const ROOT = "a1".repeat(32);
|
||||
const MODERATOR = "b2".repeat(32);
|
||||
const CREATOR = "d4".repeat(32);
|
||||
const TRUSTED = "01".repeat(32);
|
||||
|
||||
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 fixture = createFixture(
|
||||
"test-fixture",
|
||||
{},
|
||||
{ type: "user", pubkey: "test" },
|
||||
{ visibility: "allow" }
|
||||
);
|
||||
const baseFixture = () => ({
|
||||
name: "sample",
|
||||
profile: "feed",
|
||||
now: 1_750_000_000,
|
||||
authority: {
|
||||
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
|
||||
const mockEvaluator = () => ({ visibility: "allow" });
|
||||
describe("buildSnapshot", () => {
|
||||
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);
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.actual).toEqual({ visibility: "allow" });
|
||||
expect(result.expected).toEqual({ visibility: "allow" });
|
||||
it("converts viewer arrays into sets and maps", () => {
|
||||
const fixture = {
|
||||
...baseFixture(),
|
||||
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
|
||||
import { describe, it, expect } from "vitest";
|
||||
// Conformance suite for the characterization corpus.
|
||||
//
|
||||
// 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
|
||||
const BITVID_CHARACTERIZATION_CASES = [
|
||||
"personal-block-precedence",
|
||||
"user-deny",
|
||||
"event-deny",
|
||||
"address-deny",
|
||||
"allowlist-miss",
|
||||
"protected-target",
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { runConformanceCase } from "@nostr-governance/testing/conformance";
|
||||
import { REFERENCE_POLICY } from "@nostr-governance/bitvid-compat/profile";
|
||||
|
||||
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",
|
||||
"duplicate-reporter-dedupe",
|
||||
"blocked-reporter-ignored",
|
||||
"trusted-mute-below-threshold",
|
||||
"trusted-mute-at-threshold",
|
||||
"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",
|
||||
"home-hide-bypass",
|
||||
"cache-fallback-effective",
|
||||
"report-category-thresholds",
|
||||
"mute-aggregation-rules",
|
||||
"hide-vs-restrict-vs-downrank",
|
||||
"moderation-decorator-fields"
|
||||
];
|
||||
|
||||
describe("BitVid parity characterization", () => {
|
||||
// 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);
|
||||
for (const name of required) {
|
||||
expect(names, `missing required characterization case: ${name}`).toContain(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Placeholder for when we have real fixtures
|
||||
describe.skip("trusted-report-threshold-reached", () => {
|
||||
it("should hide targets with sufficient trusted reports", async () => {
|
||||
// Load fixture
|
||||
// const fixture = await import("../../fixtures/bitvid/trusted-report-threshold.fixture.js");
|
||||
// Run through evaluator
|
||||
// const decision = evaluate(fixture.state, fixture.target);
|
||||
// Compare to expectation
|
||||
// expect(decision).toEqual(fixture.expectation);
|
||||
describe.each(fixtures.map((fixture) => [fixture.name, fixture]))("%s", (_name, fixture) => {
|
||||
it(fixture.description, () => {
|
||||
const { passed, mismatches, decision } = runConformanceCase(fixture, REFERENCE_POLICY);
|
||||
expect(passed, `${fixture.behavior}\n ${mismatches.join("\n ")}`).toBe(true);
|
||||
expect(decision.policyProfile).toBe(fixture.profile);
|
||||
expect(decision.policyVersion).toBe(REFERENCE_POLICY.version);
|
||||
expect(decision.snapshotFingerprint).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
|
||||
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