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

+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;
}
}