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.
546 lines
17 KiB
JavaScript
546 lines
17 KiB
JavaScript
// 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 "@bitgate/core";
|
|
import {
|
|
MAX_POLICY_BYTES,
|
|
canonicalIdentifier,
|
|
decodePolicy,
|
|
decodeReport,
|
|
verifyEvents,
|
|
} from "@bitgate/nostr";
|
|
import {
|
|
ERROR_CODES,
|
|
createCommands,
|
|
createBitGate,
|
|
createMemoryTransport,
|
|
} from "@bitgate/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 = createBitGate({
|
|
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('@bitgate/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 = createBitGate({
|
|
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 = createBitGate({
|
|
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");
|
|
});
|
|
});
|