mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 16:41:03 +00:00
Renames the project and makes it as easy to adopt as BitLogin, which was the stated bar and which it did not previously meet. Rename (ADR 0005): @nostr-governance/* -> @bitgate/*, governance-* elements -> bitgate-*, createGovernanceRuntime -> createBitGate. "Governance" reads as DAO voting to this audience and named a category rather than a product; BitGate names the benefit and joins the bit- family that BitVid and BitRoad compose with. Domain types keep their names — the product is BitGate, the subject matter is still governance. The migration plan is left as written; this ADR records the change rather than rewriting history. Drop-in distribution (ADR 0006): adopting BitGate meant supplying a transport, authoring a policy, and wiring every element in JavaScript. Now: - createRelayTransport(urls) — WebSocket transport with reconnection, backoff, subscription replay, and cross-relay dedupe - policy presets (social, commerce, admin-only) addressable by name; the engine still carries no thresholds of its own - <bitgate-provider> — configuration in markup, with descendants finding the runtime through a bubbling context event rather than DOM walking, which breaks across shadow boundaries - npm run build:widget — a self-contained dist/bitgate.js That last one came from running it in a browser rather than from a test. Bare specifiers resolve in Node and vitest but not in a browser, so the widget source worked everywhere except a plain static page — the one place it exists to serve. The bundle is not committed: consumers build from reviewed source. Also found by running it: the provider's ready event fires during element upgrade, before a page that defines elements after parsing can attach a listener, so it now exposes `ready` as a promise too. Four bugs fixed along the way, three found by new tests and one by the browser: admin state stayed silent when a roster change altered capabilities but no denials; the relay transport sent every subscription twice on connect; the commerce preset computed a meaningless visibility verdict at checkout and could hide a listing from its own seller; <bitgate-status> threw on an unusable decision. 759 tests; lint, typecheck, and both builds clean.
451 lines
14 KiB
JavaScript
451 lines
14 KiB
JavaScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createPolicyDefinition } from "@bitgate/core";
|
|
import {
|
|
CANONICAL_KIND,
|
|
MUTE_LIST_KIND,
|
|
REPORT_KIND,
|
|
canonicalIdentifier,
|
|
encodeContribution,
|
|
encodeRoles,
|
|
} from "@bitgate/nostr";
|
|
|
|
import { GovernanceRuntime, chunk, createBitGate } from "../src/runtime.js";
|
|
import { createMemoryTransport, storageKey } from "../src/interfaces.js";
|
|
|
|
const ROOT = "a1".repeat(32);
|
|
const MODERATOR = "b2".repeat(32);
|
|
const CREATOR = "d4".repeat(32);
|
|
const VIEWER = "e5".repeat(32);
|
|
const TRUSTED = "01".repeat(32);
|
|
const EVENT_ID = "1b".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" },
|
|
viewerBlock: { visibility: "hide", interaction: "deny" },
|
|
reports: { default: { restrict: 1 } },
|
|
mutes: {},
|
|
},
|
|
},
|
|
});
|
|
|
|
/**
|
|
* @param {Partial<import('@bitgate/nostr').NostrEvent>} parts
|
|
* @returns {import('@bitgate/nostr').NostrEvent}
|
|
*/
|
|
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"),
|
|
});
|
|
|
|
/** @param {import('@bitgate/core').GovernanceTarget} [target] */
|
|
const denyEvent = (target = { type: "user", pubkey: CREATOR }) =>
|
|
event({
|
|
id: "bb".repeat(32),
|
|
...encodeContribution({ actor: MODERATOR, kind: "user-deny", targets: [target] }, "app"),
|
|
});
|
|
|
|
function makeRuntime(events = []) {
|
|
const transport = createMemoryTransport(events);
|
|
const runtime = createBitGate({
|
|
applicationId: "test-app",
|
|
namespace: "app",
|
|
transport,
|
|
policy: POLICY,
|
|
now: () => NOW,
|
|
});
|
|
return { runtime, transport };
|
|
}
|
|
|
|
describe("chunk", () => {
|
|
it("splits into bounded pieces", () => {
|
|
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
|
});
|
|
|
|
it("returns one chunk for a non-positive size", () => {
|
|
expect(chunk([1, 2], 0)).toEqual([[1, 2]]);
|
|
});
|
|
|
|
it("handles an empty list", () => {
|
|
expect(chunk([], 10)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("storageKey", () => {
|
|
it("namespaces by deployment and schema", () => {
|
|
expect(
|
|
storageKey({
|
|
applicationId: "bitroad",
|
|
namespace: "bitroad",
|
|
rootFingerprint: "abcd",
|
|
schemaVersion: "v1",
|
|
scope: "admin",
|
|
}),
|
|
).toBe("bitgate:bitroad:bitroad:abcd:v1:admin");
|
|
});
|
|
|
|
it("appends the viewer for viewer-scoped state", () => {
|
|
const key = storageKey({
|
|
applicationId: "a",
|
|
namespace: "n",
|
|
rootFingerprint: "f",
|
|
schemaVersion: "v1",
|
|
scope: "overrides",
|
|
viewerPubkey: VIEWER,
|
|
});
|
|
expect(key.endsWith(VIEWER)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("construction", () => {
|
|
it("requires an application id and namespace", () => {
|
|
expect(() =>
|
|
new GovernanceRuntime(/** @type {any} */ ({ transport: createMemoryTransport() })),
|
|
).toThrow(/applicationId and namespace/);
|
|
});
|
|
|
|
it("requires a transport", () => {
|
|
expect(() =>
|
|
new GovernanceRuntime(/** @type {any} */ ({ applicationId: "a", namespace: "n" })),
|
|
).toThrow(/transport adapter/);
|
|
});
|
|
|
|
it("changes its storage key when the root authority changes", () => {
|
|
const { runtime } = makeRuntime();
|
|
const before = runtime.storageKeyFor("admin");
|
|
runtime.admin.setRoles({ root: ROOT, actors: {} });
|
|
expect(runtime.storageKeyFor("admin")).not.toBe(before);
|
|
});
|
|
});
|
|
|
|
describe("ingestEvent", () => {
|
|
it("routes a roles document", () => {
|
|
const { runtime } = makeRuntime();
|
|
expect(runtime.ingestEvent(rolesEvent())).toBe(true);
|
|
expect(runtime.admin.authority.root).toBe(ROOT);
|
|
});
|
|
|
|
it("routes a contribution", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.ingestEvent(rolesEvent());
|
|
expect(runtime.ingestEvent(denyEvent())).toBe(true);
|
|
expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true);
|
|
});
|
|
|
|
it("routes a report", () => {
|
|
const { runtime } = makeRuntime();
|
|
const report = event({
|
|
kind: REPORT_KIND,
|
|
pubkey: TRUSTED,
|
|
tags: [["e", EVENT_ID, "spam"]],
|
|
});
|
|
expect(runtime.ingestEvent(report)).toBe(true);
|
|
expect(runtime.reports.recordsFor(`event:${EVENT_ID}`)).toHaveLength(1);
|
|
});
|
|
|
|
it("routes a mute list", () => {
|
|
const { runtime } = makeRuntime();
|
|
const list = event({ kind: MUTE_LIST_KIND, pubkey: TRUSTED, tags: [["p", CREATOR]] });
|
|
expect(runtime.ingestEvent(list)).toBe(true);
|
|
expect(runtime.mutes.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects a policy document from anyone but the root", () => {
|
|
const { runtime } = makeRuntime();
|
|
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", version: "1", profiles: { f: { name: "f" } } }),
|
|
}),
|
|
);
|
|
|
|
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", () => {
|
|
const { runtime } = makeRuntime();
|
|
expect(runtime.ingestEvent(event({ kind: 1 }))).toBe(false);
|
|
expect(runtime.diagnostics.unknownEvents).toBe(1);
|
|
});
|
|
|
|
it("ignores malformed input", () => {
|
|
const { runtime } = makeRuntime();
|
|
expect(runtime.ingestEvent(/** @type {any} */ (null))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("loadAdministrativeState", () => {
|
|
it("ingests only the newest document per coordinate", async () => {
|
|
const older = event({
|
|
id: "11".repeat(32),
|
|
created_at: NOW - 100,
|
|
...encodeContribution(
|
|
{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] },
|
|
"app",
|
|
),
|
|
});
|
|
const newer = event({
|
|
id: "22".repeat(32),
|
|
created_at: NOW,
|
|
...encodeContribution({ actor: MODERATOR, kind: "user-deny", targets: [] }, "app"),
|
|
});
|
|
|
|
const { runtime } = makeRuntime([rolesEvent(), older, newer]);
|
|
await runtime.loadAdministrativeState();
|
|
expect(runtime.admin.state.userDeny.size).toBe(0);
|
|
});
|
|
|
|
it("can restrict the query to known authors", async () => {
|
|
const transport = createMemoryTransport([rolesEvent(), denyEvent()]);
|
|
const listSpy = vi.spyOn(transport, "list");
|
|
const runtime = createBitGate({
|
|
applicationId: "a",
|
|
namespace: "app",
|
|
transport,
|
|
policy: POLICY,
|
|
now: () => NOW,
|
|
});
|
|
|
|
await runtime.loadAdministrativeState({ authors: [MODERATOR] });
|
|
expect(listSpy.mock.calls[0][0][0]).toMatchObject({ authors: [MODERATOR] });
|
|
});
|
|
});
|
|
|
|
describe("active targets and subscriptions", () => {
|
|
it("chunks report subscriptions rather than opening one per target", () => {
|
|
const transport = createMemoryTransport();
|
|
const subscribeSpy = vi.spyOn(transport, "subscribe");
|
|
const runtime = createBitGate({
|
|
applicationId: "a",
|
|
namespace: "app",
|
|
transport,
|
|
policy: POLICY,
|
|
now: () => NOW,
|
|
chunkSize: 2,
|
|
});
|
|
|
|
runtime.setActiveTargets([
|
|
{ type: "event", id: "01".repeat(32) },
|
|
{ type: "event", id: "02".repeat(32) },
|
|
{ type: "event", id: "03".repeat(32) },
|
|
]);
|
|
runtime.subscribeToActiveTargetReports();
|
|
|
|
expect(subscribeSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("separates event and user filters", () => {
|
|
const transport = createMemoryTransport();
|
|
const subscribeSpy = vi.spyOn(transport, "subscribe");
|
|
const runtime = createBitGate({
|
|
applicationId: "a",
|
|
namespace: "app",
|
|
transport,
|
|
policy: POLICY,
|
|
now: () => NOW,
|
|
});
|
|
|
|
runtime.setActiveTargets([
|
|
{ type: "event", id: EVENT_ID },
|
|
{ type: "user", pubkey: CREATOR },
|
|
]);
|
|
runtime.subscribeToActiveTargetReports();
|
|
|
|
const filters = subscribeSpy.mock.calls.map((call) => call[0][0]);
|
|
expect(filters.some((filter) => "#e" in filter)).toBe(true);
|
|
expect(filters.some((filter) => "#p" in filter)).toBe(true);
|
|
});
|
|
|
|
it("ingests reports delivered to a live subscription", () => {
|
|
const { runtime, transport } = makeRuntime();
|
|
runtime.setActiveTargets([{ type: "event", id: EVENT_ID }]);
|
|
runtime.subscribeToActiveTargetReports();
|
|
|
|
transport.deliver(
|
|
event({ kind: REPORT_KIND, pubkey: TRUSTED, tags: [["e", EVENT_ID, "spam"]] }),
|
|
);
|
|
expect(runtime.reports.recordsFor(`event:${EVENT_ID}`)).toHaveLength(1);
|
|
});
|
|
|
|
it("skips invalid targets", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.setActiveTargets([
|
|
{ type: "user", pubkey: CREATOR },
|
|
/** @type {any} */ ({ type: "user", pubkey: "bad" }),
|
|
]);
|
|
expect(runtime.activeTargets.size).toBe(1);
|
|
});
|
|
|
|
it("closes subscriptions on close", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.setActiveTargets([{ type: "event", id: EVENT_ID }]);
|
|
const handle = runtime.subscribeToActiveTargetReports();
|
|
expect(runtime.subscriptions.size).toBe(1);
|
|
handle.close();
|
|
expect(runtime.subscriptions.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("viewer lifecycle", () => {
|
|
it("clears viewer-specific state on switch", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.setViewer(VIEWER);
|
|
runtime.trust.setBlocks([CREATOR]);
|
|
runtime.overrides.set(`user:${CREATOR}`, { visibility: "allow" });
|
|
|
|
runtime.setViewer(TRUSTED);
|
|
expect(runtime.trust.blocks.size).toBe(0);
|
|
expect(runtime.overrides.toMap().size).toBe(0);
|
|
});
|
|
|
|
it("keeps operator seeds across a viewer switch", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.trust.setSeeds([TRUSTED]);
|
|
runtime.setViewer(VIEWER);
|
|
expect(runtime.trust.seeds.has(TRUSTED)).toBe(true);
|
|
});
|
|
|
|
it("does not churn when the viewer is unchanged", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.setViewer(VIEWER);
|
|
const handler = vi.fn();
|
|
runtime.on("viewer", handler);
|
|
runtime.setViewer(VIEWER);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("evaluation", () => {
|
|
it("evaluates against live store state", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.ingestEvent(rolesEvent());
|
|
runtime.ingestEvent(denyEvent());
|
|
|
|
const decision = runtime.evaluate({ type: "user", pubkey: CREATOR }, { profile: "feed" });
|
|
expect(decision.visibility.effect).toBe("hide");
|
|
});
|
|
|
|
it("reflects a report ingested afterwards", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.trust.setContacts([TRUSTED]);
|
|
runtime.ingestEvent(
|
|
event({ kind: REPORT_KIND, pubkey: TRUSTED, tags: [["e", EVENT_ID, "spam"]] }),
|
|
);
|
|
|
|
const decision = runtime.evaluate({ type: "event", id: EVENT_ID }, { profile: "feed" });
|
|
expect(decision.visibility.effect).toBe("restrict");
|
|
});
|
|
|
|
it("applies viewer blocks", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.trust.setBlocks([CREATOR]);
|
|
expect(
|
|
runtime.evaluate({ type: "user", pubkey: CREATOR }, { profile: "feed" }).visibility.effect,
|
|
).toBe("hide");
|
|
});
|
|
|
|
it("evaluates many targets consistently with one", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.ingestEvent(rolesEvent());
|
|
runtime.ingestEvent(denyEvent());
|
|
|
|
/** @type {import('@bitgate/core').GovernanceTarget} */
|
|
const target = { type: "user", pubkey: CREATOR };
|
|
const many = runtime.evaluateMany([target], { profile: "feed" });
|
|
expect(many.get(`user:${CREATOR}`)).toEqual(runtime.evaluate(target, { profile: "feed" }));
|
|
});
|
|
});
|
|
|
|
describe("diagnostics and teardown", () => {
|
|
it("describes runtime state", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.ingestEvent(rolesEvent());
|
|
const description = runtime.describe();
|
|
expect(description.applicationId).toBe("test-app");
|
|
expect(description.policyId).toBe("app");
|
|
expect(description.eventsIngested).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("closes subscriptions and detaches listeners on destroy", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.setActiveTargets([{ type: "event", id: EVENT_ID }]);
|
|
runtime.subscribeToActiveTargetReports();
|
|
|
|
const handler = vi.fn();
|
|
runtime.on("change", handler);
|
|
runtime.destroy();
|
|
|
|
expect(runtime.subscriptions.size).toBe(0);
|
|
runtime.admin.setRoles({ root: ROOT, actors: {} });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("is safe to destroy twice", () => {
|
|
const { runtime } = makeRuntime();
|
|
runtime.destroy();
|
|
expect(() => runtime.destroy()).not.toThrow();
|
|
});
|
|
});
|