feat(core): implement policy module with decision making and effect composition

This commit is contained in:
thePR0M3TH3AN committed 2026-07-26 19:11:49 -04:00
1 parent 8492411265
commit b24500010f
2 files changed
+808

No files matched your search

+380
View File
@@ -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<string, PolicyEffect>} 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<string, PolicyProfile>} 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<string, PolicyProfile>}
*/
export const BUILTIN_POLICY_PROFILES = {
"default": {
name: "default",
effects: {
"visibility": { effect: "allow" },
"interaction": { effect: "allow" }
}
},
"moderate": {
name: "moderate",
effects: {
"visibility": { effect: "restrict" },
"interaction": { effect: "allow" }
}
},
"strict": {
name: "strict",
effects: {
"visibility": { effect: "hide" },
"interaction": { effect: "deny" }
}
},
"commerce-default": {
name: "commerce-default",
effects: {
"visibility": { effect: "allow" },
"interaction": { effect: "allow" },
"transaction": { effect: "allow" }
}
},
"commerce-transaction": {
name: "commerce-transaction",
effects: {
"visibility": { effect: "allow" },
"interaction": { effect: "allow" },
"transaction": { effect: "deny" }
}
}
};
/**
* Create a policy effect
* @param {"allow"|"restrict"|"hide"|"deny"} effect
* @param {string} [reason]
* @param {any} [details]
* @returns {PolicyEffect}
*/
export function createPolicyEffect(effect, reason, details) {
if (!["allow", "restrict", "hide", "deny"].includes(effect)) {
throw new Error("Invalid policy effect: " + effect);
}
const policyEffect = { effect };
if (reason) {
policyEffect.reason = reason;
}
if (details) {
policyEffect.details = details;
}
return policyEffect;
}
/**
* Create a policy profile
* @param {string} name
* @param {Record<string, PolicyEffect>} effects
* @returns {PolicyProfile}
*/
export function createPolicyProfile(name, effects) {
if (typeof name !== "string" || !name.trim()) {
throw new Error("Policy profile name must be a non-empty string");
}
if (!effects || typeof effects !== "object") {
throw new Error("Policy profile effects must be an object");
}
// Validate each effect
for (const [effectType, effect] of Object.entries(effects)) {
if (typeof effectType !== "string" || !effectType.trim()) {
throw new Error("Effect types must be non-empty strings");
}
if (!effect || typeof effect !== "object") {
throw new Error("Effects must be objects");
}
if (!["allow", "restrict", "hide", "deny"].includes(effect.effect)) {
throw new Error("Invalid policy effect: " + effect.effect);
}
}
return {
name: name.trim(),
effects
};
}
/**
* Create a policy definition
* @param {string} id
* @param {string} name
* @param {string} description
* @param {Record<string, PolicyProfile>} profiles
* @returns {PolicyDefinition}
*/
export function createPolicyDefinition(id, name, description, profiles) {
if (typeof id !== "string" || !id.trim()) {
throw new Error("Policy ID must be a non-empty string");
}
if (typeof name !== "string" || !name.trim()) {
throw new Error("Policy name must be a non-empty string");
}
if (typeof description !== "string") {
throw new Error("Policy description must be a string");
}
if (!profiles || typeof profiles !== "object") {
throw new Error("Policy profiles must be an object");
}
// Validate each profile
for (const [profileName, profile] of Object.entries(profiles)) {
if (typeof profileName !== "string" || !profileName.trim()) {
throw new Error("Profile names must be non-empty strings");
}
if (!profile || typeof profile !== "object") {
throw new Error("Profiles must be objects");
}
if (profile.name !== profileName) {
throw new Error(`Profile name mismatch: ${profileName} !== ${profile.name}`);
}
}
return {
id: id.trim(),
name: name.trim(),
description,
profiles
};
}
/**
* Create a policy context
* @param {string} surface
* @param {string} policyProfile
* @param {Object} [enforcement]
* @returns {PolicyContext}
*/
export function createPolicyContext(surface, policyProfile, enforcement) {
if (typeof surface !== "string" || !surface.trim()) {
throw new Error("Surface must be a non-empty string");
}
if (typeof policyProfile !== "string" || !policyProfile.trim()) {
throw new Error("Policy profile must be a non-empty string");
}
const context = {
surface: surface.trim(),
policyProfile: policyProfile.trim()
};
if (enforcement) {
context.enforcement = {};
if (typeof enforcement.hardHide === "boolean") {
context.enforcement.hardHide = enforcement.hardHide;
}
if (typeof enforcement.allowOverrides === "boolean") {
context.enforcement.allowOverrides = enforcement.allowOverrides;
}
}
return context;
}
/**
* Create a governance decision
* @param {PolicyEffect} visibility
* @param {PolicyEffect} interaction
* @param {PolicyEffect} [transaction]
* @param {string[]} [reasons]
* @param {any[]} [evidence]
* @param {Object} [metadata]
* @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<string, PolicyDefinition>} [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;
}
+428
View File
@@ -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" });
});
});
});