Files
bitgate/docs/integration-guide.md
T
thePR0M3TH3AN adeba151b8 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.
2026-08-09 19:58:45 -04:00

8.2 KiB

Integration guide

How to put this engine into an application that is not a video app.

Everything application-specific lives in two places you write: an adapter (what is my object, in governance terms) and a policy (what does the evidence mean to me). Nothing else needs to change, and nothing in the core needs to know your application exists.

1. Decide what your objects are

Three target types cover the model:

Target Use for
user Accounts: authors, sellers, reviewers, reporters, curators
event One exact, immutable event that should stay denied on its own
address A replaceable coordinate: a product across revisions, a storefront, an article

The distinction between event and address matters more than it looks. Denying an address survives the author republishing under a new event id. Denying an event does not — which is exactly what you want when only one revision was bad. Most objects want both.

2. Write an adapter

import { createApplicationAdapter } from "@bitgate/core";

export const productAdapter = createApplicationAdapter({
  applicationId: "my-shop",

  // Every target whose status should reach this object.
  toTargets: (product) => [
    { type: "user", pubkey: product.sellerPubkey },
    { type: "event", id: product.id, author: product.sellerPubkey, kind: 30078 },
    { type: "address", kind: "30078", pubkey: product.sellerPubkey, identifier: product.sku },
  ],

  // The object's own identity, used to key its decision.
  getPrimaryTargetKey: (product) => `address:30078:${product.sellerPubkey}:${product.sku}`,

  // Optional: map the decision back into your shape.
  applyDecision: (product, decision) => ({
    ...product,
    hidden: decision.visibility.effect === "hide",
    rankPenalty: decision.ranking.weight,
    checkoutAllowed: (decision.transaction?.effect ?? "allow") === "allow",
  }),
});

Composition across an object's targets is ladder-maximum per dimension: the strictest verdict reaching the object wins. That is what makes "deny the seller" take their whole catalogue down without you writing any cascade logic.

Be deliberate about what you don't include. A review should answer to its reviewer and its own event — not to the product it reviews, or a bad product would silence honest reviews of it.

3. Write a policy

A policy is a set of named profiles, one per surface. Profiles read the same evidence and are free to reach different conclusions.

import { createPolicyDefinition } from "@bitgate/core";

export const policy = createPolicyDefinition({
  id: "my-shop",
  version: "1.0.0",
  defaultProfile: "browse",
  profiles: {
    browse: {
      name: "browse",
      administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" },
      requireAllowlist: true,
      allowlistMiss: { visibility: "hide", interaction: "deny" },
      allowViewerOverride: false,
      reports: {
        malware: { hide: 1, transactionDeny: 1 },
        scam: { downrank: 1, restrict: 3, transactionDeny: 3 },
        default: { downrank: 2, warn: 4 },
      },
      mutes: { default: { downrank: 1 } },
      muteWindowSeconds: 60 * 24 * 60 * 60,
    },

    checkout: {
      name: "checkout",
      administrativeDeny: { visibility: "allow", transaction: "deny" },
      allowViewerOverride: false,
      reports: { malware: { transactionDeny: 1 }, scam: { transactionDeny: 3 } },
      mutes: {},
    },
  },
});

Profile options

Option Effect
administrativeDeny Effects applied when the target is administratively denied
viewerBlock Effects applied when the viewer personally blocks
requireAllowlist / allowlistMiss Enforce an allowlist on this surface
allowViewerOverride Whether a viewer may soften a decision
exposeEvidence Whether contributor pubkeys reach the consumer
bypassHide / bypassHideCeiling Cap a hide on discovery surfaces
disabled Turn governance off for this surface, with a reason
reports / mutes Category thresholds
muteWindowSeconds Ignore mutes older than this

Threshold gates

A gate fires when count >= threshold. A threshold of 0 means disabled, not "always on" — so you can zero a gate out without it triggering on every target.

downrank → ranking      warn, restrict, hide, deny → visibility
requireExplicitAction, interactionDeny → interaction
transactionReview, transactionDeny → transaction

4. Supply a transport

The runtime depends on no relay pool. Implement three methods:

const transport = {
  async list(filters, options) { /* → NostrEvent[] */ },
  subscribe(filters, handlers, options) { /* → { close() } */ },
  async publish(event, options) {
    return { ok: true, accepted: ["wss://..."], failed: [] };
  },
};

publish should report partial acceptance honestly: ok: true with a non-empty failed list is a normal, successful outcome. The first acceptance means the event exists on the network; the remaining results are diagnostics.

A signer (getPublicKey, signEvent) and storage (read, write, remove) follow the same injection pattern. In-memory versions ship for testing:

import { createMemoryTransport, createMemoryStorage } from "@bitgate/runtime";

5. Wire the pipeline

import { collectTargets, evaluateObject } from "@bitgate/core";

runtime.setViewer(viewerPubkey);        // do this FIRST — it clears viewer state
runtime.trust.setContacts(followList);  // then seed trust
await runtime.loadAdministrativeState();

runtime.setActiveTargets(collectTargets(products, productAdapter));
runtime.subscribeToActiveTargetReports();

const governed = products.map((product) =>
  productAdapter.applyDecision(
    product,
    evaluateObject(product, productAdapter, (target) =>
      runtime.evaluate(target, { profile: "browse" }),
    ),
  ),
);

Order matters in one place: setViewer clears viewer-scoped state by design, so seed contacts, blocks, and overrides after it, never before.

6. Define your roles

Roles are application-defined bundles over a fixed capability vocabulary:

import { createRoleDefinition } from "@bitgate/core";

const roles = {
  listing_moderator: createRoleDefinition("listing_moderator", [
    "contribute-event-deny",
    "contribute-address-deny",
    "review-evidence",
  ]),
  seller_moderator: createRoleDefinition("seller_moderator", [
    "contribute-user-deny",
    "review-evidence",
  ]),
};

If you pass a custom roles map to setRoles, it replaces the defaults — include super_admin yourself if you still want it. The root administrator always holds every capability regardless, so a misconfigured roster cannot lock you out of your own instance.

Every command checks capability locally before the signer is invoked. An unauthorized actor cannot produce a signed mutation at all, rather than publishing one that quietly fails to take effect.

Common mistakes

Recomputing policy in your view layer. If your renderer is comparing report counts to thresholds, the extraction has failed. Consume decision.

Treating downrank as a filter. Downranking is a sort input. A downranked item still appears and can still be acted on; conflating the two is what makes moderation feel like censorship to the people affected.

Skipping the checkout re-check. Evaluate transaction-critical actions against their own profile at the moment of action. State moves between page render and button press.

Exposing evidence everywhere. exposeEvidence is off by default for a reason: public surfaces get counts, appeal surfaces get identities.

Assuming an allowlist implies trust. Being allowed to publish grants no trust, no moderator authority, and no immunity from personal blocks. The two systems are deliberately separate.

Testing your integration

The conformance harness takes JSON fixtures and asserts decisions:

import { runConformanceCase } from "@bitgate/testing/conformance";

const { passed, mismatches } = runConformanceCase(fixture, policy);

Expectations are partial — assert the dimensions your case is about and stay silent about the rest. See fixtures/bitvid/cases/ for the shape and examples/commerce/test/proof-of-fit.test.js for a full application suite.