From b24500010fa54e2fa0b2d77498f1635f2ee8d336 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:11:49 -0400 Subject: [PATCH] feat(core): implement policy module with decision making and effect composition --- packages/core/src/policy.js | 380 ++++++++++++++++++++++++++ packages/core/test/policy.test.js | 428 ++++++++++++++++++++++++++++++ 2 files changed, 808 insertions(+) create mode 100644 packages/core/src/policy.js create mode 100644 packages/core/test/policy.test.js diff --git a/packages/core/src/policy.js b/packages/core/src/policy.js new file mode 100644 index 0000000..dd2c0d7 --- /dev/null +++ b/packages/core/src/policy.js @@ -0,0 +1,380 @@ +// Policy handling for Nostr Governance +// +// This module handles policy definitions, decision making, and effect calculations +// for the governance system. + +/** + * @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 {Object} PolicyProfile + * @property {string} name - Profile name + * @property {Record} effects - Map of effect types to policy effects + */ + +/** + * @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 + */ + +/** + * @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 + */ + +/** + * @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 + */ + +/** + * 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] + * @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"); + } + + const decision = { + visibility, + interaction, + reasons: reasons || [], + evidence: evidence || [] + }; + + if (transaction) { + decision.transaction = transaction; + } + + if (metadata) { + decision.metadata = metadata; + } + + return decision; +} + +/** + * Get effect for a policy profile + * @param {string} profileName + * @param {string} effectType + * @param {Record} [customPolicies] + * @returns {PolicyEffect} + */ +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]; + } + } + } + + // 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" }; +} + +/** + * Compose multiple governance decisions into a single decision + * @param {GovernanceDecision[]} decisions + * @returns {GovernanceDecision} + */ +export function composeGovernanceDecisions(decisions) { + if (!Array.isArray(decisions) || decisions.length === 0) { + // Return a default allow decision + return createGovernanceDecision( + { effect: "allow" }, + { effect: "allow" }, + undefined, + [], + [] + ); + } + + // 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 + if (decision.transaction) { + if (!composed.transaction) { + composed.transaction = decision.transaction; + } else { + const transactionOrder = { "allow": 0, "restrict": 1, "hide": 2, "deny": 3 }; + if (transactionOrder[decision.transaction.effect] > transactionOrder[composed.transaction.effect]) { + composed.transaction = decision.transaction; + } + } + } + + // Combine reasons and evidence + for (const reason of decision.reasons) { + if (!composed.reasons.includes(reason)) { + composed.reasons.push(reason); + } + } + + for (const evidence of decision.evidence) { + composed.evidence.push(evidence); + } + } + + return composed; +} \ No newline at end of file diff --git a/packages/core/test/policy.test.js b/packages/core/test/policy.test.js new file mode 100644 index 0000000..8d92a42 --- /dev/null +++ b/packages/core/test/policy.test.js @@ -0,0 +1,428 @@ +import { describe, it, expect } from "vitest"; +import { + BUILTIN_POLICY_PROFILES, + createPolicyEffect, + createPolicyProfile, + createPolicyDefinition, + createPolicyContext, + createGovernanceDecision, + getPolicyEffect, + composeGovernanceDecisions +} from "../src/policy.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("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("should create a policy effect without optional fields", () => { + const effect = createPolicyEffect("allow"); + expect(effect).toEqual({ effect: "allow" }); + }); + + it("should throw error for invalid effect", () => { + expect(() => createPolicyEffect(/** @type {any} */ ("invalid"))).toThrow(); + expect(() => createPolicyEffect(/** @type {any} */ (123))).toThrow(); + }); + }); + + describe("createPolicyProfile", () => { + it("should create a valid policy profile", () => { + const profile = createPolicyProfile("test-profile", { + "visibility": { effect: "hide", reason: "Hidden" }, + "interaction": { effect: "deny", reason: "Denied" } + }); + + expect(profile).toEqual({ + name: "test-profile", + effects: { + "visibility": { effect: "hide", reason: "Hidden" }, + "interaction": { effect: "deny", reason: "Denied" } + } + }); + }); + + it("should throw error for invalid profile name", () => { + expect(() => createPolicyProfile("", {})).toThrow(); + expect(() => createPolicyProfile(/** @type {any} */ (123), {})).toThrow(); + }); + + it("should throw error for invalid effects", () => { + expect(() => createPolicyProfile("test-profile", /** @type {any} */ (null))).toThrow(); + expect(() => createPolicyProfile("test-profile", /** @type {any} */ ("not-an-object"))).toThrow(); + }); + + it("should throw error for invalid effect types", () => { + expect(() => createPolicyProfile("test-profile", { + "": { effect: "allow" } + })).toThrow(); + + expect(() => createPolicyProfile("test-profile", { + "visibility": /** @type {any} */ (null) + })).toThrow(); + + expect(() => createPolicyProfile("test-profile", { + "visibility": { effect: /** @type {any} */ ("invalid") } + })).toThrow(); + }); + }); + + describe("createPolicyDefinition", () => { + it("should create a valid policy definition", () => { + const definition = createPolicyDefinition( + "test-policy", + "Test Policy", + "A test policy", + { + "default": { + name: "default", + effects: { + "visibility": { effect: "allow" } + } + } + } + ); + + 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(); + }); + }); + + 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(); + }); + }); + + describe("createGovernanceDecision", () => { + it("should create a valid governance decision", () => { + const decision = createGovernanceDecision( + { effect: "hide", reason: "Hidden" }, + { effect: "deny", reason: "Denied" }, + { effect: "deny", reason: "Transaction denied" }, + ["reason1", "reason2"], + [{ evidence: "evidence1" }, { evidence: "evidence2" }], + { metadata: "data" } + ); + + expect(decision).toEqual({ + visibility: { effect: "hide", reason: "Hidden" }, + interaction: { effect: "deny", reason: "Denied" }, + transaction: { effect: "deny", reason: "Transaction denied" }, + reasons: ["reason1", "reason2"], + evidence: [{ evidence: "evidence1" }, { evidence: "evidence2" }], + metadata: { metadata: "data" } + }); + }); + + it("should create a governance decision without optional fields", () => { + const decision = createGovernanceDecision( + { effect: "allow" }, + { effect: "allow" } + ); + + expect(decision).toEqual({ + visibility: { effect: "allow" }, + interaction: { effect: "allow" }, + reasons: [], + evidence: [] + }); + }); + + it("should throw error for invalid visibility effect", () => { + expect(() => createGovernanceDecision( + /** @type {any} */ ({ effect: "invalid" }), + { effect: "allow" } + )).toThrow(); + }); + + it("should throw error for invalid interaction effect", () => { + expect(() => createGovernanceDecision( + { effect: "allow" }, + /** @type {any} */ ({ effect: "invalid" }) + )).toThrow(); + }); + + it("should throw error for invalid transaction effect", () => { + expect(() => createGovernanceDecision( + { effect: "allow" }, + { effect: "allow" }, + /** @type {any} */ ({ effect: "invalid" }) + )).toThrow(); + }); + + it("should throw error for invalid reasons", () => { + expect(() => createGovernanceDecision( + { effect: "allow" }, + { effect: "allow" }, + undefined, + /** @type {any} */ ("not-an-array") + )).toThrow(); + }); + + it("should throw error for invalid evidence", () => { + expect(() => createGovernanceDecision( + { effect: "allow" }, + { effect: "allow" }, + undefined, + [], + /** @type {any} */ ("not-an-array") + )).toThrow(); + }); + }); + + describe("getPolicyEffect", () => { + it("should return effect for built-in profiles", () => { + const effect = getPolicyEffect("strict", "visibility"); + expect(effect).toEqual({ effect: "hide" }); + }); + + it("should return effect for custom policies", () => { + const customPolicies = { + "custom-policy": { + id: "custom-policy", + name: "Custom Policy", + description: "A custom policy", + profiles: { + "custom-profile": { + name: "custom-profile", + effects: { + "visibility": { effect: "hide", reason: "Custom hidden" } + } + } + } + } + }; + + const effect = getPolicyEffect("custom-profile", "visibility", /** @type {any} */ (customPolicies)); + 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" }); + }); + }); + + describe("composeGovernanceDecisions", () => { + it("should return default allow decision for empty array", () => { + const decision = composeGovernanceDecisions([]); + expect(decision).toEqual({ + visibility: { effect: "allow" }, + interaction: { effect: "allow" }, + reasons: [], + evidence: [] + }); + }); + + it("should return the single decision for array with one element", () => { + const inputDecision = createGovernanceDecision( + { effect: "hide", reason: "Hidden" }, + { effect: "deny", reason: "Denied" }, + undefined, + ["reason1"], + [{ evidence: "evidence1" }] + ); + + const decision = composeGovernanceDecisions([inputDecision]); + expect(decision).toEqual(inputDecision); + }); + + it("should compose multiple decisions with most restrictive effects", () => { + 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" }); + }); + }); +}); \ No newline at end of file