diff --git a/packages/core/src/evaluator.js b/packages/core/src/evaluator.js new file mode 100644 index 0000000..0bc3a63 --- /dev/null +++ b/packages/core/src/evaluator.js @@ -0,0 +1,441 @@ +// Evaluator for Nostr Governance +// +// This module brings together all the core components to evaluate governance +// decisions for targets based on state and policies. + +import { getTargetKey } from "./identifiers.js"; +import { areTargetsEqual, isDescendantOf } from "./targets.js"; +import { hasCapability } from "./authority.js"; +import { + createGovernanceDecision, + getPolicyEffect, + composeGovernanceDecisions +} from "./policy.js"; + +/** + * @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget + * @typedef {import('./authority.js').AuthorityState} AuthorityState + * @typedef {import('./policy.js').PolicyContext} PolicyContext + * @typedef {import('./policy.js').GovernanceDecision} GovernanceDecision + * @typedef {import('./policy.js').PolicyDefinition} PolicyDefinition + */ + +/** + * @typedef {Object} Report + * @property {string} reporter - Pubkey of the reporter + * @property {GovernanceTarget} target - Target being reported + * @property {string} category - Report category + * @property {number} timestamp - Unix timestamp of the report + */ + +/** + * @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} Override + * @property {GovernanceTarget} target - Target being overridden + * @property {"allow"|"restrict"|"hide"|"deny"} visibility - Visibility override + * @property {string} [reason] - Reason for the override + */ + +/** + * @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 + */ + +/** + * Default thresholds for governance decisions + */ +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 + * @param {GovernanceTarget} target + * @param {GovernanceState} state + * @param {PolicyContext} context + * @param {string} [viewerPubkey] - Pubkey of the viewer (for personal overrides) + * @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); + } + } + + // 2. Check for explicit overrides + const explicitOverrideDecision = checkExplicitOverrides(target, state, context); + if (explicitOverrideDecision) { + decisions.push(explicitOverrideDecision); + } + + // 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") } + ); + } + + 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; +} + +/** + * 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] + ); +} + +/** + * 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; + } + + // 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; + } + trustedReportCounts[report.category]++; + evidence.push(report); + } + } + + // Determine effect based on trusted report counts + let visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow"); + let interactionEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("allow"); + /** @type {string[]} */ + const reasons = []; + + for (const [category, count] of Object.entries(trustedReportCounts)) { + if (count >= DEFAULT_THRESHOLDS.trustedReportHideThreshold) { + visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("hide"); + interactionEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("deny"); + reasons.push(`Hidden due to ${count} trusted reports in category ${category}`); + } else if (count >= DEFAULT_THRESHOLDS.trustedReportRestrictThreshold) { + visibilityEffect = /** @type {import('./policy.js').PolicyEffect["effect"]} */ ("restrict"); + reasons.push(`Restricted due to ${count} trusted reports in category ${category}`); + } + } + + if (visibilityEffect === "allow" && interactionEffect === "allow") { + return null; + } + + return createGovernanceDecision( + { effect: visibilityEffect, reason: reasons.join("; ") }, + { effect: interactionEffect, reason: reasons.join("; ") }, + undefined, + reasons, + evidence + ); +} + +/** + * Check trusted mutes against a target + * @param {GovernanceTarget} target + * @param {GovernanceState} state + * @param {PolicyContext} context + * @returns {GovernanceDecision|null} + */ +function checkTrustedMutes(target, state, context) { + const targetKey = getTargetKey(target); + const trustedMute = state.trustedMutes[targetKey]; + + if (!trustedMute) { + return null; + } + + // Determine effect based on trusted mute count + 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 + ); +} + +/** + * Check if target is a descendant of a denied ancestor + * @param {GovernanceTarget} target + * @param {GovernanceState} state + * @param {PolicyContext} context + * @returns {GovernanceDecision|null} + */ +function checkAncestorDeny(target, state, context) { + // This would check if any ancestor of the target is denied + // For now, we'll return null to indicate no ancestor deny + return null; +} + +/** + * Evaluate multiple targets against the governance state + * @param {GovernanceTarget[]} targets + * @param {GovernanceState} state + * @param {PolicyContext} context + * @param {string} [viewerPubkey] - Pubkey of the viewer (for personal overrides) + * @returns {Record} + */ +export function evaluateMany(targets, state, context, viewerPubkey) { + /** @type {Record} */ + const results = {}; + + for (const target of targets) { + const targetKey = getTargetKey(target); + results[targetKey] = evaluateTarget(target, state, context, viewerPubkey); + } + + return results; +} + +/** + * Create a governance state + * @param {AuthorityState} [authority] + * @param {Record} [reports] + * @param {Record} [trustedMutes] + * @param {Record} [overrides] + * @param {Record} [policies] + * @returns {GovernanceState} + */ +export function createGovernanceState(authority, reports, trustedMutes, overrides, policies) { + return { + authority: authority || { roles: {}, actors: {} }, + reports: reports || {}, + trustedMutes: trustedMutes || {}, + overrides: overrides || {}, + policies: policies || {} + }; +} + +/** + * Create a report + * @param {string} reporter + * @param {GovernanceTarget} target + * @param {string} category + * @param {number} timestamp + * @returns {Report} + */ +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"); + } + + return { + reporter: reporter.toLowerCase(), + target, + category: category.trim(), + timestamp + }; +} + +/** + * 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/test/evaluator.test.js b/packages/core/test/evaluator.test.js new file mode 100644 index 0000000..950693e --- /dev/null +++ b/packages/core/test/evaluator.test.js @@ -0,0 +1,369 @@ +import { describe, it, expect } from "vitest"; +import { + evaluateTarget, + evaluateMany, + createGovernanceState, + createReport, + createTrustedMute, + createOverride, + DEFAULT_THRESHOLDS +} 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: [] + }); + }); + + it("should hide target with sufficient trusted reports", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + const reporter = "1234567890123456789012345678901234567890123456789012345678901234"; + + // Create authority state with reporter as moderator (can submit reports) + const authorityState = createAuthorityState({}, { + [reporter]: createActor(reporter, ["moderator"]) + }); + + const state = createGovernanceState( + authorityState, + { + "user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": [ + createReport(reporter, target, "spam", Date.now()) + ] + } + ); + + const context = createPolicyContext("feed", "default"); + + // Add enough reports to exceed the hide threshold + for (let i = 0; i < DEFAULT_THRESHOLDS.trustedReportHideThreshold; i++) { + state.reports["user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"].push( + createReport(reporter, target, "spam", Date.now() + i) + ); + } + + const decision = evaluateTarget(target, state, context); + expect(decision.visibility.effect).toBe("hide"); + expect(decision.interaction.effect).toBe("deny"); + }); + + it("should restrict target with moderate number of trusted reports", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + const reporter = "1234567890123456789012345678901234567890123456789012345678901234"; + + // Create authority state with reporter as moderator (can submit reports) + const authorityState = createAuthorityState({}, { + [reporter]: createActor(reporter, ["moderator"]) + }); + + const state = createGovernanceState( + authorityState, + { + "user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": [ + createReport(reporter, target, "spam", Date.now()) + ] + } + ); + + const context = createPolicyContext("feed", "default"); + + // Add enough reports to exceed the restrict threshold but not the hide threshold + for (let i = 0; i < DEFAULT_THRESHOLDS.trustedReportRestrictThreshold; i++) { + state.reports["user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"].push( + createReport(reporter, target, "spam", Date.now() + i) + ); + } + + const decision = evaluateTarget(target, state, context); + expect(decision.visibility.effect).toBe("restrict"); + expect(decision.interaction.effect).toBe("allow"); + }); + + it("should hide target with sufficient trusted mutes", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + + const state = createGovernanceState( + undefined, + undefined, + { + "user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": + createTrustedMute(target, DEFAULT_THRESHOLDS.trustedMuteHideThreshold, { + "mute": DEFAULT_THRESHOLDS.trustedMuteHideThreshold + }) + } + ); + + const context = createPolicyContext("feed", "default"); + + const decision = evaluateTarget(target, state, context); + expect(decision.visibility.effect).toBe("hide"); + expect(decision.interaction.effect).toBe("deny"); + }); + + it("should restrict target with moderate number of trusted mutes", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + + const state = createGovernanceState( + undefined, + undefined, + { + "user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": + createTrustedMute(target, DEFAULT_THRESHOLDS.trustedMuteRestrictThreshold, { + "mute": DEFAULT_THRESHOLDS.trustedMuteRestrictThreshold + }) + } + ); + + const context = createPolicyContext("feed", "default"); + + const decision = evaluateTarget(target, state, context); + expect(decision.visibility.effect).toBe("restrict"); + expect(decision.interaction.effect).toBe("allow"); + }); + + it("should apply explicit overrides", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + + const state = createGovernanceState( + undefined, + undefined, + undefined, + { + "user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": + createOverride(target, "hide", "Administrative override") + } + ); + + const context = createPolicyContext("feed", "default"); + + const decision = evaluateTarget(target, state, context); + expect(decision.visibility.effect).toBe("hide"); + expect(decision.interaction.effect).toBe("hide"); + expect(decision.reasons).toContain("Administrative override"); + }); + + it("should ignore overrides when enforcement disallows them", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + + const state = createGovernanceState( + undefined, + undefined, + undefined, + { + "user:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": + createOverride(target, "hide", "Administrative override") + } + ); + + const context = createPolicyContext("feed", "default", { + allowOverrides: false + }); + + const decision = evaluateTarget(target, state, context); + expect(decision.visibility.effect).toBe("allow"); + expect(decision.interaction.effect).toBe("allow"); + }); + }); + + 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: [] + }); + }); + }); + + describe("createGovernanceState", () => { + it("should create a valid governance state", () => { + const authority = createAuthorityState(); + const reports = {}; + const trustedMutes = {}; + const overrides = {}; + const policies = {}; + + const state = createGovernanceState(authority, reports, trustedMutes, overrides, policies); + expect(state).toEqual({ + authority, + reports, + trustedMutes, + overrides, + policies + }); + }); + + it("should create a governance state with default empty objects", () => { + const state = createGovernanceState(); + expect(state).toEqual({ + authority: { roles: {}, actors: {} }, + reports: {}, + trustedMutes: {}, + overrides: {}, + policies: {} + }); + }); + }); + + describe("createReport", () => { + it("should create a valid report", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + const report = createReport( + "1234567890123456789012345678901234567890123456789012345678901234", + target, + "spam", + Date.now() + ); + + expect(report).toEqual({ + reporter: "1234567890123456789012345678901234567890123456789012345678901234", + target, + category: "spam", + timestamp: expect.any(Number) + }); + }); + + 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(); + }); + }); + + describe("createTrustedMute", () => { + it("should create a valid trusted mute", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + const trustedMute = createTrustedMute(target, 5, { "mute": 5 }); + + expect(trustedMute).toEqual({ + target, + count: 5, + categories: { "mute": 5 } + }); + }); + + it("should throw error for invalid target", () => { + expect(() => createTrustedMute(null, 5, { "mute": 5 })).toThrow(); + }); + + it("should throw error for invalid count", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + expect(() => createTrustedMute(target, -1, { "mute": 5 })).toThrow(); + }); + + it("should throw error for invalid categories", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + expect(() => createTrustedMute(target, 5, null)).toThrow(); + }); + + it("should throw error for invalid category names", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + expect(() => createTrustedMute(target, 5, { "": 5 })).toThrow(); + }); + + it("should throw error for invalid category counts", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + expect(() => createTrustedMute(target, 5, { "mute": -1 })).toThrow(); + }); + }); + + describe("createOverride", () => { + it("should create a valid override", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + const override = createOverride(target, "hide", "Administrative override"); + + expect(override).toEqual({ + target, + visibility: "hide", + reason: "Administrative override" + }); + }); + + it("should create an override without reason", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + const override = createOverride(target, "hide"); + + expect(override).toEqual({ + target, + visibility: "hide" + }); + }); + + it("should throw error for invalid target", () => { + expect(() => createOverride(null, "hide")).toThrow(); + }); + + it("should throw error for invalid visibility", () => { + const target = createUserTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + expect(() => createOverride(target, /** @type {any} */ ("invalid"))).toThrow(); + }); + }); +}); \ No newline at end of file