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:
thePR0M3TH3AN committed 2026-08-09 17:49:10 -04:00
1 parent 5e0fffb32e
commit 4d460c12fe
19 files changed
+2909 -19

No files matched your search

+1 -1
View File
@@ -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"
+7 -4
View File
@@ -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"]
}
+8 -4
View File
@@ -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"
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist/types",
"rootDir": "src"
},
"include": ["src/**/*.js"]
}
+8 -4
View File
@@ -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"
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist/types",
"rootDir": "src"
},
"include": ["src/**/*.js"]
}
+8 -4
View File
@@ -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"
+387
View File
@@ -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);
}
+69
View File
@@ -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();
}
}
+40 -2
View File
@@ -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
*/
+214
View File
@@ -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");
},
};
}
+470
View File
@@ -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);
}
+510
View File
@@ -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;
}
}
+335
View File
@@ -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,
);
});
});
+408
View File
@@ -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();
});
});
+384
View File
@@ -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);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist/types",
"rootDir": "src"
},
"include": ["src/**/*.js"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist/types",
"rootDir": "src"
},
"include": ["src/**/*.js"]
}