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