mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +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.
276 lines
9.1 KiB
JavaScript
276 lines
9.1 KiB
JavaScript
// @vitest-environment happy-dom
|
|
//
|
|
// The drop-in path: configuration in markup, descendants finding the runtime
|
|
// themselves, and targets read from attributes.
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { defineBitGateElements } from "../src/index.js";
|
|
import { targetFromAttributes } from "../src/base.js";
|
|
|
|
const ROOT = "a1".repeat(32);
|
|
const CREATOR = "d4".repeat(32);
|
|
const EVENT_ID = "1b".repeat(32);
|
|
|
|
defineBitGateElements();
|
|
|
|
/** Sockets opened during a test, so nothing tries to reach a real relay. */
|
|
let sockets = [];
|
|
|
|
class FakeSocket {
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.sent = [];
|
|
this.onopen = null;
|
|
this.onmessage = null;
|
|
this.onclose = null;
|
|
this.onerror = null;
|
|
sockets.push(this);
|
|
// Open on a microtask so listeners are attached first.
|
|
queueMicrotask(() => this.onopen?.());
|
|
}
|
|
send(payload) {
|
|
const frame = JSON.parse(payload);
|
|
this.sent.push(frame);
|
|
// Answer every REQ immediately so loadAdministrativeState resolves.
|
|
if (frame[0] === "REQ") {
|
|
queueMicrotask(() => this.onmessage?.({ data: JSON.stringify(["EOSE", frame[1]]) }));
|
|
}
|
|
}
|
|
close() {}
|
|
}
|
|
|
|
/**
|
|
* Mount a provider and wait for it to finish loading.
|
|
* @param {Record<string, string|null>} [attributes]
|
|
* @param {string} [children]
|
|
* @returns {Promise<any>}
|
|
*/
|
|
async function mountProvider(attributes = {}, children = "") {
|
|
const provider = /** @type {any} */ (document.createElement("bitgate-provider"));
|
|
for (const [name, value] of Object.entries({
|
|
relays: "wss://relay.example",
|
|
root: ROOT,
|
|
policy: "social",
|
|
...attributes,
|
|
})) {
|
|
if (value !== null) {
|
|
provider.setAttribute(name, value);
|
|
}
|
|
}
|
|
provider.innerHTML = children;
|
|
|
|
const loaded = new Promise((resolve) => {
|
|
provider.addEventListener("bitgate:loaded", resolve, { once: true });
|
|
provider.addEventListener("bitgate:error", resolve, { once: true });
|
|
});
|
|
|
|
document.body.append(provider);
|
|
await loaded;
|
|
return provider;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
document.body.innerHTML = "";
|
|
sockets = [];
|
|
globalThis.WebSocket = /** @type {any} */ (FakeSocket);
|
|
globalThis.localStorage?.clear?.();
|
|
});
|
|
|
|
describe("targetFromAttributes", () => {
|
|
const parse = (attributes) => {
|
|
const element = document.createElement("div");
|
|
for (const [name, value] of Object.entries(attributes)) {
|
|
element.setAttribute(name, value);
|
|
}
|
|
return targetFromAttributes(element);
|
|
};
|
|
|
|
it("reads a user target", () => {
|
|
expect(parse({ "target-user": CREATOR })).toEqual({ type: "user", pubkey: CREATOR });
|
|
});
|
|
|
|
it("reads an event target with author and kind", () => {
|
|
expect(parse({ "target-event": EVENT_ID, "target-author": CREATOR, "target-kind": "1" })).toEqual({
|
|
type: "event",
|
|
id: EVENT_ID,
|
|
author: CREATOR,
|
|
kind: 1,
|
|
});
|
|
});
|
|
|
|
it("reads an address coordinate", () => {
|
|
expect(parse({ "target-address": `30078:${CREATOR}:sku-001` })).toEqual({
|
|
type: "address",
|
|
kind: "30078",
|
|
pubkey: CREATOR,
|
|
identifier: "sku-001",
|
|
});
|
|
});
|
|
|
|
it("keeps colons inside the d-tag", () => {
|
|
const target = parse({ "target-address": `30078:${CREATOR}:shop:sku:1` });
|
|
expect(target?.type === "address" && target.identifier).toBe("shop:sku:1");
|
|
});
|
|
|
|
it("returns null when no target attribute is present", () => {
|
|
expect(parse({})).toBeNull();
|
|
});
|
|
|
|
it("returns null for a malformed coordinate", () => {
|
|
expect(parse({ "target-address": "garbage" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("<bitgate-provider>", () => {
|
|
it("builds a runtime from attributes alone", async () => {
|
|
const provider = await mountProvider();
|
|
expect(provider.runtime).toBeTruthy();
|
|
expect(provider.commands).toBeTruthy();
|
|
expect(provider.runtime.admin.authority.root).toBe(ROOT);
|
|
});
|
|
|
|
it("uses the named policy preset", async () => {
|
|
const provider = await mountProvider({ policy: "commerce" });
|
|
expect(provider.runtime.policies.policy.id).toBe("bitgate-commerce");
|
|
});
|
|
|
|
it("defaults to administrative-only governance", async () => {
|
|
const provider = await mountProvider({ policy: null });
|
|
expect(provider.runtime.policies.policy.id).toBe("bitgate-admin-only");
|
|
});
|
|
|
|
it("opens a socket per relay", async () => {
|
|
await mountProvider({ relays: "wss://one.example, wss://two.example" });
|
|
expect(sockets.map((socket) => socket.url)).toEqual(["wss://one.example", "wss://two.example"]);
|
|
});
|
|
|
|
it("sets the viewer when given one", async () => {
|
|
const provider = await mountProvider({ viewer: CREATOR });
|
|
expect(provider.runtime.viewerPubkey).toBe(CREATOR);
|
|
});
|
|
|
|
it("reports an unknown policy without throwing", async () => {
|
|
const provider = await mountProvider({ policy: "invented" });
|
|
expect(provider.runtime).toBeNull();
|
|
expect(provider.getAttribute("data-error")).toMatch(/Unknown policy preset/);
|
|
});
|
|
|
|
it("reports a missing relays attribute", async () => {
|
|
const provider = await mountProvider({ relays: null });
|
|
expect(provider.getAttribute("data-error")).toMatch(/relays attribute/);
|
|
});
|
|
|
|
it("announces readiness before administrative state finishes loading", async () => {
|
|
const provider = /** @type {any} */ (document.createElement("bitgate-provider"));
|
|
provider.setAttribute("relays", "wss://relay.example");
|
|
provider.setAttribute("policy", "social");
|
|
|
|
const ready = vi.fn();
|
|
provider.addEventListener("bitgate:ready", ready);
|
|
document.body.append(provider);
|
|
|
|
await new Promise((resolve) => provider.addEventListener("bitgate:loaded", resolve, { once: true }));
|
|
expect(ready).toHaveBeenCalled();
|
|
});
|
|
|
|
it("tears the runtime down when removed", async () => {
|
|
const provider = await mountProvider();
|
|
const runtime = provider.runtime;
|
|
provider.remove();
|
|
|
|
expect(provider.runtime).toBeNull();
|
|
expect(runtime.destroyed).toBe(true);
|
|
});
|
|
|
|
it("accepts a signer and adopts its pubkey as the viewer", async () => {
|
|
const provider = await mountProvider();
|
|
const signer = {
|
|
async getPublicKey() {
|
|
return CREATOR;
|
|
},
|
|
async signEvent(template) {
|
|
return { ...template, id: "ff".repeat(32), pubkey: CREATOR, sig: "00".repeat(64) };
|
|
},
|
|
};
|
|
|
|
const pubkey = await provider.useSigner(signer);
|
|
expect(pubkey).toBe(CREATOR);
|
|
expect(provider.runtime.viewerPubkey).toBe(CREATOR);
|
|
expect(provider.runtime.signer).toBe(signer);
|
|
});
|
|
|
|
it("refuses a signer before it is ready", async () => {
|
|
const provider = /** @type {any} */ (document.createElement("bitgate-provider"));
|
|
await expect(provider.useSigner(/** @type {any} */ ({}))).rejects.toThrow(/not ready/);
|
|
});
|
|
});
|
|
|
|
describe("context discovery", () => {
|
|
it("gives a descendant the runtime without any JavaScript", async () => {
|
|
const provider = await mountProvider(
|
|
{},
|
|
`<bitgate-veil profile="feed" target-user="${CREATOR}"><p>content</p></bitgate-veil>`,
|
|
);
|
|
const veil = /** @type {any} */ (provider.querySelector("bitgate-veil"));
|
|
expect(veil.runtime).toBe(provider.runtime);
|
|
});
|
|
|
|
it("hands commands to elements that publish", async () => {
|
|
const provider = await mountProvider(
|
|
{},
|
|
`<bitgate-report target-user="${CREATOR}"></bitgate-report>`,
|
|
);
|
|
const report = /** @type {any} */ (provider.querySelector("bitgate-report"));
|
|
expect(report.commands).toBe(provider.commands);
|
|
});
|
|
|
|
it("renders a decision from markup alone", async () => {
|
|
const provider = await mountProvider(
|
|
{ policy: "admin-only" },
|
|
`<bitgate-veil target-user="${CREATOR}"><p>content</p></bitgate-veil>`,
|
|
);
|
|
|
|
provider.runtime.admin.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
|
|
provider.runtime.admin.upsertContribution({
|
|
actor: ROOT,
|
|
kind: "user-deny",
|
|
targets: [{ type: "user", pubkey: CREATOR }],
|
|
createdAt: 1,
|
|
});
|
|
|
|
const veil = /** @type {any} */ (provider.querySelector("bitgate-veil"));
|
|
expect(veil.shadowRoot.textContent).toContain("Content hidden");
|
|
});
|
|
|
|
it("lets a directly assigned runtime win over the provider's", async () => {
|
|
const provider = await mountProvider(
|
|
{},
|
|
`<bitgate-status target-user="${CREATOR}"></bitgate-status>`,
|
|
);
|
|
const status = /** @type {any} */ (provider.querySelector("bitgate-status"));
|
|
const other = { on: () => () => {}, evaluate: () => null };
|
|
|
|
status.runtime = /** @type {any} */ (other);
|
|
expect(status.runtime).toBe(other);
|
|
});
|
|
|
|
it("leaves an element outside any provider unconfigured", () => {
|
|
const veil = /** @type {any} */ (document.createElement("bitgate-veil"));
|
|
document.body.append(veil);
|
|
expect(veil.runtime).toBeNull();
|
|
});
|
|
|
|
it("adopts a provider that becomes ready later", async () => {
|
|
// An element parsed before its provider finishes starting must still end up
|
|
// configured, or markup order would silently decide whether it works.
|
|
const veil = /** @type {any} */ (document.createElement("bitgate-veil"));
|
|
veil.setAttribute("target-user", CREATOR);
|
|
document.body.append(veil);
|
|
expect(veil.runtime).toBeNull();
|
|
|
|
const provider = await mountProvider();
|
|
expect(veil.runtime).toBe(provider.runtime);
|
|
});
|
|
});
|