diff --git a/docs/checkpoints.md b/docs/checkpoints.md index 570e10f..bf7aa90 100644 --- a/docs/checkpoints.md +++ b/docs/checkpoints.md @@ -72,6 +72,13 @@ close on destroy; report subscriptions are chunked (200 per filter) rather than opened per target; author evidence is queryable in batches; expired mute lists prune against the injected clock. +### Correction + +An earlier revision of this file claimed storage acceptance that was not met: +storage was injected and then never used, so nothing persisted or hydrated and +the characterized cache-fallback behavior existed only at the reduction level. +Closed in Checkpoint 8. + ## Checkpoint 5 — Public commands (completed) Delivered: contribution, role, policy, community-source, report, and override @@ -117,9 +124,38 @@ keys) are properties of the injected signer and of operational practice rather than of this code; the root-authorized contributor model is what removes the need for a shared moderator key. +## Checkpoint 8 — Runtime wiring (completed) + +Closes the gaps between interfaces that existed and pipelines that used them. + +Delivered: +- **Storage**: `persist()` / `hydrate()`, hydrate-before-query on load, and + last-known-good state retained with a `stale` flag when relays fail. Cache + keys are namespaced by root identity — not by the full roster, which would + rotate the key every time a moderator was added. +- **Signature verification** wired into the load path through an injected + verifier, with rejected counts in diagnostics and an explicit + `signatureVerification: enabled|disabled` readout. +- **Trusted mute-list subscriptions** by author, chunked. Without this the + trusted-mute store never populated in a real deployment. +- **Community source resolution**: curator lists are fetched and merged with a + source marker, still subject to the curator's capabilities. +- **Root policy application**: policy documents are now applied rather than + merely announced, accepted only from the root, and a malformed document + leaves the working policy in place. +- **Import/export** (§10.14) exporting contributions rather than conclusions, + so an import re-derives effective state against the importing roster. +- **Capability queries** (§10.8): `can()`, `capabilitiesOf()`, + `viewerCapabilities()`. + +Design note: the root administrator is now a constructor option, because it is +deployment configuration rather than discovered state. Hydration could not +otherwise find its own cache — the key derives from the root fingerprint the +cache itself would have had to supply. + ## Status -CI gates green: reference guard, typecheck, 605 tests, per-package type build. +CI gates green: reference guard, typecheck, 634 tests, per-package type build. Typecheck and the declaration build both pass from a cold checkout with no `dist/` present. diff --git a/packages/runtime/src/runtime.js b/packages/runtime/src/runtime.js index 45a6ba7..daff0f4 100644 --- a/packages/runtime/src/runtime.js +++ b/packages/runtime/src/runtime.js @@ -7,9 +7,13 @@ import { NEUTRAL_POLICY, evaluateTarget, + getActorCapabilities, getTargetKey, + hasCapability, isValidTarget, + normalizePolicyDefinition, normalizePubkey, + serializeAdminState, } from "@nostr-governance/core"; import { CANONICAL_KIND, @@ -23,6 +27,7 @@ import { decodeReport, decodeRoles, selectReplaceable, + verifyEvents, } from "@nostr-governance/nostr"; import { snapshotFingerprint } from "@nostr-governance/core"; @@ -104,6 +109,8 @@ export class GovernanceRuntime extends Emitter { * @param {() => number} [options.now] - Injected clock, unix seconds * @param {number} [options.chunkSize] * @param {number} [options.muteWindowSeconds] + * @param {import('@nostr-governance/nostr').SignatureVerifier} [options.verifySignature] + * @param {string} [options.root] - Root administrator pubkey, from deployment config */ constructor({ applicationId, @@ -115,6 +122,8 @@ export class GovernanceRuntime extends Emitter { now, chunkSize = DEFAULT_CHUNK_SIZE, muteWindowSeconds = 0, + verifySignature, + root, }) { super(); @@ -134,6 +143,12 @@ export class GovernanceRuntime extends Emitter { this.chunkSize = chunkSize; this.schemaVersion = "v1"; + // Verification is injected: signature checking needs a crypto library, and + // forcing one into this package would make it unusable where the host + // already has its own. When absent, the runtime says so in diagnostics + // rather than pretending state was verified. + this.verifySignature = verifySignature; + this.admin = new GovernanceAdminStore(); this.trust = new TrustGraphStore(); this.reports = new ReportStore(); @@ -142,6 +157,14 @@ export class GovernanceRuntime extends Emitter { this.policies = new PolicyStore(policy ?? NEUTRAL_POLICY); this.overrides = new OverrideStore({ now: this.now }); + // The root administrator is deployment configuration, not discovered state. + // Seeding it here keeps the storage key stable from construction; without + // it, hydration could not find its own cache, because the key is derived + // from the root fingerprint the cache would have to supply. + if (root) { + this.admin.setRoles({ root }); + } + /** @type {string} */ this.viewerPubkey = ""; /** @type {Map} */ @@ -151,6 +174,14 @@ export class GovernanceRuntime extends Emitter { /** @type {boolean} */ this.destroyed = false; + // True when the last relay load failed and the runtime is serving the + // previous state. Surfaced in diagnostics so an application can tell a + // viewer that moderation data may be out of date. + /** @type {boolean} */ + this.stale = false; + /** @type {number} */ + this.lastLoadedAt = 0; + /** @type {Array<() => void>} */ this.storeUnsubscribes = [ // Administrative, trust, and policy changes can alter any decision, so @@ -196,13 +227,14 @@ export class GovernanceRuntime extends Emitter { /** @type {string} */ this.cachedSnapshotFingerprint = ""; - /** @type {{ eventsIngested: number, unknownEvents: number, subscriptions: number, cacheHits: number, cacheMisses: number }} */ this.diagnostics = { eventsIngested: 0, unknownEvents: 0, subscriptions: 0, cacheHits: 0, cacheMisses: 0, + rejectedSignatures: 0, + hydratedFromCache: false, }; } @@ -263,9 +295,9 @@ export class GovernanceRuntime extends Emitter { return true; } - const policy = decodePolicy(event); - if (policy) { - this.emit("policy-document", { policy }); + const policyDocument = decodePolicy(event); + if (policyDocument) { + this.applyRootPolicy(policyDocument, event.pubkey); return true; } @@ -288,32 +320,253 @@ export class GovernanceRuntime extends Emitter { return false; } + /** + * Apply a root-published policy document. + * + * Only the root administrator may set policy, and a malformed document is + * rejected rather than replacing a working policy: a bad publish must not be + * able to disable governance. + * + * @param {unknown} document + * @param {string} publisher + * @returns {boolean} Whether the policy was accepted + */ + applyRootPolicy(document, publisher) { + const root = this.admin.authority.root; + if (!root || normalizePubkey(publisher) !== root) { + this.emit("policy-rejected", { reason: "not-root", publisher }); + return false; + } + + try { + const policy = normalizePolicyDefinition(/** @type {any} */ (document)); + this.policies.setRootPolicy(policy); + this.emit("policy-document", { policy }); + return true; + } catch (error) { + this.emit("policy-rejected", { reason: "invalid", publisher, error: String(error) }); + return false; + } + } + + /** + * Persist administrative and trust state. + * + * Keys are namespaced by root-authority fingerprint, so rotating the root + * administrator cannot silently reuse the previous administration's cache. + * + * @returns {Promise} + */ + async persist() { + await this.storage.write(this.storageKeyFor("admin"), { + schemaVersion: this.schemaVersion, + savedAt: this.now(), + authority: { + root: this.admin.authority.root ?? "", + actors: this.admin.authority.actors, + protectedActors: this.admin.authority.protectedActors, + }, + contributions: this.admin.contributions, + }); + + if (this.viewerPubkey) { + await this.storage.write(this.storageKeyFor("viewer", true), { + schemaVersion: this.schemaVersion, + savedAt: this.now(), + contacts: Array.from(this.trust.contacts), + blocks: Array.from(this.trust.blocks), + mutes: Array.from(this.trust.mutes), + overrides: Array.from(this.overrides.overrides.entries()), + }); + } + } + + /** + * Restore state written by {@link persist}. + * + * This is what keeps administrative state effective when relays are + * unreachable. A cache written under a different schema version is ignored + * rather than migrated. + * + * @returns {Promise} Whether anything was restored + */ + async hydrate() { + let restored = false; + + const admin = /** @type {any} */ (await this.storage.read(this.storageKeyFor("admin"))); + if (admin?.schemaVersion === this.schemaVersion) { + if (admin.authority) { + this.admin.setRoles(admin.authority); + } + if (Array.isArray(admin.contributions)) { + this.admin.setContributions(admin.contributions); + } + restored = true; + } + + if (this.viewerPubkey) { + const viewer = /** @type {any} */ ( + await this.storage.read(this.storageKeyFor("viewer", true)) + ); + if (viewer?.schemaVersion === this.schemaVersion) { + this.trust.setContacts(viewer.contacts ?? []); + this.trust.setBlocks(viewer.blocks ?? []); + this.trust.setMutes(viewer.mutes ?? []); + for (const [key, override] of viewer.overrides ?? []) { + this.overrides.set(key, override); + } + restored = true; + } + } + + this.diagnostics.hydratedFromCache = restored; + return restored; + } + + /** + * Verify a batch of events when a verifier is configured. + * @param {NostrEvent[]} events + * @returns {Promise} + */ + async #verified(events) { + if (!this.verifySignature) { + return events; + } + const verified = await verifyEvents(events, this.verifySignature); + this.diagnostics.rejectedSignatures += events.length - verified.length; + return verified; + } + /** * Load administrative state from relays. * - * Replaceable selection runs before ingestion so that only the newest - * document per coordinate reaches the stores. + * Signatures are verified before anything authoritative is accepted, and + * replaceable selection runs before ingestion so only the newest document per + * coordinate reaches the stores. + * + * On relay failure the previous state is left intact and the runtime is + * marked stale, rather than emptying moderation state because a relay blinked. * * @param {Object} [options] * @param {string[]} [options.authors] - Restrict to known contributor pubkeys + * @param {boolean} [options.hydrateFirst] - Restore cached state before querying + * @param {boolean} [options.persistAfter] - Persist state after a successful load * @returns {Promise} Number of events ingested */ - async loadAdministrativeState({ authors } = {}) { + async loadAdministrativeState({ authors, hydrateFirst = true, persistAfter = true } = {}) { + if (hydrateFirst) { + await this.hydrate(); + } + /** @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()); + let events; + try { + events = await this.transport.list([filter]); + } catch (error) { + this.stale = true; + this.emit("stale", { reason: "relay-unreachable", error: String(error) }); + throw error; + } + const effective = Array.from(selectReplaceable(await this.#verified(events)).values()); for (const event of effective) { this.ingestEvent(event); } + + this.stale = false; + this.lastLoadedAt = this.now(); + + if (persistAfter) { + await this.persist(); + } + return effective.length; } + /** + * Subscribe to the mute lists published by the viewer's trusted contacts. + * + * Without this the trusted-mute store never populates in a real deployment: + * mute lists are published by each contact under their own key, so they have + * to be fetched per author rather than per target. + * + * @returns {{ close: () => void }} + */ + subscribeToTrustedMuteLists() { + const owners = Array.from(this.trust.trustSet); + + /** @type {Array<{ close: () => void }>} */ + const opened = []; + for (const authorChunk of chunk(owners, this.chunkSize)) { + if (!authorChunk.length) continue; + opened.push( + this.transport.subscribe([{ kinds: [MUTE_LIST_KIND], authors: authorChunk }], { + 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; + } + + /** + * Resolve community-curated lists the root has pointed at. + * + * Curators publish under their own keys; the root only publishes references. + * Each resolved list is merged with a source marker so a consumer can tell a + * federated denial from a direct administrative one. + * + * @param {Array<{ curator: string, identifier: string, kind: number }>} sources + * @returns {Promise} Number of curator lists merged + */ + async loadCommunitySources(sources) { + let merged = 0; + + for (const source of sources ?? []) { + let events; + try { + events = await this.transport.list([ + { kinds: [source.kind], authors: [source.curator], "#d": [source.identifier] }, + ]); + } catch (error) { + this.emit("stale", { reason: "community-source-unreachable", source, error: String(error) }); + continue; + } + + const effective = Array.from(selectReplaceable(await this.#verified(events)).values()); + for (const event of effective) { + const contribution = decodeContribution(event) ?? decodeLegacyList(event); + if (!contribution) { + continue; + } + this.admin.upsertContribution({ + ...contribution, + source: `${source.curator}:${source.identifier}`, + }); + merged += 1; + } + } + + return merged; + } + /** * Subscribe to reports for the currently active targets. * @@ -542,6 +795,94 @@ export class GovernanceRuntime extends Emitter { return results; } + /** + * Export governance state as a portable, JSON-serializable snapshot. + * + * Contributions are exported as published rather than as reduced state, so an + * import re-derives effective state against whatever roster is current. A + * snapshot of *conclusions* would silently outlive the authority that + * produced them. + * + * @returns {Object} + */ + exportState() { + return { + schemaVersion: this.schemaVersion, + applicationId: this.applicationId, + namespace: this.namespace, + exportedAt: this.now(), + authority: { + root: this.admin.authority.root ?? "", + actors: this.admin.authority.actors, + protectedActors: this.admin.authority.protectedActors, + }, + contributions: this.admin.contributions, + effectiveState: serializeAdminState(this.admin.state), + policy: { id: this.policies.policy.id, version: this.policies.policy.version }, + fingerprints: { + root: this.admin.rootFingerprint, + admin: this.admin.fingerprint, + trust: this.trust.fingerprint, + }, + }; + } + + /** + * Import a snapshot produced by {@link exportState}. + * + * Rejects a snapshot from a different schema version or namespace rather than + * attempting a migration. + * + * @param {any} snapshot + * @returns {boolean} Whether the snapshot was applied + */ + importState(snapshot) { + if (!snapshot || typeof snapshot !== "object") { + return false; + } + if (snapshot.schemaVersion !== this.schemaVersion) { + return false; + } + if (snapshot.namespace && snapshot.namespace !== this.namespace) { + return false; + } + + if (snapshot.authority) { + this.admin.setRoles(snapshot.authority); + } + if (Array.isArray(snapshot.contributions)) { + this.admin.setContributions(snapshot.contributions); + } + return true; + } + + /** + * Whether an actor holds a capability under the current roster. + * @param {string} pubkey + * @param {import('@nostr-governance/core').GovernanceCapability} capability + * @returns {boolean} + */ + can(pubkey, capability) { + return hasCapability(pubkey, capability, this.admin.authority); + } + + /** + * Every capability an actor holds under the current roster. + * @param {string} pubkey + * @returns {import('@nostr-governance/core').GovernanceCapability[]} + */ + capabilitiesOf(pubkey) { + return getActorCapabilities(pubkey, this.admin.authority); + } + + /** + * What the current viewer is allowed to do. + * @returns {import('@nostr-governance/core').GovernanceCapability[]} + */ + viewerCapabilities() { + return this.viewerPubkey ? this.capabilitiesOf(this.viewerPubkey) : []; + } + /** Diagnostic summary for support and debugging. */ describe() { return { @@ -554,6 +895,9 @@ export class GovernanceRuntime extends Emitter { adminFingerprint: this.admin.fingerprint, trustFingerprint: this.trust.fingerprint, activeTargets: this.activeTargets.size, + stale: this.stale, + lastLoadedAt: this.lastLoadedAt, + signatureVerification: this.verifySignature ? "enabled" : "disabled", ...this.diagnostics, }; } diff --git a/packages/runtime/src/stores.js b/packages/runtime/src/stores.js index 705715c..7f50302 100644 --- a/packages/runtime/src/stores.js +++ b/packages/runtime/src/stores.js @@ -90,9 +90,15 @@ export class GovernanceAdminStore extends Emitter { this.#recompute(); } - /** The root-authority fingerprint, used to namespace cached state. */ + /** + * The root-authority fingerprint, used to namespace cached state. + * + * Derived from the root identity alone, not the full roster: rotating the + * root administrator must invalidate the cache, but adding a moderator must + * not, or the cache would be discarded on every roster change. + */ get rootFingerprint() { - return fingerprint({ root: this.authority.root ?? "", actors: this.authority.actors }); + return fingerprint({ root: this.authority.root ?? "" }); } #recompute() { diff --git a/packages/runtime/test/persistence.test.js b/packages/runtime/test/persistence.test.js new file mode 100644 index 0000000..3749c55 --- /dev/null +++ b/packages/runtime/test/persistence.test.js @@ -0,0 +1,443 @@ +// Persistence, signature verification, and the ingestion flows that were +// previously interface-only. + +import { describe, expect, it, vi } from "vitest"; + +import { createPolicyDefinition } from "@nostr-governance/core"; +import { + CANONICAL_KIND, + MUTE_LIST_KIND, + encodeContribution, + encodeRoles, +} from "@nostr-governance/nostr"; + +import { createGovernanceRuntime } from "../src/runtime.js"; +import { createMemoryStorage, 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 VIEWER = "e5".repeat(32); +const TRUSTED = "01".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" }, + reports: {}, + mutes: { default: { hide: 1 } }, + }, + }, +}); + +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"), + }); + +const denyEvent = () => + event({ + id: "bb".repeat(32), + ...encodeContribution( + { actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }, + "app", + ), + }); + +/** + * @param {Object} [options] + * @param {import('../src/interfaces.js').GovernanceStorage} [options.storage] + * @param {any} [options.transport] + * @param {import('@nostr-governance/nostr').SignatureVerifier} [options.verifySignature] + * @param {string} [options.root] + */ +function makeRuntime({ storage, transport, verifySignature, root = ROOT } = {}) { + return createGovernanceRuntime({ + applicationId: "test", + namespace: "app", + transport: transport ?? createMemoryTransport([rolesEvent(), denyEvent()]), + storage, + policy: POLICY, + now: () => NOW, + verifySignature, + root, + }); +} + +describe("persistence", () => { + it("writes administrative state under a namespaced key", async () => { + const storage = createMemoryStorage(); + const writes = vi.spyOn(storage, "write"); + const runtime = makeRuntime({ storage }); + + await runtime.loadAdministrativeState(); + + expect(writes).toHaveBeenCalled(); + const [key] = writes.mock.calls[0]; + expect(key).toMatch(/^nostr-governance:test:app:[0-9a-f]{16}:v1:admin$/); + }); + + it("restores administrative state without touching a relay", async () => { + const storage = createMemoryStorage(); + const first = makeRuntime({ storage }); + await first.loadAdministrativeState(); + expect(first.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + + // A fresh runtime with an empty relay, sharing the same storage. + const offline = createMemoryTransport([]); + const second = makeRuntime({ storage, transport: offline }); + const restored = await second.hydrate(); + + expect(restored).toBe(true); + expect(second.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + expect(second.diagnostics.hydratedFromCache).toBe(true); + }); + + it("keeps administrative state effective when the relay is unreachable", async () => { + const storage = createMemoryStorage(); + const first = makeRuntime({ storage }); + await first.loadAdministrativeState(); + + const failing = createMemoryTransport([]); + failing.list = async () => { + throw new Error("relay unreachable"); + }; + const second = makeRuntime({ storage, transport: failing }); + + await expect(second.loadAdministrativeState()).rejects.toThrow(/unreachable/); + + expect(second.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + expect(second.stale).toBe(true); + expect(second.describe().stale).toBe(true); + }); + + it("announces staleness so an application can warn the viewer", async () => { + const failing = createMemoryTransport([]); + failing.list = async () => { + throw new Error("down"); + }; + const runtime = makeRuntime({ transport: failing }); + const handler = vi.fn(); + runtime.on("stale", handler); + + await expect(runtime.loadAdministrativeState()).rejects.toThrow(); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ reason: "relay-unreachable" })); + }); + + it("clears staleness after a successful load", async () => { + const runtime = makeRuntime(); + await runtime.loadAdministrativeState(); + expect(runtime.stale).toBe(false); + expect(runtime.lastLoadedAt).toBe(NOW); + }); + + it("separates cached state by root authority", async () => { + const storage = createMemoryStorage(); + const runtime = makeRuntime({ storage }); + await runtime.loadAdministrativeState(); + + const underOldRoot = runtime.storageKeyFor("admin"); + runtime.admin.setRoles({ root: CREATOR, actors: {} }); + expect(runtime.storageKeyFor("admin")).not.toBe(underOldRoot); + + // The cache written by the previous administration must not be readable + // under the new one. + expect(await storage.read(runtime.storageKeyFor("admin"))).toBeNull(); + }); + + it("persists and restores viewer-scoped state separately", async () => { + const storage = createMemoryStorage(); + const runtime = makeRuntime({ storage }); + runtime.setViewer(VIEWER); + runtime.trust.setBlocks([CREATOR]); + runtime.overrides.set(`user:${CREATOR}`, { visibility: "allow" }); + await runtime.persist(); + + const second = makeRuntime({ storage }); + second.setViewer(VIEWER); + await second.hydrate(); + + expect(second.trust.blocks.has(CREATOR)).toBe(true); + expect(second.overrides.toMap().has(`user:${CREATOR}`)).toBe(true); + }); + + it("does not leak one viewer's cached state into another", async () => { + const storage = createMemoryStorage(); + const runtime = makeRuntime({ storage }); + runtime.setViewer(VIEWER); + runtime.trust.setBlocks([CREATOR]); + await runtime.persist(); + + const other = makeRuntime({ storage }); + other.setViewer(TRUSTED); + await other.hydrate(); + + expect(other.trust.blocks.size).toBe(0); + }); + + it("ignores a cache written under a different schema version", async () => { + const storage = createMemoryStorage(); + const runtime = makeRuntime({ storage }); + await storage.write(runtime.storageKeyFor("admin"), { + schemaVersion: "v0", + contributions: [{ actor: MODERATOR, kind: "user-deny", targets: [] }], + }); + + expect(await runtime.hydrate()).toBe(false); + }); + + it("can skip hydration and persistence when asked", async () => { + const storage = createMemoryStorage(); + const writes = vi.spyOn(storage, "write"); + const runtime = makeRuntime({ storage }); + + await runtime.loadAdministrativeState({ hydrateFirst: false, persistAfter: false }); + expect(writes).not.toHaveBeenCalled(); + }); +}); + +describe("signature verification", () => { + it("accepts state when the verifier passes it", async () => { + const runtime = makeRuntime({ verifySignature: () => true }); + await runtime.loadAdministrativeState(); + expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + }); + + it("rejects unsigned state before it becomes authoritative", async () => { + const runtime = makeRuntime({ verifySignature: () => false }); + await runtime.loadAdministrativeState(); + + expect(runtime.admin.state.userDeny.size).toBe(0); + expect(runtime.diagnostics.rejectedSignatures).toBeGreaterThan(0); + }); + + it("accepts only the events that verify", async () => { + const runtime = makeRuntime({ + verifySignature: (candidate) => candidate.pubkey === ROOT, + }); + await runtime.loadAdministrativeState(); + + expect(runtime.admin.authority.root).toBe(ROOT); + expect(runtime.admin.state.userDeny.size).toBe(0); + }); + + it("supports an async verifier", async () => { + const runtime = makeRuntime({ verifySignature: async () => true }); + await runtime.loadAdministrativeState(); + expect(runtime.admin.state.userDeny.size).toBe(1); + }); + + it("reports whether verification is enabled", () => { + expect(makeRuntime().describe().signatureVerification).toBe("disabled"); + expect(makeRuntime({ verifySignature: () => true }).describe().signatureVerification).toBe( + "enabled", + ); + }); +}); + +describe("trusted mute list subscriptions", () => { + it("subscribes by author, chunked", () => { + const transport = createMemoryTransport(); + const subscribe = vi.spyOn(transport, "subscribe"); + const runtime = createGovernanceRuntime({ + applicationId: "t", + namespace: "app", + transport, + policy: POLICY, + now: () => NOW, + chunkSize: 2, + }); + + runtime.trust.setContacts(["01".repeat(32), "02".repeat(32), "03".repeat(32)]); + runtime.subscribeToTrustedMuteLists(); + + expect(subscribe).toHaveBeenCalledTimes(2); + expect(subscribe.mock.calls[0][0][0]).toMatchObject({ kinds: [MUTE_LIST_KIND] }); + }); + + it("ingests a delivered mute list into evaluation", () => { + const transport = createMemoryTransport(); + const runtime = createGovernanceRuntime({ + applicationId: "t", + namespace: "app", + transport, + policy: POLICY, + now: () => NOW, + }); + + runtime.trust.setContacts([TRUSTED]); + runtime.subscribeToTrustedMuteLists(); + + transport.deliver({ + id: "cc".repeat(32), + pubkey: TRUSTED, + kind: MUTE_LIST_KIND, + created_at: NOW, + tags: [["p", CREATOR]], + content: "", + }); + + expect( + runtime.evaluate({ type: "user", pubkey: CREATOR }, { profile: "feed" }).visibility.effect, + ).toBe("hide"); + }); + + it("closes mute subscriptions on destroy", () => { + const runtime = makeRuntime(); + runtime.trust.setContacts([TRUSTED]); + runtime.subscribeToTrustedMuteLists(); + expect(runtime.subscriptions.size).toBe(1); + + runtime.destroy(); + expect(runtime.subscriptions.size).toBe(0); + }); +}); + +describe("community sources", () => { + const curatedList = () => ({ + id: "dd".repeat(32), + pubkey: CURATOR, + created_at: NOW, + ...encodeContribution( + { actor: CURATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }, + "app", + ), + }); + + it("resolves a curator list and marks it as community-sourced", async () => { + const transport = createMemoryTransport([curatedList()]); + const runtime = makeRuntime({ transport }); + runtime.admin.setRoles({ + root: ROOT, + actors: { [ROOT]: ["super_admin"], [CURATOR]: ["curator"] }, + }); + + const merged = await runtime.loadCommunitySources([ + { curator: CURATOR, identifier: "app:governance:user-deny:v1", kind: CANONICAL_KIND }, + ]); + + expect(merged).toBe(1); + expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + expect(runtime.admin.state.communitySources.get(`user:${CREATOR}`)).toEqual([ + `${CURATOR}:app:governance:user-deny:v1`, + ]); + }); + + it("still refuses a curator contribution beyond their capability", async () => { + const transport = createMemoryTransport([ + { + id: "ee".repeat(32), + pubkey: CURATOR, + created_at: NOW, + ...encodeContribution( + { actor: CURATOR, kind: "event-deny", targets: [{ type: "event", id: "1b".repeat(32) }] }, + "app", + ), + }, + ]); + const runtime = makeRuntime({ transport }); + runtime.admin.setRoles({ root: ROOT, actors: { [CURATOR]: ["curator"] } }); + + await runtime.loadCommunitySources([ + { curator: CURATOR, identifier: "app:governance:event-deny:v1", kind: CANONICAL_KIND }, + ]); + + expect(runtime.admin.state.eventDeny.size).toBe(0); + }); + + it("survives an unreachable curator relay", async () => { + const transport = createMemoryTransport([]); + transport.list = async () => { + throw new Error("curator relay down"); + }; + const runtime = makeRuntime({ transport }); + const handler = vi.fn(); + runtime.on("stale", handler); + + await expect( + runtime.loadCommunitySources([{ curator: CURATOR, identifier: "x", kind: CANONICAL_KIND }]), + ).resolves.toBe(0); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ reason: "community-source-unreachable" }), + ); + }); +}); + +describe("import and export", () => { + it("round-trips administrative state", async () => { + const source = makeRuntime(); + await source.loadAdministrativeState(); + + const target = makeRuntime({ transport: createMemoryTransport([]) }); + expect(target.importState(source.exportState())).toBe(true); + expect(target.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + }); + + it("exports contributions rather than conclusions", async () => { + const source = makeRuntime(); + await source.loadAdministrativeState(); + const snapshot = source.exportState(); + + expect(Array.isArray(snapshot.contributions)).toBe(true); + expect(snapshot.effectiveState.userDeny).toContain(`user:${CREATOR}`); + }); + + it("re-derives effective state against the importing roster", async () => { + const source = makeRuntime(); + await source.loadAdministrativeState(); + const snapshot = source.exportState(); + + // Strip the moderator's role before importing: their contribution should + // no longer take effect. + snapshot.authority = { root: ROOT, actors: {}, protectedActors: [ROOT] }; + + const target = makeRuntime({ transport: createMemoryTransport([]) }); + target.importState(snapshot); + expect(target.admin.state.userDeny.size).toBe(0); + }); + + it("rejects a snapshot from another schema version or namespace", () => { + const runtime = makeRuntime(); + expect(runtime.importState({ schemaVersion: "v0" })).toBe(false); + expect(runtime.importState({ schemaVersion: "v1", namespace: "elsewhere" })).toBe(false); + expect(runtime.importState(null)).toBe(false); + }); +}); + +describe("capability queries", () => { + it("answers what an actor may do", async () => { + const runtime = makeRuntime(); + await runtime.loadAdministrativeState(); + + expect(runtime.can(MODERATOR, "contribute-user-deny")).toBe(true); + expect(runtime.can(MODERATOR, "manage-roles")).toBe(false); + expect(runtime.capabilitiesOf(MODERATOR)).toContain("contribute-event-deny"); + }); + + it("answers what the current viewer may do", async () => { + const runtime = makeRuntime(); + await runtime.loadAdministrativeState(); + + expect(runtime.viewerCapabilities()).toEqual([]); + runtime.setViewer(MODERATOR); + expect(runtime.viewerCapabilities()).toContain("contribute-user-deny"); + }); +}); diff --git a/packages/runtime/test/runtime.test.js b/packages/runtime/test/runtime.test.js index cc7889b..b7561f9 100644 --- a/packages/runtime/test/runtime.test.js +++ b/packages/runtime/test/runtime.test.js @@ -169,17 +169,59 @@ describe("ingestEvent", () => { expect(runtime.mutes.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(1); }); - it("emits a policy document rather than applying it blindly", () => { + it("rejects a policy document from anyone but the root", () => { const { runtime } = makeRuntime(); - const handler = vi.fn(); - runtime.on("policy-document", handler); + runtime.ingestEvent(rolesEvent()); + + const rejected = vi.fn(); + runtime.on("policy-rejected", rejected); runtime.ingestEvent( event({ + pubkey: MODERATOR, tags: [["d", canonicalIdentifier("app", "policy")]], - content: JSON.stringify({ id: "x" }), + content: JSON.stringify({ id: "x", version: "1", profiles: { f: { name: "f" } } }), }), ); - expect(handler).toHaveBeenCalled(); + + expect(rejected).toHaveBeenCalledWith(expect.objectContaining({ reason: "not-root" })); + expect(runtime.policies.rootPolicy).toBeNull(); + }); + + it("applies a root-published policy", () => { + const { runtime } = makeRuntime(); + runtime.ingestEvent(rolesEvent()); + + runtime.ingestEvent( + event({ + pubkey: ROOT, + tags: [["d", canonicalIdentifier("app", "policy")]], + content: JSON.stringify({ + id: "root-policy", + version: "3.0.0", + profiles: { feed: { name: "feed" } }, + }), + }), + ); + + expect(runtime.policies.policy.id).toBe("root-policy"); + }); + + it("keeps the working policy when the root publishes a malformed one", () => { + const { runtime } = makeRuntime(); + runtime.ingestEvent(rolesEvent()); + + const rejected = vi.fn(); + runtime.on("policy-rejected", rejected); + runtime.ingestEvent( + event({ + pubkey: ROOT, + tags: [["d", canonicalIdentifier("app", "policy")]], + content: JSON.stringify({ id: "broken" }), + }), + ); + + expect(rejected).toHaveBeenCalledWith(expect.objectContaining({ reason: "invalid" })); + expect(runtime.policies.policy.id).toBe("app"); }); it("counts unrecognized events instead of throwing", () => {