diff --git a/README.md b/README.md index 7c8d996..45563ef 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,34 @@ const decision = runtime.evaluate({ type: "user", pubkey: authorPubkey }, { prof if (decision.visibility.effect === "hide") return null; ``` +### Headless, straight from NIP-32 labels + +If you don't want the runtime's transport orchestration either — you already +have relay events in hand and just want the engine — the whole path is four +pure functions. **NIP-32 label events (kind 1985) are the canonical, signed +decision format**; you don't invent one: + +```js +import { createSnapshot, evaluateTarget, reduceAdminState, createAuthorityState } from "@bitgate/core"; +import { decodeLabels, labelsToContributions } from "@bitgate/nostr"; + +const authority = createAuthorityState({ root: ROOT, actors: honoredModerators }); // moderator set as data +const contributions = labelsToContributions( + labelEvents.flatMap(decodeLabels), // kind-1985 events off your relays + { namespace: "org.bitblocks.plugins", denyValues: ["deny"], allowValues: ["allow"] }, +); +const snapshot = createSnapshot({ authority, admin: reduceAdminState(contributions, authority) }); +const decision = evaluateTarget(target, snapshot, { policy, surface: "registry" }); + +if (!decision.transaction || decision.transaction.effect === "allow") install(); // gate a non-visual action +``` + +A decision has four dimensions, so `transaction` can block an install or +checkout while `visibility` still shows the listing with a warning — something a +`hidden` boolean can't express. [`examples/headless-quickstart.mjs`](examples/headless-quickstart.mjs) +is a runnable end-to-end walkthrough (`node examples/headless-quickstart.mjs`); +[docs/labels.md](docs/labels.md) covers the NIP-32 wire format and interop. + ## Extending to a new application Write an adapter saying what your object is in governance terms, and a policy diff --git a/docs/TODO.md b/docs/TODO.md index 4f089cf..df18e31 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -230,30 +230,36 @@ adoption the obvious choice. ### Why we didn't reach for BitGate first -- **No obvious "just the engine + events over Nostr" path for a static site.** - We wanted: moderators publish signed decisions to relays; every client - resolves the honored set and composes decisions ladder-max; enforce in the UI. - That is precisely BitGate's model, but the on-ramp we found was the - ``/`` widgets, which assume you're veiling - DOM content, not making install/transaction decisions in app logic. A - documented recipe for "headless `@bitgate/core` + your own decision events + - your own enforcement" would have won us over. -- **The decision event format is ours, not yours.** We invented a 33604 shape - because we didn't find a canonical BitGate "decision/label event" to emit and - consume over relays. If BitGate defines (or blesses a NIP-32-based) wire - format for a signed decision across the four dimensions, other clients — - including ours — could interoperate instead of each inventing one. +- **[ADDRESSED 2026-08-11] No obvious "just the engine + events over Nostr" path + for a static site.** The headless path existed but was buried under the widget + on-ramp. Added a "Headless, straight from NIP-32 labels" subsection to the + README (four pure functions: decode → contributions → reduce → evaluate, no + runtime transport, no DOM) and a runnable `examples/headless-quickstart.mjs`. + Pinned by `tests/headless-quickstart.test.js`. +- **[ADDRESSED 2026-08-11] The decision event format is ours, not yours.** There + was no need to invent 33604: **NIP-32 label events (kind 1985) are the + canonical signed decision format** — `@bitgate/nostr` already has the codec + (`encodeLabel`/`decodeLabels`/`labelsToContributions`) and `docs/labels.md` + documents interop. The README and the headless example now say so explicitly + and round-trip real 1985 events into a four-dimension decision, so other + clients interoperate instead of each inventing a shape. ### High-value asks -- **Headless quickstart**: `@bitgate/core` composing decisions from an array of - events + a viewer + a policy, with zero DOM. Show the `transaction` dimension - gating a non-visual action (install/checkout), which is where the four- - dimension model really shines and where a `hidden` boolean can't reach. -- **Moderator-set as data.** Tessera made "who is a moderator" a signed, - root-controlled kind-30000 set resolved at runtime, so moderators can be - added in-app without a redeploy. A first-class BitGate primitive for the - honored-moderator/authority set (with delegation) would be broadly useful. +- **[DONE 2026-08-11] Headless quickstart**: shipped `examples/headless-quickstart.mjs` + and a README subsection — `@bitgate/core` composing a decision from an array + of kind-1985 label events + an honored moderator set + a policy, zero DOM. It + shows exactly the `transaction`-dimension case the ask names: a malware plugin + stays **visible (warn)** but **not installable (transaction deny)** — where a + `hidden` boolean can't reach. Pinned by `tests/headless-quickstart.test.js` + (incl. that an out-of-set labeller's deny is ignored). +- **[PARTIAL 2026-08-11] Moderator-set as data.** The primitive exists + (`createAuthorityState({ root, actors })` + capability-gated + `reduceAdminState`), and the headless example demonstrates resolving the + honored set as data and ignoring an unauthorized labeller. Still open as a + first-class helper: resolving that actor map from a *signed, root-controlled + kind-30000 set* at runtime (with delegation) rather than the app assembling + the `actors` object itself. Left as a focused follow-up. - **[DONE 2026-08-11] Allowlist / whitelist mode.** The engine already honored `requireAllowlist` + `allowlistMiss` (evaluator.js), and the commerce example configured a seller allowlist by hand — but there was no reusable named diff --git a/examples/headless-quickstart.mjs b/examples/headless-quickstart.mjs new file mode 100644 index 0000000..7e60de9 --- /dev/null +++ b/examples/headless-quickstart.mjs @@ -0,0 +1,130 @@ +/** + * BitGate, headless — the whole round-trip in one file, no DOM, no widget. + * + * moderators publish signed NIP-32 label events (kind 1985) <- the wire format + * -> decode them on the client + * -> map labels to governance contributions + * -> reduce to admin state under an honored moderator set <- moderator set as data + * -> evaluate a target against a policy + * -> apply the decision <- your UI / app logic + * + * The point the widget on-ramp hides: a decision has four dimensions, and the + * `transaction` one gates a NON-visual action. A plugin can be visible (you see + * the listing, with a warning) yet not installable — something a single `hidden` + * boolean can't express. NIP-32 labels are the canonical, interoperable decision + * event; see docs/labels.md. + * + * node examples/headless-quickstart.mjs + */ +import { + createAuthorityState, + createEventTarget, + createPolicyDefinition, + createSnapshot, + evaluateTarget, + reduceAdminState, +} from "@bitgate/core"; +import { decodeLabels, encodeLabel, labelsToContributions } from "@bitgate/nostr"; + +// --- identities (64-hex, as on the wire) ----------------------------------- +const ROOT = "a1".repeat(32); +const MOD_A = "b2".repeat(32); +const MOD_B = "c3".repeat(32); +const CLEAN_PLUGIN = "d4".repeat(32); // a well-behaved plugin release (event id) +const MALWARE_PLUGIN = "e5".repeat(32); // ships a keylogger +const NOW = 1_750_000_000; +const NAMESPACE = "org.bitblocks.plugins"; + +// --- 1. A moderator publishes a signed NIP-32 label denying the bad plugin --- +// encodeLabel returns the event TEMPLATE; in production your signer fills in +// pubkey/id/sig. Here we simulate a signed event by attaching the labeller. +function signedLabelEvent(labellerPubkey, template) { + return { ...template, pubkey: labellerPubkey, created_at: NOW, id: "0".repeat(64), sig: "" }; +} + +const relayEvents = [ + signedLabelEvent( + MOD_A, + encodeLabel({ + value: "deny", + namespace: NAMESPACE, + targets: [createEventTarget(MALWARE_PLUGIN)], + content: "ships an undisclosed keylogger", + }), + ), + // MOD_B independently agrees — labels compose; one honored labeller is enough + // here, but real deployments want more than one before a deny sticks. + signedLabelEvent( + MOD_B, + encodeLabel({ + value: "deny", + namespace: NAMESPACE, + targets: [createEventTarget(MALWARE_PLUGIN)], + content: "confirmed: exfiltrates clipboard", + }), + ), +]; + +// --- 2. The honored moderator set, resolved as data (not hard-coded UI) ------ +// In production you resolve this from a signed, root-controlled Nostr set and +// pass the pubkeys here — adding a moderator needs no redeploy. +const authority = createAuthorityState({ + root: ROOT, + actors: { [MOD_A]: ["moderator"], [MOD_B]: ["moderator"] }, +}); + +// --- 3. Decode labels -> contributions -> admin state ------------------------ +const labels = relayEvents.flatMap((event) => decodeLabels(event)); +const contributions = labelsToContributions(labels, { + namespace: NAMESPACE, + denyValues: ["deny"], + allowValues: ["allow"], +}); +// reduceAdminState only honors contributions from actors the authority permits, +// so an unauthorized "labeller" publishing a deny changes nothing. +const admin = reduceAdminState(contributions, authority); +const snapshot = createSnapshot({ authority, admin }); + +// --- 4. A plugin-registry policy. A denied plugin stays VISIBLE with a warning +// but is NOT installable — the four-dimension model's whole point. ------ +const registryPolicy = createPolicyDefinition({ + id: "plugin-registry", + name: "Plugin registry", + version: "1.0.0", + defaultProfile: "registry", + profiles: { + registry: { + name: "registry", + administrativeDeny: { visibility: "warn", interaction: "allow", transaction: "deny" }, + viewerBlock: { visibility: "hide", interaction: "deny" }, + allowViewerOverride: false, + reports: {}, + mutes: {}, + }, + }, +}); + +// --- 5. Evaluate, and apply the decision to a NON-visual action -------------- +function report(name, eventId) { + const decision = evaluateTarget(createEventTarget(eventId), snapshot, { + surface: "registry", + policyProfile: "registry", + policy: registryPolicy, + now: NOW, + }); + const installable = (decision.transaction?.effect ?? "allow") === "allow"; + console.log( + ` ${name.padEnd(16)} visible=${decision.visibility.effect.padEnd(6)} installable=${installable}` + + (decision.reasons.length ? ` (${decision.reasons.map((r) => r.id).join(", ")})` : ""), + ); +} + +console.log("Plugin registry decisions (headless, from NIP-32 labels over relays):\n"); +report("clean plugin", CLEAN_PLUGIN); +report("malware plugin", MALWARE_PLUGIN); +console.log(` +The malware plugin is still VISIBLE (visible=warn) but installable=false: the +transaction dimension gated the install without hiding the listing. A single +"hidden" boolean can't say "show it, but don't let anyone install it." Swap the +fake relay events for a real subscription to kind-1985 labels from your honored +moderators and this same code is your production enforcement.`); diff --git a/tests/headless-quickstart.test.js b/tests/headless-quickstart.test.js new file mode 100644 index 0000000..67dc46f --- /dev/null +++ b/tests/headless-quickstart.test.js @@ -0,0 +1,89 @@ +// Pins examples/headless-quickstart.mjs: the NIP-32-labels -> decision round +// trip, and specifically that the `transaction` dimension gates a non-visual +// action (install) while the listing stays visible. If this drifts, the example +// in the docs is wrong. +import { describe, expect, it } from "vitest"; + +import { + createAuthorityState, + createEventTarget, + createPolicyDefinition, + createSnapshot, + evaluateTarget, + reduceAdminState, +} from "@bitgate/core"; +import { decodeLabels, encodeLabel, labelsToContributions } from "@bitgate/nostr"; + +const ROOT = "a1".repeat(32); +const MOD_A = "b2".repeat(32); +const MOD_B = "c3".repeat(32); +const OUTSIDER = "f6".repeat(32); // not in the honored moderator set +const CLEAN = "d4".repeat(32); +const MALWARE = "e5".repeat(32); +const NOW = 1_750_000_000; +const NS = "org.bitblocks.plugins"; + +/** createEventTarget returns null on a bad id; every id here is valid 64-hex. */ +const eventTarget = (id) => { + const target = createEventTarget(id); + if (!target) throw new Error(`bad event id: ${id}`); + return target; +}; + +const signed = (pubkey, template) => ({ ...template, pubkey, created_at: NOW, id: "0".repeat(64), sig: "" }); +const denyLabel = (pubkey, eventId) => + signed(pubkey, encodeLabel({ value: "deny", namespace: NS, targets: [eventTarget(eventId)] })); + +const policy = createPolicyDefinition({ + id: "plugin-registry", + name: "Plugin registry", + version: "1.0.0", + defaultProfile: "registry", + profiles: { + registry: { + name: "registry", + administrativeDeny: { visibility: "warn", interaction: "allow", transaction: "deny" }, + viewerBlock: { visibility: "hide", interaction: "deny" }, + allowViewerOverride: false, + reports: {}, + mutes: {}, + }, + }, +}); + +function decide(events, eventId) { + const authority = createAuthorityState({ root: ROOT, actors: { [MOD_A]: ["moderator"], [MOD_B]: ["moderator"] } }); + const contributions = labelsToContributions( + events.flatMap((e) => decodeLabels(e)), + { namespace: NS, denyValues: ["deny"], allowValues: ["allow"] }, + ); + const snapshot = createSnapshot({ authority, admin: reduceAdminState(contributions, authority) }); + return evaluateTarget(eventTarget(eventId), snapshot, { + surface: "registry", + policyProfile: "registry", + policy, + now: NOW, + }); +} + +describe("headless quickstart: NIP-32 labels -> decision", () => { + const events = [denyLabel(MOD_A, MALWARE), denyLabel(MOD_B, MALWARE)]; + + it("leaves a clean plugin visible and installable", () => { + const d = decide(events, CLEAN); + expect(d.visibility.effect).toBe("allow"); + expect(d.transaction?.effect ?? "allow").toBe("allow"); + }); + + it("gates install on the malware plugin while keeping the listing visible", () => { + const d = decide(events, MALWARE); + // The four-dimension point: shown (warn) but not installable (transaction deny). + expect(d.visibility.effect).toBe("warn"); + expect(d.transaction?.effect).toBe("deny"); + }); + + it("ignores a deny label from a labeller outside the honored moderator set", () => { + const d = decide([denyLabel(OUTSIDER, MALWARE)], MALWARE); + expect(d.transaction?.effect ?? "allow").toBe("allow"); + }); +});