mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
docs: headless quickstart — NIP-32 labels to a four-dimension decision
Tessera's #1 reason for not adopting BitGate: the on-ramp they found was the <bitgate-provider>/<bitgate-veil> widgets, so they never saw the "just the engine + events over Nostr" path, and — not finding a canonical decision event — invented their own kind-33604 shape. Both concerns were already solved, just not surfaced: - NIP-32 label events (kind 1985) ARE the canonical signed decision format; @bitgate/nostr already ships the codec and docs/labels.md documents interop. No 33604 needed. - The headless path is four pure functions, no runtime transport, no DOM. Add examples/headless-quickstart.mjs (runnable): moderators publish signed kind-1985 deny labels -> decodeLabels -> labelsToContributions -> reduce under an honored moderator set -> evaluateTarget against a plugin-registry policy. It shows exactly the transaction-dimension case the feedback names — a malware plugin stays visible (warn) but not installable (transaction deny), where a `hidden` boolean can't reach. Add a "Headless, straight from NIP-32 labels" README subsection stating labels are the canonical wire format and linking the example and docs/labels.md. Pin the behavior in tests/headless-quickstart.test.js (clean installable; malware visible-but-not-installable; an out-of-set labeller's deny ignored). Addresses the "no just-the-engine path", "decision format is ours", and "headless quickstart" items of the Tessera feedback (docs/TODO.md). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
This commit is contained in:
1 parent
af2818fbce
commit
7eb4064556
4 files changed
+274
-21
No files matched your search
@@ -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
|
||||
|
||||
+27
-21
@@ -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
|
||||
`<bitgate-provider>`/`<bitgate-veil>` 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
|
||||
|
||||
@@ -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.`);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user