diff --git a/packages/core/src/authority.js b/packages/core/src/authority.js new file mode 100644 index 0000000..7694924 --- /dev/null +++ b/packages/core/src/authority.js @@ -0,0 +1,278 @@ +// Authority handling for Nostr Governance +// +// This module handles role resolution, capability checking, and authority +// validation for the governance system. + +/** + * @typedef {Object} RoleDefinition + * @property {string} name - Role name + * @property {string[]} capabilities - Array of capability names this role has + */ + +/** + * @typedef {Object} Actor + * @property {string} pubkey - 64-character hex pubkey of the actor + * @property {string[]} roles - Array of role names assigned to this actor + */ + +/** + * @typedef {Object} AuthorityState + * @property {Record} roles - Map of role names to definitions + * @property {Record} actors - Map of pubkeys to actors + */ + +/** + * Built-in role definitions + * @type {Record} + */ +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 []; +} + +/** + * Check if an actor has a specific role + * @param {string} pubkey - 64-character hex pubkey + * @param {string} roleName + * @param {AuthorityState} [authorityState] + * @returns {boolean} + */ +export function hasRole(pubkey, roleName, authorityState) { + // Normalize pubkey + const normalizedPubkey = pubkey.toLowerCase(); + + // Check custom actors first + if (authorityState && authorityState.actors[normalizedPubkey]) { + return authorityState.actors[normalizedPubkey].roles.includes(roleName); + } + + // No actor found means no roles + return false; +} + +/** + * Get all roles for an actor + * @param {string} pubkey - 64-character hex pubkey + * @param {AuthorityState} [authorityState] + * @returns {string[]} + */ +export function getActorRoles(pubkey, authorityState) { + // Normalize pubkey + const normalizedPubkey = pubkey.toLowerCase(); + + // Check custom actors first + if (authorityState && authorityState.actors[normalizedPubkey]) { + return authorityState.actors[normalizedPubkey].roles; + } + + // No actor found means no roles + return []; +} + +/** + * Check if an actor has a specific capability + * @param {string} pubkey - 64-character hex pubkey + * @param {string} capability + * @param {AuthorityState} [authorityState] + * @returns {boolean} + */ +export function hasCapability(pubkey, capability, authorityState) { + // Get all roles for the actor + const roles = getActorRoles(pubkey, authorityState); + + // Check if any role has the capability + for (const role of roles) { + const capabilities = getRoleCapabilities(role, authorityState); + if (capabilities.includes(capability)) { + return true; + } + } + + return false; +} + +/** + * Get all capabilities for an actor + * @param {string} pubkey - 64-character hex pubkey + * @param {AuthorityState} [authorityState] + * @returns {string[]} + */ +export function getActorCapabilities(pubkey, authorityState) { + // Get all roles for the actor + const roles = getActorRoles(pubkey, authorityState); + + // Collect all capabilities from all roles + const capabilities = new Set(); + for (const role of roles) { + const roleCapabilities = getRoleCapabilities(role, authorityState); + for (const capability of roleCapabilities) { + 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} + */ +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"); + } + + // Validate each capability + for (const capability of capabilities) { + if (typeof capability !== "string" || !capability.trim()) { + throw new Error("All capabilities must be non-empty strings"); + } + } + + return { + name: name.trim(), + capabilities: capabilities.map(c => c.trim()) + }; +} + +/** + * Create a new actor + * @param {string} pubkey - 64-character hex pubkey + * @param {string[]} roles - Array of role names + * @returns {Actor} + */ +export function createActor(pubkey, roles) { + // Validate pubkey + if (typeof pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(pubkey)) { + throw new Error("Pubkey must be a 64-character hex string"); + } + + // Validate roles + if (!Array.isArray(roles)) { + throw new Error("Roles must be an array"); + } + + // Validate each role + for (const role of roles) { + if (typeof role !== "string" || !role.trim()) { + throw new Error("All roles must be non-empty strings"); + } + } + + return { + pubkey: pubkey.toLowerCase(), + roles: roles.map(r => r.trim()) + }; +} + +/** + * Create a new authority state + * @param {Record} [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/test/authority.test.js b/packages/core/test/authority.test.js new file mode 100644 index 0000000..f091b17 --- /dev/null +++ b/packages/core/test/authority.test.js @@ -0,0 +1,392 @@ +import { describe, it, expect } from "vitest"; +import { + BUILTIN_ROLES, + getRoleCapabilities, + hasRole, + getActorRoles, + hasCapability, + getActorCapabilities, + createRoleDefinition, + createActor, + createAuthorityState +} 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); + } + }); + }); + + 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("hasRole", () => { + it("should return true for actors with the specified role", () => { + const authorityState = createAuthorityState({}, { + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": { + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + roles: ["moderator"] + } + }); + + const result = hasRole( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "moderator", + authorityState + ); + expect(result).toBe(true); + }); + + it("should return false for actors without the specified role", () => { + const authorityState = createAuthorityState({}, { + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": { + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + roles: ["user"] + } + }); + + const result = hasRole( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "moderator", + authorityState + ); + expect(result).toBe(false); + }); + + it("should return false for unknown actors", () => { + const authorityState = createAuthorityState(); + const result = hasRole( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "moderator", + authorityState + ); + expect(result).toBe(false); + }); + + it("should normalize pubkeys to lowercase", () => { + const authorityState = createAuthorityState({}, { + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": { + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + roles: ["moderator"] + } + }); + + const result = hasRole( + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", + "moderator", + authorityState + ); + expect(result).toBe(true); + }); + }); + + describe("getActorRoles", () => { + it("should return roles for known actors", () => { + const authorityState = createAuthorityState({}, { + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": { + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + roles: ["user", "trusted-user"] + } + }); + + const roles = getActorRoles( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + authorityState + ); + expect(roles).toEqual(["user", "trusted-user"]); + }); + + it("should return empty array for unknown actors", () => { + const authorityState = createAuthorityState(); + const roles = getActorRoles( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + authorityState + ); + expect(roles).toEqual([]); + }); + }); + + describe("hasCapability", () => { + it("should return true for actors with the specified capability", () => { + const authorityState = createAuthorityState({}, { + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": { + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + roles: ["moderator"] + } + }); + + const result = hasCapability( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "submit-reports", + authorityState + ); + expect(result).toBe(true); + }); + + it("should return false for actors without the specified capability", () => { + const authorityState = createAuthorityState({}, { + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": { + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + roles: ["user"] + } + }); + + const result = hasCapability( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "manage-admin-lists", + authorityState + ); + expect(result).toBe(false); + }); + + it("should return false for unknown actors", () => { + const authorityState = createAuthorityState(); + const result = hasCapability( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "submit-reports", + authorityState + ); + expect(result).toBe(false); + }); + }); + + describe("getActorCapabilities", () => { + it("should return all capabilities for an actor's roles", () => { + const authorityState = createAuthorityState({}, { + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789": { + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + roles: ["moderator", "trusted-user"] + } + }); + + const capabilities = getActorCapabilities( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + authorityState + ); + + // Should have capabilities from both roles, with duplicates removed + expect(capabilities).toContain("submit-reports"); + expect(capabilities).toContain("view-reports"); + expect(capabilities).toContain("submit-trust-endorsements"); + }); + + it("should return empty array for unknown actors", () => { + const authorityState = createAuthorityState(); + const capabilities = getActorCapabilities( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + authorityState + ); + expect(capabilities).toEqual([]); + }); + }); + + describe("createRoleDefinition", () => { + it("should create a valid role definition", () => { + const role = createRoleDefinition("test-role", ["capability1", "capability2"]); + expect(role).toEqual({ + name: "test-role", + capabilities: ["capability1", "capability2"] + }); + }); + + it("should trim whitespace from role name and capabilities", () => { + const role = createRoleDefinition(" test-role ", [" capability1 ", " capability2 "]); + expect(role).toEqual({ + name: "test-role", + capabilities: ["capability1", "capability2"] + }); + }); + + it("should throw error for invalid role name", () => { + expect(() => createRoleDefinition("", ["capability"])).toThrow(); + expect(() => createRoleDefinition(/** @type {any} */ (123), ["capability"])).toThrow(); + }); + + it("should throw error for invalid capabilities", () => { + expect(() => createRoleDefinition("test-role", /** @type {any} */ ("not-an-array"))).toThrow(); + expect(() => createRoleDefinition("test-role", [/** @type {any} */ (123)])).toThrow(); + expect(() => createRoleDefinition("test-role", [""])).toThrow(); + }); + }); + + describe("createActor", () => { + it("should create a valid actor", () => { + const actor = createActor( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + ["role1", "role2"] + ); + expect(actor).toEqual({ + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + roles: ["role1", "role2"] + }); + }); + + it("should normalize pubkey to lowercase", () => { + const actor = createActor( + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", + ["role1"] + ); + expect(actor.pubkey).toBe("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + }); + + it("should trim whitespace from roles", () => { + const actor = createActor( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + [" role1 ", " role2 "] + ); + expect(actor.roles).toEqual(["role1", "role2"]); + }); + + it("should throw error for invalid pubkey", () => { + expect(() => createActor("invalid-pubkey", ["role"])).toThrow(); + expect(() => createActor(/** @type {any} */ (123), ["role"])).toThrow(); + expect(() => createActor("", ["role"])).toThrow(); + }); + + it("should throw error for invalid roles", () => { + expect(() => createActor( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + /** @type {any} */ ("not-an-array") + )).toThrow(); + expect(() => createActor( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + [/** @type {any} */ (123)] + )).toThrow(); + expect(() => createActor( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + [""] + )).toThrow(); + }); + }); + + 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(); + }); + }); +}); \ No newline at end of file