mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
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:
1 parent
2beb7a7bb5
commit
c4477ae84b
8 files changed
+1085
-29
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user