feat: rename to BitGate and close the drop-in gap

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.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-09 19:58:45 -04:00
1 parent 7f70a64d61
commit adeba151b8
63 files changed
+2774 -401

No files matched your search

+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@nostr-governance/runtime",
"name": "@bitgate/runtime",
"version": "0.1.0",
"type": "module",
"main": "src/index.js",
@@ -19,7 +19,7 @@
"test": "vitest run"
},
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/nostr": "*"
"@bitgate/core": "*",
"@bitgate/nostr": "*"
}
}
+13 -13
View File
@@ -11,8 +11,8 @@ import {
hasCapability,
isProtectedActor,
isValidTarget,
} from "@nostr-governance/core";
import { encodeContribution, encodeReport, encodeRoles } from "@nostr-governance/nostr";
} from "@bitgate/core";
import { encodeContribution, encodeReport, encodeRoles } from "@bitgate/nostr";
/**
* Stable error codes. Applications match on these rather than on message text.
@@ -61,7 +61,7 @@ function failure(code, message) {
return { ok: false, code, message };
}
/** @type {Record<string, import('@nostr-governance/core').GovernanceCapability>} */
/** @type {Record<string, import('@bitgate/core').GovernanceCapability>} */
const KIND_CAPABILITY = {
"user-allow": "contribute-user-allow",
"user-deny": "contribute-user-deny",
@@ -131,8 +131,8 @@ export class GovernanceCommands {
/**
* Publish a contribution list.
*
* @param {import('@nostr-governance/core').Contribution["kind"]} kind
* @param {import('@nostr-governance/core').GovernanceTarget[]} targets
* @param {import('@bitgate/core').Contribution["kind"]} kind
* @param {import('@bitgate/core').GovernanceTarget[]} targets
* @param {Object} [options]
* @param {string} [options.source] - Community list marker
* @returns {Promise<CommandResult>}
@@ -259,7 +259,7 @@ export class GovernanceCommands {
/**
* Publish a policy document. Requires `manage-policy`.
* @param {import('@nostr-governance/core').PolicyDefinition} policy
* @param {import('@bitgate/core').PolicyDefinition} policy
* @returns {Promise<CommandResult>}
*/
async setPolicy(policy) {
@@ -281,9 +281,9 @@ export class GovernanceCommands {
kind: 30078,
content: JSON.stringify(policy),
tags: [
["d", `${this.runtime.namespace}:governance:policy:v1`],
["d", `${this.runtime.namespace}:bitgate:policy:v1`],
["v", "1"],
["client", "nostr-governance"],
["client", "bitgate"],
["scope", "policy"],
],
});
@@ -307,9 +307,9 @@ export class GovernanceCommands {
}
const tags = [
["d", `${this.runtime.namespace}:governance:community-sources:v1`],
["d", `${this.runtime.namespace}:bitgate:community-sources:v1`],
["v", "1"],
["client", "nostr-governance"],
["client", "bitgate"],
["scope", "community-sources"],
];
for (const source of sources ?? []) {
@@ -325,7 +325,7 @@ export class GovernanceCommands {
* Reporting needs no capability: any account may report, and whether the
* report counts is decided by the viewer's trust graph at evaluation time.
*
* @param {import('@nostr-governance/core').GovernanceTarget} target
* @param {import('@bitgate/core').GovernanceTarget} target
* @param {string} category
* @param {string} [content]
* @returns {Promise<CommandResult>}
@@ -349,7 +349,7 @@ export class GovernanceCommands {
* Set a viewer override. Viewer-local, so no capability is required and
* nothing is published.
*
* @param {import('@nostr-governance/core').GovernanceTarget} target
* @param {import('@bitgate/core').GovernanceTarget} target
* @param {string} visibility
* @param {Object} [options]
* @param {string} [options.reason]
@@ -366,7 +366,7 @@ export class GovernanceCommands {
/**
* Clear a viewer override.
* @param {import('@nostr-governance/core').GovernanceTarget} target
* @param {import('@bitgate/core').GovernanceTarget} target
* @returns {CommandResult}
*/
clearOverride(target) {
+4 -2
View File
@@ -1,4 +1,4 @@
// @nostr-governance/runtime
// @bitgate/runtime
//
// Stateful orchestration: stores, transport wiring, viewer lifecycle,
// evaluation, and commands. Transport, signer, and storage are injected, so the
@@ -6,6 +6,8 @@
export { Emitter } from "./emitter.js";
export { createRelayTransport } from "./relayTransport.js";
export {
storageKey,
createMemoryStorage,
@@ -26,7 +28,7 @@ export {
DEFAULT_CHUNK_SIZE,
GovernanceRuntime,
chunk,
createGovernanceRuntime,
createBitGate,
} from "./runtime.js";
export { ERROR_CODES, GovernanceCommands, createCommands } from "./commands.js";
+2 -2
View File
@@ -5,7 +5,7 @@
// and so an application can boot a working runtime before wiring real relays.
/**
* @typedef {import('@nostr-governance/nostr').NostrEvent} NostrEvent
* @typedef {import('@bitgate/nostr').NostrEvent} NostrEvent
*/
/**
@@ -73,7 +73,7 @@ export function storageKey({
viewerPubkey,
}) {
const segments = [
"nostr-governance",
"bitgate",
applicationId,
namespace,
rootFingerprint,
+408
View File
@@ -0,0 +1,408 @@
// WebSocket relay transport.
//
// The runtime depends on no particular relay pool, and an application with one
// should keep using it. This exists so that nobody has to write a transport
// just to get started: it is the difference between "supply an adapter" and
// "pass some relay URLs".
//
// Deliberately small. It speaks enough of the Nostr protocol to load governance
// state, subscribe to reports and mute lists, and publish administrative
// events. It is not a general-purpose relay client and does not try to be.
/**
* @typedef {import('@bitgate/nostr').NostrEvent} NostrEvent
* @typedef {import('./interfaces.js').GovernanceTransport} GovernanceTransport
* @typedef {import('./interfaces.js').SubscriptionHandlers} SubscriptionHandlers
*/
/** Milliseconds to wait for a relay's stored events before resolving `list`. */
const DEFAULT_LIST_TIMEOUT = 8_000;
/** Milliseconds between reconnection attempts, before backoff. */
const DEFAULT_RECONNECT_DELAY = 1_000;
/** Ceiling for reconnect backoff. */
const MAX_RECONNECT_DELAY = 30_000;
let subscriptionCounter = 0;
/**
* Create a transport backed by plain WebSockets.
*
* @param {string[]} urls - Relay URLs (`wss://…`)
* @param {Object} [options]
* @param {typeof WebSocket} [options.WebSocketImpl] - Injected for tests or Node
* @param {number} [options.listTimeout] - Milliseconds to wait for stored events
* @param {number} [options.reconnectDelay] - Initial reconnect delay
* @param {(message: string, detail?: unknown) => void} [options.onDiagnostic]
* @returns {GovernanceTransport & { close: () => void, relays: string[] }}
*/
export function createRelayTransport(urls, options = {}) {
const relays = (Array.isArray(urls) ? urls : [])
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean);
if (relays.length === 0) {
throw new Error("createRelayTransport requires at least one relay URL");
}
const SocketImpl = options.WebSocketImpl ?? globalThis.WebSocket;
if (typeof SocketImpl !== "function") {
throw new Error("No WebSocket implementation available; pass options.WebSocketImpl");
}
const listTimeout = options.listTimeout ?? DEFAULT_LIST_TIMEOUT;
const baseReconnectDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
const diagnostic = options.onDiagnostic ?? (() => {});
/** @type {Map<string, { socket: any, ready: boolean, queue: string[], attempts: number, timer: any }>} */
const connections = new Map();
// OK frames answer a publish, not a subscription, so they are dispatched
// through their own listener set. Keeping them here rather than rebinding a
// socket's onmessage avoids stacking wrappers across repeated publishes.
/** @type {Set<(url: string, frame: any[]) => void>} */
const okListeners = new Set();
/** @type {Map<string, { filters: object[], handlers: SubscriptionHandlers, relays: Set<string> }>} */
const subscriptions = new Map();
let closed = false;
/**
* @param {string} url
* @param {string} payload
*/
function send(url, payload) {
const connection = connections.get(url);
if (!connection) {
return;
}
if (connection.ready) {
try {
connection.socket.send(payload);
} catch (error) {
diagnostic("relay send failed", { url, error: String(error) });
}
return;
}
// REQ frames are never queued: every live subscription is re-issued from
// the subscription map when the socket opens, so queueing one too would
// send it twice. Everything else (EVENT, CLOSE) is queued rather than
// dropped, since nothing else would re-send it.
if (!payload.startsWith('["REQ"')) {
connection.queue.push(payload);
}
}
/** @param {string} url */
function connect(url) {
if (closed) {
return;
}
/** @type {any} */
const existing = connections.get(url) ?? { socket: null, ready: false, queue: [], attempts: 0, timer: null };
connections.set(url, existing);
let socket;
try {
socket = new SocketImpl(url);
} catch (error) {
diagnostic("relay connect threw", { url, error: String(error) });
scheduleReconnect(url);
return;
}
existing.socket = socket;
existing.ready = false;
socket.onopen = () => {
existing.ready = true;
existing.attempts = 0;
// Re-open every live subscription: a reconnect that dropped them would
// leave the UI silently stale rather than visibly broken.
for (const [id, subscription] of subscriptions) {
subscription.relays.add(url);
send(url, JSON.stringify(["REQ", id, ...subscription.filters]));
}
const queued = existing.queue.splice(0);
for (const payload of queued) {
send(url, payload);
}
};
socket.onmessage = (message) => {
let frame;
try {
frame = JSON.parse(typeof message?.data === "string" ? message.data : "");
} catch {
return;
}
if (!Array.isArray(frame)) {
return;
}
const [verb, id, payload] = frame;
if (verb === "OK") {
for (const listener of Array.from(okListeners)) {
listener(url, frame);
}
return;
}
if (verb === "NOTICE") {
diagnostic("relay notice", { url, message: id });
return;
}
const subscription = subscriptions.get(id);
if (!subscription) {
return;
}
if (verb === "EVENT" && payload) {
subscription.handlers.onEvent?.(payload);
} else if (verb === "EOSE") {
subscription.handlers.onEose?.();
} else if (verb === "CLOSED") {
diagnostic("relay closed subscription", { url, id, reason: payload });
}
};
socket.onerror = (error) => {
diagnostic("relay socket error", { url, error: String(error?.message ?? error) });
};
socket.onclose = () => {
existing.ready = false;
for (const subscription of subscriptions.values()) {
subscription.relays.delete(url);
}
scheduleReconnect(url);
};
}
/** @param {string} url */
function scheduleReconnect(url) {
if (closed) {
return;
}
const connection = connections.get(url);
if (!connection || connection.timer) {
return;
}
connection.attempts += 1;
const delay = Math.min(baseReconnectDelay * 2 ** (connection.attempts - 1), MAX_RECONNECT_DELAY);
connection.timer = setTimeout(() => {
connection.timer = null;
connect(url);
}, delay);
// Never let a reconnect timer hold a Node process open.
connection.timer?.unref?.();
}
for (const url of relays) {
connect(url);
}
return {
relays,
/**
* Collect stored events matching the filters.
*
* Resolves on EOSE from every relay, or when the timeout elapses —
* whichever comes first. A single slow relay must not stall a page load,
* and results already received are returned rather than discarded.
*
* @param {object[]} filters
* @param {{ timeout?: number }} [listOptions]
* @returns {Promise<NostrEvent[]>}
*/
async list(filters, listOptions = {}) {
const id = `bg-list-${(subscriptionCounter += 1)}`;
/** @type {Map<string, NostrEvent>} */
const collected = new Map();
return new Promise((resolve) => {
let settled = false;
let pendingEose = relays.length;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
subscriptions.delete(id);
for (const url of relays) {
send(url, JSON.stringify(["CLOSE", id]));
}
resolve([...collected.values()]);
};
const timer = setTimeout(finish, listOptions.timeout ?? listTimeout);
timer?.unref?.();
subscriptions.set(id, {
filters,
relays: new Set(),
handlers: {
onEvent: (event) => {
// Relays overlap heavily; dedupe by id so a caller does not have to.
if (event?.id && !collected.has(event.id)) {
collected.set(event.id, event);
}
},
onEose: () => {
pendingEose -= 1;
if (pendingEose <= 0) {
finish();
}
},
},
});
for (const url of relays) {
send(url, JSON.stringify(["REQ", id, ...filters]));
}
});
},
/**
* Open a live subscription across every relay.
* @param {object[]} filters
* @param {SubscriptionHandlers} handlers
* @returns {{ close: () => void }}
*/
subscribe(filters, handlers) {
const id = `bg-sub-${(subscriptionCounter += 1)}`;
/** @type {Set<string>} */
const seen = new Set();
subscriptions.set(id, {
filters,
relays: new Set(),
handlers: {
onEvent: (event) => {
if (!event?.id || seen.has(event.id)) {
return;
}
seen.add(event.id);
handlers.onEvent?.(event);
},
onEose: handlers.onEose,
onError: handlers.onError,
},
});
const payload = JSON.stringify(["REQ", id, ...filters]);
for (const url of relays) {
send(url, payload);
}
return {
close() {
subscriptions.delete(id);
const closePayload = JSON.stringify(["CLOSE", id]);
for (const url of relays) {
send(url, closePayload);
}
},
};
},
/**
* Publish an event to every relay.
*
* Resolves once every relay has answered or the timeout elapses. Partial
* acceptance is success: one relay accepting means the event exists on the
* network, and the failures stay on the result for diagnostics.
*
* @param {NostrEvent} event
* @param {{ timeout?: number }} [publishOptions]
* @returns {Promise<import('./interfaces.js').PublishResult>}
*/
async publish(event, publishOptions = {}) {
/** @type {string[]} */
const accepted = [];
/** @type {Array<{ relay: string, error: string }>} */
const failed = [];
return new Promise((resolve) => {
let settled = false;
const answered = new Set();
/** @param {string} url @param {any[]} frame */
const listener = (url, frame) => {
if (frame[1] !== event.id || answered.has(url)) {
return;
}
answered.add(url);
if (frame[2] === true) {
accepted.push(url);
} else {
failed.push({ relay: url, error: String(frame[3] ?? "rejected") });
}
if (answered.size >= relays.length) {
finish();
}
};
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
okListeners.delete(listener);
// Relays that never answered are reported as timed out rather than
// silently omitted, so diagnostics show the full picture.
for (const url of relays) {
if (!answered.has(url)) {
failed.push({ relay: url, error: "timeout" });
}
}
resolve({ ok: accepted.length > 0, accepted, failed });
};
const timer = setTimeout(finish, publishOptions.timeout ?? listTimeout);
timer?.unref?.();
okListeners.add(listener);
send(relays[0], JSON.stringify(["EVENT", event]));
for (const url of relays.slice(1)) {
send(url, JSON.stringify(["EVENT", event]));
}
});
},
/** Close every socket and stop reconnecting. */
close() {
closed = true;
subscriptions.clear();
for (const connection of connections.values()) {
if (connection.timer) {
clearTimeout(connection.timer);
connection.timer = null;
}
try {
connection.socket?.close?.();
} catch {
// Closing an already-dead socket is not an error worth surfacing.
}
}
connections.clear();
},
};
}
+14 -14
View File
@@ -14,7 +14,7 @@ import {
normalizePolicyDefinition,
normalizePubkey,
serializeAdminState,
} from "@nostr-governance/core";
} from "@bitgate/core";
import {
CANONICAL_KIND,
LEGACY_KIND,
@@ -28,9 +28,9 @@ import {
decodeRoles,
selectReplaceable,
verifyEvents,
} from "@nostr-governance/nostr";
} from "@bitgate/nostr";
import { snapshotFingerprint } from "@nostr-governance/core";
import { snapshotFingerprint } from "@bitgate/core";
import { Emitter } from "./emitter.js";
import { createMemoryStorage, createNullSigner, storageKey } from "./interfaces.js";
@@ -44,15 +44,15 @@ import {
} from "./stores.js";
/**
* @typedef {import('@nostr-governance/nostr').NostrEvent} NostrEvent
* @typedef {import('@bitgate/nostr').NostrEvent} NostrEvent
* @typedef {import('./interfaces.js').GovernanceTransport} GovernanceTransport
* @typedef {import('./interfaces.js').GovernanceSigner} GovernanceSigner
* @typedef {import('./interfaces.js').GovernanceStorage} GovernanceStorage
* @typedef {import('@nostr-governance/core').PolicyDefinition} PolicyDefinition
* @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget
* @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision
* @typedef {import('@nostr-governance/core').GovernanceSnapshot} GovernanceSnapshot
* @typedef {import('@nostr-governance/core').ViewerState} ViewerState
* @typedef {import('@bitgate/core').PolicyDefinition} PolicyDefinition
* @typedef {import('@bitgate/core').GovernanceTarget} GovernanceTarget
* @typedef {import('@bitgate/core').GovernanceDecision} GovernanceDecision
* @typedef {import('@bitgate/core').GovernanceSnapshot} GovernanceSnapshot
* @typedef {import('@bitgate/core').ViewerState} ViewerState
*/
/** How many targets go into one relay filter chunk. */
@@ -109,7 +109,7 @@ 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 {import('@bitgate/nostr').SignatureVerifier} [options.verifySignature]
* @param {string} [options.root] - Root administrator pubkey, from deployment config
*/
constructor({
@@ -859,7 +859,7 @@ export class GovernanceRuntime extends Emitter {
/**
* Whether an actor holds a capability under the current roster.
* @param {string} pubkey
* @param {import('@nostr-governance/core').GovernanceCapability} capability
* @param {import('@bitgate/core').GovernanceCapability} capability
* @returns {boolean}
*/
can(pubkey, capability) {
@@ -869,7 +869,7 @@ export class GovernanceRuntime extends Emitter {
/**
* Every capability an actor holds under the current roster.
* @param {string} pubkey
* @returns {import('@nostr-governance/core').GovernanceCapability[]}
* @returns {import('@bitgate/core').GovernanceCapability[]}
*/
capabilitiesOf(pubkey) {
return getActorCapabilities(pubkey, this.admin.authority);
@@ -877,7 +877,7 @@ export class GovernanceRuntime extends Emitter {
/**
* What the current viewer is allowed to do.
* @returns {import('@nostr-governance/core').GovernanceCapability[]}
* @returns {import('@bitgate/core').GovernanceCapability[]}
*/
viewerCapabilities() {
return this.viewerPubkey ? this.capabilitiesOf(this.viewerPubkey) : [];
@@ -942,6 +942,6 @@ export class GovernanceRuntime extends Emitter {
* @param {ConstructorParameters<typeof GovernanceRuntime>[0]} options
* @returns {GovernanceRuntime}
*/
export function createGovernanceRuntime(options) {
export function createBitGate(options) {
return new GovernanceRuntime(options);
}
+9 -9
View File
@@ -11,17 +11,17 @@ import {
normalizePubkey,
reduceAdminState,
serializeAdminState,
} from "@nostr-governance/core";
import { selectReplaceable, toMuteRecords } from "@nostr-governance/nostr";
} from "@bitgate/core";
import { selectReplaceable, toMuteRecords } from "@bitgate/nostr";
import { Emitter } from "./emitter.js";
/**
* @typedef {import('@nostr-governance/core').PolicyDefinition} PolicyDefinition
* @typedef {import('@nostr-governance/core').Contribution} Contribution
* @typedef {import('@nostr-governance/core').AuthorityState} AuthorityState
* @typedef {import('@nostr-governance/core').AdminState} AdminState
* @typedef {import('@nostr-governance/nostr').NostrEvent} NostrEvent
* @typedef {import('@bitgate/core').PolicyDefinition} PolicyDefinition
* @typedef {import('@bitgate/core').Contribution} Contribution
* @typedef {import('@bitgate/core').AuthorityState} AuthorityState
* @typedef {import('@bitgate/core').AdminState} AdminState
* @typedef {import('@bitgate/nostr').NostrEvent} NostrEvent
*/
/**
@@ -248,7 +248,7 @@ export class ReportStore extends Emitter {
* The target is identified by `targetKey`; a decoded report's own `target`
* field is accepted but not required, since it would be redundant.
*
* @param {{ reporter: string, category: string, createdAt?: number, target?: import('@nostr-governance/core').GovernanceTarget }} report
* @param {{ reporter: string, category: string, createdAt?: number, target?: import('@bitgate/core').GovernanceTarget }} report
* @param {string} targetKey
*/
ingest(report, targetKey) {
@@ -349,7 +349,7 @@ export class TrustedMuteStore extends Emitter {
* A newer list fully replaces the older one; an out-of-order delivery of an
* older list is ignored rather than resurrecting removed entries.
*
* @param {import('@nostr-governance/nostr').DecodedMuteList} list
* @param {import('@bitgate/nostr').DecodedMuteList} list
*/
replaceList(list) {
if (!list?.owner) {
+6 -6
View File
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { REPORT_KIND, encodeRoles } from "@nostr-governance/nostr";
import { createPolicyDefinition } from "@bitgate/core";
import { REPORT_KIND, encodeRoles } from "@bitgate/nostr";
import { ERROR_CODES, createCommands } from "../src/commands.js";
import { createGovernanceRuntime } from "../src/runtime.js";
import { createBitGate } from "../src/runtime.js";
import { createMemoryTransport } from "../src/interfaces.js";
const ROOT = "a1".repeat(32);
@@ -45,7 +45,7 @@ function signerFor(pubkey) {
*/
function setup({ actor = MODERATOR, signer, transport } = {}) {
const memory = transport ?? createMemoryTransport();
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "test",
namespace: "app",
transport: memory,
@@ -177,7 +177,7 @@ describe("argument validation", () => {
describe("signer failures", () => {
it("reports a missing signer", async () => {
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "test",
namespace: "app",
transport: createMemoryTransport(),
@@ -244,7 +244,7 @@ describe("contribution round-trip", () => {
const { runtime, commands, transport } = setup();
await commands.denyUser(CREATOR);
const fresh = createGovernanceRuntime({
const fresh = createBitGate({
applicationId: "test",
namespace: "app",
transport: createMemoryTransport(),
+11 -11
View File
@@ -3,15 +3,15 @@
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
import {
CANONICAL_KIND,
MUTE_LIST_KIND,
encodeContribution,
encodeRoles,
} from "@nostr-governance/nostr";
} from "@bitgate/nostr";
import { createGovernanceRuntime } from "../src/runtime.js";
import { createBitGate } from "../src/runtime.js";
import { createMemoryStorage, createMemoryTransport } from "../src/interfaces.js";
const ROOT = "a1".repeat(32);
@@ -66,11 +66,11 @@ const denyEvent = () =>
* @param {Object} [options]
* @param {import('../src/interfaces.js').GovernanceStorage} [options.storage]
* @param {any} [options.transport]
* @param {import('@nostr-governance/nostr').SignatureVerifier} [options.verifySignature]
* @param {import('@bitgate/nostr').SignatureVerifier} [options.verifySignature]
* @param {string} [options.root]
*/
function makeRuntime({ storage, transport, verifySignature, root = ROOT } = {}) {
return createGovernanceRuntime({
return createBitGate({
applicationId: "test",
namespace: "app",
transport: transport ?? createMemoryTransport([rolesEvent(), denyEvent()]),
@@ -92,7 +92,7 @@ describe("persistence", () => {
expect(writes).toHaveBeenCalled();
const [key] = writes.mock.calls[0];
expect(key).toMatch(/^nostr-governance:test:app:[0-9a-f]{16}:v1:admin$/);
expect(key).toMatch(/^bitgate:test:app:[0-9a-f]{16}:v1:admin$/);
});
it("restores administrative state without touching a relay", async () => {
@@ -257,7 +257,7 @@ describe("trusted mute list subscriptions", () => {
it("subscribes by author, chunked", () => {
const transport = createMemoryTransport();
const subscribe = vi.spyOn(transport, "subscribe");
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "t",
namespace: "app",
transport,
@@ -275,7 +275,7 @@ describe("trusted mute list subscriptions", () => {
it("ingests a delivered mute list into evaluation", () => {
const transport = createMemoryTransport();
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "t",
namespace: "app",
transport,
@@ -331,13 +331,13 @@ describe("community sources", () => {
});
const merged = await runtime.loadCommunitySources([
{ curator: CURATOR, identifier: "app:governance:user-deny:v1", kind: CANONICAL_KIND },
{ curator: CURATOR, identifier: "app:bitgate: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`,
`${CURATOR}:app:bitgate:user-deny:v1`,
]);
});
@@ -357,7 +357,7 @@ describe("community sources", () => {
runtime.admin.setRoles({ root: ROOT, actors: { [CURATOR]: ["curator"] } });
await runtime.loadCommunitySources([
{ curator: CURATOR, identifier: "app:governance:event-deny:v1", kind: CANONICAL_KIND },
{ curator: CURATOR, identifier: "app:bitgate:event-deny:v1", kind: CANONICAL_KIND },
]);
expect(runtime.admin.state.eventDeny.size).toBe(0);
@@ -0,0 +1,391 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRelayTransport } from "../src/relayTransport.js";
const NOW = 1_750_000_000;
/** Every socket built during a test, so assertions can drive them. */
let sockets = [];
/** A scriptable WebSocket stand-in. */
class FakeSocket {
constructor(url) {
this.url = url;
this.sent = [];
this.closed = false;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
sockets.push(this);
}
send(payload) {
this.sent.push(JSON.parse(payload));
}
close() {
this.closed = true;
}
/** Simulate the relay accepting the connection. */
open() {
this.onopen?.();
}
/** Simulate a frame arriving from the relay. */
deliver(frame) {
this.onmessage?.({ data: JSON.stringify(frame) });
}
/** Simulate the connection dropping. */
drop() {
this.onclose?.();
}
/** Frames of a given verb that this socket was asked to send. */
sentOf(verb) {
return this.sent.filter((frame) => frame[0] === verb);
}
}
const event = (id) => ({
id,
pubkey: "a1".repeat(32),
kind: 1,
created_at: NOW,
tags: [],
content: "",
});
function makeTransport(urls = ["wss://one.example"], options = {}) {
return createRelayTransport(urls, { WebSocketImpl: /** @type {any} */ (FakeSocket), ...options });
}
afterEach(() => {
sockets = [];
vi.useRealTimers();
});
describe("construction", () => {
it("requires at least one relay", () => {
expect(() => makeTransport([])).toThrow(/at least one relay/);
expect(() => makeTransport([" ", ""])).toThrow(/at least one relay/);
});
it("requires a usable WebSocket implementation", () => {
// Node 22 has a global WebSocket, so passing null correctly falls back to
// it. The guard is for environments that have neither.
expect(() =>
createRelayTransport(["wss://one.example"], { WebSocketImpl: /** @type {any} */ (42) }),
).toThrow(/WebSocket implementation/);
});
it("falls back to the global WebSocket when none is injected", () => {
expect(() => createRelayTransport(["wss://one.example"])).not.toThrow();
});
it("opens one socket per relay", () => {
makeTransport(["wss://one.example", "wss://two.example"]);
expect(sockets.map((socket) => socket.url)).toEqual([
"wss://one.example",
"wss://two.example",
]);
});
it("trims and drops blank relay URLs", () => {
const transport = makeTransport([" wss://one.example ", ""]);
expect(transport.relays).toEqual(["wss://one.example"]);
});
});
describe("connection lifecycle", () => {
it("issues a subscription exactly once when it opens", () => {
const transport = makeTransport();
transport.subscribe([{ kinds: [1] }], {});
// Nothing can be sent yet — the socket has not opened.
expect(sockets[0].sent).toHaveLength(0);
sockets[0].open();
// Re-issued from the subscription map, not also flushed from the queue.
expect(sockets[0].sentOf("REQ")).toHaveLength(1);
});
it("queues a publish made before the socket opens", async () => {
const transport = makeTransport();
const published = event("bb".repeat(32));
const pending = transport.publish(published);
expect(sockets[0].sentOf("EVENT")).toHaveLength(0);
sockets[0].open();
expect(sockets[0].sentOf("EVENT")).toHaveLength(1);
sockets[0].deliver(["OK", published.id, true, ""]);
expect((await pending).ok).toBe(true);
});
it("re-opens live subscriptions after a reconnect", () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example"], { reconnectDelay: 10 });
sockets[0].open();
transport.subscribe([{ kinds: [1] }], {});
expect(sockets[0].sentOf("REQ")).toHaveLength(1);
sockets[0].drop();
vi.advanceTimersByTime(20);
// A fresh socket was created and the subscription re-issued on open.
expect(sockets).toHaveLength(2);
sockets[1].open();
expect(sockets[1].sentOf("REQ")).toHaveLength(1);
});
it("backs off between reconnect attempts", () => {
vi.useFakeTimers();
makeTransport(["wss://one.example"], { reconnectDelay: 100 });
sockets[0].drop();
vi.advanceTimersByTime(100);
expect(sockets).toHaveLength(2);
// Second failure waits longer than the first.
sockets[1].drop();
vi.advanceTimersByTime(100);
expect(sockets).toHaveLength(2);
vi.advanceTimersByTime(100);
expect(sockets).toHaveLength(3);
});
it("stops reconnecting once closed", () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example"], { reconnectDelay: 10 });
transport.close();
sockets[0].drop();
vi.advanceTimersByTime(500);
expect(sockets).toHaveLength(1);
});
it("closes every socket on close", () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
transport.close();
expect(sockets.every((socket) => socket.closed)).toBe(true);
});
});
describe("list", () => {
it("resolves with stored events once every relay sends EOSE", async () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
sockets.forEach((socket) => socket.open());
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].deliver(["EVENT", id, event("aa".repeat(32))]);
sockets[0].deliver(["EOSE", id]);
sockets[1].deliver(["EOSE", id]);
expect(await pending).toHaveLength(1);
});
it("deduplicates events served by more than one relay", async () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
sockets.forEach((socket) => socket.open());
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
const duplicate = event("aa".repeat(32));
sockets[0].deliver(["EVENT", id, duplicate]);
sockets[1].deliver(["EVENT", id, duplicate]);
sockets[0].deliver(["EOSE", id]);
sockets[1].deliver(["EOSE", id]);
expect(await pending).toHaveLength(1);
});
it("returns what it has when a relay never answers", async () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example", "wss://slow.example"], {
listTimeout: 1_000,
});
sockets.forEach((socket) => socket.open());
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].deliver(["EVENT", id, event("aa".repeat(32))]);
sockets[0].deliver(["EOSE", id]);
// The second relay stays silent; the timeout must not discard what arrived.
await vi.advanceTimersByTimeAsync(1_000);
expect(await pending).toHaveLength(1);
});
it("closes the subscription when it resolves", async () => {
const transport = makeTransport();
sockets[0].open();
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].deliver(["EOSE", id]);
await pending;
expect(sockets[0].sentOf("CLOSE")[0][1]).toBe(id);
});
it("ignores malformed frames", async () => {
const transport = makeTransport();
sockets[0].open();
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].onmessage?.({ data: "not json" });
sockets[0].onmessage?.({ data: JSON.stringify({ not: "an array" }) });
sockets[0].deliver(["EOSE", id]);
expect(await pending).toEqual([]);
});
});
describe("subscribe", () => {
it("delivers events to the handler", () => {
const transport = makeTransport();
sockets[0].open();
const onEvent = vi.fn();
transport.subscribe([{ kinds: [1] }], { onEvent });
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].deliver(["EVENT", id, event("aa".repeat(32))]);
expect(onEvent).toHaveBeenCalledTimes(1);
});
it("deduplicates across relays", () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
sockets.forEach((socket) => socket.open());
const onEvent = vi.fn();
transport.subscribe([{ kinds: [1] }], { onEvent });
const id = sockets[0].sentOf("REQ")[0][1];
const duplicate = event("aa".repeat(32));
sockets[0].deliver(["EVENT", id, duplicate]);
sockets[1].deliver(["EVENT", id, duplicate]);
expect(onEvent).toHaveBeenCalledTimes(1);
});
it("reports EOSE", () => {
const transport = makeTransport();
sockets[0].open();
const onEose = vi.fn();
transport.subscribe([{ kinds: [1] }], { onEose });
sockets[0].deliver(["EOSE", sockets[0].sentOf("REQ")[0][1]]);
expect(onEose).toHaveBeenCalled();
});
it("stops delivering after close", () => {
const transport = makeTransport();
sockets[0].open();
const onEvent = vi.fn();
const handle = transport.subscribe([{ kinds: [1] }], { onEvent });
const id = sockets[0].sentOf("REQ")[0][1];
handle.close();
sockets[0].deliver(["EVENT", id, event("aa".repeat(32))]);
expect(onEvent).not.toHaveBeenCalled();
expect(sockets[0].sentOf("CLOSE")).toHaveLength(1);
});
});
describe("publish", () => {
it("succeeds when a relay accepts", async () => {
const transport = makeTransport();
sockets[0].open();
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, true, ""]);
const result = await pending;
expect(result.ok).toBe(true);
expect(result.accepted).toEqual(["wss://one.example"]);
});
it("treats partial acceptance as success and keeps failures visible", async () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
sockets.forEach((socket) => socket.open());
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, true, ""]);
sockets[1].deliver(["OK", published.id, false, "blocked: spam"]);
const result = await pending;
expect(result.ok).toBe(true);
expect(result.accepted).toEqual(["wss://one.example"]);
expect(result.failed).toEqual([{ relay: "wss://two.example", error: "blocked: spam" }]);
});
it("fails when every relay rejects", async () => {
const transport = makeTransport();
sockets[0].open();
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, false, "rejected"]);
const result = await pending;
expect(result.ok).toBe(false);
expect(result.failed).toHaveLength(1);
});
it("reports silent relays as timed out rather than omitting them", async () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example", "wss://quiet.example"], {
listTimeout: 500,
});
sockets.forEach((socket) => socket.open());
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, true, ""]);
await vi.advanceTimersByTimeAsync(500);
const result = await pending;
expect(result.ok).toBe(true);
expect(result.failed).toEqual([{ relay: "wss://quiet.example", error: "timeout" }]);
});
it("ignores OK frames for a different event", async () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example"], { listTimeout: 200 });
sockets[0].open();
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", "cc".repeat(32), true, ""]);
await vi.advanceTimersByTimeAsync(200);
const result = await pending;
expect(result.ok).toBe(false);
});
it("does not stack handlers across repeated publishes", async () => {
const transport = makeTransport();
sockets[0].open();
for (const id of ["aa", "bb", "cc"]) {
const published = event(id.repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, true, ""]);
expect((await pending).ok).toBe(true);
}
expect(sockets[0].sentOf("EVENT")).toHaveLength(3);
});
});
+12 -12
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
import {
CANONICAL_KIND,
MUTE_LIST_KIND,
@@ -8,9 +8,9 @@ import {
canonicalIdentifier,
encodeContribution,
encodeRoles,
} from "@nostr-governance/nostr";
} from "@bitgate/nostr";
import { GovernanceRuntime, chunk, createGovernanceRuntime } from "../src/runtime.js";
import { GovernanceRuntime, chunk, createBitGate } from "../src/runtime.js";
import { createMemoryTransport, storageKey } from "../src/interfaces.js";
const ROOT = "a1".repeat(32);
@@ -37,8 +37,8 @@ const POLICY = createPolicyDefinition({
});
/**
* @param {Partial<import('@nostr-governance/nostr').NostrEvent>} parts
* @returns {import('@nostr-governance/nostr').NostrEvent}
* @param {Partial<import('@bitgate/nostr').NostrEvent>} parts
* @returns {import('@bitgate/nostr').NostrEvent}
*/
const event = (parts) => ({
id: "00".repeat(32),
@@ -57,7 +57,7 @@ const rolesEvent = () =>
...encodeRoles({ actors: { [MODERATOR]: ["moderator"] }, protectedActors: [] }, "app"),
});
/** @param {import('@nostr-governance/core').GovernanceTarget} [target] */
/** @param {import('@bitgate/core').GovernanceTarget} [target] */
const denyEvent = (target = { type: "user", pubkey: CREATOR }) =>
event({
id: "bb".repeat(32),
@@ -66,7 +66,7 @@ const denyEvent = (target = { type: "user", pubkey: CREATOR }) =>
function makeRuntime(events = []) {
const transport = createMemoryTransport(events);
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "test-app",
namespace: "app",
transport,
@@ -100,7 +100,7 @@ describe("storageKey", () => {
schemaVersion: "v1",
scope: "admin",
}),
).toBe("nostr-governance:bitroad:bitroad:abcd:v1:admin");
).toBe("bitgate:bitroad:bitroad:abcd:v1:admin");
});
it("appends the viewer for viewer-scoped state", () => {
@@ -260,7 +260,7 @@ describe("loadAdministrativeState", () => {
it("can restrict the query to known authors", async () => {
const transport = createMemoryTransport([rolesEvent(), denyEvent()]);
const listSpy = vi.spyOn(transport, "list");
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "a",
namespace: "app",
transport,
@@ -277,7 +277,7 @@ 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 = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "a",
namespace: "app",
transport,
@@ -299,7 +299,7 @@ describe("active targets and subscriptions", () => {
it("separates event and user filters", () => {
const transport = createMemoryTransport();
const subscribeSpy = vi.spyOn(transport, "subscribe");
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "a",
namespace: "app",
transport,
@@ -411,7 +411,7 @@ describe("evaluation", () => {
runtime.ingestEvent(rolesEvent());
runtime.ingestEvent(denyEvent());
/** @type {import('@nostr-governance/core').GovernanceTarget} */
/** @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" }));
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
import { Emitter } from "../src/emitter.js";
import {
@@ -18,7 +18,7 @@ const CREATOR = "d4".repeat(32);
const OTHER = "e5".repeat(32);
const DAY = 86_400;
/** @param {string} pubkey @returns {import("@nostr-governance/core").UserTarget} */
/** @param {string} pubkey @returns {import("@bitgate/core").UserTarget} */
const user = (pubkey) => ({ type: "user", pubkey });
const roster = {