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

238 lines
8.2 KiB
Markdown

# 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
```js
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.
```js
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:
```js
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:
```js
import { createMemoryTransport, createMemoryStorage } from "@bitgate/runtime";
```
## 5. Wire the pipeline
```js
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:
```js
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:
```js
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.