From 4f689184e2d0e7108f56800d92732d7842211fb4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:33:41 -0400 Subject: [PATCH] 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. --- fixtures/bitvid/cases/admin-address-deny.json | 63 ++ .../cases/admin-event-deny-applies.json | 61 ++ fixtures/bitvid/cases/admin-event-deny.json | 56 ++ fixtures/bitvid/cases/admin-user-deny.json | 59 ++ .../cases/author-deny-reaches-events.json | 60 ++ fixtures/bitvid/cases/author-override.json | 65 ++ .../cases/blocked-reporter-ignored.json | 98 ++ .../cases/cache-fallback-effective.json | 57 ++ .../bitvid/cases/clean-target-allowed.json | 71 ++ .../cases/community-blacklist-merge.json | 58 ++ .../cases/curator-cannot-deny-events.json | 56 ++ .../bitvid/cases/denied-reporter-ignored.json | 108 +++ .../bitvid/cases/duplicate-muter-dedupe.json | 88 ++ .../cases/duplicate-reporter-dedupe.json | 100 ++ .../cases/evidence-redacted-by-default.json | 93 ++ .../bitvid/cases/expired-trusted-mute.json | 164 ++++ fixtures/bitvid/cases/home-hide-bypass.json | 158 +++ .../bitvid/cases/moderator-trust-seed.json | 72 ++ .../cases/non-spam-reports-never-hide.json | 114 +++ .../cases/personal-block-precedence.json | 80 ++ .../bitvid/cases/playback-enforces-hide.json | 56 ++ .../cases/playback-exposes-evidence.json | 93 ++ fixtures/bitvid/cases/protected-target.json | 56 ++ fixtures/bitvid/cases/recent-hide-bypass.json | 57 ++ .../revoked-actor-contribution-dropped.json | 48 + .../cases/seeds-yield-to-real-contacts.json | 74 ++ .../cases/spam-report-hide-threshold.json | 103 ++ .../cases/trusted-mute-at-threshold.json | 156 +++ .../cases/trusted-mute-below-threshold.json | 156 +++ .../cases/trusted-report-below-threshold.json | 87 ++ .../trusted-report-threshold-reached.json | 93 ++ .../cases/untrusted-reporter-ignored.json | 64 ++ .../viewer-override-cannot-escalate.json | 78 ++ .../cases/viewer-override-softens-hide.json | 166 ++++ fixtures/bitvid/characterization-cases.json | 39 +- package-lock.json | 6 +- packages/bitvid-compat/package.json | 4 + packages/bitvid-compat/src/profile.js | 127 +++ packages/core/src/adminState.js | 255 +++++ packages/core/src/authority.js | 442 ++++----- packages/core/src/evaluator.js | 888 ++++++++++------- packages/core/src/evidence.js | 104 ++ packages/core/src/fingerprint.js | 102 ++ packages/core/src/identifiers.js | 22 +- packages/core/src/index.js | 120 ++- packages/core/src/policy.js | 869 ++++++++++------- packages/core/src/reasons.js | 135 +++ packages/core/src/targets.js | 18 +- packages/core/test/adminState.test.js | 233 +++++ packages/core/test/authority.test.js | 531 ++++------- packages/core/test/evaluator.test.js | 898 +++++++++++------- packages/core/test/evidence.test.js | 73 ++ packages/core/test/fingerprint.test.js | 94 ++ packages/core/test/policy.test.js | 686 ++++++------- packages/core/test/reasons.test.js | 76 ++ packages/testing/package.json | 15 +- packages/testing/src/conformance.js | 215 ++++- packages/testing/test/conformance.test.js | 188 +++- scripts/build-characterization-corpus.mjs | 800 ++++++++++++++++ tests/conformance/bitvid-fixtures.test.js | 123 ++- 60 files changed, 7870 insertions(+), 2161 deletions(-) create mode 100644 fixtures/bitvid/cases/admin-address-deny.json create mode 100644 fixtures/bitvid/cases/admin-event-deny-applies.json create mode 100644 fixtures/bitvid/cases/admin-event-deny.json create mode 100644 fixtures/bitvid/cases/admin-user-deny.json create mode 100644 fixtures/bitvid/cases/author-deny-reaches-events.json create mode 100644 fixtures/bitvid/cases/author-override.json create mode 100644 fixtures/bitvid/cases/blocked-reporter-ignored.json create mode 100644 fixtures/bitvid/cases/cache-fallback-effective.json create mode 100644 fixtures/bitvid/cases/clean-target-allowed.json create mode 100644 fixtures/bitvid/cases/community-blacklist-merge.json create mode 100644 fixtures/bitvid/cases/curator-cannot-deny-events.json create mode 100644 fixtures/bitvid/cases/denied-reporter-ignored.json create mode 100644 fixtures/bitvid/cases/duplicate-muter-dedupe.json create mode 100644 fixtures/bitvid/cases/duplicate-reporter-dedupe.json create mode 100644 fixtures/bitvid/cases/evidence-redacted-by-default.json create mode 100644 fixtures/bitvid/cases/expired-trusted-mute.json create mode 100644 fixtures/bitvid/cases/home-hide-bypass.json create mode 100644 fixtures/bitvid/cases/moderator-trust-seed.json create mode 100644 fixtures/bitvid/cases/non-spam-reports-never-hide.json create mode 100644 fixtures/bitvid/cases/personal-block-precedence.json create mode 100644 fixtures/bitvid/cases/playback-enforces-hide.json create mode 100644 fixtures/bitvid/cases/playback-exposes-evidence.json create mode 100644 fixtures/bitvid/cases/protected-target.json create mode 100644 fixtures/bitvid/cases/recent-hide-bypass.json create mode 100644 fixtures/bitvid/cases/revoked-actor-contribution-dropped.json create mode 100644 fixtures/bitvid/cases/seeds-yield-to-real-contacts.json create mode 100644 fixtures/bitvid/cases/spam-report-hide-threshold.json create mode 100644 fixtures/bitvid/cases/trusted-mute-at-threshold.json create mode 100644 fixtures/bitvid/cases/trusted-mute-below-threshold.json create mode 100644 fixtures/bitvid/cases/trusted-report-below-threshold.json create mode 100644 fixtures/bitvid/cases/trusted-report-threshold-reached.json create mode 100644 fixtures/bitvid/cases/untrusted-reporter-ignored.json create mode 100644 fixtures/bitvid/cases/viewer-override-cannot-escalate.json create mode 100644 fixtures/bitvid/cases/viewer-override-softens-hide.json create mode 100644 packages/bitvid-compat/src/profile.js create mode 100644 packages/core/src/adminState.js create mode 100644 packages/core/src/evidence.js create mode 100644 packages/core/src/fingerprint.js create mode 100644 packages/core/src/reasons.js create mode 100644 packages/core/test/adminState.test.js create mode 100644 packages/core/test/evidence.test.js create mode 100644 packages/core/test/fingerprint.test.js create mode 100644 packages/core/test/reasons.test.js create mode 100644 scripts/build-characterization-corpus.mjs diff --git a/fixtures/bitvid/cases/admin-address-deny.json b/fixtures/bitvid/cases/admin-address-deny.json new file mode 100644 index 0000000..df374f5 --- /dev/null +++ b/fixtures/bitvid/cases/admin-address-deny.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/admin-event-deny-applies.json b/fixtures/bitvid/cases/admin-event-deny-applies.json new file mode 100644 index 0000000..226d438 --- /dev/null +++ b/fixtures/bitvid/cases/admin-event-deny-applies.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/admin-event-deny.json b/fixtures/bitvid/cases/admin-event-deny.json new file mode 100644 index 0000000..8aecd77 --- /dev/null +++ b/fixtures/bitvid/cases/admin-event-deny.json @@ -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": [] + } +} diff --git a/fixtures/bitvid/cases/admin-user-deny.json b/fixtures/bitvid/cases/admin-user-deny.json new file mode 100644 index 0000000..f7ea092 --- /dev/null +++ b/fixtures/bitvid/cases/admin-user-deny.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/author-deny-reaches-events.json b/fixtures/bitvid/cases/author-deny-reaches-events.json new file mode 100644 index 0000000..06604be --- /dev/null +++ b/fixtures/bitvid/cases/author-deny-reaches-events.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/author-override.json b/fixtures/bitvid/cases/author-override.json new file mode 100644 index 0000000..7e67e07 --- /dev/null +++ b/fixtures/bitvid/cases/author-override.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/cases/blocked-reporter-ignored.json b/fixtures/bitvid/cases/blocked-reporter-ignored.json new file mode 100644 index 0000000..2e45060 --- /dev/null +++ b/fixtures/bitvid/cases/blocked-reporter-ignored.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/cache-fallback-effective.json b/fixtures/bitvid/cases/cache-fallback-effective.json new file mode 100644 index 0000000..e1a73af --- /dev/null +++ b/fixtures/bitvid/cases/cache-fallback-effective.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/cases/clean-target-allowed.json b/fixtures/bitvid/cases/clean-target-allowed.json new file mode 100644 index 0000000..d253a98 --- /dev/null +++ b/fixtures/bitvid/cases/clean-target-allowed.json @@ -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": [] + } +} diff --git a/fixtures/bitvid/cases/community-blacklist-merge.json b/fixtures/bitvid/cases/community-blacklist-merge.json new file mode 100644 index 0000000..3ae1dc4 --- /dev/null +++ b/fixtures/bitvid/cases/community-blacklist-merge.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/cases/curator-cannot-deny-events.json b/fixtures/bitvid/cases/curator-cannot-deny-events.json new file mode 100644 index 0000000..443814f --- /dev/null +++ b/fixtures/bitvid/cases/curator-cannot-deny-events.json @@ -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": [] + } +} diff --git a/fixtures/bitvid/cases/denied-reporter-ignored.json b/fixtures/bitvid/cases/denied-reporter-ignored.json new file mode 100644 index 0000000..dbd1e6d --- /dev/null +++ b/fixtures/bitvid/cases/denied-reporter-ignored.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/duplicate-muter-dedupe.json b/fixtures/bitvid/cases/duplicate-muter-dedupe.json new file mode 100644 index 0000000..da18355 --- /dev/null +++ b/fixtures/bitvid/cases/duplicate-muter-dedupe.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/duplicate-reporter-dedupe.json b/fixtures/bitvid/cases/duplicate-reporter-dedupe.json new file mode 100644 index 0000000..c45765d --- /dev/null +++ b/fixtures/bitvid/cases/duplicate-reporter-dedupe.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/evidence-redacted-by-default.json b/fixtures/bitvid/cases/evidence-redacted-by-default.json new file mode 100644 index 0000000..d312eaa --- /dev/null +++ b/fixtures/bitvid/cases/evidence-redacted-by-default.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/expired-trusted-mute.json b/fixtures/bitvid/cases/expired-trusted-mute.json new file mode 100644 index 0000000..886e9de --- /dev/null +++ b/fixtures/bitvid/cases/expired-trusted-mute.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/home-hide-bypass.json b/fixtures/bitvid/cases/home-hide-bypass.json new file mode 100644 index 0000000..861135a --- /dev/null +++ b/fixtures/bitvid/cases/home-hide-bypass.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/cases/moderator-trust-seed.json b/fixtures/bitvid/cases/moderator-trust-seed.json new file mode 100644 index 0000000..7a298cc --- /dev/null +++ b/fixtures/bitvid/cases/moderator-trust-seed.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/non-spam-reports-never-hide.json b/fixtures/bitvid/cases/non-spam-reports-never-hide.json new file mode 100644 index 0000000..b70234c --- /dev/null +++ b/fixtures/bitvid/cases/non-spam-reports-never-hide.json @@ -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" + } + } +} diff --git a/fixtures/bitvid/cases/personal-block-precedence.json b/fixtures/bitvid/cases/personal-block-precedence.json new file mode 100644 index 0000000..1bf73c9 --- /dev/null +++ b/fixtures/bitvid/cases/personal-block-precedence.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/playback-enforces-hide.json b/fixtures/bitvid/cases/playback-enforces-hide.json new file mode 100644 index 0000000..3e1c0ed --- /dev/null +++ b/fixtures/bitvid/cases/playback-enforces-hide.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/cases/playback-exposes-evidence.json b/fixtures/bitvid/cases/playback-exposes-evidence.json new file mode 100644 index 0000000..e3ca392 --- /dev/null +++ b/fixtures/bitvid/cases/playback-exposes-evidence.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/protected-target.json b/fixtures/bitvid/cases/protected-target.json new file mode 100644 index 0000000..f568c90 --- /dev/null +++ b/fixtures/bitvid/cases/protected-target.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/recent-hide-bypass.json b/fixtures/bitvid/cases/recent-hide-bypass.json new file mode 100644 index 0000000..6185b12 --- /dev/null +++ b/fixtures/bitvid/cases/recent-hide-bypass.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/cases/revoked-actor-contribution-dropped.json b/fixtures/bitvid/cases/revoked-actor-contribution-dropped.json new file mode 100644 index 0000000..167a770 --- /dev/null +++ b/fixtures/bitvid/cases/revoked-actor-contribution-dropped.json @@ -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": [] + } +} diff --git a/fixtures/bitvid/cases/seeds-yield-to-real-contacts.json b/fixtures/bitvid/cases/seeds-yield-to-real-contacts.json new file mode 100644 index 0000000..a6cd659 --- /dev/null +++ b/fixtures/bitvid/cases/seeds-yield-to-real-contacts.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/spam-report-hide-threshold.json b/fixtures/bitvid/cases/spam-report-hide-threshold.json new file mode 100644 index 0000000..a5a8d4a --- /dev/null +++ b/fixtures/bitvid/cases/spam-report-hide-threshold.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/cases/trusted-mute-at-threshold.json b/fixtures/bitvid/cases/trusted-mute-at-threshold.json new file mode 100644 index 0000000..24d6e33 --- /dev/null +++ b/fixtures/bitvid/cases/trusted-mute-at-threshold.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/trusted-mute-below-threshold.json b/fixtures/bitvid/cases/trusted-mute-below-threshold.json new file mode 100644 index 0000000..9c95e59 --- /dev/null +++ b/fixtures/bitvid/cases/trusted-mute-below-threshold.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/trusted-report-below-threshold.json b/fixtures/bitvid/cases/trusted-report-below-threshold.json new file mode 100644 index 0000000..3d4ec9c --- /dev/null +++ b/fixtures/bitvid/cases/trusted-report-below-threshold.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/cases/trusted-report-threshold-reached.json b/fixtures/bitvid/cases/trusted-report-threshold-reached.json new file mode 100644 index 0000000..e8bed82 --- /dev/null +++ b/fixtures/bitvid/cases/trusted-report-threshold-reached.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/cases/untrusted-reporter-ignored.json b/fixtures/bitvid/cases/untrusted-reporter-ignored.json new file mode 100644 index 0000000..967ff15 --- /dev/null +++ b/fixtures/bitvid/cases/untrusted-reporter-ignored.json @@ -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 + } + } +} diff --git a/fixtures/bitvid/cases/viewer-override-cannot-escalate.json b/fixtures/bitvid/cases/viewer-override-cannot-escalate.json new file mode 100644 index 0000000..5596ea1 --- /dev/null +++ b/fixtures/bitvid/cases/viewer-override-cannot-escalate.json @@ -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": [] + } +} diff --git a/fixtures/bitvid/cases/viewer-override-softens-hide.json b/fixtures/bitvid/cases/viewer-override-softens-hide.json new file mode 100644 index 0000000..445680d --- /dev/null +++ b/fixtures/bitvid/cases/viewer-override-softens-hide.json @@ -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" + ] + } +} diff --git a/fixtures/bitvid/characterization-cases.json b/fixtures/bitvid/characterization-cases.json index 10f5c10..d586237 100644 --- a/fixtures/bitvid/characterization-cases.json +++ b/fixtures/bitvid/characterization-cases.json @@ -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" -] \ No newline at end of file + "clean-target-allowed" +] diff --git a/package-lock.json b/package-lock.json index 7291384..e55c673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1894,7 +1894,11 @@ } }, "packages/testing": { - "name": "@nostr-governance/testing" + "name": "@nostr-governance/testing", + "version": "0.1.0", + "dependencies": { + "@nostr-governance/core": "*" + } } } } diff --git a/packages/bitvid-compat/package.json b/packages/bitvid-compat/package.json index 00c2f83..6c4ff63 100644 --- a/packages/bitvid-compat/package.json +++ b/packages/bitvid-compat/package.json @@ -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": { diff --git a/packages/bitvid-compat/src/profile.js b/packages/bitvid-compat/src/profile.js new file mode 100644 index 0000000..a1dbbf5 --- /dev/null +++ b/packages/bitvid-compat/src/profile.js @@ -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} + */ +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} + */ +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, + }, + }, +}); diff --git a/packages/core/src/adminState.js b/packages/core/src/adminState.js new file mode 100644 index 0000000..a48e50b --- /dev/null +++ b/packages/core/src/adminState.js @@ -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} userAllow - Target keys explicitly allowed + * @property {Set} userDeny - Target keys denied + * @property {Set} eventDeny + * @property {Set} addressDeny + * @property {Set} trustSeeds - Pubkeys seeding the trust graph + * @property {Map} contributors - Target key to contributing actor pubkeys + * @property {Map} communitySources - Target key to community list ids that denied it + */ + +/** @type {Record} */ +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} */ +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(), + }; +} diff --git a/packages/core/src/authority.js b/packages/core/src/authority.js index 7694924..ac577e2 100644 --- a/packages/core/src/authority.js +++ b/packages/core/src/authority.js @@ -1,278 +1,258 @@ -// 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} + */ +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} roles - Map of role names to definitions - * @property {Record} actors - Map of pubkeys to actors + * @property {Record} roles - Role name to capabilities + * @property {Record} 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} + * 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" - ] - } -}; - -/** - * Get capabilities for a role - * @param {string} roleName - * @param {AuthorityState} [authorityState] - * @returns {string[]} - */ -export function getRoleCapabilities(roleName, authorityState) { - // Check custom roles first - if (authorityState && authorityState.roles[roleName]) { - return authorityState.roles[roleName].capabilities; - } - - // Check built-in roles - if (BUILTIN_ROLES[roleName]) { - return BUILTIN_ROLES[roleName].capabilities; - } - - // Unknown role has no capabilities - return []; +export function isGovernanceCapability(value) { + return typeof value === "string" && CAPABILITY_SET.has(/** @type {GovernanceCapability} */ (value)); } /** - * Check if an actor has a specific role - * @param {string} pubkey - 64-character hex pubkey - * @param {string} roleName - * @param {AuthorityState} [authorityState] - * @returns {boolean} + * 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} [options.roles] + * @param {Record} [options.actors] + * @param {string[]} [options.protectedActors] + * @param {string} [options.root] + * @returns {AuthorityState} */ -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); +export function createAuthorityState(options = {}) { + /** @type {Record} */ + 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), + ); } - - // 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; + /** @type {Record} */ + 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)); } } - - return false; + + 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 all capabilities for an actor - * @param {string} pubkey - 64-character hex pubkey - * @param {AuthorityState} [authorityState] + * Get the role names assigned to an actor. + * @param {string} pubkey + * @param {AuthorityState} authority * @returns {string[]} */ -export function getActorCapabilities(pubkey, authorityState) { - // Get all roles for the actor - const roles = getActorRoles(pubkey, authorityState); - - // Collect all capabilities from all roles +export function getActorRoles(pubkey, authority) { + const normalized = normalizePubkey(pubkey); + if (!normalized) { + return []; + } + return [...(authority.actors[normalized] ?? [])]; +} + +/** + * 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 []; + } + + if (authority.root && normalized === authority.root) { + return [...GOVERNANCE_CAPABILITIES]; + } + + /** @type {Set} */ 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); } } - + return Array.from(capabilities); } /** - * 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()) - }; + return { name: name.trim(), capabilities: [...new Set(capabilities)] }; } - -/** - * Create a new authority state - * @param {Record} [roles] - * @param {Record} [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 - }; -} \ No newline at end of file diff --git a/packages/core/src/evaluator.js b/packages/core/src/evaluator.js index 0bc3a63..b408b2b 100644 --- a/packages/core/src/evaluator.js +++ b/packages/core/src/evaluator.js @@ -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} 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} [blocks] - Pubkeys the viewer blocks outright + * @property {Set} [mutes] - Pubkeys the viewer mutes + * @property {Map} [overrides] - Viewer overrides by target key */ /** - * @typedef {Object} GovernanceState - * @property {AuthorityState} authority - Authority state - * @property {Record} reports - Reports by target key - * @property {Record} trustedMutes - Trusted mutes by target key - * @property {Record} overrides - Overrides by target key - * @property {Record} policies - Policy definitions by ID + * @typedef {Object} TrustState + * @property {Set} [contacts] - Pubkeys the viewer trusts directly + * @property {Set} [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} [reports] - Reports by target key + * @property {Map} [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} + */ +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} 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} trustSet + * @returns {{ total: number, byCategory: Record, reporters: string[] }} + */ +export function aggregateReports(key, snapshot, viewer, trustSet) { + const records = snapshot.reports?.get(key) ?? []; + /** @type {Map>} */ + const byCategory = new Map(); + /** @type {Set} */ + 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} */ (byCategory.get(category)).add(record.reporter); + reporters.add(record.reporter); + } + + /** @type {Record} */ + 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} trustSet + * @param {number} now - Unix seconds + * @param {number} [windowSeconds] - 0 or undefined disables expiry + * @returns {{ total: number, byCategory: Record, 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} */ + const muters = new Set(); + /** @type {Map>} */ + 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} */ (byCategory.get(category)).add(record.muter); + } + } + + /** @type {Record} */ + 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); - } - - // 4. Check trusted mutes - const muteDecision = checkTrustedMutes(target, state, context); - if (muteDecision) { - decisions.push(muteDecision); - } - - // 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") } + + const authors = authorChain(target); + + // 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; + + // 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); -} -/** - * 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; -} + if (authors.some((pubkey) => viewer.mutes?.has(pubkey))) { + evidence.personalMute = true; + decision.reasons = dedupeReasons([...decision.reasons, createReason("viewer-mute")]); + } -/** - * 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 (!override) { - return null; - } - - // 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, - reasons, - [override] - ); -} + // 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}`)); -/** - * 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; + 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; + } + + if (targetDenied || authorDenied) { + const reasonId = + target.type === "event" && targetDenied + ? "admin-event-deny" + : target.type === "address" && targetDenied + ? "admin-address-deny" + : "admin-user-deny"; + + 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] })); + } + + applyEffects( + decision, + profile.administrativeDeny ?? { visibility: "hide", interaction: "deny" }, + reasons, + ); + } + } 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} */ - 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; + + // 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")], + ); + } + } else { + evidence.userAllowed = authors.some((pubkey) => snapshot.admin.userAllow.has(`user:${pubkey}`)); + } + + // 9-11. Trust signals. Aggregated after administrative state so that denied + // accounts are already excluded from counting. + const trustSet = resolveTrustSet(snapshot.trust); + + const mutes = aggregateMutes(key, snapshot, viewer, trustSet, now, profile.muteWindowSeconds); + evidence.trustedMuteTotal = mutes.total; + evidence.trustedMutesByCategory = mutes.byCategory; + evidence.trustedMuterPubkeys = mutes.muters; + + 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); + + 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), + }), + ]); } - 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}`); + + 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), + }), + ]); } } - - if (visibilityEffect === "allow" && interactionEffect === "allow") { - return null; + + 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")]); + } + } } - - return createGovernanceDecision( - { effect: visibilityEffect, reason: reasons.join("; ") }, - { effect: interactionEffect, reason: reasons.join("; ") }, - undefined, - reasons, - evidence - ); + + // 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; } /** - * Check trusted mutes against a target - * @param {GovernanceTarget} target - * @param {GovernanceState} state - * @param {PolicyContext} context - * @returns {GovernanceDecision|null} + * 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 checkTrustedMutes(target, state, context) { - const targetKey = getTargetKey(target); - const trustedMute = state.trustedMutes[targetKey]; - - if (!trustedMute) { - return null; +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; + } + } } - - // 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]; - - 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`); - } - - if (visibilityEffect === "allow" && interactionEffect === "allow") { - return null; - } - - return createGovernanceDecision( - { effect: visibilityEffect, reason: reasons.join("; ") }, - { effect: interactionEffect, reason: reasons.join("; ") }, - undefined, - reasons, - evidence - ); + return false; } /** - * Check if target is a descendant of a denied ancestor - * @param {GovernanceTarget} target - * @param {GovernanceState} state - * @param {PolicyContext} context - * @returns {GovernanceDecision|null} + * 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 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; +function lowestFiredThreshold(thresholds, firedGates) { + let lowest; + for (const gate of firedGates) { + const value = /** @type {Record} */ (thresholds)[gate]; + if (Number.isFinite(value) && (lowest === undefined || /** @type {number} */ (value) < lowest)) { + lowest = /** @type {number} */ (value); + } + } + return lowest; } /** - * Evaluate multiple targets against the governance state + * 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} + * @param {GovernanceSnapshot} snapshot + * @param {PolicyContext} [context] + * @param {ViewerState} [viewerState] + * @returns {Map} */ -export function evaluateMany(targets, state, context, viewerPubkey) { - /** @type {Record} */ - const results = {}; - +export function evaluateMany(targets, snapshot, context, viewerState) { + /** @type {Map} */ + 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} [reports] - * @param {Record} [trustedMutes] - * @param {Record} [overrides] - * @param {Record} [policies] - * @returns {GovernanceState} + * Create a governance snapshot with empty defaults. + * @param {Partial} [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} [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} 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; -} \ No newline at end of file diff --git a/packages/core/src/evidence.js b/packages/core/src/evidence.js new file mode 100644 index 0000000..c4ed598 --- /dev/null +++ b/packages/core/src/evidence.js @@ -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} trustedReportsByCategory + * @property {string[]} trustedReporterPubkeys + * @property {number} trustedMuteTotal + * @property {Record} 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} */ + const reportsByCategory = {}; + for (const key of Object.keys(evidence.trustedReportsByCategory).sort()) { + reportsByCategory[key] = evidence.trustedReportsByCategory[key]; + } + + /** @type {Record} */ + 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: [], + }; +} diff --git a/packages/core/src/fingerprint.js b/packages/core/src/fingerprint.js new file mode 100644 index 0000000..a91d6ba --- /dev/null +++ b/packages/core/src/fingerprint.js @@ -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} */ (value)).sort(); + const parts = []; + for (const key of keys) { + const entry = /** @type {Record} */ (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, + }); +} diff --git a/packages/core/src/identifiers.js b/packages/core/src/identifiers.js index 77db2cf..0db0833 100644 --- a/packages/core/src/identifiers.js +++ b/packages/core/src/identifiers.js @@ -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; } /** diff --git a/packages/core/src/index.js b/packages/core/src/index.js index fbfd7d3..b52794d 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1,2 +1,118 @@ -// Placeholder for core module exports -export {}; \ No newline at end of file +// @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 + */ diff --git a/packages/core/src/policy.js b/packages/core/src/policy.js index dd2c0d7..06fda70 100644 --- a/packages/core/src/policy.js +++ b/packages/core/src/policy.js @@ -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} */ +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} 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} [reports] - Report thresholds by category ("default" applies to unlisted categories) + * @property {Record} [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} 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} 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} - */ -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} 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} 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} [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 {}; + } + + /** @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; } - - // Check built-in profiles - if (BUILTIN_POLICY_PROFILES[profileName] && - BUILTIN_POLICY_PROFILES[profileName].effects[effectType]) { - return BUILTIN_POLICY_PROFILES[profileName].effects[effectType]; - } - - // Default to allow - return { effect: "allow" }; + + return normalized; } /** - * Compose multiple governance decisions into a single decision + * Normalize category thresholds, dropping non-numeric entries. + * @param {Record} [thresholds] + * @param {string} [location] + * @returns {Record} + */ +export function normalizeCategoryThresholds(thresholds, location = "profile") { + if (!thresholds || typeof thresholds !== "object") { + return {}; + } + + /** @type {Record} */ + 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} */ (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} */ + 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} [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) + ); + } + + if (effects.interaction) { + decision.interaction.effect = /** @type {InteractionEffect} */ ( + maxEffect("interaction", decision.interaction.effect, effects.interaction) + ); + } + + if (effects.transaction) { + if (!decision.transaction) { + decision.transaction = { effect: "allow" }; + } + decision.transaction.effect = /** @type {TransactionEffect} */ ( + maxEffect("transaction", decision.transaction.effect, effects.transaction) + ); + } + + 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 composeGovernanceDecisions(decisions) { +export function composeDecisions(decisions) { if (!Array.isArray(decisions) || decisions.length === 0) { - // Return a default allow decision - return createGovernanceDecision( - { effect: "allow" }, - { effect: "allow" }, - undefined, - [], - [] - ); + return createNeutralDecision(); } - - // 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; - } - - // 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; - } - - // Compose transaction effect if present + + 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; - } + composed.transaction.effect = /** @type {TransactionEffect} */ ( + maxEffect("transaction", composed.transaction.effect, decision.transaction.effect) + ); } } - - // Combine reasons and evidence - for (const reason of decision.reasons) { - if (!composed.reasons.includes(reason)) { - composed.reasons.push(reason); - } - } - - for (const evidence of decision.evidence) { - composed.evidence.push(evidence); - } + + composed.reasons = [...composed.reasons, ...decision.reasons]; } - + + composed.reasons = dedupeReasons(composed.reasons); return composed; -} \ No newline at end of file +} + +/** + * 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 }; diff --git a/packages/core/src/reasons.js b/packages/core/src/reasons.js new file mode 100644 index 0000000..b6d797a --- /dev/null +++ b/packages/core/src/reasons.js @@ -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} */ + 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()); +} diff --git a/packages/core/src/targets.js b/packages/core/src/targets.js index c70d2fe..deca06f 100644 --- a/packages/core/src/targets.js +++ b/packages/core/src/targets.js @@ -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); diff --git a/packages/core/test/adminState.test.js b/packages/core/test/adminState.test.js new file mode 100644 index 0000000..0919a2e --- /dev/null +++ b/packages/core/test/adminState.test.js @@ -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); + }); +}); diff --git a/packages/core/test/authority.test.js b/packages/core/test/authority.test.js index f091b17..4c49a82 100644 --- a/packages/core/test/authority.test.js +++ b/packages/core/test/authority.test.js @@ -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"); - - // 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 ROOT = "a1".repeat(32); +const MODERATOR = "b2".repeat(32); +const CURATOR = "c3".repeat(32); +const STRANGER = "d4".repeat(32); + +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"] - } - }); - - const capabilities = getRoleCapabilities("custom-role", authorityState); - expect(capabilities).toEqual(["custom-capability"]); - }); - - it("should prefer custom roles over built-in roles", () => { - const authorityState = createAuthorityState({ - "root": { - name: "root", - capabilities: ["custom-root-capability"] - } - }); - - const capabilities = getRoleCapabilities("root", authorityState); - expect(capabilities).toEqual(["custom-root-capability"]); - }); - - it("should return empty array for unknown roles", () => { - const capabilities = getRoleCapabilities("unknown-role"); - expect(capabilities).toEqual([]); - }); +describe("capabilities", () => { + it("recognizes every documented capability", () => { + for (const capability of GOVERNANCE_CAPABILITIES) { + expect(isGovernanceCapability(capability)).toBe(true); + } }); - 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); - }); + it("rejects unknown capabilities", () => { + expect(isGovernanceCapability("delete-everything")).toBe(false); + expect(isGovernanceCapability("")).toBe(false); + expect(isGovernanceCapability(null)).toBe(false); }); - 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([]); - }); + it("gives super_admin every capability", () => { + expect([...DEFAULT_ROLE_CAPABILITIES.super_admin].sort()).toEqual( + [...GOVERNANCE_CAPABILITIES].sort(), + ); }); - 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); - }); + 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); }); - 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([]); - }); + 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"); }); - 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("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"); + }); +}); - 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("createAuthorityState", () => { + 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); }); - 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("drops actors with no roles", () => { + const state = createAuthorityState({ actors: { [MODERATOR]: [] } }); + expect(state.actors[MODERATOR]).toBeUndefined(); }); - 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("should create empty authority state with no arguments", () => { - const state = createAuthorityState(); - expect(state).toEqual({ roles: {}, actors: {} }); - }); - - it("should validate role names", () => { - const roles = { - "": { - name: "", - capabilities: ["test-capability"] - } - }; - - expect(() => createAuthorityState(roles)).toThrow(); - }); - - it("should validate role definitions", () => { - const roles = { - "test-role": /** @type {any} */ (null) - }; - - expect(() => createAuthorityState(roles)).toThrow(); - }); - - it("should validate role name consistency", () => { - const roles = { - "test-role": { - name: "different-name", - capabilities: ["test-capability"] - } - }; - - expect(() => createAuthorityState(roles)).toThrow(); - }); - - it("should validate actor pubkeys", () => { - const actors = { - "invalid-pubkey": { - pubkey: "invalid-pubkey", - roles: ["test-role"] - } - }; - - expect(() => createAuthorityState({}, actors)).toThrow(); - }); - - 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(); - }); + it("deduplicates repeated roles", () => { + const state = createAuthorityState({ actors: { [MODERATOR]: ["moderator", "moderator"] } }); + expect(state.actors[MODERATOR]).toEqual(["moderator"]); }); -}); \ No newline at end of file + + it("always protects the root administrator", () => { + const state = createAuthorityState({ root: ROOT }); + expect(state.protectedActors).toContain(ROOT); + }); + + 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("filters unknown capabilities out of custom roles", () => { + const state = createAuthorityState({ + roles: { odd: /** @type {any} */ (["manage-policy", "not-a-capability"]) }, + }); + 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/); + }); +}); diff --git a/packages/core/test/evaluator.test.js b/packages/core/test/evaluator.test.js index 950693e..21116e6 100644 --- a/packages/core/test/evaluator.test.js +++ b/packages/core/test/evaluator.test.js @@ -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", () => { - 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: [] - }); - }); +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; - 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"); - }); +const trusted = (index) => index.toString(16).padStart(2, "0").repeat(32); +const TRUSTED = Array.from({ length: 24 }, (_, index) => trusted(index + 1)); - 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"); - }); +/** @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 }); - 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"); - }); +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: {}, + }, + }, +}); - it("should restrict target with moderate number of trusted mutes", () => { - const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); - - const state = createGovernanceState( - undefined, - undefined, - { - "user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": - createTrustedMute(target, DEFAULT_THRESHOLDS.trustedMuteRestrictThreshold, { - "mute": DEFAULT_THRESHOLDS.trustedMuteRestrictThreshold - }) - } - ); - - const context = createPolicyContext("feed", "default"); - - const decision = evaluateTarget(target, state, context); - expect(decision.visibility.effect).toBe("restrict"); - expect(decision.interaction.effect).toBe("allow"); - }); - - it("should apply explicit overrides", () => { - const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); - - const state = createGovernanceState( - undefined, - undefined, - undefined, - { - "user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": - createOverride(target, "hide", "Administrative override") - } - ); - - const context = createPolicyContext("feed", "default"); - - const decision = evaluateTarget(target, state, context); - expect(decision.visibility.effect).toBe("hide"); - expect(decision.interaction.effect).toBe("hide"); - expect(decision.reasons).toContain("Administrative override"); - }); - - it("should ignore overrides when enforcement disallows them", () => { - const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); - - const state = createGovernanceState( - undefined, - undefined, - undefined, - { - "user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": - createOverride(target, "hide", "Administrative override") - } - ); - - const context = createPolicyContext("feed", "default", { - allowOverrides: false - }); - - const decision = evaluateTarget(target, state, context); - expect(decision.visibility.effect).toBe("allow"); - expect(decision.interaction.effect).toBe("allow"); - }); +const authority = () => + createAuthorityState({ + root: ROOT, + actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] }, }); - 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: [] - }); - - expect(results["event:1234567890123456789012345678901234567890123456789012345678901234"]).toEqual({ - visibility: { effect: "allow" }, - interaction: { effect: "allow" }, - reasons: [], - evidence: [] - }); - }); +/** + * @param {Object} [parts] + * @param {import("../src/adminState.js").Contribution[]} [parts.contributions] + * @param {{ contacts?: Set, seeds?: Set }} [parts.trust] + * @param {Map} [parts.reports] + * @param {Map} [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("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([]); }); - 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("rejects an invalid target", () => { + expect(() => evaluateTarget(/** @type {any} */ ({ type: "user", pubkey: "nope" }), snapshotWith(), context())).toThrow( + /Invalid governance target/, + ); + }); - it("should create a governance state with default empty objects", () => { - const state = createGovernanceState(); - expect(state).toEqual({ - authority: { roles: {}, actors: {} }, + 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: {}, - trustedMutes: {}, - overrides: {}, - policies: {} - }); - }); + mutes: {}, + }, + }, }); - describe("createReport", () => { - it("should create a valid report", () => { - const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); - const report = createReport( - "1234567890123456789012345678901234567890123456789012345678901234", - target, - "spam", - Date.now() - ); - - expect(report).toEqual({ - reporter: "1234567890123456789012345678901234567890123456789012345678901234", - target, - category: "spam", - timestamp: expect.any(Number) - }); + it("caps a hide at the configured ceiling", () => { + const snapshot = snapshotWith({ + contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }], }); - - it("should throw error for invalid reporter", () => { - const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); - expect(() => createReport("invalid", target, "spam", Date.now())).toThrow(); - }); - - 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(); + 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"); }); - 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("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"); + }); +}); - 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("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([]); }); - 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("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); }); -}); \ No newline at end of file +}); + +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("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"); + }); + + it("skips invalid targets rather than throwing", () => { + const results = evaluateMany( + [user(CREATOR), /** @type {any} */ ({ type: "user", pubkey: "bad" })], + snapshotWith(), + context(), + ); + expect(results.size).toBe(1); + }); + + it("deduplicates repeated targets", () => { + const results = evaluateMany([user(CREATOR), user(CREATOR)], snapshotWith(), context()); + expect(results.size).toBe(1); + }); + + 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())); + }); +}); diff --git a/packages/core/test/evidence.test.js b/packages/core/test/evidence.test.js new file mode 100644 index 0000000..ec0e665 --- /dev/null +++ b/packages/core/test/evidence.test.js @@ -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 }); + }); +}); diff --git a/packages/core/test/fingerprint.test.js b/packages/core/test/fingerprint.test.js new file mode 100644 index 0000000..aee2c24 --- /dev/null +++ b/packages/core/test/fingerprint.test.js @@ -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}$/); + }); +}); diff --git a/packages/core/test/policy.test.js b/packages/core/test/policy.test.js index 8d92a42..7fdccb0 100644 --- a/packages/core/test/policy.test.js +++ b/packages/core/test/policy.test.js @@ -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"); - - // 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"); - } +describe("effect ladders", () => { + it("orders visibility from permissive to severe", () => { + expect(VISIBILITY_LADDER).toEqual(["allow", "warn", "restrict", "hide", "deny"]); + }); + + 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("normalizeDimensionEffects", () => { + it("passes valid effects through", () => { + expect(normalizeDimensionEffects({ visibility: "hide", interaction: "deny" })).toEqual({ + visibility: "hide", + interaction: "deny", }); }); - 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" } - }); - }); + it("rejects an effect from the wrong ladder", () => { + expect(() => normalizeDimensionEffects({ interaction: /** @type {any} */ ("hide") })).toThrow( + /invalid interaction effect/, + ); + }); - it("should create a policy effect without optional fields", () => { - const effect = createPolicyEffect("allow"); - expect(effect).toEqual({ effect: "allow" }); - }); + it("returns an empty object for missing input", () => { + expect(normalizeDimensionEffects(/** @type {any} */ (undefined))).toEqual({}); + }); +}); - it("should throw error for invalid effect", () => { - expect(() => createPolicyEffect(/** @type {any} */ ("invalid"))).toThrow(); - expect(() => createPolicyEffect(/** @type {any} */ (123))).toThrow(); +describe("normalizeCategoryThresholds", () => { + it("lowercases categories and floors values", () => { + expect(normalizeCategoryThresholds({ SPAM: { hide: 5.7 } })).toEqual({ spam: { hide: 5 } }); + }); + + it("drops non-numeric gates", () => { + expect(normalizeCategoryThresholds({ spam: { hide: /** @type {any} */ ("many") } })).toEqual({ + spam: {}, }); }); - 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("rejects negative thresholds", () => { + expect(() => normalizeCategoryThresholds({ spam: { hide: -1 } })).toThrow(/must be >= 0/); + }); +}); - it("should throw error for invalid profile name", () => { - expect(() => createPolicyProfile("", {})).toThrow(); - expect(() => createPolicyProfile(/** @type {any} */ (123), {})).toThrow(); - }); +describe("normalizePolicyDefinition", () => { + const valid = { + id: "app", + version: "1.0.0", + profiles: { feed: { name: "feed" } }, + }; - 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(); - }); + 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); }); - describe("createPolicyDefinition", () => { - it("should create a valid policy definition", () => { - const definition = createPolicyDefinition( - "test-policy", - "Test Policy", - "A test policy", - { - "default": { - name: "default", - effects: { - "visibility": { effect: "allow" } - } - } - } - ); - - expect(definition).toEqual({ - id: "test-policy", - name: "Test Policy", - description: "A test policy", - profiles: { - "default": { - name: "default", - effects: { - "visibility": { effect: "allow" } - } - } - } - }); - }); - - 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(); - }); - - 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("requires an id and a version", () => { + expect(() => normalizePolicyDefinition({ ...valid, id: "" })).toThrow(/non-empty id/); + expect(() => normalizePolicyDefinition({ ...valid, version: "" })).toThrow(/version string/); }); - describe("createPolicyContext", () => { - it("should create a valid policy context", () => { - const context = createPolicyContext("feed", "default"); - expect(context).toEqual({ - surface: "feed", - policyProfile: "default" - }); - }); - - 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(); - }); + it("requires at least one profile", () => { + expect(() => normalizePolicyDefinition({ ...valid, profiles: {} })).toThrow(/at least one profile/); }); - 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(); - }); + it("rejects a default profile that does not exist", () => { + expect(() => normalizePolicyDefinition({ ...valid, defaultProfile: "nope" })).toThrow( + /is not defined/, + ); }); - describe("getPolicyEffect", () => { - it("should return effect for built-in profiles", () => { - const effect = getPolicyEffect("strict", "visibility"); - expect(effect).toEqual({ effect: "hide" }); - }); - - it("should return effect for custom policies", () => { - const customPolicies = { - "custom-policy": { - id: "custom-policy", - name: "Custom Policy", - description: "A custom policy", - profiles: { - "custom-profile": { - name: "custom-profile", - effects: { - "visibility": { effect: "hide", reason: "Custom hidden" } - } - } - } - } - }; - - const effect = getPolicyEffect("custom-profile", "visibility", /** @type {any} */ (customPolicies)); - expect(effect).toEqual({ effect: "hide", reason: "Custom hidden" }); - }); - - 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" } - } - } - } - } - }; - - const effect = getPolicyEffect("default", "visibility", /** @type {any} */ (customPolicies)); - expect(effect).toEqual({ effect: "hide", reason: "Custom default" }); - }); - - it("should return default allow effect for unknown profiles/effects", () => { - const effect = getPolicyEffect("unknown-profile", "unknown-effect"); - expect(effect).toEqual({ effect: "allow" }); + 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"); }); - 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("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("should return the single decision for array with one element", () => { - const inputDecision = createGovernanceDecision( - { effect: "hide", reason: "Hidden" }, - { effect: "deny", reason: "Denied" }, - undefined, - ["reason1"], - [{ evidence: "evidence1" }] - ); - - const decision = composeGovernanceDecisions([inputDecision]); - expect(decision).toEqual(inputDecision); + it("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, }); - - 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("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("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" } - ) - ]; - - const decision = composeGovernanceDecisions(decisions); - expect(decision.transaction).toEqual({ effect: "deny", reason: "Transaction denied" }); + expect(effects).toEqual({ + ranking: "downrank", + visibility: "restrict", + interaction: "require-explicit-action", + transaction: "deny", }); }); -}); \ No newline at end of file +}); + +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; + }; + + it("returns a neutral decision for an empty list", () => { + const composed = composeDecisions([]); + expect(composed.visibility.effect).toBe("allow"); + }); + + it("keeps the most severe effect", () => { + expect(composeDecisions([withVisibility("warn"), withVisibility("hide")]).visibility.effect).toBe( + "hide", + ); + }); + + 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("sums ranking weight", () => { + expect(composeDecisions([withVisibility("allow", 2), withVisibility("allow", 3)]).ranking.weight).toBe(5); + }); + + 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); + }); + + 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({}); + }); +}); diff --git a/packages/core/test/reasons.test.js b/packages/core/test/reasons.test.js new file mode 100644 index 0000000..2ae7281 --- /dev/null +++ b/packages/core/test/reasons.test.js @@ -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); + }); +}); diff --git a/packages/testing/package.json b/packages/testing/package.json index 9d7584b..81e18fb 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -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": "*" } -} \ No newline at end of file +} diff --git a/packages/testing/src/conformance.js b/packages/testing/src/conformance.js index 37b8637..de95150 100644 --- a/packages/testing/src/conformance.js +++ b/packages/testing/src/conformance.js @@ -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 || {}, +// 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} [evidence] - Subset of evidence fields + */ + +/** + * Build a governance snapshot from a fixture. + * @param {Object} fixture + * @returns {{ snapshot: ReturnType, viewer: ReturnType }} + */ +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>} */ + const reports = new Map(); + for (const [key, records] of Object.entries(fixture.reports ?? {})) { + reports.set(key, records); + } + + /** @type {Map>} */ + 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 ?? []), }, - target, - expectation, - }; + reports, + trustedMutes, + }); + + /** @type {Map} */ + 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 }; } -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, - }; -} \ No newline at end of file +/** + * 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} */ (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} */ (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 }; +} diff --git a/packages/testing/test/conformance.test.js b/packages/testing/test/conformance.test.js index 86924a0..9b02332 100644 --- a/packages/testing/test/conformance.test.js +++ b/packages/testing/test/conformance.test.js @@ -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: {}, + }, + }, +}); + +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" }, + }, +}); + +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); + }); + + it("converts viewer arrays into sets and maps", () => { + const fixture = { + ...baseFixture(), + viewer: { + blocks: [CREATOR], + mutes: [MODERATOR], + overrides: [{ key: `user:${CREATOR}`, visibility: "allow" }], }, - { 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" }); + }; + 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("runs conformance tests", () => { - const fixture = createFixture( - "test-fixture", - {}, - { type: "user", pubkey: "test" }, - { visibility: "allow" } - ); - - // Mock evaluator that always returns the expectation - const mockEvaluator = () => ({ visibility: "allow" }); - - const result = runConformanceTest(fixture, mockEvaluator); - expect(result.passed).toBe(true); - expect(result.actual).toEqual({ visibility: "allow" }); - expect(result.expected).toEqual({ visibility: "allow" }); + + 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); }); -}); \ No newline at end of file +}); + +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); + }); +}); diff --git a/scripts/build-characterization-corpus.mjs b/scripts/build-characterization-corpus.mjs new file mode 100644 index 0000000..476b358 --- /dev/null +++ b/scripts/build-characterization-corpus.mjs @@ -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); +}); diff --git a/tests/conformance/bitvid-fixtures.test.js b/tests/conformance/bitvid-fixtures.test.js index 411491a..5ce6c9c 100644 --- a/tests/conformance/bitvid-fixtures.test.js +++ b/tests/conformance/bitvid-fixtures.test.js @@ -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", - "trusted-report-threshold-reached", - "duplicate-reporter-dedupe", - "blocked-reporter-ignored", - "trusted-mute-below-threshold", - "trusted-mute-at-threshold", - "expired-trusted-mute", - "viewer-override", - "author-override", - "cache-fallback-effective", - "report-category-thresholds", - "mute-aggregation-rules", - "hide-vs-restrict-vs-downrank", - "moderation-decorator-fields" -]; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; -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); +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); }); - - // 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); - }); + + 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()); }); -}); \ No newline at end of file + + 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", + "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", + ]; + for (const name of required) { + expect(names, `missing required characterization case: ${name}`).toContain(name); + } + }); +}); + +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); + } + }); +});