diff --git a/package.json b/package.json index fc17dab..eab468c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test:unit": "vitest run --passWithNoTests", "test:conformance": "vitest run tests/conformance --passWithNoTests", "typecheck": "tsc -p tsconfig.json --noEmit", - "build:types": "tsc -p tsconfig.types.json", + "build:types": "tsc -p packages/core/tsconfig.types.json && tsc -p packages/nostr/tsconfig.types.json && tsc -p packages/runtime/tsconfig.types.json && tsc -p packages/bitvid-compat/tsconfig.types.json && tsc -p packages/testing/tsconfig.types.json", "lint": "npm run check:references && npm run check:types", "check:references": "node scripts/verify-no-reference-imports.mjs", "check:types": "npm run typecheck" diff --git a/packages/bitvid-compat/package.json b/packages/bitvid-compat/package.json index 6c4ff63..775abb9 100644 --- a/packages/bitvid-compat/package.json +++ b/packages/bitvid-compat/package.json @@ -3,15 +3,18 @@ "version": "0.1.0", "type": "module", "main": "src/index.js", - "types": "dist/types/src/index.d.ts", - "files": ["src", "dist"], + "types": "dist/types/index.d.ts", + "files": [ + "src", + "dist" + ], "exports": { ".": { - "types": "./dist/types/src/index.d.ts", + "types": "./dist/types/index.d.ts", "import": "./src/index.js" }, "./profile": { - "types": "./dist/types/src/profile.d.ts", + "types": "./dist/types/profile.d.ts", "import": "./src/profile.js" } }, diff --git a/packages/bitvid-compat/tsconfig.types.json b/packages/bitvid-compat/tsconfig.types.json new file mode 100644 index 0000000..384c9af --- /dev/null +++ b/packages/bitvid-compat/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist/types", + "rootDir": "src" + }, + "include": ["src/**/*.js"] +} diff --git a/packages/core/package.json b/packages/core/package.json index e0ed575..e3dcbe2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,13 +3,17 @@ "version": "0.1.0", "type": "module", "main": "src/index.js", - "types": "dist/types/src/index.d.ts", - "files": ["src", "dist"], + "types": "dist/types/index.d.ts", + "files": [ + "src", + "dist" + ], "exports": { ".": { - "types": "./dist/types/src/index.d.ts", + "types": "./dist/types/index.d.ts", "import": "./src/index.js" - } + }, + "./dist/types/*": "./dist/types/*" }, "scripts": { "test": "vitest run" diff --git a/packages/core/tsconfig.types.json b/packages/core/tsconfig.types.json new file mode 100644 index 0000000..384c9af --- /dev/null +++ b/packages/core/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist/types", + "rootDir": "src" + }, + "include": ["src/**/*.js"] +} diff --git a/packages/nostr/package.json b/packages/nostr/package.json index f2eaf8d..00b9766 100644 --- a/packages/nostr/package.json +++ b/packages/nostr/package.json @@ -3,13 +3,17 @@ "version": "0.1.0", "type": "module", "main": "src/index.js", - "types": "dist/types/src/index.d.ts", - "files": ["src", "dist"], + "types": "dist/types/index.d.ts", + "files": [ + "src", + "dist" + ], "exports": { ".": { - "types": "./dist/types/src/index.d.ts", + "types": "./dist/types/index.d.ts", "import": "./src/index.js" - } + }, + "./dist/types/*": "./dist/types/*" }, "scripts": { "test": "vitest run" diff --git a/packages/nostr/tsconfig.types.json b/packages/nostr/tsconfig.types.json new file mode 100644 index 0000000..384c9af --- /dev/null +++ b/packages/nostr/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist/types", + "rootDir": "src" + }, + "include": ["src/**/*.js"] +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 1667916..5731be6 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -3,13 +3,17 @@ "version": "0.1.0", "type": "module", "main": "src/index.js", - "types": "dist/types/src/index.d.ts", - "files": ["src", "dist"], + "types": "dist/types/index.d.ts", + "files": [ + "src", + "dist" + ], "exports": { ".": { - "types": "./dist/types/src/index.d.ts", + "types": "./dist/types/index.d.ts", "import": "./src/index.js" - } + }, + "./dist/types/*": "./dist/types/*" }, "scripts": { "test": "vitest run" diff --git a/packages/runtime/src/commands.js b/packages/runtime/src/commands.js new file mode 100644 index 0000000..2b46c49 --- /dev/null +++ b/packages/runtime/src/commands.js @@ -0,0 +1,387 @@ +// Public governance commands. +// +// Every command checks authority before signing. An unauthorized actor must not +// be able to produce an accepted mutation, so the capability check happens +// locally before the signer is ever invoked — a relay is not an access-control +// mechanism, and a client that skipped this check would publish an event that +// merely fails to take effect, which is far harder to diagnose. + +import { + getTargetKey, + hasCapability, + isProtectedActor, + isValidTarget, +} from "@nostr-governance/core"; +import { encodeContribution, encodeReport, encodeRoles } from "@nostr-governance/nostr"; + +/** + * Stable error codes. Applications match on these rather than on message text. + */ +export const ERROR_CODES = Object.freeze({ + NO_SIGNER: "no-signer-configured", + NOT_AUTHORIZED: "not-authorized", + INVALID_TARGET: "invalid-target", + PROTECTED_TARGET: "protected-target", + PUBLISH_FAILED: "publish-failed", + INVALID_ARGUMENT: "invalid-argument", +}); + +/** + * The signed event a command published. + * + * Described structurally rather than imported so that this package's public + * types stay nameable without reaching into another package's internal modules. + * + * @typedef {Object} SignedEvent + * @property {string} id + * @property {string} pubkey + * @property {number} kind + * @property {number} created_at + * @property {string[][]} tags + * @property {string} content + * @property {string} [sig] + */ + +/** + * @typedef {Object} CommandResult + * @property {boolean} ok + * @property {string} [code] - One of ERROR_CODES when ok is false + * @property {string} [message] + * @property {SignedEvent} [event] + * @property {string[]} [accepted] - Relays that accepted + * @property {Array<{ relay: string, error: string }>} [failed] + */ + +/** + * @param {string} code + * @param {string} message + * @returns {CommandResult} + */ +function failure(code, message) { + return { ok: false, code, message }; +} + +/** @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", +}; + +export class GovernanceCommands { + /** + * @param {import('./runtime.js').GovernanceRuntime} runtime + */ + constructor(runtime) { + this.runtime = runtime; + } + + /** + * Sign and publish an event template. + * + * A partial relay acceptance still succeeds: the first acceptance means the + * event exists on the network. The remaining relay results stay on the result + * so diagnostics can surface them. + * + * @param {{ kind: number, content: string, tags: string[][] }} template + * @returns {Promise} + */ + async #publish(template) { + let signed; + try { + signed = await this.runtime.signer.signEvent({ + ...template, + created_at: this.runtime.now(), + }); + } catch (error) { + return failure(ERROR_CODES.NO_SIGNER, String(error)); + } + + try { + const result = await this.runtime.transport.publish(signed); + if (!result?.ok) { + return { + ...failure(ERROR_CODES.PUBLISH_FAILED, "no relay accepted the event"), + event: signed, + accepted: result?.accepted ?? [], + failed: result?.failed ?? [], + }; + } + return { + ok: true, + event: signed, + accepted: result.accepted ?? [], + failed: result.failed ?? [], + }; + } catch (error) { + return { ...failure(ERROR_CODES.PUBLISH_FAILED, String(error)), event: signed }; + } + } + + /** + * Resolve the acting pubkey from the signer. + * @returns {Promise} + */ + async #actor() { + return this.runtime.signer.getPublicKey(); + } + + /** + * Publish a contribution list. + * + * @param {import('@nostr-governance/core').Contribution["kind"]} kind + * @param {import('@nostr-governance/core').GovernanceTarget[]} targets + * @param {Object} [options] + * @param {string} [options.source] - Community list marker + * @returns {Promise} + */ + async contribute(kind, targets, { source } = {}) { + const capability = KIND_CAPABILITY[kind]; + if (!capability) { + return failure(ERROR_CODES.INVALID_ARGUMENT, `unknown contribution kind: ${kind}`); + } + if (!Array.isArray(targets) || targets.length === 0) { + return failure(ERROR_CODES.INVALID_ARGUMENT, "targets must be a non-empty array"); + } + + for (const target of targets) { + if (!isValidTarget(target)) { + return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target)); + } + } + + let actor; + try { + actor = await this.#actor(); + } catch (error) { + return failure(ERROR_CODES.NO_SIGNER, String(error)); + } + + const authority = this.runtime.admin.authority; + if (!hasCapability(actor, capability, authority)) { + return failure(ERROR_CODES.NOT_AUTHORIZED, `actor lacks ${capability}`); + } + + // Protected actors are rejected at the command boundary as well as during + // reduction, so an operator sees the refusal instead of publishing a list + // entry that silently never takes effect. + if (kind === "user-deny") { + for (const target of targets) { + if (target.type === "user" && isProtectedActor(target.pubkey, authority)) { + return failure(ERROR_CODES.PROTECTED_TARGET, target.pubkey); + } + } + } + + const template = encodeContribution( + { actor, kind, targets, ...(source ? { source } : {}) }, + this.runtime.namespace, + ); + + return this.#publish(template); + } + + /** + * Deny a user. + * @param {string} pubkey + * @param {Object} [options] + * @returns {Promise} + */ + async denyUser(pubkey, options) { + return this.contribute("user-deny", [{ type: "user", pubkey }], options); + } + + /** + * Allow a user. + * @param {string} pubkey + * @returns {Promise} + */ + async allowUser(pubkey) { + return this.contribute("user-allow", [{ type: "user", pubkey }]); + } + + /** + * Deny an exact event. + * @param {string} id + * @returns {Promise} + */ + async denyEvent(id) { + return this.contribute("event-deny", [{ type: "event", id }]); + } + + /** + * Deny an address coordinate. + * @param {string} kind + * @param {string} pubkey + * @param {string} identifier + * @returns {Promise} + */ + async denyAddress(kind, pubkey, identifier) { + return this.contribute("address-deny", [{ type: "address", kind, pubkey, identifier }]); + } + + /** + * Add trust seeds. + * @param {string[]} pubkeys + * @returns {Promise} + */ + async addTrustSeeds(pubkeys) { + return this.contribute( + "trust-seed", + (pubkeys ?? []).map((pubkey) => ({ type: "user", pubkey })), + ); + } + + /** + * Publish the role roster. Requires `manage-roles`. + * @param {Object} roster + * @param {Record} [roster.actors] + * @param {Record} [roster.capabilities] + * @param {string[]} [roster.protectedActors] + * @returns {Promise} + */ + async setRoles(roster) { + let actor; + try { + actor = await this.#actor(); + } catch (error) { + return failure(ERROR_CODES.NO_SIGNER, String(error)); + } + + if (!hasCapability(actor, "manage-roles", this.runtime.admin.authority)) { + return failure(ERROR_CODES.NOT_AUTHORIZED, "actor lacks manage-roles"); + } + + return this.#publish(encodeRoles(roster ?? {}, this.runtime.namespace)); + } + + /** + * Publish a policy document. Requires `manage-policy`. + * @param {import('@nostr-governance/core').PolicyDefinition} policy + * @returns {Promise} + */ + async setPolicy(policy) { + let actor; + try { + actor = await this.#actor(); + } catch (error) { + return failure(ERROR_CODES.NO_SIGNER, String(error)); + } + + if (!hasCapability(actor, "manage-policy", this.runtime.admin.authority)) { + return failure(ERROR_CODES.NOT_AUTHORIZED, "actor lacks manage-policy"); + } + if (!policy || typeof policy !== "object") { + return failure(ERROR_CODES.INVALID_ARGUMENT, "policy must be an object"); + } + + return this.#publish({ + kind: 30078, + content: JSON.stringify(policy), + tags: [ + ["d", `${this.runtime.namespace}:governance:policy:v1`], + ["v", "1"], + ["client", "nostr-governance"], + ["scope", "policy"], + ], + }); + } + + /** + * Register a community source. Requires `manage-community-sources`. + * @param {Array<{ curator: string, identifier: string, kind: number }>} sources + * @returns {Promise} + */ + async setCommunitySources(sources) { + let actor; + try { + actor = await this.#actor(); + } catch (error) { + return failure(ERROR_CODES.NO_SIGNER, String(error)); + } + + if (!hasCapability(actor, "manage-community-sources", this.runtime.admin.authority)) { + return failure(ERROR_CODES.NOT_AUTHORIZED, "actor lacks manage-community-sources"); + } + + const tags = [ + ["d", `${this.runtime.namespace}:governance:community-sources:v1`], + ["v", "1"], + ["client", "nostr-governance"], + ["scope", "community-sources"], + ]; + for (const source of sources ?? []) { + tags.push(["a", `${source.kind}:${source.curator}:${source.identifier}`]); + } + + return this.#publish({ kind: 30078, content: "", tags }); + } + + /** + * Publish a NIP-56 report. + * + * Reporting needs no capability: any account may report, and whether the + * report counts is decided by the viewer's trust graph at evaluation time. + * + * @param {import('@nostr-governance/core').GovernanceTarget} target + * @param {string} category + * @param {string} [content] + * @returns {Promise} + */ + async report(target, category, content = "") { + if (!isValidTarget(target)) { + return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target)); + } + + let template; + try { + template = encodeReport(target, category, content); + } catch (error) { + return failure(ERROR_CODES.INVALID_ARGUMENT, String(error)); + } + + return this.#publish(template); + } + + /** + * Set a viewer override. Viewer-local, so no capability is required and + * nothing is published. + * + * @param {import('@nostr-governance/core').GovernanceTarget} target + * @param {string} visibility + * @param {Object} [options] + * @param {string} [options.reason] + * @param {number} [options.expiresAt] + * @returns {CommandResult} + */ + setOverride(target, visibility, { reason, expiresAt } = {}) { + if (!isValidTarget(target)) { + return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target)); + } + this.runtime.overrides.set(getTargetKey(target), { visibility, reason, expiresAt }); + return { ok: true }; + } + + /** + * Clear a viewer override. + * @param {import('@nostr-governance/core').GovernanceTarget} target + * @returns {CommandResult} + */ + clearOverride(target) { + if (!isValidTarget(target)) { + return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target)); + } + this.runtime.overrides.remove(getTargetKey(target)); + return { ok: true }; + } +} + +/** + * @param {import('./runtime.js').GovernanceRuntime} runtime + * @returns {GovernanceCommands} + */ +export function createCommands(runtime) { + return new GovernanceCommands(runtime); +} diff --git a/packages/runtime/src/emitter.js b/packages/runtime/src/emitter.js new file mode 100644 index 0000000..547e370 --- /dev/null +++ b/packages/runtime/src/emitter.js @@ -0,0 +1,69 @@ +// Minimal event emitter used by the stores. +// +// Stores emit only when state actually changes, so consumers can treat every +// emission as a real invalidation rather than polling for differences. + +/** + * @typedef {(detail: any) => void} Listener + */ + +export class Emitter { + constructor() { + /** @type {Map>} */ + this.listeners = new Map(); + } + + /** + * Subscribe to an event. + * @param {string} name + * @param {Listener} handler + * @returns {() => void} Unsubscribe function + */ + on(name, handler) { + if (typeof name !== "string" || typeof handler !== "function") { + return () => {}; + } + + let handlers = this.listeners.get(name); + if (!handlers) { + handlers = new Set(); + this.listeners.set(name, handlers); + } + handlers.add(handler); + + return () => { + handlers.delete(handler); + if (!handlers.size) { + this.listeners.delete(name); + } + }; + } + + /** + * Emit an event. + * + * A throwing listener must not prevent the remaining listeners from running, + * nor propagate out of a store's internal state update. + * + * @param {string} name + * @param {any} [detail] + */ + emit(name, detail) { + const handlers = this.listeners.get(name); + if (!handlers?.size) { + return; + } + for (const handler of Array.from(handlers)) { + try { + handler(detail); + } catch { + // Listener errors are contained; state has already been updated. + } + } + } + + /** Drop every listener. */ + clear() { + this.listeners.clear(); + } +} diff --git a/packages/runtime/src/index.js b/packages/runtime/src/index.js index e94d8f9..bc198db 100644 --- a/packages/runtime/src/index.js +++ b/packages/runtime/src/index.js @@ -1,2 +1,40 @@ -// Placeholder for runtime module exports -export {}; \ No newline at end of file +// @nostr-governance/runtime +// +// Stateful orchestration: stores, transport wiring, viewer lifecycle, +// evaluation, and commands. Transport, signer, and storage are injected, so the +// package depends on no particular relay pool or key manager. + +export { Emitter } from "./emitter.js"; + +export { + storageKey, + createMemoryStorage, + createMemoryTransport, + createNullSigner, +} from "./interfaces.js"; + +export { + GovernanceAdminStore, + TrustGraphStore, + ReportStore, + TrustedMuteStore, + PolicyStore, + OverrideStore, +} from "./stores.js"; + +export { + DEFAULT_CHUNK_SIZE, + GovernanceRuntime, + chunk, + createGovernanceRuntime, +} from "./runtime.js"; + +export { ERROR_CODES, GovernanceCommands, createCommands } from "./commands.js"; + +/** + * @typedef {import('./interfaces.js').GovernanceTransport} GovernanceTransport + * @typedef {import('./interfaces.js').GovernanceSigner} GovernanceSigner + * @typedef {import('./interfaces.js').GovernanceStorage} GovernanceStorage + * @typedef {import('./interfaces.js').PublishResult} PublishResult + * @typedef {import('./commands.js').CommandResult} CommandResult + */ diff --git a/packages/runtime/src/interfaces.js b/packages/runtime/src/interfaces.js new file mode 100644 index 0000000..984439b --- /dev/null +++ b/packages/runtime/src/interfaces.js @@ -0,0 +1,214 @@ +// Injected dependency contracts and their in-memory test doubles. +// +// The package depends on no relay pool, signer, or storage implementation. +// Consumers supply adapters; these doubles exist so the runtime can be tested +// and so an application can boot a working runtime before wiring real relays. + +/** + * @typedef {import('@nostr-governance/nostr').NostrEvent} NostrEvent + */ + +/** + * @typedef {Object} GovernanceSubscription + * @property {() => void} close + */ + +/** + * @typedef {Object} SubscriptionHandlers + * @property {(event: NostrEvent) => void} [onEvent] + * @property {() => void} [onEose] + * @property {(error: unknown) => void} [onError] + */ + +/** + * @typedef {Object} GovernanceTransport + * @property {(filters: object[], options?: object) => Promise} list + * @property {(filters: object[], handlers: SubscriptionHandlers, options?: object) => GovernanceSubscription} subscribe + * @property {(event: NostrEvent, options?: object) => Promise} publish + */ + +/** + * @typedef {Object} PublishResult + * @property {boolean} ok - Whether at least one relay accepted the event + * @property {string[]} accepted - Relay URLs that accepted + * @property {Array<{ relay: string, error: string }>} failed + */ + +/** + * @typedef {Object} GovernanceSigner + * @property {() => Promise} getPublicKey + * @property {(event: object) => Promise} signEvent + */ + +/** + * @typedef {Object} GovernanceStorage + * @property {(key: string) => Promise} read + * @property {(key: string, value: unknown) => Promise} write + * @property {(key: string) => Promise} remove + */ + +/** + * Build a namespaced storage key. + * + * Keys carry the application, namespace, root-authority fingerprint, schema + * version, and — when the data is viewer-specific — the viewer. Without the + * root fingerprint, switching a deployment's root administrator would silently + * reuse the previous administration's cached state. + * + * @param {Object} parts + * @param {string} parts.applicationId + * @param {string} parts.namespace + * @param {string} parts.rootFingerprint + * @param {string} parts.schemaVersion + * @param {string} parts.scope + * @param {string} [parts.viewerPubkey] + * @returns {string} + */ +export function storageKey({ + applicationId, + namespace, + rootFingerprint, + schemaVersion, + scope, + viewerPubkey, +}) { + const segments = [ + "nostr-governance", + applicationId, + namespace, + rootFingerprint, + schemaVersion, + scope, + ]; + if (viewerPubkey) { + segments.push(viewerPubkey); + } + return segments.join(":"); +} + +/** + * In-memory storage, used for tests and as a no-persistence default. + * @returns {GovernanceStorage} + */ +export function createMemoryStorage() { + /** @type {Map} */ + const store = new Map(); + + return { + async read(key) { + return store.has(key) ? store.get(key) : null; + }, + async write(key, value) { + store.set(key, value); + }, + async remove(key) { + store.delete(key); + }, + }; +} + +/** + * In-memory transport backed by a fixed event list. + * + * Filters support the subset the stores actually use: kinds, authors, #d, #e, + * #p, and #a. + * + * @param {NostrEvent[]} [events] + * @returns {GovernanceTransport & { events: NostrEvent[], published: NostrEvent[], deliver: (event: NostrEvent) => void }} + */ +export function createMemoryTransport(events = []) { + /** @type {NostrEvent[]} */ + const all = [...events]; + /** @type {NostrEvent[]} */ + const published = []; + /** @type {Set<{ filters: object[], handlers: SubscriptionHandlers }>} */ + const subscriptions = new Set(); + + /** + * @param {NostrEvent} event + * @param {any} filter + * @returns {boolean} + */ + function matches(event, filter) { + if (Array.isArray(filter.kinds) && !filter.kinds.includes(event.kind)) { + return false; + } + if (Array.isArray(filter.authors) && !filter.authors.includes(event.pubkey)) { + return false; + } + for (const [key, values] of Object.entries(filter)) { + if (!key.startsWith("#") || !Array.isArray(values)) { + continue; + } + const tagName = key.slice(1); + const present = (event.tags ?? []).some( + (tag) => tag[0] === tagName && values.includes(tag[1]), + ); + if (!present) { + return false; + } + } + return true; + } + + return { + events: all, + published, + + async list(filters) { + return all.filter((event) => filters.some((filter) => matches(event, filter))); + }, + + subscribe(filters, handlers) { + const entry = { filters, handlers }; + subscriptions.add(entry); + + for (const event of all) { + if (filters.some((filter) => matches(event, filter))) { + handlers.onEvent?.(event); + } + } + handlers.onEose?.(); + + return { + close() { + subscriptions.delete(entry); + }, + }; + }, + + async publish(event) { + published.push(event); + return { ok: true, accepted: ["memory://"], failed: [] }; + }, + + /** + * Push an event to live subscriptions, simulating a relay delivery. + * @param {NostrEvent} event + */ + deliver(event) { + all.push(event); + for (const { filters, handlers } of subscriptions) { + if (filters.some((filter) => matches(event, filter))) { + handlers.onEvent?.(event); + } + } + }, + }; +} + +/** + * Signer that refuses to sign, used as the default so an unconfigured runtime + * fails loudly at the point of a write instead of appearing to succeed. + * @returns {GovernanceSigner} + */ +export function createNullSigner() { + return { + async getPublicKey() { + throw new Error("no-signer-configured"); + }, + async signEvent() { + throw new Error("no-signer-configured"); + }, + }; +} diff --git a/packages/runtime/src/runtime.js b/packages/runtime/src/runtime.js new file mode 100644 index 0000000..d5474ae --- /dev/null +++ b/packages/runtime/src/runtime.js @@ -0,0 +1,470 @@ +// GovernanceRuntime. +// +// Owns store lifecycle, transport orchestration, viewer and active-target +// changes, evaluation, and diagnostics. It is the only stateful object an +// application needs to hold. + +import { + NEUTRAL_POLICY, + evaluateMany, + evaluateTarget, + getTargetKey, + isValidTarget, + normalizePubkey, +} from "@nostr-governance/core"; +import { + CANONICAL_KIND, + LEGACY_KIND, + MUTE_LIST_KIND, + REPORT_KIND, + decodeContribution, + decodeLegacyList, + decodeMuteList, + decodePolicy, + decodeReport, + decodeRoles, + selectReplaceable, +} from "@nostr-governance/nostr"; + +import { Emitter } from "./emitter.js"; +import { createMemoryStorage, createNullSigner, storageKey } from "./interfaces.js"; +import { + GovernanceAdminStore, + OverrideStore, + PolicyStore, + ReportStore, + TrustGraphStore, + TrustedMuteStore, +} from "./stores.js"; + +/** + * @typedef {import('@nostr-governance/nostr').NostrEvent} NostrEvent + * @typedef {import('./interfaces.js').GovernanceTransport} GovernanceTransport + * @typedef {import('./interfaces.js').GovernanceSigner} GovernanceSigner + * @typedef {import('./interfaces.js').GovernanceStorage} GovernanceStorage + * @typedef {import('@nostr-governance/core').PolicyDefinition} PolicyDefinition + * @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget + * @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision + * @typedef {import('@nostr-governance/core').GovernanceSnapshot} GovernanceSnapshot + * @typedef {import('@nostr-governance/core').ViewerState} ViewerState + */ + +/** How many targets go into one relay filter chunk. */ +export const DEFAULT_CHUNK_SIZE = 200; + +/** + * Split a list into bounded chunks. + * + * Report subscriptions are chunked rather than opened per target: one + * subscription per feed item is what the extraction must not regress to. + * + * @template T + * @param {T[]} items + * @param {number} size + * @returns {T[][]} + */ +export function chunk(items, size) { + if (size <= 0) { + return [items]; + } + /** @type {T[][]} */ + const chunks = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +} + +export class GovernanceRuntime extends Emitter { + /** + * @param {Object} options + * @param {string} options.applicationId + * @param {string} options.namespace + * @param {GovernanceTransport} options.transport + * @param {GovernanceSigner} [options.signer] + * @param {GovernanceStorage} [options.storage] + * @param {PolicyDefinition} [options.policy] + * @param {() => number} [options.now] - Injected clock, unix seconds + * @param {number} [options.chunkSize] + * @param {number} [options.muteWindowSeconds] + */ + constructor({ + applicationId, + namespace, + transport, + signer, + storage, + policy, + now, + chunkSize = DEFAULT_CHUNK_SIZE, + muteWindowSeconds = 0, + }) { + super(); + + if (!applicationId || !namespace) { + throw new Error("GovernanceRuntime requires applicationId and namespace"); + } + if (!transport) { + throw new Error("GovernanceRuntime requires a transport adapter"); + } + + this.applicationId = applicationId; + this.namespace = namespace; + this.transport = transport; + this.signer = signer ?? createNullSigner(); + this.storage = storage ?? createMemoryStorage(); + this.now = now ?? (() => 0); + this.chunkSize = chunkSize; + this.schemaVersion = "v1"; + + this.admin = new GovernanceAdminStore(); + this.trust = new TrustGraphStore(); + this.reports = new ReportStore(); + this.mutes = new TrustedMuteStore({ windowSeconds: muteWindowSeconds, now: this.now }); + /** @type {PolicyStore} */ + this.policies = new PolicyStore(policy ?? NEUTRAL_POLICY); + this.overrides = new OverrideStore({ now: this.now }); + + /** @type {string} */ + this.viewerPubkey = ""; + /** @type {Map} */ + this.activeTargets = new Map(); + /** @type {Set<{ close: () => void }>} */ + this.subscriptions = new Set(); + /** @type {boolean} */ + this.destroyed = false; + + /** @type {Array<() => void>} */ + this.storeUnsubscribes = [ + this.admin.on("change", () => this.emit("change", { source: "admin" })), + this.trust.on("change", () => this.emit("change", { source: "trust" })), + this.reports.on("change", (detail) => this.emit("change", { source: "reports", ...detail })), + this.mutes.on("change", () => this.emit("change", { source: "mutes" })), + this.policies.on("change", () => this.emit("change", { source: "policy" })), + this.overrides.on("change", () => this.emit("change", { source: "overrides" })), + ]; + + /** @type {{ eventsIngested: number, unknownEvents: number, subscriptions: number }} */ + this.diagnostics = { eventsIngested: 0, unknownEvents: 0, subscriptions: 0 }; + } + + /** + * Build a namespaced storage key for this deployment. + * @param {string} scope + * @param {boolean} [viewerScoped] + * @returns {string} + */ + storageKeyFor(scope, viewerScoped = false) { + return storageKey({ + applicationId: this.applicationId, + namespace: this.namespace, + rootFingerprint: this.admin.rootFingerprint, + schemaVersion: this.schemaVersion, + scope, + viewerPubkey: viewerScoped ? this.viewerPubkey : undefined, + }); + } + + /** + * Route a raw event to the right store. + * + * Unrecognized events are counted rather than thrown on: relays return what + * they like, and an unknown kind is not an error condition. + * + * @param {NostrEvent} event + * @returns {boolean} Whether the event was recognized + */ + ingestEvent(event) { + if (!event || typeof event.kind !== "number") { + return false; + } + + this.diagnostics.eventsIngested += 1; + + if (event.kind === REPORT_KIND) { + const reports = decodeReport(event); + for (const report of reports) { + this.reports.ingest(report, getTargetKey(report.target)); + } + return reports.length > 0; + } + + if (event.kind === MUTE_LIST_KIND) { + const list = decodeMuteList(event); + if (list) { + this.mutes.replaceList(list); + return true; + } + return false; + } + + if (event.kind === CANONICAL_KIND) { + const roles = decodeRoles(event); + if (roles) { + this.admin.setRoles(roles); + return true; + } + + const policy = decodePolicy(event); + if (policy) { + this.emit("policy-document", { policy }); + return true; + } + + const contribution = decodeContribution(event); + if (contribution) { + this.admin.upsertContribution(contribution); + return true; + } + } + + if (event.kind === LEGACY_KIND) { + const contribution = decodeLegacyList(event); + if (contribution) { + this.admin.upsertContribution(contribution); + return true; + } + } + + this.diagnostics.unknownEvents += 1; + return false; + } + + /** + * Load administrative state from relays. + * + * Replaceable selection runs before ingestion so that only the newest + * document per coordinate reaches the stores. + * + * @param {Object} [options] + * @param {string[]} [options.authors] - Restrict to known contributor pubkeys + * @returns {Promise} Number of events ingested + */ + async loadAdministrativeState({ authors } = {}) { + /** @type {any} */ + const filter = { kinds: [CANONICAL_KIND, LEGACY_KIND] }; + if (authors?.length) { + filter.authors = authors; + } + + const events = await this.transport.list([filter]); + const effective = Array.from(selectReplaceable(events).values()); + + for (const event of effective) { + this.ingestEvent(event); + } + return effective.length; + } + + /** + * Subscribe to reports for the currently active targets. + * + * Targets are chunked into bounded filters rather than one subscription per + * target. + * + * @returns {{ close: () => void }} + */ + subscribeToActiveTargetReports() { + const eventIds = []; + const pubkeys = []; + + for (const target of this.activeTargets.values()) { + if (target.type === "event") { + eventIds.push(target.id); + } else if (target.type === "user") { + pubkeys.push(target.pubkey); + } + } + + /** @type {Array<{ close: () => void }>} */ + const opened = []; + + for (const ids of chunk(eventIds, this.chunkSize)) { + if (!ids.length) continue; + opened.push( + this.transport.subscribe([{ kinds: [REPORT_KIND], "#e": ids }], { + onEvent: (event) => this.ingestEvent(event), + }), + ); + } + + for (const keys of chunk(pubkeys, this.chunkSize)) { + if (!keys.length) continue; + opened.push( + this.transport.subscribe([{ kinds: [REPORT_KIND], "#p": keys }], { + onEvent: (event) => this.ingestEvent(event), + }), + ); + } + + const handle = { + close: () => { + for (const subscription of opened) { + subscription.close(); + } + this.subscriptions.delete(handle); + this.diagnostics.subscriptions = this.subscriptions.size; + }, + }; + + this.subscriptions.add(handle); + this.diagnostics.subscriptions = this.subscriptions.size; + return handle; + } + + /** + * Set the active targets whose evidence should be kept fresh. + * @param {GovernanceTarget[]} targets + */ + setActiveTargets(targets) { + this.activeTargets.clear(); + for (const target of targets ?? []) { + if (isValidTarget(target)) { + this.activeTargets.set(getTargetKey(target), target); + } + } + this.emit("active-targets", { count: this.activeTargets.size }); + } + + /** + * Switch the viewer. + * + * Viewer-specific state is cleared so one account's blocks, mutes, and + * overrides can never leak into another's session. + * + * @param {string} pubkey + */ + setViewer(pubkey) { + const normalized = normalizePubkey(pubkey ?? ""); + if (normalized === this.viewerPubkey) { + return; + } + this.viewerPubkey = normalized; + this.trust.clearViewerState(); + this.overrides.clearOverrides(); + this.emit("viewer", { pubkey: normalized }); + } + + /** + * Build a snapshot for the pure evaluator. + * @returns {GovernanceSnapshot} + */ + snapshot() { + return { + authority: this.admin.authority, + admin: this.admin.state, + trust: { contacts: this.trust.contacts, seeds: this.trust.seeds }, + reports: this.reports.toRecordMap(), + trustedMutes: this.mutes.toRecordMap(), + }; + } + + /** @returns {ViewerState} */ + viewerState() { + return { + blocks: this.trust.blocks, + mutes: this.trust.mutes, + overrides: this.overrides.toMap(), + }; + } + + /** + * Evaluate one target. + * @param {GovernanceTarget} target + * @param {Object} [options] + * @param {string} [options.profile] + * @param {string} [options.surface] + * @returns {GovernanceDecision} + */ + evaluate(target, { profile, surface } = {}) { + return evaluateTarget( + target, + this.snapshot(), + { + surface: surface ?? profile ?? "default", + policyProfile: profile, + policy: this.policies.policy, + now: this.now(), + }, + this.viewerState(), + ); + } + + /** + * Evaluate many targets against one snapshot. + * @param {GovernanceTarget[]} targets + * @param {Object} [options] + * @param {string} [options.profile] + * @param {string} [options.surface] + * @returns {Map} + */ + evaluateMany(targets, { profile, surface } = {}) { + return evaluateMany( + targets, + this.snapshot(), + { + surface: surface ?? profile ?? "default", + policyProfile: profile, + policy: this.policies.policy, + now: this.now(), + }, + this.viewerState(), + ); + } + + /** Diagnostic summary for support and debugging. */ + describe() { + return { + applicationId: this.applicationId, + namespace: this.namespace, + viewerPubkey: this.viewerPubkey, + policyId: this.policies.policy.id, + policyVersion: this.policies.policy.version, + rootFingerprint: this.admin.rootFingerprint, + adminFingerprint: this.admin.fingerprint, + trustFingerprint: this.trust.fingerprint, + activeTargets: this.activeTargets.size, + ...this.diagnostics, + }; + } + + /** + * Close every subscription and drop every listener. + * + * Idempotent: calling destroy twice is safe, because applications tear down + * from more than one place. + */ + destroy() { + if (this.destroyed) { + return; + } + this.destroyed = true; + + for (const subscription of Array.from(this.subscriptions)) { + subscription.close(); + } + this.subscriptions.clear(); + + for (const unsubscribe of this.storeUnsubscribes) { + unsubscribe(); + } + + this.admin.clear(); + this.trust.clear(); + this.reports.clear(); + this.mutes.clear(); + this.policies.clear(); + this.overrides.clearOverrides(); + this.overrides.clear(); + this.clear(); + } +} + +/** + * Create a runtime. + * @param {ConstructorParameters[0]} options + * @returns {GovernanceRuntime} + */ +export function createGovernanceRuntime(options) { + return new GovernanceRuntime(options); +} diff --git a/packages/runtime/src/stores.js b/packages/runtime/src/stores.js new file mode 100644 index 0000000..d71d17e --- /dev/null +++ b/packages/runtime/src/stores.js @@ -0,0 +1,510 @@ +// Governance stores. +// +// Each store owns one slice of state, emits only on real changes, and exposes a +// plain snapshot the pure evaluator can consume. Stores never reach for a relay +// themselves: the runtime feeds them decoded events. + +import { + createAuthorityState, + createEmptyAdminState, + fingerprint, + normalizePubkey, + reduceAdminState, + serializeAdminState, +} from "@nostr-governance/core"; +import { selectReplaceable, toMuteRecords } from "@nostr-governance/nostr"; + +import { Emitter } from "./emitter.js"; + +/** + * @typedef {import('@nostr-governance/core').PolicyDefinition} PolicyDefinition + * @typedef {import('@nostr-governance/core').Contribution} Contribution + * @typedef {import('@nostr-governance/core').AuthorityState} AuthorityState + * @typedef {import('@nostr-governance/core').AdminState} AdminState + * @typedef {import('@nostr-governance/nostr').NostrEvent} NostrEvent + */ + +/** + * Roles, capabilities, contributions, and the effective merged admin state. + */ +export class GovernanceAdminStore extends Emitter { + constructor() { + super(); + /** @type {AuthorityState} */ + this.authority = createAuthorityState({}); + /** @type {Contribution[]} */ + this.contributions = []; + /** @type {AdminState} */ + this.state = createEmptyAdminState(); + /** @type {string} */ + this.fingerprint = fingerprint(serializeAdminState(this.state)); + } + + /** + * Replace the role roster. + * @param {Object} roster + * @param {string} [roster.root] + * @param {Record} [roster.actors] + * @param {Record} [roster.roles] + * @param {string[]} [roster.protectedActors] + */ + setRoles(roster) { + this.authority = createAuthorityState(roster ?? {}); + this.#recompute(); + } + + /** + * Replace every contribution. + * @param {Contribution[]} contributions + */ + setContributions(contributions) { + this.contributions = Array.isArray(contributions) ? [...contributions] : []; + this.#recompute(); + } + + /** + * Add or replace one actor's contribution of a given kind. + * + * Replacement is by (actor, kind) because a contribution list is itself a + * replaceable document: a moderator's newest deny list supersedes their + * previous one rather than accumulating with it. + * + * @param {Contribution} contribution + */ + upsertContribution(contribution) { + if (!contribution?.actor || !contribution?.kind) { + return; + } + const index = this.contributions.findIndex( + (entry) => entry.actor === contribution.actor && entry.kind === contribution.kind, + ); + if (index >= 0) { + const existing = this.contributions[index]; + if ((existing.createdAt ?? 0) > (contribution.createdAt ?? 0)) { + return; + } + this.contributions[index] = contribution; + } else { + this.contributions.push(contribution); + } + this.#recompute(); + } + + /** The root-authority fingerprint, used to namespace cached state. */ + get rootFingerprint() { + return fingerprint({ root: this.authority.root ?? "", actors: this.authority.actors }); + } + + #recompute() { + const next = reduceAdminState(this.contributions, this.authority); + const nextFingerprint = fingerprint(serializeAdminState(next)); + this.state = next; + + if (nextFingerprint !== this.fingerprint) { + this.fingerprint = nextFingerprint; + this.emit("change", { fingerprint: nextFingerprint }); + } + } +} + +/** + * Viewer follows, trust seeds, blocks, and mutes. + */ +export class TrustGraphStore extends Emitter { + constructor() { + super(); + /** @type {Set} */ + this.contacts = new Set(); + /** @type {Set} */ + this.seeds = new Set(); + /** @type {Set} */ + this.blocks = new Set(); + /** @type {Set} */ + this.mutes = new Set(); + this.fingerprint = this.#computeFingerprint(); + } + + /** @param {Iterable} pubkeys */ + setContacts(pubkeys) { + this.contacts = this.#normalizeSet(pubkeys); + this.#emitIfChanged(); + } + + /** @param {Iterable} pubkeys */ + setSeeds(pubkeys) { + this.seeds = this.#normalizeSet(pubkeys); + this.#emitIfChanged(); + } + + /** @param {Iterable} pubkeys */ + setBlocks(pubkeys) { + this.blocks = this.#normalizeSet(pubkeys); + this.#emitIfChanged(); + } + + /** @param {Iterable} pubkeys */ + setMutes(pubkeys) { + this.mutes = this.#normalizeSet(pubkeys); + this.#emitIfChanged(); + } + + /** + * Clear viewer-specific state when the viewer changes. + * + * Seeds survive: they are operator configuration, not viewer data. + */ + clearViewerState() { + this.contacts = new Set(); + this.blocks = new Set(); + this.mutes = new Set(); + this.#emitIfChanged(); + } + + /** The effective trust set: real contacts when present, seeds otherwise. */ + get trustSet() { + return this.contacts.size > 0 ? this.contacts : this.seeds; + } + + /** + * @param {Iterable} pubkeys + * @returns {Set} + */ + #normalizeSet(pubkeys) { + /** @type {Set} */ + const normalized = new Set(); + for (const pubkey of pubkeys ?? []) { + const hex = normalizePubkey(pubkey); + if (hex) { + normalized.add(hex); + } + } + return normalized; + } + + #computeFingerprint() { + return fingerprint({ + contacts: this.contacts, + seeds: this.seeds, + blocks: this.blocks, + mutes: this.mutes, + }); + } + + #emitIfChanged() { + const next = this.#computeFingerprint(); + if (next !== this.fingerprint) { + this.fingerprint = next; + this.emit("change", { fingerprint: next }); + } + } +} + +/** + * NIP-56 reports, aggregated per target. + */ +export class ReportStore extends Emitter { + constructor() { + super(); + /** @type {Map>>} reports[targetKey][reporter][category] = createdAt */ + this.reports = new Map(); + this.fingerprint = fingerprint({}); + } + + /** + * Ingest a decoded report. + * + * Storage is keyed by target → reporter → category, so deduplication is + * structural: re-ingesting the same report cannot inflate a count, which + * matters because relays routinely deliver duplicates. + * + * @param {{ reporter: string, target: import('@nostr-governance/core').GovernanceTarget, category: string, createdAt?: number }} report + * @param {string} targetKey + */ + ingest(report, targetKey) { + if (!report?.reporter || !report?.category || !targetKey) { + return; + } + + let byReporter = this.reports.get(targetKey); + if (!byReporter) { + byReporter = new Map(); + this.reports.set(targetKey, byReporter); + } + + let byCategory = byReporter.get(report.reporter); + if (!byCategory) { + byCategory = new Map(); + byReporter.set(report.reporter, byCategory); + } + + const existing = byCategory.get(report.category); + const createdAt = report.createdAt ?? 0; + if (existing !== undefined && existing >= createdAt) { + return; + } + + byCategory.set(report.category, createdAt); + this.#emitChange(targetKey); + } + + /** + * Report records for a target, in the shape the evaluator consumes. + * @param {string} targetKey + * @returns {Array<{ reporter: string, category: string, createdAt: number }>} + */ + recordsFor(targetKey) { + const byReporter = this.reports.get(targetKey); + if (!byReporter) { + return []; + } + + /** @type {Array<{ reporter: string, category: string, createdAt: number }>} */ + const records = []; + for (const [reporter, byCategory] of byReporter.entries()) { + for (const [category, createdAt] of byCategory.entries()) { + records.push({ reporter, category, createdAt }); + } + } + return records; + } + + /** @returns {Map>} */ + toRecordMap() { + /** @type {Map>} */ + const map = new Map(); + for (const targetKey of this.reports.keys()) { + map.set(targetKey, this.recordsFor(targetKey)); + } + return map; + } + + /** @param {string} targetKey */ + clearTarget(targetKey) { + if (this.reports.delete(targetKey)) { + this.#emitChange(targetKey); + } + } + + /** @param {string} targetKey */ + #emitChange(targetKey) { + this.fingerprint = fingerprint( + Array.from(this.reports.keys()).sort().map((key) => [key, this.recordsFor(key).length]), + ); + this.emit("change", { targetKey, fingerprint: this.fingerprint }); + } +} + +/** + * NIP-51 trusted mute lists. + */ +export class TrustedMuteStore extends Emitter { + /** + * @param {Object} [options] + * @param {number} [options.windowSeconds] - Mutes older than this are pruned + * @param {() => number} [options.now] - Injected clock, unix seconds + */ + constructor({ windowSeconds = 0, now = () => 0 } = {}) { + super(); + this.windowSeconds = windowSeconds; + this.now = now; + /** @type {Map }>} */ + this.lists = new Map(); + this.fingerprint = fingerprint({}); + } + + /** + * Replace an owner's mute list. + * + * A newer list fully replaces the older one; an out-of-order delivery of an + * older list is ignored rather than resurrecting removed entries. + * + * @param {import('@nostr-governance/nostr').DecodedMuteList} list + */ + replaceList(list) { + if (!list?.owner) { + return; + } + const existing = this.lists.get(list.owner); + if (existing && existing.updatedAt > list.updatedAt) { + return; + } + this.lists.set(list.owner, { + owner: list.owner, + updatedAt: list.updatedAt, + entries: list.entries, + }); + this.#emitChange(); + } + + /** + * Drop lists that have fallen outside the validity window. + * @returns {number} Number of lists pruned + */ + prune() { + if (!this.windowSeconds) { + return 0; + } + const cutoff = this.now() - this.windowSeconds; + let pruned = 0; + for (const [owner, list] of this.lists.entries()) { + if (list.updatedAt < cutoff) { + this.lists.delete(owner); + pruned += 1; + } + } + if (pruned) { + this.#emitChange(); + } + return pruned; + } + + /** @returns {Map>} */ + toRecordMap() { + return toMuteRecords( + Array.from(this.lists.values()).map((list) => ({ + owner: list.owner, + updatedAt: list.updatedAt, + entries: list.entries, + hasEncryptedEntries: false, + })), + ); + } + + /** + * Mute counts for many authors in one pass. + * + * Batched deliberately: per-item store lookups across a large feed were the + * regression the extraction has to avoid. + * + * @param {string[]} pubkeys + * @returns {Map} + */ + countsForAuthors(pubkeys) { + const records = this.toRecordMap(); + /** @type {Map} */ + const counts = new Map(); + for (const pubkey of pubkeys) { + counts.set(pubkey, (records.get(`user:${pubkey}`) ?? []).length); + } + return counts; + } + + #emitChange() { + this.fingerprint = fingerprint( + Array.from(this.lists.values()) + .map((list) => [list.owner, list.updatedAt, list.entries.length]) + .sort(), + ); + this.emit("change", { fingerprint: this.fingerprint }); + } +} + +/** + * Policy definitions, layered default → root-published → local override. + */ +export class PolicyStore extends Emitter { + /** + * @param {PolicyDefinition} defaultPolicy + */ + constructor(defaultPolicy) { + super(); + /** @type {PolicyDefinition} */ + this.defaultPolicy = defaultPolicy; + /** @type {PolicyDefinition|null} */ + this.rootPolicy = null; + /** @type {PolicyDefinition|null} */ + this.localPolicy = null; + } + + /** + * The effective policy. + * + * A local application policy wins over a root-published one so an application + * can pin behavior it depends on; the root policy wins over the built-in + * default. + * @returns {PolicyDefinition} + */ + get policy() { + return this.localPolicy ?? this.rootPolicy ?? this.defaultPolicy; + } + + /** @param {PolicyDefinition|null} policy */ + setRootPolicy(policy) { + const before = this.policy; + this.rootPolicy = policy; + if (this.policy !== before) { + this.emit("change", { policy: this.policy }); + } + } + + /** @param {PolicyDefinition|null} policy */ + setLocalPolicy(policy) { + const before = this.policy; + this.localPolicy = policy; + if (this.policy !== before) { + this.emit("change", { policy: this.policy }); + } + } +} + +/** + * Viewer-scoped overrides. + */ +export class OverrideStore extends Emitter { + /** + * @param {Object} [options] + * @param {() => number} [options.now] - Injected clock, unix seconds + */ + constructor({ now = () => 0 } = {}) { + super(); + this.now = now; + /** @type {Map} */ + this.overrides = new Map(); + } + + /** + * @param {string} targetKey + * @param {{ visibility: string, reason?: string, expiresAt?: number }} override + */ + set(targetKey, override) { + this.overrides.set(targetKey, override); + this.emit("change", { targetKey }); + } + + /** @param {string} targetKey */ + remove(targetKey) { + if (this.overrides.delete(targetKey)) { + this.emit("change", { targetKey }); + } + } + + /** + * Drop every override, e.g. when the viewer changes. + * + * Named distinctly from the inherited listener `clear()` so that tearing down + * overrides and tearing down subscribers stay separate operations. + */ + clearOverrides() { + if (this.overrides.size) { + this.overrides.clear(); + this.emit("change", {}); + } + } + + /** + * Live overrides, with expired entries filtered out. + * @returns {Map} + */ + toMap() { + const now = this.now(); + /** @type {Map} */ + const live = new Map(); + for (const [key, override] of this.overrides.entries()) { + if (override.expiresAt !== undefined && override.expiresAt <= now) { + continue; + } + live.set(key, { visibility: override.visibility, reason: override.reason }); + } + return live; + } +} diff --git a/packages/runtime/test/commands.test.js b/packages/runtime/test/commands.test.js new file mode 100644 index 0000000..7eccac7 --- /dev/null +++ b/packages/runtime/test/commands.test.js @@ -0,0 +1,335 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createPolicyDefinition } from "@nostr-governance/core"; +import { REPORT_KIND, encodeRoles } from "@nostr-governance/nostr"; + +import { ERROR_CODES, createCommands } from "../src/commands.js"; +import { createGovernanceRuntime } from "../src/runtime.js"; +import { createMemoryTransport } from "../src/interfaces.js"; + +const ROOT = "a1".repeat(32); +const MODERATOR = "b2".repeat(32); +const CURATOR = "c3".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 POLICY = createPolicyDefinition({ + id: "app", + version: "1.0.0", + profiles: { feed: { name: "feed" } }, +}); + +/** + * A signer for a given pubkey. Signing simply stamps an id, which is enough for + * transport assertions and keeps crypto out of the test. + * @param {string} pubkey + */ +function signerFor(pubkey) { + return { + async getPublicKey() { + return pubkey; + }, + async signEvent(template) { + return { ...template, id: "ff".repeat(32), pubkey, sig: "00".repeat(64) }; + }, + }; +} + +/** + * @param {Object} [options] + * @param {string} [options.actor] + * @param {any} [options.signer] + * @param {any} [options.transport] + */ +function setup({ actor = MODERATOR, signer, transport } = {}) { + const memory = transport ?? createMemoryTransport(); + const runtime = createGovernanceRuntime({ + applicationId: "test", + namespace: "app", + transport: memory, + signer: signer ?? signerFor(actor), + policy: POLICY, + now: () => NOW, + }); + + runtime.admin.setRoles({ + root: ROOT, + actors: { + [ROOT]: ["super_admin"], + [MODERATOR]: ["moderator"], + [CURATOR]: ["curator"], + }, + }); + + return { runtime, commands: createCommands(runtime), transport: memory }; +} + +describe("authority enforcement", () => { + it("permits a moderator to deny a user", async () => { + const { commands, transport } = setup(); + const result = await commands.denyUser(CREATOR); + expect(result.ok).toBe(true); + expect(transport.published).toHaveLength(1); + }); + + it("refuses an actor with no roles", async () => { + const { commands, transport } = setup({ actor: STRANGER }); + const result = await commands.denyUser(CREATOR); + expect(result.ok).toBe(false); + expect(result.code).toBe(ERROR_CODES.NOT_AUTHORIZED); + expect(transport.published).toHaveLength(0); + }); + + it("refuses a curator denying an event", async () => { + const { commands } = setup({ actor: CURATOR }); + const result = await commands.denyEvent(EVENT_ID); + expect(result.code).toBe(ERROR_CODES.NOT_AUTHORIZED); + }); + + it("permits a curator denying a user", async () => { + const { commands } = setup({ actor: CURATOR }); + expect((await commands.denyUser(CREATOR)).ok).toBe(true); + }); + + it("never invokes the signer for an unauthorized command", async () => { + const signer = signerFor(STRANGER); + const signSpy = vi.spyOn(signer, "signEvent"); + const { commands } = setup({ signer }); + + await commands.denyUser(CREATOR); + expect(signSpy).not.toHaveBeenCalled(); + }); + + it("stops accepting commands after a role is revoked", async () => { + const { runtime, commands } = setup(); + expect((await commands.denyUser(CREATOR)).ok).toBe(true); + + runtime.admin.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } }); + expect((await commands.denyUser(CREATOR)).code).toBe(ERROR_CODES.NOT_AUTHORIZED); + }); + + it("refuses role management to a moderator", async () => { + const { commands } = setup(); + expect((await commands.setRoles({ actors: {} })).code).toBe(ERROR_CODES.NOT_AUTHORIZED); + }); + + it("permits role management to the root", async () => { + const { commands } = setup({ actor: ROOT }); + expect((await commands.setRoles({ actors: { [MODERATOR]: ["moderator"] } })).ok).toBe(true); + }); + + it("refuses policy management to a moderator", async () => { + const { commands } = setup(); + expect((await commands.setPolicy(POLICY)).code).toBe(ERROR_CODES.NOT_AUTHORIZED); + }); + + it("refuses community-source management to a curator", async () => { + const { commands } = setup({ actor: CURATOR }); + const result = await commands.setCommunitySources([ + { curator: CURATOR, identifier: "list", kind: 30078 }, + ]); + expect(result.code).toBe(ERROR_CODES.NOT_AUTHORIZED); + }); +}); + +describe("protected targets", () => { + it("refuses to deny the root", async () => { + const { commands } = setup(); + const result = await commands.denyUser(ROOT); + expect(result.code).toBe(ERROR_CODES.PROTECTED_TARGET); + }); + + it("does not publish a refused denial", async () => { + const { commands, transport } = setup(); + await commands.denyUser(ROOT); + expect(transport.published).toHaveLength(0); + }); +}); + +describe("argument validation", () => { + it("rejects an unknown contribution kind", async () => { + const { commands } = setup(); + const result = await commands.contribute(/** @type {any} */ ("nonsense"), [ + { type: "user", pubkey: CREATOR }, + ]); + expect(result.code).toBe(ERROR_CODES.INVALID_ARGUMENT); + }); + + it("rejects an empty target list", async () => { + const { commands } = setup(); + expect((await commands.contribute("user-deny", [])).code).toBe(ERROR_CODES.INVALID_ARGUMENT); + }); + + it("rejects a malformed target", async () => { + const { commands } = setup(); + expect((await commands.denyUser("not-a-pubkey")).code).toBe(ERROR_CODES.INVALID_TARGET); + }); + + it("rejects a non-object policy", async () => { + const { commands } = setup({ actor: ROOT }); + expect((await commands.setPolicy(/** @type {any} */ (null))).code).toBe( + ERROR_CODES.INVALID_ARGUMENT, + ); + }); +}); + +describe("signer failures", () => { + it("reports a missing signer", async () => { + const runtime = createGovernanceRuntime({ + applicationId: "test", + namespace: "app", + transport: createMemoryTransport(), + policy: POLICY, + now: () => NOW, + }); + const commands = createCommands(runtime); + const result = await commands.denyUser(CREATOR); + expect(result.code).toBe(ERROR_CODES.NO_SIGNER); + }); +}); + +describe("publishing", () => { + it("succeeds on partial relay acceptance and keeps failures visible", async () => { + const transport = createMemoryTransport(); + transport.publish = async (event) => ({ + ok: true, + accepted: ["wss://a"], + failed: [{ relay: "wss://b", error: "rejected" }], + }); + const { commands } = setup({ transport }); + + const result = await commands.denyUser(CREATOR); + expect(result.ok).toBe(true); + expect(result.accepted).toEqual(["wss://a"]); + expect(result.failed).toHaveLength(1); + }); + + it("fails when no relay accepts", async () => { + const transport = createMemoryTransport(); + transport.publish = async () => ({ + ok: false, + accepted: [], + failed: [{ relay: "wss://b", error: "rejected" }], + }); + const { commands } = setup({ transport }); + + const result = await commands.denyUser(CREATOR); + expect(result.ok).toBe(false); + expect(result.code).toBe(ERROR_CODES.PUBLISH_FAILED); + }); + + it("reports a transport that throws", async () => { + const transport = createMemoryTransport(); + transport.publish = async () => { + throw new Error("network down"); + }; + const { commands } = setup({ transport }); + + const result = await commands.denyUser(CREATOR); + expect(result.code).toBe(ERROR_CODES.PUBLISH_FAILED); + expect(result.event).toBeDefined(); + }); + + it("stamps the injected clock onto signed events", async () => { + const { commands, transport } = setup(); + await commands.denyUser(CREATOR); + expect(transport.published[0].created_at).toBe(NOW); + }); +}); + +describe("contribution round-trip", () => { + it("produces an event the runtime can ingest back into state", async () => { + const { runtime, commands, transport } = setup(); + await commands.denyUser(CREATOR); + + const fresh = createGovernanceRuntime({ + applicationId: "test", + namespace: "app", + transport: createMemoryTransport(), + policy: POLICY, + now: () => NOW, + }); + fresh.admin.setRoles({ + root: ROOT, + actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] }, + }); + fresh.ingestEvent(transport.published[0]); + + expect(fresh.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + }); + + it("marks a community source on the published event", async () => { + const { commands, transport } = setup({ actor: CURATOR }); + await commands.denyUser(CREATOR, { source: "list-a" }); + expect(transport.published[0].tags).toContainEqual(["source", "list-a"]); + }); + + it("denies an address coordinate", async () => { + const { commands, transport } = setup(); + const result = await commands.denyAddress("30078", CREATOR, "sku-001"); + expect(result.ok).toBe(true); + expect(transport.published[0].tags).toContainEqual(["a", `30078:${CREATOR}:sku-001`]); + }); + + it("adds trust seeds", async () => { + const { commands } = setup(); + expect((await commands.addTrustSeeds([CREATOR])).ok).toBe(true); + }); + + it("allows a user when the actor holds the capability", async () => { + const { commands } = setup({ actor: ROOT }); + expect((await commands.allowUser(CREATOR)).ok).toBe(true); + }); + + it("refuses user-allow to a moderator", async () => { + const { commands } = setup(); + expect((await commands.allowUser(CREATOR)).code).toBe(ERROR_CODES.NOT_AUTHORIZED); + }); +}); + +describe("reporting", () => { + it("needs no capability", async () => { + const { commands, transport } = setup({ actor: STRANGER }); + const result = await commands.report({ type: "event", id: EVENT_ID }, "spam"); + expect(result.ok).toBe(true); + expect(transport.published[0].kind).toBe(REPORT_KIND); + }); + + it("rejects a malformed target", async () => { + const { commands } = setup(); + const result = await commands.report(/** @type {any} */ ({ type: "event", id: "x" }), "spam"); + expect(result.code).toBe(ERROR_CODES.INVALID_TARGET); + }); + + it("rejects an empty category", async () => { + const { commands } = setup(); + const result = await commands.report({ type: "event", id: EVENT_ID }, " "); + expect(result.code).toBe(ERROR_CODES.INVALID_ARGUMENT); + }); +}); + +describe("viewer overrides", () => { + it("sets an override locally without publishing", () => { + const { runtime, commands, transport } = setup(); + const result = commands.setOverride({ type: "user", pubkey: CREATOR }, "allow"); + expect(result.ok).toBe(true); + expect(transport.published).toHaveLength(0); + expect(runtime.overrides.toMap().has(`user:${CREATOR}`)).toBe(true); + }); + + it("clears an override", () => { + const { runtime, commands } = setup(); + commands.setOverride({ type: "user", pubkey: CREATOR }, "allow"); + commands.clearOverride({ type: "user", pubkey: CREATOR }); + expect(runtime.overrides.toMap().size).toBe(0); + }); + + it("rejects a malformed target", () => { + const { commands } = setup(); + expect(commands.setOverride(/** @type {any} */ ({ type: "user", pubkey: "x" }), "allow").code).toBe( + ERROR_CODES.INVALID_TARGET, + ); + }); +}); diff --git a/packages/runtime/test/runtime.test.js b/packages/runtime/test/runtime.test.js new file mode 100644 index 0000000..cc7889b --- /dev/null +++ b/packages/runtime/test/runtime.test.js @@ -0,0 +1,408 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createPolicyDefinition } from "@nostr-governance/core"; +import { + CANONICAL_KIND, + MUTE_LIST_KIND, + REPORT_KIND, + canonicalIdentifier, + encodeContribution, + encodeRoles, +} from "@nostr-governance/nostr"; + +import { GovernanceRuntime, chunk, createGovernanceRuntime } from "../src/runtime.js"; +import { createMemoryTransport, storageKey } from "../src/interfaces.js"; + +const ROOT = "a1".repeat(32); +const MODERATOR = "b2".repeat(32); +const CREATOR = "d4".repeat(32); +const VIEWER = "e5".repeat(32); +const TRUSTED = "01".repeat(32); +const EVENT_ID = "1b".repeat(32); +const NOW = 1_750_000_000; + +const POLICY = createPolicyDefinition({ + id: "app", + version: "1.0.0", + defaultProfile: "feed", + profiles: { + feed: { + name: "feed", + administrativeDeny: { visibility: "hide", interaction: "deny" }, + viewerBlock: { visibility: "hide", interaction: "deny" }, + reports: { default: { restrict: 1 } }, + mutes: {}, + }, + }, +}); + +/** + * @param {Partial} parts + * @returns {import('@nostr-governance/nostr').NostrEvent} + */ +const event = (parts) => ({ + id: "00".repeat(32), + pubkey: MODERATOR, + kind: CANONICAL_KIND, + created_at: NOW, + tags: [], + content: "", + ...parts, +}); + +const rolesEvent = () => + event({ + pubkey: ROOT, + id: "aa".repeat(32), + ...encodeRoles({ actors: { [MODERATOR]: ["moderator"] }, protectedActors: [] }, "app"), + }); + +/** @param {import('@nostr-governance/core').GovernanceTarget} [target] */ +const denyEvent = (target = { type: "user", pubkey: CREATOR }) => + event({ + id: "bb".repeat(32), + ...encodeContribution({ actor: MODERATOR, kind: "user-deny", targets: [target] }, "app"), + }); + +function makeRuntime(events = []) { + const transport = createMemoryTransport(events); + const runtime = createGovernanceRuntime({ + applicationId: "test-app", + namespace: "app", + transport, + policy: POLICY, + now: () => NOW, + }); + return { runtime, transport }; +} + +describe("chunk", () => { + it("splits into bounded pieces", () => { + expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + + it("returns one chunk for a non-positive size", () => { + expect(chunk([1, 2], 0)).toEqual([[1, 2]]); + }); + + it("handles an empty list", () => { + expect(chunk([], 10)).toEqual([]); + }); +}); + +describe("storageKey", () => { + it("namespaces by deployment and schema", () => { + expect( + storageKey({ + applicationId: "bitroad", + namespace: "bitroad", + rootFingerprint: "abcd", + schemaVersion: "v1", + scope: "admin", + }), + ).toBe("nostr-governance:bitroad:bitroad:abcd:v1:admin"); + }); + + it("appends the viewer for viewer-scoped state", () => { + const key = storageKey({ + applicationId: "a", + namespace: "n", + rootFingerprint: "f", + schemaVersion: "v1", + scope: "overrides", + viewerPubkey: VIEWER, + }); + expect(key.endsWith(VIEWER)).toBe(true); + }); +}); + +describe("construction", () => { + it("requires an application id and namespace", () => { + expect(() => + new GovernanceRuntime(/** @type {any} */ ({ transport: createMemoryTransport() })), + ).toThrow(/applicationId and namespace/); + }); + + it("requires a transport", () => { + expect(() => + new GovernanceRuntime(/** @type {any} */ ({ applicationId: "a", namespace: "n" })), + ).toThrow(/transport adapter/); + }); + + it("changes its storage key when the root authority changes", () => { + const { runtime } = makeRuntime(); + const before = runtime.storageKeyFor("admin"); + runtime.admin.setRoles({ root: ROOT, actors: {} }); + expect(runtime.storageKeyFor("admin")).not.toBe(before); + }); +}); + +describe("ingestEvent", () => { + it("routes a roles document", () => { + const { runtime } = makeRuntime(); + expect(runtime.ingestEvent(rolesEvent())).toBe(true); + expect(runtime.admin.authority.root).toBe(ROOT); + }); + + it("routes a contribution", () => { + const { runtime } = makeRuntime(); + runtime.ingestEvent(rolesEvent()); + expect(runtime.ingestEvent(denyEvent())).toBe(true); + expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + }); + + it("routes a report", () => { + const { runtime } = makeRuntime(); + const report = event({ + kind: REPORT_KIND, + pubkey: TRUSTED, + tags: [["e", EVENT_ID, "spam"]], + }); + expect(runtime.ingestEvent(report)).toBe(true); + expect(runtime.reports.recordsFor(`event:${EVENT_ID}`)).toHaveLength(1); + }); + + it("routes a mute list", () => { + const { runtime } = makeRuntime(); + const list = event({ kind: MUTE_LIST_KIND, pubkey: TRUSTED, tags: [["p", CREATOR]] }); + expect(runtime.ingestEvent(list)).toBe(true); + expect(runtime.mutes.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(1); + }); + + it("emits a policy document rather than applying it blindly", () => { + const { runtime } = makeRuntime(); + const handler = vi.fn(); + runtime.on("policy-document", handler); + runtime.ingestEvent( + event({ + tags: [["d", canonicalIdentifier("app", "policy")]], + content: JSON.stringify({ id: "x" }), + }), + ); + expect(handler).toHaveBeenCalled(); + }); + + it("counts unrecognized events instead of throwing", () => { + const { runtime } = makeRuntime(); + expect(runtime.ingestEvent(event({ kind: 1 }))).toBe(false); + expect(runtime.diagnostics.unknownEvents).toBe(1); + }); + + it("ignores malformed input", () => { + const { runtime } = makeRuntime(); + expect(runtime.ingestEvent(/** @type {any} */ (null))).toBe(false); + }); +}); + +describe("loadAdministrativeState", () => { + it("ingests only the newest document per coordinate", async () => { + const older = event({ + id: "11".repeat(32), + created_at: NOW - 100, + ...encodeContribution( + { actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }, + "app", + ), + }); + const newer = event({ + id: "22".repeat(32), + created_at: NOW, + ...encodeContribution({ actor: MODERATOR, kind: "user-deny", targets: [] }, "app"), + }); + + const { runtime } = makeRuntime([rolesEvent(), older, newer]); + await runtime.loadAdministrativeState(); + expect(runtime.admin.state.userDeny.size).toBe(0); + }); + + it("can restrict the query to known authors", async () => { + const transport = createMemoryTransport([rolesEvent(), denyEvent()]); + const listSpy = vi.spyOn(transport, "list"); + const runtime = createGovernanceRuntime({ + applicationId: "a", + namespace: "app", + transport, + policy: POLICY, + now: () => NOW, + }); + + await runtime.loadAdministrativeState({ authors: [MODERATOR] }); + expect(listSpy.mock.calls[0][0][0]).toMatchObject({ authors: [MODERATOR] }); + }); +}); + +describe("active targets and subscriptions", () => { + it("chunks report subscriptions rather than opening one per target", () => { + const transport = createMemoryTransport(); + const subscribeSpy = vi.spyOn(transport, "subscribe"); + const runtime = createGovernanceRuntime({ + applicationId: "a", + namespace: "app", + transport, + policy: POLICY, + now: () => NOW, + chunkSize: 2, + }); + + runtime.setActiveTargets([ + { type: "event", id: "01".repeat(32) }, + { type: "event", id: "02".repeat(32) }, + { type: "event", id: "03".repeat(32) }, + ]); + runtime.subscribeToActiveTargetReports(); + + expect(subscribeSpy).toHaveBeenCalledTimes(2); + }); + + it("separates event and user filters", () => { + const transport = createMemoryTransport(); + const subscribeSpy = vi.spyOn(transport, "subscribe"); + const runtime = createGovernanceRuntime({ + applicationId: "a", + namespace: "app", + transport, + policy: POLICY, + now: () => NOW, + }); + + runtime.setActiveTargets([ + { type: "event", id: EVENT_ID }, + { type: "user", pubkey: CREATOR }, + ]); + runtime.subscribeToActiveTargetReports(); + + const filters = subscribeSpy.mock.calls.map((call) => call[0][0]); + expect(filters.some((filter) => "#e" in filter)).toBe(true); + expect(filters.some((filter) => "#p" in filter)).toBe(true); + }); + + it("ingests reports delivered to a live subscription", () => { + const { runtime, transport } = makeRuntime(); + runtime.setActiveTargets([{ type: "event", id: EVENT_ID }]); + runtime.subscribeToActiveTargetReports(); + + transport.deliver( + event({ kind: REPORT_KIND, pubkey: TRUSTED, tags: [["e", EVENT_ID, "spam"]] }), + ); + expect(runtime.reports.recordsFor(`event:${EVENT_ID}`)).toHaveLength(1); + }); + + it("skips invalid targets", () => { + const { runtime } = makeRuntime(); + runtime.setActiveTargets([ + { type: "user", pubkey: CREATOR }, + /** @type {any} */ ({ type: "user", pubkey: "bad" }), + ]); + expect(runtime.activeTargets.size).toBe(1); + }); + + it("closes subscriptions on close", () => { + const { runtime } = makeRuntime(); + runtime.setActiveTargets([{ type: "event", id: EVENT_ID }]); + const handle = runtime.subscribeToActiveTargetReports(); + expect(runtime.subscriptions.size).toBe(1); + handle.close(); + expect(runtime.subscriptions.size).toBe(0); + }); +}); + +describe("viewer lifecycle", () => { + it("clears viewer-specific state on switch", () => { + const { runtime } = makeRuntime(); + runtime.setViewer(VIEWER); + runtime.trust.setBlocks([CREATOR]); + runtime.overrides.set(`user:${CREATOR}`, { visibility: "allow" }); + + runtime.setViewer(TRUSTED); + expect(runtime.trust.blocks.size).toBe(0); + expect(runtime.overrides.toMap().size).toBe(0); + }); + + it("keeps operator seeds across a viewer switch", () => { + const { runtime } = makeRuntime(); + runtime.trust.setSeeds([TRUSTED]); + runtime.setViewer(VIEWER); + expect(runtime.trust.seeds.has(TRUSTED)).toBe(true); + }); + + it("does not churn when the viewer is unchanged", () => { + const { runtime } = makeRuntime(); + runtime.setViewer(VIEWER); + const handler = vi.fn(); + runtime.on("viewer", handler); + runtime.setViewer(VIEWER); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe("evaluation", () => { + it("evaluates against live store state", () => { + const { runtime } = makeRuntime(); + runtime.ingestEvent(rolesEvent()); + runtime.ingestEvent(denyEvent()); + + const decision = runtime.evaluate({ type: "user", pubkey: CREATOR }, { profile: "feed" }); + expect(decision.visibility.effect).toBe("hide"); + }); + + it("reflects a report ingested afterwards", () => { + const { runtime } = makeRuntime(); + runtime.trust.setContacts([TRUSTED]); + runtime.ingestEvent( + event({ kind: REPORT_KIND, pubkey: TRUSTED, tags: [["e", EVENT_ID, "spam"]] }), + ); + + const decision = runtime.evaluate({ type: "event", id: EVENT_ID }, { profile: "feed" }); + expect(decision.visibility.effect).toBe("restrict"); + }); + + it("applies viewer blocks", () => { + const { runtime } = makeRuntime(); + runtime.trust.setBlocks([CREATOR]); + expect( + runtime.evaluate({ type: "user", pubkey: CREATOR }, { profile: "feed" }).visibility.effect, + ).toBe("hide"); + }); + + it("evaluates many targets consistently with one", () => { + const { runtime } = makeRuntime(); + runtime.ingestEvent(rolesEvent()); + runtime.ingestEvent(denyEvent()); + + /** @type {import('@nostr-governance/core').GovernanceTarget} */ + const target = { type: "user", pubkey: CREATOR }; + const many = runtime.evaluateMany([target], { profile: "feed" }); + expect(many.get(`user:${CREATOR}`)).toEqual(runtime.evaluate(target, { profile: "feed" })); + }); +}); + +describe("diagnostics and teardown", () => { + it("describes runtime state", () => { + const { runtime } = makeRuntime(); + runtime.ingestEvent(rolesEvent()); + const description = runtime.describe(); + expect(description.applicationId).toBe("test-app"); + expect(description.policyId).toBe("app"); + expect(description.eventsIngested).toBeGreaterThan(0); + }); + + it("closes subscriptions and detaches listeners on destroy", () => { + const { runtime } = makeRuntime(); + runtime.setActiveTargets([{ type: "event", id: EVENT_ID }]); + runtime.subscribeToActiveTargetReports(); + + const handler = vi.fn(); + runtime.on("change", handler); + runtime.destroy(); + + expect(runtime.subscriptions.size).toBe(0); + runtime.admin.setRoles({ root: ROOT, actors: {} }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("is safe to destroy twice", () => { + const { runtime } = makeRuntime(); + runtime.destroy(); + expect(() => runtime.destroy()).not.toThrow(); + }); +}); diff --git a/packages/runtime/test/stores.test.js b/packages/runtime/test/stores.test.js new file mode 100644 index 0000000..bba4c78 --- /dev/null +++ b/packages/runtime/test/stores.test.js @@ -0,0 +1,384 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createPolicyDefinition } from "@nostr-governance/core"; + +import { Emitter } from "../src/emitter.js"; +import { + GovernanceAdminStore, + OverrideStore, + PolicyStore, + ReportStore, + TrustGraphStore, + TrustedMuteStore, +} from "../src/stores.js"; + +const ROOT = "a1".repeat(32); +const MODERATOR = "b2".repeat(32); +const CREATOR = "d4".repeat(32); +const OTHER = "e5".repeat(32); +const DAY = 86_400; + +/** @param {string} pubkey @returns {import("@nostr-governance/core").UserTarget} */ +const user = (pubkey) => ({ type: "user", pubkey }); + +const roster = { + root: ROOT, + actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] }, +}; + +describe("Emitter", () => { + it("delivers events to subscribers", () => { + const emitter = new Emitter(); + const handler = vi.fn(); + emitter.on("change", handler); + emitter.emit("change", { a: 1 }); + expect(handler).toHaveBeenCalledWith({ a: 1 }); + }); + + it("unsubscribes", () => { + const emitter = new Emitter(); + const handler = vi.fn(); + emitter.on("change", handler)(); + emitter.emit("change"); + expect(handler).not.toHaveBeenCalled(); + }); + + it("contains a throwing listener", () => { + const emitter = new Emitter(); + const good = vi.fn(); + emitter.on("change", () => { + throw new Error("boom"); + }); + emitter.on("change", good); + expect(() => emitter.emit("change")).not.toThrow(); + expect(good).toHaveBeenCalled(); + }); + + it("ignores malformed subscriptions", () => { + const emitter = new Emitter(); + expect(() => emitter.on(/** @type {any} */ (null), () => {})()).not.toThrow(); + }); +}); + +describe("GovernanceAdminStore", () => { + it("reduces contributions through the roster", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + store.upsertContribution({ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }); + expect(store.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + }); + + it("emits only when effective state changes", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + const handler = vi.fn(); + store.on("change", handler); + + store.upsertContribution({ + actor: MODERATOR, + kind: "user-deny", + targets: [user(CREATOR)], + createdAt: 1, + }); + expect(handler).toHaveBeenCalledTimes(1); + + store.upsertContribution({ + actor: MODERATOR, + kind: "user-deny", + targets: [user(CREATOR)], + createdAt: 2, + }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("replaces an actor's list rather than accumulating", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + store.upsertContribution({ + actor: MODERATOR, + kind: "user-deny", + targets: [user(CREATOR)], + createdAt: 1, + }); + store.upsertContribution({ + actor: MODERATOR, + kind: "user-deny", + targets: [user(OTHER)], + createdAt: 2, + }); + expect(store.state.userDeny.has(`user:${CREATOR}`)).toBe(false); + expect(store.state.userDeny.has(`user:${OTHER}`)).toBe(true); + }); + + it("ignores an out-of-order older list", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + store.upsertContribution({ + actor: MODERATOR, + kind: "user-deny", + targets: [user(OTHER)], + createdAt: 5, + }); + store.upsertContribution({ + actor: MODERATOR, + kind: "user-deny", + targets: [user(CREATOR)], + createdAt: 1, + }); + expect(store.state.userDeny.has(`user:${OTHER}`)).toBe(true); + }); + + it("drops entries when the contributor's role is revoked", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + store.upsertContribution({ actor: MODERATOR, kind: "user-deny", targets: [user(CREATOR)] }); + expect(store.state.userDeny.size).toBe(1); + + store.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } }); + expect(store.state.userDeny.size).toBe(0); + }); + + it("changes its root fingerprint when the roster changes", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + const before = store.rootFingerprint; + store.setRoles({ root: OTHER, actors: {} }); + expect(store.rootFingerprint).not.toBe(before); + }); + + it("ignores a malformed contribution", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + store.upsertContribution(/** @type {any} */ ({})); + expect(store.state.userDeny.size).toBe(0); + }); +}); + +describe("TrustGraphStore", () => { + it("prefers contacts over seeds", () => { + const store = new TrustGraphStore(); + store.setSeeds([MODERATOR]); + expect(store.trustSet.has(MODERATOR)).toBe(true); + store.setContacts([CREATOR]); + expect(store.trustSet.has(CREATOR)).toBe(true); + expect(store.trustSet.has(MODERATOR)).toBe(false); + }); + + it("emits only on real change", () => { + const store = new TrustGraphStore(); + const handler = vi.fn(); + store.on("change", handler); + store.setContacts([CREATOR]); + store.setContacts([CREATOR]); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("normalizes and drops invalid pubkeys", () => { + const store = new TrustGraphStore(); + store.setContacts([CREATOR.toUpperCase(), "junk"]); + expect([...store.contacts]).toEqual([CREATOR]); + }); + + it("clears viewer state but keeps operator seeds", () => { + const store = new TrustGraphStore(); + store.setSeeds([MODERATOR]); + store.setContacts([CREATOR]); + store.setBlocks([OTHER]); + store.clearViewerState(); + expect(store.contacts.size).toBe(0); + expect(store.blocks.size).toBe(0); + expect(store.seeds.has(MODERATOR)).toBe(true); + }); +}); + +describe("ReportStore", () => { + const report = (reporter, category, createdAt = 1) => ({ + reporter, + target: user(CREATOR), + category, + createdAt, + }); + const KEY = `user:${CREATOR}`; + + it("aggregates reports per target", () => { + const store = new ReportStore(); + store.ingest(report(MODERATOR, "spam"), KEY); + expect(store.recordsFor(KEY)).toEqual([ + { reporter: MODERATOR, category: "spam", createdAt: 1 }, + ]); + }); + + it("deduplicates a redelivered report", () => { + const store = new ReportStore(); + store.ingest(report(MODERATOR, "spam", 5), KEY); + store.ingest(report(MODERATOR, "spam", 5), KEY); + expect(store.recordsFor(KEY)).toHaveLength(1); + }); + + it("keeps distinct categories from one reporter", () => { + const store = new ReportStore(); + store.ingest(report(MODERATOR, "spam"), KEY); + store.ingest(report(MODERATOR, "nudity"), KEY); + expect(store.recordsFor(KEY)).toHaveLength(2); + }); + + it("does not emit for a duplicate", () => { + const store = new ReportStore(); + const handler = vi.fn(); + store.on("change", handler); + store.ingest(report(MODERATOR, "spam", 5), KEY); + store.ingest(report(MODERATOR, "spam", 5), KEY); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("ignores malformed reports", () => { + const store = new ReportStore(); + store.ingest(/** @type {any} */ ({ reporter: "", category: "" }), KEY); + expect(store.recordsFor(KEY)).toEqual([]); + }); + + it("builds a record map for the evaluator", () => { + const store = new ReportStore(); + store.ingest(report(MODERATOR, "spam"), KEY); + expect(store.toRecordMap().get(KEY)).toHaveLength(1); + }); + + it("clears a target", () => { + const store = new ReportStore(); + store.ingest(report(MODERATOR, "spam"), KEY); + store.clearTarget(KEY); + expect(store.recordsFor(KEY)).toEqual([]); + }); +}); + +describe("TrustedMuteStore", () => { + const list = (owner, updatedAt, entries) => ({ + owner, + updatedAt, + entries, + hasEncryptedEntries: false, + }); + + it("stores a mute list", () => { + const store = new TrustedMuteStore(); + store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }])); + expect(store.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(1); + }); + + it("replaces an older list from the same owner", () => { + const store = new TrustedMuteStore(); + store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }])); + store.replaceList(list(MODERATOR, 200, [{ pubkey: OTHER }])); + expect(store.toRecordMap().has(`user:${CREATOR}`)).toBe(false); + expect(store.toRecordMap().has(`user:${OTHER}`)).toBe(true); + }); + + it("ignores an out-of-order older list", () => { + const store = new TrustedMuteStore(); + store.replaceList(list(MODERATOR, 200, [{ pubkey: OTHER }])); + store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }])); + expect(store.toRecordMap().has(`user:${OTHER}`)).toBe(true); + }); + + it("counts distinct owners for one target", () => { + const store = new TrustedMuteStore(); + store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }])); + store.replaceList(list(ROOT, 100, [{ pubkey: CREATOR }])); + expect(store.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(2); + }); + + it("prunes lists outside the window using the injected clock", () => { + const store = new TrustedMuteStore({ windowSeconds: 60 * DAY, now: () => 100 * DAY }); + store.replaceList(list(MODERATOR, 10 * DAY, [{ pubkey: CREATOR }])); + store.replaceList(list(ROOT, 90 * DAY, [{ pubkey: CREATOR }])); + expect(store.prune()).toBe(1); + expect(store.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(1); + }); + + it("does not prune when no window is configured", () => { + const store = new TrustedMuteStore(); + store.replaceList(list(MODERATOR, 1, [{ pubkey: CREATOR }])); + expect(store.prune()).toBe(0); + }); + + it("answers batched author queries", () => { + const store = new TrustedMuteStore(); + store.replaceList(list(MODERATOR, 100, [{ pubkey: CREATOR }])); + const counts = store.countsForAuthors([CREATOR, OTHER]); + expect(counts.get(CREATOR)).toBe(1); + expect(counts.get(OTHER)).toBe(0); + }); +}); + +describe("PolicyStore", () => { + const policy = (id) => + createPolicyDefinition({ id, version: "1.0.0", profiles: { feed: { name: "feed" } } }); + + it("falls back to the default policy", () => { + const store = new PolicyStore(policy("default")); + expect(store.policy.id).toBe("default"); + }); + + it("prefers a root policy over the default", () => { + const store = new PolicyStore(policy("default")); + store.setRootPolicy(policy("root")); + expect(store.policy.id).toBe("root"); + }); + + it("prefers a local policy over a root policy", () => { + const store = new PolicyStore(policy("default")); + store.setRootPolicy(policy("root")); + store.setLocalPolicy(policy("local")); + expect(store.policy.id).toBe("local"); + }); + + it("emits when the effective policy changes", () => { + const store = new PolicyStore(policy("default")); + const handler = vi.fn(); + store.on("change", handler); + store.setRootPolicy(policy("root")); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("reverts to the default when the root policy is cleared", () => { + const store = new PolicyStore(policy("default")); + store.setRootPolicy(policy("root")); + store.setRootPolicy(null); + expect(store.policy.id).toBe("default"); + }); +}); + +describe("OverrideStore", () => { + it("stores and reads an override", () => { + const store = new OverrideStore(); + store.set("user:x", { visibility: "allow" }); + expect(store.toMap().get("user:x")).toEqual({ visibility: "allow", reason: undefined }); + }); + + it("filters expired overrides using the injected clock", () => { + const store = new OverrideStore({ now: () => 100 }); + store.set("user:x", { visibility: "allow", expiresAt: 50 }); + store.set("user:y", { visibility: "allow", expiresAt: 150 }); + expect(store.toMap().has("user:x")).toBe(false); + expect(store.toMap().has("user:y")).toBe(true); + }); + + it("removes an override", () => { + const store = new OverrideStore(); + store.set("user:x", { visibility: "allow" }); + store.remove("user:x"); + expect(store.toMap().size).toBe(0); + }); + + it("clears overrides without clearing listeners", () => { + const store = new OverrideStore(); + const handler = vi.fn(); + store.on("change", handler); + store.set("user:x", { visibility: "allow" }); + store.clearOverrides(); + expect(store.toMap().size).toBe(0); + + store.set("user:y", { visibility: "allow" }); + expect(handler).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/runtime/tsconfig.types.json b/packages/runtime/tsconfig.types.json new file mode 100644 index 0000000..384c9af --- /dev/null +++ b/packages/runtime/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist/types", + "rootDir": "src" + }, + "include": ["src/**/*.js"] +} diff --git a/packages/testing/tsconfig.types.json b/packages/testing/tsconfig.types.json new file mode 100644 index 0000000..384c9af --- /dev/null +++ b/packages/testing/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist/types", + "rootDir": "src" + }, + "include": ["src/**/*.js"] +}