diff --git a/CHANGELOG.md b/CHANGELOG.md index e332e84..9130506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,15 @@ public API changes with it. them. - **Commerce proof-of-fit** (`examples/commerce`) demonstrating the engine outside a video application using only the public API. +- **Decision caching** keyed by (profile, target) with targeted invalidation: + report and mute changes invalidate only the targets they touch, while + administrative, trust, policy, and viewer changes drop the whole cache. + Cached decisions are deeply frozen so a consumer cannot corrupt later reads. +- **Performance fixture** (`tests/performance/`) over 5,000 targets, 500 + authors, and 100 trusted muters per author, asserting no network access, + bounded subscription counts, and targeted invalidation. +- **Security requirements suite** (`tests/security/`) covering the enumerated + requirements testable at this layer. - GPL-3.0-or-later `LICENSE`, `README`, and an integration guide. ### Fixed @@ -50,6 +59,12 @@ public API changes with it. governed by several targets lost the evidence that drove its verdict. - `OverrideStore.clear()` shadowed the inherited listener `clear()`, leaking subscribers on teardown. Now `clearOverrides()`. +- Evaluation was quadratic in state size: both the snapshot and its fingerprint + were rebuilt for every target, so each call walked every report and mute + list. A 5,000-target pass took ~47s. The runtime now memoizes the snapshot + and its fingerprint until a store changes, and `evaluateMany` computes the + fingerprint once for the batch. The same pass now takes ~85ms cold and ~6ms + warm. ### Changed diff --git a/docs/checkpoints.md b/docs/checkpoints.md index 6d29b7a..570e10f 100644 --- a/docs/checkpoints.md +++ b/docs/checkpoints.md @@ -90,9 +90,38 @@ covering the ten proof-of-fit behaviors the migration plan requires — includin that no core change was needed. Plus `README.md`, `docs/integration-guide.md`, and `examples/commerce/README.md`. +## Checkpoint 7 — Performance and security fixtures (completed) + +Delivered: +- `tests/performance/large-feed.test.js` — 5,000 targets, 500 authors, 100 + trusted muters per author, multiple report categories and administrative + contributors, viewer switching, and roster changes +- `tests/security/requirements.test.js` — one case per enumerated security + requirement testable at this layer + +Acceptance: +- `evaluateMany(5,000)` performs no network access +- report subscriptions are chunked, never one per card +- updating one report invalidates only that target's decisions +- updating one author's mute state invalidates only that author's targets +- changing policy, roster, or viewer invalidates everything +- destroy leaves no subscriptions or cached decisions + +This fixture found a real defect: evaluation was quadratic in state size, +because the snapshot and its fingerprint were rebuilt per target. A +5,000-target pass took ~47 seconds. After memoizing both, the same pass takes +~85ms cold and ~6ms warm. + +Requirements 15 and 16 (never export signer secrets, avoid shared moderator +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. + ## Status -CI gates green: reference guard, typecheck, 556 tests, per-package type build. +CI gates green: reference guard, typecheck, 605 tests, per-package type build. +Typecheck and the declaration build both pass from a cold checkout with no +`dist/` present. ## Not done here diff --git a/packages/core/src/evaluator.js b/packages/core/src/evaluator.js index b408b2b..e1fdcc7 100644 --- a/packages/core/src/evaluator.js +++ b/packages/core/src/evaluator.js @@ -268,13 +268,19 @@ export function evaluateTarget(target, snapshot, context = { surface: "default" decision.policyProfile = profile.name; decision.policyVersion = policy.version; decision.evaluatedAt = now; - decision.snapshotFingerprint = snapshotFingerprint({ - authority: snapshot.authority, - admin: snapshot.admin, - reports: snapshot.reports, - trustedMutes: snapshot.trustedMutes, - policy: { id: policy.id, version: policy.version, profile: profile.name }, - }); + + // Fingerprinting walks the whole snapshot, so a caller evaluating many + // targets against one snapshot should compute it once and pass it in. + // Recomputing per target makes a large feed quadratic in state size. + decision.snapshotFingerprint = + context.snapshotFingerprint ?? + snapshotFingerprint({ + authority: snapshot.authority, + admin: snapshot.admin, + reports: snapshot.reports, + trustedMutes: snapshot.trustedMutes, + policy: { id: policy.id, version: policy.version, profile: profile.name }, + }); const evidence = createEmptyEvidence(); @@ -550,6 +556,21 @@ export function evaluateMany(targets, snapshot, context, viewerState) { /** @type {Map} */ const results = new Map(); + const policy = context?.policy ?? NEUTRAL_POLICY; + const profile = resolveProfile(policy, context); + const sharedContext = { + ...(context ?? { surface: "default" }), + snapshotFingerprint: + context?.snapshotFingerprint ?? + snapshotFingerprint({ + authority: snapshot.authority, + admin: snapshot.admin, + reports: snapshot.reports, + trustedMutes: snapshot.trustedMutes, + policy: { id: policy.id, version: policy.version, profile: profile.name }, + }), + }; + for (const target of targets) { if (!isValidTarget(target)) { continue; @@ -558,7 +579,7 @@ export function evaluateMany(targets, snapshot, context, viewerState) { if (results.has(key)) { continue; } - results.set(key, evaluateTarget(target, snapshot, context, viewerState)); + results.set(key, evaluateTarget(target, snapshot, sharedContext, viewerState)); } return results; diff --git a/packages/core/src/policy.js b/packages/core/src/policy.js index acc571e..f2c1852 100644 --- a/packages/core/src/policy.js +++ b/packages/core/src/policy.js @@ -124,6 +124,7 @@ export function maxEffect(dimension, a, b) { * @property {string} [policyProfile] - Profile name; defaults to the policy's default profile * @property {PolicyDefinition} [policy] - Policy definition to evaluate against * @property {number} [now] - Evaluation time in unix seconds (injected clock) + * @property {string} [snapshotFingerprint] - Precomputed fingerprint, reused across a batch * @property {Object} [enforcement] * @property {boolean} [enforcement.allowOverrides] - Hard switch for viewer overrides */ diff --git a/packages/runtime/src/runtime.js b/packages/runtime/src/runtime.js index d5474ae..45a6ba7 100644 --- a/packages/runtime/src/runtime.js +++ b/packages/runtime/src/runtime.js @@ -6,7 +6,6 @@ import { NEUTRAL_POLICY, - evaluateMany, evaluateTarget, getTargetKey, isValidTarget, @@ -26,6 +25,8 @@ import { selectReplaceable, } from "@nostr-governance/nostr"; +import { snapshotFingerprint } from "@nostr-governance/core"; + import { Emitter } from "./emitter.js"; import { createMemoryStorage, createNullSigner, storageKey } from "./interfaces.js"; import { @@ -75,6 +76,22 @@ export function chunk(items, size) { return chunks; } +/** + * Recursively freeze a decision so cached instances cannot be mutated. + * @template T + * @param {T} value + * @returns {T} + */ +function deepFreeze(value) { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const entry of Object.values(value)) { + deepFreeze(entry); + } + } + return value; +} + export class GovernanceRuntime extends Emitter { /** * @param {Object} options @@ -136,16 +153,57 @@ export class GovernanceRuntime extends Emitter { /** @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" })), + // Administrative, trust, and policy changes can alter any decision, so + // they drop the whole cache. Report and mute changes name the targets + // they touch, so only those entries are invalidated. + this.admin.on("change", () => { + this.invalidateDecisions(); + this.emit("change", { source: "admin" }); + }), + this.trust.on("change", () => { + this.invalidateDecisions(); + this.emit("change", { source: "trust" }); + }), + this.reports.on("change", (detail) => { + this.invalidateDecisions(detail?.targetKey ? [detail.targetKey] : undefined); + this.emit("change", { source: "reports", ...detail }); + }), + this.mutes.on("change", (detail) => { + this.invalidateDecisions(detail?.targetKeys); + this.emit("change", { source: "mutes" }); + }), + this.policies.on("change", () => { + this.invalidateDecisions(); + this.emit("change", { source: "policy" }); + }), + this.overrides.on("change", (detail) => { + this.invalidateDecisions(detail?.targetKey ? [detail.targetKey] : undefined); + this.emit("change", { source: "overrides" }); + }), ]; - /** @type {{ eventsIngested: number, unknownEvents: number, subscriptions: number }} */ - this.diagnostics = { eventsIngested: 0, unknownEvents: 0, subscriptions: 0 }; + // Decisions are cached per (profile, target). A feed re-renders far more + // often than governance state changes, and re-deriving an unchanged verdict + // for every card is the cost the extraction is supposed to remove. + /** @type {Map} */ + this.decisionCache = new Map(); + + // The snapshot and its fingerprint are rebuilt only when a store changes. + // Deriving them per target walks every report and mute list, which turns a + // large feed into quadratic work. + /** @type {GovernanceSnapshot|null} */ + this.cachedSnapshot = null; + /** @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, + }; } /** @@ -340,6 +398,7 @@ export class GovernanceRuntime extends Emitter { return; } this.viewerPubkey = normalized; + this.decisionCache.clear(); this.trust.clearViewerState(); this.overrides.clearOverrides(); this.emit("viewer", { pubkey: normalized }); @@ -347,16 +406,44 @@ export class GovernanceRuntime extends Emitter { /** * Build a snapshot for the pure evaluator. + * + * Memoized until a store reports a change, so repeated evaluation over one + * feed reuses a single materialized view of state. + * * @returns {GovernanceSnapshot} */ snapshot() { - return { + if (this.cachedSnapshot) { + return this.cachedSnapshot; + } + + this.cachedSnapshot = { 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(), }; + return this.cachedSnapshot; + } + + /** + * The current snapshot fingerprint, computed once per snapshot. + * @returns {string} + */ + snapshotFingerprint() { + if (!this.cachedSnapshotFingerprint) { + const snapshot = this.snapshot(); + const policy = this.policies.policy; + this.cachedSnapshotFingerprint = snapshotFingerprint({ + authority: snapshot.authority, + admin: snapshot.admin, + reports: snapshot.reports, + trustedMutes: snapshot.trustedMutes, + policy: { id: policy.id, version: policy.version }, + }); + } + return this.cachedSnapshotFingerprint; } /** @returns {ViewerState} */ @@ -368,6 +455,34 @@ export class GovernanceRuntime extends Emitter { }; } + /** + * Drop cached decisions. + * + * With no argument every entry is dropped; with target keys, only decisions + * for those targets across all profiles. + * + * @param {string[]} [targetKeys] + */ + invalidateDecisions(targetKeys) { + // Any change that reaches a decision also invalidates the materialized + // snapshot it was derived from. + this.cachedSnapshot = null; + this.cachedSnapshotFingerprint = ""; + + if (!targetKeys) { + this.decisionCache.clear(); + return; + } + const affected = new Set(targetKeys); + for (const cacheKey of Array.from(this.decisionCache.keys())) { + // Cache keys are `${profile}|${targetKey}`. + const targetKey = cacheKey.slice(cacheKey.indexOf("|") + 1); + if (affected.has(targetKey)) { + this.decisionCache.delete(cacheKey); + } + } + } + /** * Evaluate one target. * @param {GovernanceTarget} target @@ -377,7 +492,14 @@ export class GovernanceRuntime extends Emitter { * @returns {GovernanceDecision} */ evaluate(target, { profile, surface } = {}) { - return evaluateTarget( + const cacheKey = `${profile ?? ""}|${getTargetKey(target)}`; + const cached = this.decisionCache.get(cacheKey); + if (cached) { + this.diagnostics.cacheHits += 1; + return cached; + } + + const decision = evaluateTarget( target, this.snapshot(), { @@ -385,9 +507,16 @@ export class GovernanceRuntime extends Emitter { policyProfile: profile, policy: this.policies.policy, now: this.now(), + snapshotFingerprint: this.snapshotFingerprint(), }, this.viewerState(), ); + + this.diagnostics.cacheMisses += 1; + // Frozen because the cache hands out the same object to every caller; a + // consumer mutating a decision would otherwise corrupt later reads. + this.decisionCache.set(cacheKey, deepFreeze(decision)); + return decision; } /** @@ -399,17 +528,18 @@ export class GovernanceRuntime extends Emitter { * @returns {Map} */ evaluateMany(targets, { profile, surface } = {}) { - return evaluateMany( - targets, - this.snapshot(), - { - surface: surface ?? profile ?? "default", - policyProfile: profile, - policy: this.policies.policy, - now: this.now(), - }, - this.viewerState(), - ); + /** @type {Map} */ + const results = new Map(); + for (const target of targets ?? []) { + if (!isValidTarget(target)) { + continue; + } + const key = getTargetKey(target); + if (!results.has(key)) { + results.set(key, this.evaluate(target, { profile, surface })); + } + } + return results; } /** Diagnostic summary for support and debugging. */ @@ -449,6 +579,9 @@ export class GovernanceRuntime extends Emitter { unsubscribe(); } + this.decisionCache.clear(); + this.cachedSnapshot = null; + this.cachedSnapshotFingerprint = ""; this.admin.clear(); this.trust.clear(); this.reports.clear(); diff --git a/packages/runtime/src/stores.js b/packages/runtime/src/stores.js index d71d17e..705715c 100644 --- a/packages/runtime/src/stores.js +++ b/packages/runtime/src/stores.js @@ -328,12 +328,20 @@ export class TrustedMuteStore extends Emitter { if (existing && existing.updatedAt > list.updatedAt) { return; } + const affected = new Set(); + for (const entry of existing?.entries ?? []) { + affected.add(`user:${entry.pubkey}`); + } + for (const entry of list.entries) { + affected.add(`user:${entry.pubkey}`); + } + this.lists.set(list.owner, { owner: list.owner, updatedAt: list.updatedAt, entries: list.entries, }); - this.#emitChange(); + this.#emitChange(Array.from(affected)); } /** @@ -345,15 +353,21 @@ export class TrustedMuteStore extends Emitter { return 0; } const cutoff = this.now() - this.windowSeconds; + const affected = new Set(); let pruned = 0; + for (const [owner, list] of this.lists.entries()) { if (list.updatedAt < cutoff) { + for (const entry of list.entries) { + affected.add(`user:${entry.pubkey}`); + } this.lists.delete(owner); pruned += 1; } } + if (pruned) { - this.#emitChange(); + this.#emitChange(Array.from(affected)); } return pruned; } @@ -389,13 +403,14 @@ export class TrustedMuteStore extends Emitter { return counts; } - #emitChange() { + /** @param {string[]} [targetKeys] - Targets whose decisions this change affects */ + #emitChange(targetKeys) { this.fingerprint = fingerprint( Array.from(this.lists.values()) .map((list) => [list.owner, list.updatedAt, list.entries.length]) .sort(), ); - this.emit("change", { fingerprint: this.fingerprint }); + this.emit("change", { fingerprint: this.fingerprint, targetKeys }); } } diff --git a/tests/performance/large-feed.test.js b/tests/performance/large-feed.test.js new file mode 100644 index 0000000..9a94a0d --- /dev/null +++ b/tests/performance/large-feed.test.js @@ -0,0 +1,297 @@ +// Performance fixture. +// +// The extraction must not regress to per-item store lookups or per-card +// subscriptions. These assertions are structural rather than wall-clock: they +// check that no network call happens, that invalidation is targeted, and that +// teardown leaves nothing running. A timing assertion is included but kept +// deliberately loose, because a CI runner's clock is not a benchmark. + +import { describe, expect, it, vi } from "vitest"; + +import { createPolicyDefinition } from "@nostr-governance/core"; +import { createGovernanceRuntime, createMemoryTransport } from "@nostr-governance/runtime"; + +const AUTHOR_COUNT = 500; +const TARGET_COUNT = 5_000; +const MUTERS_PER_AUTHOR = 100; +const NOW = 1_750_000_000; + +const hex = (prefix, index) => `${prefix}${index.toString(16).padStart(10, "0")}`.padEnd(64, "0").slice(0, 64); + +/** @param {number} index @returns {import('@nostr-governance/core').GovernanceTarget} */ +const userTarget = (index) => ({ type: "user", pubkey: hex("a", index) }); +/** @param {number} index @param {number} authorIndex @returns {import('@nostr-governance/core').GovernanceTarget} */ +const eventTarget = (index, authorIndex) => ({ + type: "event", + id: hex("c", index), + author: hex("a", authorIndex), +}); + +const author = (index) => hex("a", index); +const muter = (index) => hex("b", index); +const eventId = (index) => hex("c", index); + +const POLICY = createPolicyDefinition({ + id: "perf", + version: "1.0.0", + defaultProfile: "feed", + profiles: { + feed: { + name: "feed", + administrativeDeny: { visibility: "hide", interaction: "deny" }, + reports: { spam: { downrank: 1, hide: 8 }, scam: { restrict: 3 }, default: { downrank: 2 } }, + mutes: { default: { downrank: 1, hide: 20 } }, + muteWindowSeconds: 60 * 24 * 60 * 60, + }, + strict: { + name: "strict", + administrativeDeny: { visibility: "deny", interaction: "deny" }, + reports: { default: { hide: 1 } }, + mutes: {}, + }, + }, +}); + +/** Build a runtime loaded with a large, realistic governance state. */ +function buildLargeRuntime() { + const transport = createMemoryTransport(); + const runtime = createGovernanceRuntime({ + applicationId: "perf", + namespace: "perf", + transport, + policy: POLICY, + now: () => NOW, + }); + + const ROOT = hex("f", 1); + const MODERATORS = [hex("d", 1), hex("d", 2), hex("d", 3)]; + + runtime.setViewer(hex("e", 1)); + + runtime.admin.setRoles({ + root: ROOT, + actors: { + [ROOT]: ["super_admin"], + ...Object.fromEntries(MODERATORS.map((pubkey) => [pubkey, ["moderator"]])), + }, + }); + + // Several administrative contributors, each denying a slice of authors. + MODERATORS.forEach((actor, index) => { + runtime.admin.upsertContribution({ + actor, + kind: "user-deny", + createdAt: NOW, + targets: Array.from({ length: 10 }, (_, offset) => userTarget(index * 10 + offset)), + }); + }); + + runtime.trust.setContacts(Array.from({ length: MUTERS_PER_AUTHOR }, (_, i) => muter(i))); + + // 100 trusted muters against each of the first 50 authors. + for (let authorIndex = 0; authorIndex < 50; authorIndex += 1) { + for (let muterIndex = 0; muterIndex < MUTERS_PER_AUTHOR; muterIndex += 1) { + runtime.mutes.replaceList({ + owner: muter(muterIndex), + updatedAt: NOW - 1000, + entries: Array.from({ length: 50 }, (_, i) => ({ pubkey: author(i) })), + hasEncryptedEntries: false, + }); + } + } + + // Reports across several categories. + for (let index = 0; index < 1_000; index += 1) { + runtime.reports.ingest( + { + reporter: muter(index % MUTERS_PER_AUTHOR), + target: eventTarget(index, 0), + category: ["spam", "scam", "misleading"][index % 3], + createdAt: NOW - 500, + }, + `event:${eventId(index)}`, + ); + } + + const targets = Array.from({ length: TARGET_COUNT }, (_, index) => + eventTarget(index, index % AUTHOR_COUNT), + ); + + return { runtime, transport, targets, MODERATORS }; +} + +describe("large feed evaluation", () => { + it("evaluates 5,000 targets without touching the network", () => { + const { runtime, transport, targets } = buildLargeRuntime(); + const listSpy = vi.spyOn(transport, "list"); + const subscribeSpy = vi.spyOn(transport, "subscribe"); + const publishSpy = vi.spyOn(transport, "publish"); + + const decisions = runtime.evaluateMany(targets, { profile: "feed" }); + + expect(decisions.size).toBe(TARGET_COUNT); + expect(listSpy).not.toHaveBeenCalled(); + expect(subscribeSpy).not.toHaveBeenCalled(); + expect(publishSpy).not.toHaveBeenCalled(); + }); + + it("opens a bounded number of subscriptions, not one per card", () => { + const { runtime, transport, targets } = buildLargeRuntime(); + const subscribeSpy = vi.spyOn(transport, "subscribe"); + + runtime.setActiveTargets(targets); + runtime.subscribeToActiveTargetReports(); + + // 5,000 event targets at 200 per filter chunk. + expect(subscribeSpy).toHaveBeenCalledTimes(Math.ceil(TARGET_COUNT / 200)); + }); + + it("produces the same decisions on a second pass", () => { + const { runtime, targets } = buildLargeRuntime(); + const first = runtime.evaluateMany(targets.slice(0, 200), { profile: "feed" }); + const second = runtime.evaluateMany(targets.slice(0, 200), { profile: "feed" }); + for (const [key, decision] of first) { + expect(second.get(key)).toEqual(decision); + } + }); + + it("serves a repeated pass from cache", () => { + const { runtime, targets } = buildLargeRuntime(); + const slice = targets.slice(0, 500); + + runtime.evaluateMany(slice, { profile: "feed" }); + const missesAfterFirst = runtime.diagnostics.cacheMisses; + + runtime.evaluateMany(slice, { profile: "feed" }); + expect(runtime.diagnostics.cacheMisses).toBe(missesAfterFirst); + expect(runtime.diagnostics.cacheHits).toBe(500); + }); + + it("keeps profiles separate in the cache", () => { + const { runtime, targets } = buildLargeRuntime(); + const slice = targets.slice(0, 10); + + const feed = runtime.evaluateMany(slice, { profile: "feed" }); + const strict = runtime.evaluateMany(slice, { profile: "strict" }); + + for (const [key, decision] of feed) { + expect(strict.get(key)?.policyProfile).toBe("strict"); + expect(decision.policyProfile).toBe("feed"); + } + }); + + it("completes a 5,000-target pass in reasonable time", () => { + const { runtime, targets } = buildLargeRuntime(); + const started = performance.now(); + runtime.evaluateMany(targets, { profile: "feed" }); + // Loose on purpose: this catches an accidental O(n^2), not a slow runner. + expect(performance.now() - started).toBeLessThan(10_000); + }); +}); + +describe("incremental invalidation", () => { + it("updating one report invalidates only that target", () => { + const { runtime, targets } = buildLargeRuntime(); + const slice = targets.slice(0, 100); + runtime.evaluateMany(slice, { profile: "feed" }); + expect(runtime.decisionCache.size).toBe(100); + + const firstKey = `event:${eventId(0)}`; + runtime.reports.ingest( + { reporter: muter(0), target: slice[0], category: "spam", createdAt: NOW }, + firstKey, + ); + + expect(runtime.decisionCache.size).toBe(99); + expect(runtime.decisionCache.has(`feed|${firstKey}`)).toBe(false); + }); + + it("updating one author's mute state invalidates only that author's targets", () => { + const { runtime } = buildLargeRuntime(); + const userTargets = Array.from({ length: 20 }, (_, index) => userTarget(200 + index)); + runtime.evaluateMany(userTargets, { profile: "feed" }); + expect(runtime.decisionCache.size).toBe(20); + + runtime.mutes.replaceList({ + owner: muter(0), + updatedAt: NOW, + entries: [{ pubkey: author(200) }], + hasEncryptedEntries: false, + }); + + expect(runtime.decisionCache.has(`feed|user:${author(200)}`)).toBe(false); + expect(runtime.decisionCache.has(`feed|user:${author(201)}`)).toBe(true); + }); + + it("changing policy invalidates every decision", () => { + const { runtime, targets } = buildLargeRuntime(); + runtime.evaluateMany(targets.slice(0, 50), { profile: "feed" }); + expect(runtime.decisionCache.size).toBe(50); + + runtime.policies.setLocalPolicy( + createPolicyDefinition({ + id: "perf-v2", + version: "2.0.0", + profiles: { feed: { name: "feed", reports: { default: { hide: 1 } }, mutes: {} } }, + }), + ); + expect(runtime.decisionCache.size).toBe(0); + }); + + it("changing the moderator roster invalidates every decision", () => { + const { runtime, targets } = buildLargeRuntime(); + runtime.evaluateMany(targets.slice(0, 50), { profile: "feed" }); + + runtime.admin.setRoles({ root: hex("f", 1), actors: {} }); + expect(runtime.decisionCache.size).toBe(0); + }); + + it("switching viewers invalidates every decision", () => { + const { runtime, targets } = buildLargeRuntime(); + runtime.evaluateMany(targets.slice(0, 50), { profile: "feed" }); + + runtime.setViewer(hex("e", 2)); + expect(runtime.decisionCache.size).toBe(0); + }); + + it("hands out frozen decisions so a consumer cannot corrupt the cache", () => { + const { runtime, targets } = buildLargeRuntime(); + const decision = runtime.evaluate(targets[0], { profile: "feed" }); + expect(() => { + decision.visibility.effect = "deny"; + }).toThrow(); + }); +}); + +describe("teardown", () => { + it("leaves no subscriptions or cached decisions", () => { + const { runtime, targets } = buildLargeRuntime(); + runtime.setActiveTargets(targets.slice(0, 400)); + runtime.subscribeToActiveTargetReports(); + runtime.evaluateMany(targets.slice(0, 50), { profile: "feed" }); + + runtime.destroy(); + + expect(runtime.subscriptions.size).toBe(0); + expect(runtime.decisionCache.size).toBe(0); + }); + + it("closes underlying transport subscriptions", () => { + const { runtime, transport, targets } = buildLargeRuntime(); + const closes = []; + vi.spyOn(transport, "subscribe").mockImplementation(() => { + const handle = { close: vi.fn() }; + closes.push(handle); + return handle; + }); + + runtime.setActiveTargets(targets.slice(0, 400)); + runtime.subscribeToActiveTargetReports(); + runtime.destroy(); + + expect(closes.length).toBeGreaterThan(0); + for (const handle of closes) { + expect(handle.close).toHaveBeenCalled(); + } + }); +}); diff --git a/tests/security/requirements.test.js b/tests/security/requirements.test.js new file mode 100644 index 0000000..544165b --- /dev/null +++ b/tests/security/requirements.test.js @@ -0,0 +1,545 @@ +// Security requirements. +// +// One test per enumerated requirement in the migration plan's security section +// that is testable at this layer. Requirements about key custody (never export +// signer secrets, avoid shared moderator keys) are properties of the injected +// signer and of operational practice, not of this code; they are noted where +// they fall rather than silently skipped. + +import { describe, expect, it } from "vitest"; + +import { + createAuthorityState, + createPolicyDefinition, + createSnapshot, + createViewerState, + evaluateTarget, + getActorCapabilities, + hasCapability, + isValidTarget, + normalizeAddress, + reduceAdminState, +} from "@nostr-governance/core"; +import { + MAX_POLICY_BYTES, + canonicalIdentifier, + decodePolicy, + decodeReport, + verifyEvents, +} from "@nostr-governance/nostr"; +import { + ERROR_CODES, + createCommands, + createGovernanceRuntime, + createMemoryTransport, +} from "@nostr-governance/runtime"; + +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 TRUSTED = "01".repeat(32); +const NOW = 1_750_000_000; +const DAY = 86_400; + +const authority = () => + createAuthorityState({ + root: ROOT, + actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"], [CURATOR]: ["curator"] }, + }); + +const POLICY = createPolicyDefinition({ + id: "sec", + version: "1.0.0", + defaultProfile: "feed", + profiles: { + feed: { + name: "feed", + administrativeDeny: { visibility: "hide", interaction: "deny" }, + reports: { default: { restrict: 1 } }, + mutes: { default: { hide: 1 } }, + muteWindowSeconds: 60 * DAY, + }, + }, +}); + +const event = (parts) => ({ + id: "00".repeat(32), + pubkey: MODERATOR, + kind: 30078, + created_at: NOW, + tags: [], + content: "", + ...parts, +}); + +function runtimeWithSigner(pubkey) { + const runtime = createGovernanceRuntime({ + applicationId: "sec", + namespace: "sec", + transport: createMemoryTransport(), + signer: { + async getPublicKey() { + return pubkey; + }, + async signEvent(template) { + return /** @type {any} */ ({ ...template, id: "ff".repeat(32), pubkey, sig: "00".repeat(64) }); + }, + }, + policy: POLICY, + now: () => NOW, + }); + runtime.admin.setRoles({ + root: ROOT, + actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"], [CURATOR]: ["curator"] }, + }); + return runtime; +} + +describe("1. verify signatures before accepting authoritative state", () => { + it("drops events an injected verifier rejects", async () => { + const good = event({ id: "aa".repeat(32) }); + const bad = event({ id: "bb".repeat(32) }); + const verified = await verifyEvents([good, bad], (candidate) => candidate.id === good.id); + expect(verified).toEqual([good]); + }); + + it("treats a throwing verifier as rejection, never as a pass", async () => { + expect( + await verifyEvents([event({})], () => { + throw new Error("verifier exploded"); + }), + ).toEqual([]); + }); +}); + +describe("2. validate authority independently of UI state", () => { + it("derives capabilities from the roster, not from any caller-supplied flag", () => { + expect(hasCapability(MODERATOR, "contribute-user-deny", authority())).toBe(true); + expect(hasCapability(STRANGER, "contribute-user-deny", authority())).toBe(false); + }); + + it("refuses a command from an actor without the capability", async () => { + const commands = createCommands(runtimeWithSigner(STRANGER)); + expect((await commands.denyUser(CREATOR)).code).toBe(ERROR_CODES.NOT_AUTHORIZED); + }); +}); + +describe("3. strict pubkey and event-id validation", () => { + it("rejects identifiers that are not 64 hex characters", () => { + expect(isValidTarget({ type: "user", pubkey: "a".repeat(63) })).toBe(false); + expect(isValidTarget({ type: "user", pubkey: "g".repeat(64) })).toBe(false); + expect(isValidTarget({ type: "event", id: "a".repeat(65) })).toBe(false); + }); + + it("accepts a well-formed identifier", () => { + expect(isValidTarget({ type: "user", pubkey: CREATOR })).toBe(true); + }); +}); + +describe("4. validate address coordinates", () => { + it("rejects a malformed coordinate", () => { + expect(normalizeAddress("30078", "not-a-pubkey", "sku")).toBeNull(); + expect(normalizeAddress("", CREATOR, "sku")).toBeNull(); + expect(normalizeAddress("30078", CREATOR, "")).toBeNull(); + }); + + it("accepts a well-formed coordinate", () => { + expect(normalizeAddress("30078", CREATOR, "sku")).toEqual({ + type: "address", + kind: "30078", + pubkey: CREATOR, + identifier: "sku", + }); + }); +}); + +describe("7. bound policy content size", () => { + it("rejects oversized policy content before parsing it", () => { + const oversized = event({ + tags: [["d", canonicalIdentifier("app", "policy")]], + content: "x".repeat(MAX_POLICY_BYTES + 1), + }); + expect(decodePolicy(oversized)).toBeNull(); + }); + + it("returns null for malformed policy JSON rather than throwing", () => { + const malformed = event({ + tags: [["d", canonicalIdentifier("app", "policy")]], + content: "{not json", + }); + expect(() => decodePolicy(malformed)).not.toThrow(); + expect(decodePolicy(malformed)).toBeNull(); + }); +}); + +describe("8. ignore unknown capabilities", () => { + it("filters unknown capabilities out of a role", () => { + const state = createAuthorityState({ + roles: { odd: /** @type {any} */ (["manage-policy", "not-real"]) }, + actors: { [MODERATOR]: ["odd"] }, + }); + expect(getActorCapabilities(MODERATOR, state)).toEqual(["manage-policy"]); + }); + + it("never grants an unknown capability", () => { + expect(hasCapability(ROOT, /** @type {any} */ ("invented"), authority())).toBe(false); + }); +}); + +describe("9. ignore unauthorized contributor events", () => { + it("drops a contribution beyond the actor's capability", () => { + const state = reduceAdminState( + [{ actor: CURATOR, kind: "event-deny", targets: [{ type: "event", id: "1b".repeat(32) }] }], + authority(), + ); + expect(state.eventDeny.size).toBe(0); + }); + + it("drops contributions from an actor with no roles", () => { + const state = reduceAdminState( + [{ actor: STRANGER, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }], + authority(), + ); + expect(state.userDeny.size).toBe(0); + }); + + it("stops honoring an actor's contributions the moment their role is revoked", () => { + /** @type {import('@nostr-governance/core').Contribution[]} */ + const contributions = [ + { actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }, + ]; + expect(reduceAdminState(contributions, authority()).userDeny.size).toBe(1); + + const revoked = createAuthorityState({ root: ROOT, actors: { [ROOT]: ["super_admin"] } }); + expect(reduceAdminState(contributions, revoked).userDeny.size).toBe(0); + }); + + it("ignores malformed identifiers inside an authorized contribution", () => { + const state = reduceAdminState( + [ + { + actor: MODERATOR, + kind: "user-deny", + targets: [/** @type {any} */ ({ type: "user", pubkey: "short" })], + }, + ], + authority(), + ); + expect(state.userDeny.size).toBe(0); + }); +}); + +describe("10. protect root and configured system accounts", () => { + it("cannot deny the root through any contributor list", () => { + const state = reduceAdminState( + [ + { actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: ROOT }] }, + { actor: CURATOR, kind: "user-deny", targets: [{ type: "user", pubkey: ROOT }] }, + ], + authority(), + ); + expect(state.userDeny.has(`user:${ROOT}`)).toBe(false); + }); + + it("protects configured system accounts too", () => { + const withSystem = createAuthorityState({ + root: ROOT, + protectedActors: [CREATOR], + actors: { [MODERATOR]: ["moderator"] }, + }); + const state = reduceAdminState( + [{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }], + withSystem, + ); + expect(state.userDeny.has(`user:${CREATOR}`)).toBe(false); + }); + + it("refuses the command outright rather than publishing a no-op", async () => { + const commands = createCommands(runtimeWithSigner(MODERATOR)); + expect((await commands.denyUser(ROOT)).code).toBe(ERROR_CODES.PROTECTED_TARGET); + }); +}); + +describe("11. deduplicate reports by reporter, target, and category", () => { + it("counts a repeated report once", () => { + const snapshot = createSnapshot({ + authority: authority(), + admin: reduceAdminState([], authority()), + trust: { contacts: new Set([TRUSTED]) }, + reports: new Map([ + [ + `user:${CREATOR}`, + [ + { reporter: TRUSTED, category: "spam", createdAt: NOW }, + { reporter: TRUSTED, category: "spam", createdAt: NOW - 1 }, + ], + ], + ]), + }); + const decision = evaluateTarget({ type: "user", pubkey: CREATOR }, snapshot, { + surface: "feed", + policyProfile: "feed", + policy: POLICY, + now: NOW, + }); + expect(decision.evidence?.trustedReportTotal).toBe(1); + }); + + it("does not let a denied account manufacture reports", () => { + const auth = authority(); + const snapshot = createSnapshot({ + authority: auth, + admin: reduceAdminState( + [{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: TRUSTED }] }], + auth, + ), + trust: { contacts: new Set([TRUSTED]) }, + reports: new Map([[`user:${CREATOR}`, [{ reporter: TRUSTED, category: "spam", createdAt: NOW }]]]), + }); + const decision = evaluateTarget({ type: "user", pubkey: CREATOR }, snapshot, { + surface: "feed", + policyProfile: "feed", + policy: POLICY, + now: NOW, + }); + expect(decision.evidence?.trustedReportTotal).toBe(0); + }); + + it("does not count a report the reporter never made against this target", () => { + const reports = decodeReport( + event({ kind: 1984, pubkey: TRUSTED, tags: [["e", "1b".repeat(32)]] }), + ); + expect(reports).toEqual([]); + }); +}); + +describe("12. configurable mute-list expiry", () => { + it("ignores mutes outside the configured window", () => { + const snapshot = createSnapshot({ + authority: authority(), + admin: reduceAdminState([], authority()), + trust: { contacts: new Set([TRUSTED]) }, + trustedMutes: new Map([[`user:${CREATOR}`, [{ muter: TRUSTED, updatedAt: NOW - 61 * DAY }]]]), + }); + const decision = evaluateTarget({ type: "user", pubkey: CREATOR }, snapshot, { + surface: "feed", + policyProfile: "feed", + policy: POLICY, + now: NOW, + }); + expect(decision.evidence?.trustedMuteTotal).toBe(0); + expect(decision.visibility.effect).toBe("allow"); + }); +}); + +describe("13. namespace caches correctly", () => { + it("separates state by root authority", () => { + const runtime = runtimeWithSigner(MODERATOR); + const before = runtime.storageKeyFor("admin"); + runtime.admin.setRoles({ root: STRANGER, actors: {} }); + expect(runtime.storageKeyFor("admin")).not.toBe(before); + }); + + it("separates viewer-scoped state by viewer", () => { + const runtime = runtimeWithSigner(MODERATOR); + runtime.setViewer(CREATOR); + const first = runtime.storageKeyFor("overrides", true); + runtime.setViewer(STRANGER); + expect(runtime.storageKeyFor("overrides", true)).not.toBe(first); + }); +}); + +describe("14. prevent viewer-state leakage", () => { + it("clears blocks, mutes, and overrides when the viewer changes", () => { + const runtime = runtimeWithSigner(MODERATOR); + runtime.setViewer(CREATOR); + runtime.trust.setBlocks([STRANGER]); + runtime.trust.setMutes([STRANGER]); + runtime.overrides.set(`user:${STRANGER}`, { visibility: "allow" }); + + runtime.setViewer(TRUSTED); + + expect(runtime.trust.blocks.size).toBe(0); + expect(runtime.trust.mutes.size).toBe(0); + expect(runtime.overrides.toMap().size).toBe(0); + }); + + it("drops cached decisions on a viewer switch", () => { + const runtime = runtimeWithSigner(MODERATOR); + runtime.setViewer(CREATOR); + runtime.evaluate({ type: "user", pubkey: STRANGER }, { profile: "feed" }); + expect(runtime.decisionCache.size).toBe(1); + + runtime.setViewer(TRUSTED); + expect(runtime.decisionCache.size).toBe(0); + }); +}); + +describe("17. treat relays as transport rather than authority", () => { + it("does not accept a contribution merely because a relay served it", () => { + const runtime = runtimeWithSigner(MODERATOR); + runtime.ingestEvent( + event({ + pubkey: STRANGER, + tags: [["d", canonicalIdentifier("sec", "user-deny")], ["p", CREATOR]], + }), + ); + expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(false); + }); +}); + +describe("18-19. transient relay failure and diagnostics", () => { + it("keeps the last known good state when a load fails", async () => { + const runtime = runtimeWithSigner(MODERATOR); + runtime.admin.upsertContribution({ + actor: MODERATOR, + kind: "user-deny", + targets: [{ type: "user", pubkey: CREATOR }], + createdAt: NOW, + }); + + runtime.transport.list = async () => { + throw new Error("relay unreachable"); + }; + await expect(runtime.loadAdministrativeState()).rejects.toThrow(/unreachable/); + + expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true); + }); + + it("reports state fingerprints for staleness diagnosis", () => { + const runtime = runtimeWithSigner(MODERATOR); + const description = runtime.describe(); + expect(description.adminFingerprint).toMatch(/^[0-9a-f]{16}$/); + expect(description.trustFingerprint).toMatch(/^[0-9a-f]{16}$/); + expect(description.rootFingerprint).toMatch(/^[0-9a-f]{16}$/); + }); +}); + +describe("20. fail closed when authority cannot be confirmed", () => { + it("refuses an administrative command with no signer", async () => { + const runtime = createGovernanceRuntime({ + applicationId: "sec", + namespace: "sec", + transport: createMemoryTransport(), + policy: POLICY, + now: () => NOW, + }); + expect((await createCommands(runtime).denyUser(CREATOR)).code).toBe(ERROR_CODES.NO_SIGNER); + }); + + it("refuses every administrative command when the roster is empty", async () => { + const runtime = createGovernanceRuntime({ + applicationId: "sec", + namespace: "sec", + transport: createMemoryTransport(), + signer: { + async getPublicKey() { + return MODERATOR; + }, + async signEvent(template) { + return /** @type {any} */ ({ + ...template, + id: "ff".repeat(32), + pubkey: MODERATOR, + sig: "00".repeat(64), + }); + }, + }, + policy: POLICY, + now: () => NOW, + }); + + const commands = createCommands(runtime); + expect((await commands.denyUser(CREATOR)).code).toBe(ERROR_CODES.NOT_AUTHORIZED); + expect((await commands.setRoles({ actors: {} })).code).toBe(ERROR_CODES.NOT_AUTHORIZED); + expect((await commands.setPolicy(POLICY)).code).toBe(ERROR_CODES.NOT_AUTHORIZED); + }); +}); + +describe("21. policy-configured fail-open for public viewing", () => { + it("permits a surface to disable governance explicitly and say so", () => { + const openPolicy = createPolicyDefinition({ + id: "open", + version: "1.0.0", + profiles: { public: { name: "public", disabled: true } }, + }); + const auth = authority(); + const snapshot = createSnapshot({ + authority: auth, + admin: reduceAdminState( + [{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }], + auth, + ), + }); + + const decision = evaluateTarget({ type: "user", pubkey: CREATOR }, snapshot, { + surface: "public", + policyProfile: "public", + policy: openPolicy, + now: NOW, + }); + + expect(decision.visibility.effect).toBe("allow"); + expect(decision.reasons.map((reason) => reason.id)).toEqual(["policy-disabled"]); + }); + + it("does not fail open by default", () => { + const auth = authority(); + const snapshot = createSnapshot({ + authority: auth, + admin: reduceAdminState( + [{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }], + auth, + ), + }); + const decision = evaluateTarget({ type: "user", pubkey: CREATOR }, snapshot, { + surface: "feed", + policyProfile: "feed", + policy: POLICY, + now: NOW, + }); + expect(decision.visibility.effect).toBe("hide"); + }); +}); + +describe("viewer overrides cannot bypass transactional safety", () => { + it("softens visibility without touching the transaction verdict", () => { + const commercePolicy = createPolicyDefinition({ + id: "commerce", + version: "1.0.0", + profiles: { + checkout: { + name: "checkout", + administrativeDeny: { visibility: "hide", transaction: "deny" }, + allowViewerOverride: true, + reports: {}, + mutes: {}, + }, + }, + }); + + const auth = authority(); + const snapshot = createSnapshot({ + authority: auth, + admin: reduceAdminState( + [{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] }], + auth, + ), + }); + const viewer = createViewerState({ + overrides: new Map([[`user:${CREATOR}`, { visibility: "allow" }]]), + }); + + const decision = evaluateTarget( + { type: "user", pubkey: CREATOR }, + snapshot, + { surface: "checkout", policyProfile: "checkout", policy: commercePolicy, now: NOW }, + viewer, + ); + + expect(decision.visibility.effect).toBe("allow"); + expect(decision.transaction?.effect).toBe("deny"); + }); +});