perf: memoize snapshot and fingerprint; add performance and security fixtures

The performance fixture the plan asks for found a real defect on its first run:
evaluation was quadratic in state size. Both the snapshot and its fingerprint
were rebuilt for every target, so each of 5,000 evaluations walked every report
and every mute list. A 5,000-target pass took ~47 seconds.

- the runtime memoizes the materialized snapshot and its fingerprint until a
  store reports a change
- evaluateMany computes the fingerprint once and shares it across the batch
- decisions are cached per (profile, target); 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, since the cache hands the same object to
  every caller

Same pass now takes ~85ms cold, ~6ms warm.

Adds tests/performance (5,000 targets, 500 authors, 100 muters per author) and
tests/security (one case per enumerated requirement testable at this layer).

605 tests; lint, typecheck, and the declaration build pass from a cold checkout.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-09 18:07:23 -04:00
1 parent 2beb7a7bb5
commit c4477ae84b
8 files changed
+1085 -29

No files matched your search

+15
View File
@@ -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
+30 -1
View File
@@ -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
+23 -2
View File
@@ -268,7 +268,13 @@ export function evaluateTarget(target, snapshot, context = { surface: "default"
decision.policyProfile = profile.name;
decision.policyVersion = policy.version;
decision.evaluatedAt = now;
decision.snapshotFingerprint = snapshotFingerprint({
// 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,
@@ -550,6 +556,21 @@ export function evaluateMany(targets, snapshot, context, viewerState) {
/** @type {Map<string, GovernanceDecision>} */
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;
+1
View File
@@ -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
*/
+155 -22
View File
@@ -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<string, GovernanceDecision>} */
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<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(),
);
/** @type {Map<string, GovernanceDecision>} */
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();
+19 -4
View File
@@ -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 });
}
}
+297
View File
@@ -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();
}
});
});
+545
View File
@@ -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");
});
});