mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
feat(runtime): implement Phases 4 and 5 — stores, runtime, commands
Stores (Phase 4): admin, trust graph, reports, trusted mutes, policy, and overrides. Each owns one slice of state and emits only when effective state changes, so a consumer can treat every emission as a real invalidation. - report storage is keyed target -> reporter -> category, making deduplication structural: relays redeliver constantly and a redelivery must not inflate a count - contribution lists replace by (actor, kind) and ignore out-of-order older documents, so a late delivery cannot resurrect removed entries - mute lists prune against an injected clock - viewer switching clears blocks, mutes, and overrides but keeps operator seeds, which are configuration rather than viewer data Runtime: event routing, chunked report subscriptions (200 per filter, not one per target), viewer lifecycle, evaluation, diagnostics, idempotent teardown. Transport, signer, and storage are injected; in-memory doubles ship for tests. Commands (Phase 5): every mutation checks capability locally before the signer is invoked, so an unauthorized actor cannot produce a signed event at all. Protected actors are refused at the command boundary as well as during reduction. Partial relay acceptance succeeds while keeping failures visible. Also fixes the type build: every package's "types" field pointed at a path the build never produced, so consumers got no types at all. Declarations now emit per package and resolve. 103 runtime tests; 505 total.
This commit is contained in:
1 parent
5e0fffb32e
commit
4d460c12fe
19 files changed
+2909
-19
No files matched your search
+1
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist/types",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.js"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist/types",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.js"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist/types",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.js"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, import('@nostr-governance/core').GovernanceCapability>} */
|
||||
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<CommandResult>}
|
||||
*/
|
||||
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<string>}
|
||||
*/
|
||||
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<CommandResult>}
|
||||
*/
|
||||
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<CommandResult>}
|
||||
*/
|
||||
async denyUser(pubkey, options) {
|
||||
return this.contribute("user-deny", [{ type: "user", pubkey }], options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow a user.
|
||||
* @param {string} pubkey
|
||||
* @returns {Promise<CommandResult>}
|
||||
*/
|
||||
async allowUser(pubkey) {
|
||||
return this.contribute("user-allow", [{ type: "user", pubkey }]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny an exact event.
|
||||
* @param {string} id
|
||||
* @returns {Promise<CommandResult>}
|
||||
*/
|
||||
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<CommandResult>}
|
||||
*/
|
||||
async denyAddress(kind, pubkey, identifier) {
|
||||
return this.contribute("address-deny", [{ type: "address", kind, pubkey, identifier }]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add trust seeds.
|
||||
* @param {string[]} pubkeys
|
||||
* @returns {Promise<CommandResult>}
|
||||
*/
|
||||
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<string, string[]>} [roster.actors]
|
||||
* @param {Record<string, string[]>} [roster.capabilities]
|
||||
* @param {string[]} [roster.protectedActors]
|
||||
* @returns {Promise<CommandResult>}
|
||||
*/
|
||||
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<CommandResult>}
|
||||
*/
|
||||
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<CommandResult>}
|
||||
*/
|
||||
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<CommandResult>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
@@ -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<string, Set<Listener>>} */
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,40 @@
|
||||
// Placeholder for runtime module exports
|
||||
export {};
|
||||
// @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
|
||||
*/
|
||||
@@ -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<NostrEvent[]>} list
|
||||
* @property {(filters: object[], handlers: SubscriptionHandlers, options?: object) => GovernanceSubscription} subscribe
|
||||
* @property {(event: NostrEvent, options?: object) => Promise<PublishResult>} 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<string>} getPublicKey
|
||||
* @property {(event: object) => Promise<NostrEvent>} signEvent
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} GovernanceStorage
|
||||
* @property {(key: string) => Promise<unknown>} read
|
||||
* @property {(key: string, value: unknown) => Promise<void>} write
|
||||
* @property {(key: string) => Promise<void>} 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<string, unknown>} */
|
||||
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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, GovernanceTarget>} */
|
||||
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>} 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<string, GovernanceDecision>}
|
||||
*/
|
||||
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<typeof GovernanceRuntime>[0]} options
|
||||
* @returns {GovernanceRuntime}
|
||||
*/
|
||||
export function createGovernanceRuntime(options) {
|
||||
return new GovernanceRuntime(options);
|
||||
}
|
||||
@@ -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<string, string[]>} [roster.actors]
|
||||
* @param {Record<string, readonly any[]>} [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<string>} */
|
||||
this.contacts = new Set();
|
||||
/** @type {Set<string>} */
|
||||
this.seeds = new Set();
|
||||
/** @type {Set<string>} */
|
||||
this.blocks = new Set();
|
||||
/** @type {Set<string>} */
|
||||
this.mutes = new Set();
|
||||
this.fingerprint = this.#computeFingerprint();
|
||||
}
|
||||
|
||||
/** @param {Iterable<string>} pubkeys */
|
||||
setContacts(pubkeys) {
|
||||
this.contacts = this.#normalizeSet(pubkeys);
|
||||
this.#emitIfChanged();
|
||||
}
|
||||
|
||||
/** @param {Iterable<string>} pubkeys */
|
||||
setSeeds(pubkeys) {
|
||||
this.seeds = this.#normalizeSet(pubkeys);
|
||||
this.#emitIfChanged();
|
||||
}
|
||||
|
||||
/** @param {Iterable<string>} pubkeys */
|
||||
setBlocks(pubkeys) {
|
||||
this.blocks = this.#normalizeSet(pubkeys);
|
||||
this.#emitIfChanged();
|
||||
}
|
||||
|
||||
/** @param {Iterable<string>} 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<string>} pubkeys
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
#normalizeSet(pubkeys) {
|
||||
/** @type {Set<string>} */
|
||||
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<string, Map<string, Map<string, number>>>} 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<string, Array<{ reporter: string, category: string, createdAt: number }>>} */
|
||||
toRecordMap() {
|
||||
/** @type {Map<string, Array<{ reporter: string, category: string, createdAt: number }>>} */
|
||||
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<string, { owner: string, updatedAt: number, entries: Array<{ pubkey: string, category?: string }> }>} */
|
||||
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<string, Array<{ muter: string, category?: string, updatedAt: number }>>} */
|
||||
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<string, number>}
|
||||
*/
|
||||
countsForAuthors(pubkeys) {
|
||||
const records = this.toRecordMap();
|
||||
/** @type {Map<string, number>} */
|
||||
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<string, { visibility: string, reason?: string, expiresAt?: number }>} */
|
||||
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<string, { visibility: string, reason?: string }>}
|
||||
*/
|
||||
toMap() {
|
||||
const now = this.now();
|
||||
/** @type {Map<string, { visibility: string, reason?: string }>} */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<import('@nostr-governance/nostr').NostrEvent>} 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist/types",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.js"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist/types",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.js"]
|
||||
}
|
||||
Reference in new issue
Block a user