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.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-09 19:58:45 -04:00
1 parent 7f70a64d61
commit adeba151b8
63 files changed
+2774 -401

No files matched your search

+44 -5
View File
@@ -7,6 +7,34 @@ public API changes with it.
## [Unreleased]
### Changed — renamed to BitGate
`@nostr-governance/*` is now `@bitgate/*`, `governance-*` elements are
`bitgate-*`, and `createGovernanceRuntime` / `defineGovernanceElements` are
`createBitGate` / `defineBitGateElements`. Domain types keep their names: the
product is BitGate, the subject matter is still governance. See
[ADR 0005](docs/adr/0005-rename-to-bitgate.md).
The storage key prefix changed from `nostr-governance:` to `bitgate:`.
### Added — drop-in distribution
Adopting BitGate previously meant supplying a transport, authoring a policy,
and wiring every element in JavaScript. Three pieces close that gap, plus a
build step. See [ADR 0006](docs/adr/0006-drop-in-distribution.md).
- **`createRelayTransport(urls)`** — a small WebSocket transport with
reconnection, backoff, subscription replay, and cross-relay deduplication.
- **Policy presets** — `social`, `commerce`, `admin-only`, addressable by name.
The engine still carries no thresholds of its own.
- **`<bitgate-provider>`** — configuration in markup; descendants find the
runtime through a bubbling context-request event rather than DOM walking,
which would break across shadow boundaries. Targets can be read from
attributes (`target-user`, `target-event`, `target-address`).
- **`npm run build:widget`** — a self-contained `dist/bitgate.js` via esbuild.
Bare specifiers do not resolve in browsers, so the unbundled source worked
everywhere except a plain static page. Not committed: build from source.
### Added
- **Core decision model.** Four independent dimensions (`ranking`, `visibility`,
@@ -43,10 +71,10 @@ public API changes with it.
bounded subscription counts, and targeted invalidation.
- **Security requirements suite** (`tests/security/`) covering the enumerated
requirements testable at this layer.
- **`@nostr-governance/widget`**: framework-agnostic custom elements. Viewer
surfaces (`governance-veil`, `governance-report`, `governance-status`) and
moderator surfaces (`governance-capabilities`, `governance-action`,
`governance-admin-panel`), plus a runnable demo page. Elements render
- **`@bitgate/widget`**: framework-agnostic custom elements. Viewer
surfaces (`bitgate-veil`, `bitgate-report`, `bitgate-status`) and
moderator surfaces (`bitgate-capabilities`, `bitgate-action`,
`bitgate-admin-panel`), plus a runnable demo page. Elements render
decisions and issue commands; they compute no policy.
- **Runtime wiring**: storage persistence and hydration, signature verification
in the load path, trusted mute-list subscriptions, community source
@@ -55,6 +83,17 @@ public API changes with it.
### Fixed
- `GovernanceAdminStore` announced nothing when a roster change altered
capabilities without changing any denial, leaving capability-gated UI and the
decision cache stale.
- The relay transport sent every subscription twice on connect: queued REQ
frames were flushed *and* subscriptions were replayed from the map.
- The commerce preset computed a visibility verdict at checkout, a surface that
renders nothing, and could hide a listing from its own seller — leaving no
route to appeal. Checkout now declares only transaction gates, and the seller
dashboard caps visibility at a warning.
- `<bitgate-status>` threw if a runtime returned an unusable decision.
- `ReportStore.ingest` required a `target` field it never read.
- Per-package `types` fields pointed at paths the build never produced, so
consumers received no type declarations at all. Declarations now emit per
package and resolve.
@@ -100,7 +139,7 @@ public API changes with it.
- The evaluator no longer carries application thresholds. The previous invented
defaults (5/3/10/5) are gone; applications supply category thresholds through
a policy definition. The characterized reference values live in
`@nostr-governance/bitvid-compat` as one application's profile.
`@bitgate/bitvid-compat` as one application's profile.
- `createAuthorityState` takes an options object and always grants the root
every capability and protection.
- Evaluation takes a `GovernanceSnapshot` and a `ViewerState` rather than a
+66 -25
View File
@@ -1,8 +1,10 @@
# nostr-governance
# BitGate
A headless, application-agnostic governance engine for Nostr: moderation,
access control, and trust-graph policy, with no opinion about what your
application is.
Moderation and access control for Nostr apps. Decide what to show, what to
allow, and what to sell — without writing the policy code yourself.
Pairs with [BitLogin](https://github.com/PR0M3TH3AN/bitlogin): **BitLogin
answers "who are you." BitGate answers "what may you see, and what may you do."**
The engine carries **no thresholds of its own**. Applications supply a policy
definition; the engine supplies precedence, aggregation, composition, and
@@ -41,18 +43,52 @@ equivalent to following the operator's seed list.
| Package | Purpose |
| --- | --- |
| `@nostr-governance/core` | Pure evaluation. No I/O, no browser globals, no dependencies. |
| `@nostr-governance/nostr` | Codecs: canonical v1, NIP-56, NIP-51, legacy lists, bech32. |
| `@nostr-governance/runtime` | Stores, transport orchestration, commands. |
| `@nostr-governance/testing` | Conformance harness. |
| `@nostr-governance/widget` | Drop-in custom elements: viewer controls and a moderator console. |
| `@nostr-governance/bitvid-compat` | Reference application profile for the characterization corpus. |
| `@bitgate/core` | Pure evaluation. No I/O, no browser globals, no dependencies. |
| `@bitgate/nostr` | Codecs: canonical v1, NIP-56, NIP-51, legacy lists, bech32. |
| `@bitgate/runtime` | Stores, transport orchestration, commands. |
| `@bitgate/testing` | Conformance harness. |
| `@bitgate/widget` | Drop-in custom elements: viewer controls and a moderator console. |
| `@bitgate/bitvid-compat` | Reference application profile for the characterization corpus. |
## Quick start
## Quick start — a static page
No npm install, no bundler. Build the widget once, self-host it, and configure
everything in markup:
```bash
npm install && npm run build:widget # produces packages/widget/dist/bitgate.js
```
```html
<script type="module" src="/vendor/bitgate/bitgate.js"></script>
<bitgate-provider relays="wss://relay.example" root="<root-pubkey>" policy="social">
<bitgate-veil profile="feed" target-user="<author-pubkey>">
<img src="thumbnail.jpg" alt="" />
</bitgate-veil>
</bitgate-provider>
```
That is the whole integration. The provider builds a runtime, loads
administrative state, and descendants find it themselves — no wiring code.
Sign in with BitLogin and BitGate knows who the viewer is:
```js
import { createPolicyDefinition, evaluateTarget } from "@nostr-governance/core";
import { createGovernanceRuntime, createMemoryTransport } from "@nostr-governance/runtime";
const provider = document.querySelector("bitgate-provider");
await provider.ready;
await provider.useSigner(window.nostr); // BitLogin's NIP-07 provider
```
Policy presets: `social`, `commerce`, `admin-only`. Every number in them is a
starting point — see the [integration guide](docs/integration-guide.md) to
write your own.
## Quick start — JavaScript
```js
import { createPolicyDefinition, evaluateTarget } from "@bitgate/core";
import { createBitGate, createMemoryTransport } from "@bitgate/runtime";
const policy = createPolicyDefinition({
id: "my-app",
@@ -72,10 +108,11 @@ const policy = createPolicyDefinition({
},
});
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "my-app",
namespace: "myapp",
transport: myRelayAdapter,
root: ROOT_PUBKEY,
transport: createRelayTransport(["wss://relay.example"]),
policy,
now: () => Math.floor(Date.now() / 1000),
});
@@ -94,7 +131,7 @@ Write an adapter saying what your object is in governance terms, and a policy
saying what the evidence means to you. Nothing else.
```js
import { createApplicationAdapter, evaluateObject } from "@nostr-governance/core";
import { createApplicationAdapter, evaluateObject } from "@bitgate/core";
const productAdapter = createApplicationAdapter({
applicationId: "my-shop",
@@ -119,25 +156,25 @@ seller-facing explanations. See [`docs/integration-guide.md`](docs/integration-g
## Plug-and-play UI
`@nostr-governance/widget` ships custom elements that render decisions and issue
`@bitgate/widget` ships custom elements that render decisions and issue
commands — and compute no policy of their own:
```html
<governance-veil profile="feed"><img src="thumbnail.jpg" alt="" /></governance-veil>
<bitgate-veil profile="feed"><img src="thumbnail.jpg" alt="" /></bitgate-veil>
```
```js
import { defineGovernanceElements } from "@nostr-governance/widget";
defineGovernanceElements();
import { defineBitGateElements } from "@bitgate/widget";
defineBitGateElements();
const veil = document.querySelector("governance-veil");
const veil = document.querySelector("bitgate-veil");
veil.runtime = runtime;
veil.target = { type: "event", id, author };
```
Viewer elements (`governance-veil`, `governance-report`, `governance-status`)
and moderator elements (`governance-capabilities`, `governance-action`,
`governance-admin-panel`) are separate, so a site embeds only what it needs.
Viewer elements (`bitgate-veil`, `bitgate-report`, `bitgate-status`)
and moderator elements (`bitgate-capabilities`, `bitgate-action`,
`bitgate-admin-panel`) are separate, so a site embeds only what it needs.
See [`packages/widget/README.md`](packages/widget/README.md) and the runnable
[`examples/commerce/demo.html`](examples/commerce/demo.html).
@@ -164,9 +201,13 @@ curators list them.
npm install
npm run lint # reference guard + typecheck
npm test # unit + conformance
npm run build:types # per-package .d.ts
npm run build # .d.ts for every package + the widget bundle
```
The widget bundle is **not** committed. Build it from source and self-host the
result — a moderation widget is exactly the kind of artifact where reading the
source matters.
BitVid and BitRoad are **read-only reference repositories**, pinned by commit in
[`docs/reference-map.md`](docs/reference-map.md). Behavior is copied via fixtures
and characterization tests, never by importing their source;
@@ -15,7 +15,7 @@ The core defines the *vocabulary* — target types, capabilities, effect ladders
reason identifiers, aggregation and precedence rules — and no thresholds.
Applications supply a `PolicyDefinition` of named profiles with category
thresholds. The characterized reference values live in
`@nostr-governance/bitvid-compat` as one application's profile.
`@bitgate/bitvid-compat` as one application's profile.
`NEUTRAL_POLICY` is the fallback: it enforces administrative denial and applies
no trust thresholds, so the generic engine never hides content merely because
+2 -2
View File
@@ -11,8 +11,8 @@ meant to have no dependencies and no encoding opinions.
## Decision
`@nostr-governance/core` accepts 64-character hex only. Decoding lives in
`@nostr-governance/nostr` (`normalizePubkeyInput`, `normalizeEventIdInput`),
`@bitgate/core` accepts 64-character hex only. Decoding lives in
`@bitgate/nostr` (`normalizePubkeyInput`, `normalizeEventIdInput`),
and codecs normalize at ingest, so the core only ever sees hex.
## Consequences
+52
View File
@@ -0,0 +1,52 @@
# ADR 0005 — Rename to BitGate
**Status:** accepted (supersedes the repository-name decision in §5.1 of the
migration plan)
## Context
The project shipped as `nostr-governance`, a name chosen while it was still a
specification exercise. Three problems emerged once it became a product.
"Governance" means something specific and different to this audience. In Nostr
and crypto circles it reads as DAO voting and token-weighted proposals. The
project is a moderation and access-control policy engine; the word misdirects.
It named a category rather than a product. It could not be verbed, and it gave
no signal that BitVid and BitRoad compose with it.
It named the mechanism rather than the benefit. The sibling project BitLogin is
not called "Nostr Portable Auth" — it is called BitLogin, because *login* is
what people want.
## Decision
Rename to **BitGate**, joining the existing `bit-` family (BitVid, BitRoad,
BitLogin, BitUnlock). Gates open and close, which maps onto the allow/deny
ladder, and "gate your app" is a natural verb.
- npm scope: `@nostr-governance/*` → `@bitgate/*`
- custom elements: `governance-*` → `bitgate-*`
- custom events: `governance:*` → `bitgate:*`
- storage key prefix: `nostr-governance:` → `bitgate:`
- consumer entry points: `createGovernanceRuntime` → `createBitGate`,
`defineGovernanceElements` → `defineBitGateElements`
Domain vocabulary is unchanged. `GovernanceDecision`, `GovernanceTarget`, and
`GovernanceRuntime` keep their names: the product is BitGate, the subject
matter is still governance.
The migration plan and the `.hermes` execution plan are left as written. They
record decisions as they were made; this ADR records the change rather than
rewriting history.
## Consequences
Done at 0.1.0, unpublished, with no external consumers — the cheapest this
could ever be. The storage key prefix changed, which would have invalidated
caches had anything been deployed.
The `bit-` prefix signals membership in one author's suite, which is a mild
cost for infrastructure intended for wider adoption. BitLogin carries the same
cost and it has not hurt it; the composability signal is worth more, especially
since BitLogin and BitGate are designed to be used together.
+50
View File
@@ -0,0 +1,50 @@
# ADR 0006 — Ship a built, self-contained widget
**Status:** accepted
## Context
BitGate was a well-factored SDK that was not droppable into a page. Adopting it
required supplying a transport adapter, authoring a policy definition, and
wiring every element in JavaScript. BitLogin sets a much higher bar: copy two
files, add one script tag and one element.
Testing the widget in a real browser exposed a failure no unit test could
catch. `packages/widget/src/index.js` imports `@bitgate/core` and
`@bitgate/runtime` as bare specifiers. Node and the test runner resolve those
through `node_modules`; a browser cannot resolve them at all. The source worked
everywhere except the one place the widget exists to serve.
## Decision
Three pieces close the gap.
1. **`createRelayTransport(urls)`** — a small WebSocket transport, so nobody
writes an adapter to get started. It speaks enough of the protocol to load
state, subscribe, and publish; it is not a general-purpose relay client.
2. **Policy presets** — `social`, `commerce`, `admin-only`, addressable by name.
The engine still carries no thresholds: these are application policies that
happen to ship in the box, and every number in them is documented as a
starting point.
3. **`<bitgate-provider>`** — configuration in markup. Descendants find the
runtime by firing a bubbling context-request event rather than walking up
the DOM, which would break across shadow boundaries.
Plus a build step (`npm run build:widget`) producing a single self-contained
`dist/bitgate.js` via esbuild.
## Consequences
A static page needs one script tag and one element, matching BitLogin.
The build output is deliberately **not** committed. Consumers build from
reviewed source and self-host the result, as BitLogin instructs, because a
moderation widget is exactly the kind of artifact where reading the source
matters and trusting a prebuilt minified blob does not.
The provider exposes `ready` as a promise as well as an event. The event fires
during element upgrade — before a page that calls `defineBitGateElements()`
after parsing could attach a listener — so an event alone was a race that
silently lost. This was found by running the demo, not by a test.
+1 -1
View File
@@ -35,7 +35,7 @@ Trusted spam-report hide threshold: 5
Trusted mute validity window: 60 days
```
These live in `@nostr-governance/bitvid-compat` as one application's profile,
These live in `@bitgate/bitvid-compat` as one application's profile,
not as engine defaults.
Provenance: fixtures are authored from the reference source and its documented
+6 -6
View File
@@ -25,7 +25,7 @@ revision was bad. Most objects want both.
## 2. Write an adapter
```js
import { createApplicationAdapter } from "@nostr-governance/core";
import { createApplicationAdapter } from "@bitgate/core";
export const productAdapter = createApplicationAdapter({
applicationId: "my-shop",
@@ -64,7 +64,7 @@ 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 "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
export const policy = createPolicyDefinition({
id: "my-shop",
@@ -145,13 +145,13 @@ 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 "@nostr-governance/runtime";
import { createMemoryTransport, createMemoryStorage } from "@bitgate/runtime";
```
## 5. Wire the pipeline
```js
import { collectTargets, evaluateObject } from "@nostr-governance/core";
import { collectTargets, evaluateObject } from "@bitgate/core";
runtime.setViewer(viewerPubkey); // do this FIRST — it clears viewer state
runtime.trust.setContacts(followList); // then seed trust
@@ -178,7 +178,7 @@ seed contacts, blocks, and overrides *after* it, never before.
Roles are application-defined bundles over a fixed capability vocabulary:
```js
import { createRoleDefinition } from "@nostr-governance/core";
import { createRoleDefinition } from "@bitgate/core";
const roles = {
listing_moderator: createRoleDefinition("listing_moderator", [
@@ -227,7 +227,7 @@ systems are deliberately separate.
The conformance harness takes JSON fixtures and asserts decisions:
```js
import { runConformanceCase } from "@nostr-governance/testing/conformance";
import { runConformanceCase } from "@bitgate/testing/conformance";
const { passed, mismatches } = runConformanceCase(fixture, policy);
```
+5 -5
View File
@@ -23,11 +23,11 @@ Until a lawyer-ready SPDX audit is done, treat the repository as:
| Package | Intention |
| --- | --- |
| `@nostr-governance/core` | Clean API surface + pure evaluator. Avoid BitVid names. |
| `@nostr-governance/nostr` | Codecs. Legacy BitVid codec is provenance-sensitive. |
| `@nostr-governance/runtime` | Transport/storage orchestration. |
| `@nostr-governance/bitvid-compat` | Explicit BitVid-facing adapter. Most provenance-exposed. |
| `@nostr-governance/testing` | Fakes and fixture loaders. |
| `@bitgate/core` | Clean API surface + pure evaluator. Avoid BitVid names. |
| `@bitgate/nostr` | Codecs. Legacy BitVid codec is provenance-sensitive. |
| `@bitgate/runtime` | Transport/storage orchestration. |
| `@bitgate/bitvid-compat` | Explicit BitVid-facing adapter. Most provenance-exposed. |
| `@bitgate/testing` | Fakes and fixture loaders. |
## Rules
+1 -1
View File
@@ -4,7 +4,7 @@ A marketplace built on `nostr-governance`, using only the published API.
The point is not to ship marketplace moderation. It is to prove the engine did
not accidentally stay video-specific during extraction: if a commerce consumer
needs a change to `@nostr-governance/core`, the abstraction is wrong.
needs a change to `@bitgate/core`, the abstraction is wrong.
## What it demonstrates
+13 -13
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>nostr-governance widget demo</title>
<title>BitGate widget demo</title>
<style>
:root { color-scheme: light dark; }
body {
@@ -29,20 +29,20 @@
</style>
</head>
<body>
<h1>nostr-governance widget</h1>
<h1>BitGate widget demo</h1>
<p class="lede">
Every element below reads from one shared runtime. Toggle governance state
and watch each surface reach its own conclusion from the same evidence.
</p>
<section>
<h2>Viewer surface — <code>&lt;governance-veil&gt;</code></h2>
<governance-veil id="veil" profile="public-marketplace">
<h2>Viewer surface — <code>&lt;bitgate-veil&gt;</code></h2>
<bitgate-veil id="veil" profile="public-marketplace">
<div class="card">
<img alt="" />
<p><strong>Widget</strong> — 1000 sats</p>
</div>
</governance-veil>
</bitgate-veil>
<div class="controls">
<button id="report">Add a trusted scam report</button>
@@ -61,8 +61,8 @@
</section>
<section>
<h2>Status readout — <code>&lt;governance-status&gt;</code></h2>
<governance-status id="status" profile="seller-dashboard"></governance-status>
<h2>Status readout — <code>&lt;bitgate-status&gt;</code></h2>
<bitgate-status id="status" profile="seller-dashboard"></bitgate-status>
</section>
<section>
@@ -72,20 +72,20 @@
listings but not sellers. Note that the refused control stays visible
and says what it needs.
</p>
<governance-capabilities id="caps"></governance-capabilities>
<governance-admin-panel id="panel"></governance-admin-panel>
<bitgate-capabilities id="caps"></bitgate-capabilities>
<bitgate-admin-panel id="panel"></bitgate-admin-panel>
</section>
<script type="module">
import {
createCommands,
createGovernanceRuntime,
createBitGate,
createMemoryTransport,
} from "../../packages/runtime/src/index.js";
import { defineGovernanceElements } from "../../packages/widget/src/index.js";
import { defineBitGateElements } from "../../packages/widget/src/index.js";
import { COMMERCE_POLICY, COMMERCE_ROLE_CAPABILITIES } from "./src/policy.js";
defineGovernanceElements();
defineBitGateElements();
const key = (seed) => seed.repeat(64).slice(0, 64);
const ROOT = key("a1");
@@ -95,7 +95,7 @@
const NOW = Math.floor(Date.now() / 1000);
const trusted = (i) => key(i.toString(16).padStart(2, "0"));
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "commerce-demo",
namespace: "commerce",
root: ROOT,
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@nostr-governance/example-commerce",
"name": "@bitgate/example-commerce",
"version": "0.1.0",
"type": "module",
"private": true,
@@ -9,7 +9,7 @@
"test": "vitest run"
},
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/runtime": "*"
"@bitgate/core": "*",
"@bitgate/runtime": "*"
}
}
+5 -5
View File
@@ -5,7 +5,7 @@
// no thresholds, no precedence rules, no policy logic. Those live in the
// policy definition and the engine respectively.
import { createApplicationAdapter } from "@nostr-governance/core";
import { createApplicationAdapter } from "@bitgate/core";
/** Parameterized-replaceable kind used for commerce documents in this example. */
export const PRODUCT_KIND = 30078;
@@ -43,7 +43,7 @@ export const PRODUCT_KIND = 30078;
* would not. Both are kept, because an exact-event denial must also stay
* possible for the case where only one revision was bad.
*
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<Product>}
* @type {import('@bitgate/core').GovernanceApplicationAdapter<Product>}
*/
export const productAdapter = createApplicationAdapter({
applicationId: "commerce-example",
@@ -84,7 +84,7 @@ export const productAdapter = createApplicationAdapter({
/**
* Storefronts answer to the seller, the storefront address, and the exact event.
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<Storefront>}
* @type {import('@bitgate/core').GovernanceApplicationAdapter<Storefront>}
*/
export const storefrontAdapter = createApplicationAdapter({
applicationId: "commerce-example",
@@ -114,7 +114,7 @@ export const storefrontAdapter = createApplicationAdapter({
* honest reviews of it, which is exactly the outcome an over-broad target list
* would produce.
*
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<Review>}
* @type {import('@bitgate/core').GovernanceApplicationAdapter<Review>}
*/
export const reviewAdapter = createApplicationAdapter({
applicationId: "commerce-example",
@@ -141,7 +141,7 @@ export const reviewAdapter = createApplicationAdapter({
/**
* Sellers answer only to their own user target.
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<{ pubkey: string }>}
* @type {import('@bitgate/core').GovernanceApplicationAdapter<{ pubkey: string }>}
*/
export const sellerAdapter = createApplicationAdapter({
applicationId: "commerce-example",
+5 -5
View File
@@ -4,7 +4,7 @@
// the answer. It computes no thresholds and reimplements no precedence — which
// is the property the extraction is meant to guarantee.
import { collectTargets, evaluateObject } from "@nostr-governance/core";
import { collectTargets, evaluateObject } from "@bitgate/core";
import { productAdapter } from "./adapters.js";
@@ -20,7 +20,7 @@ import { productAdapter } from "./adapters.js";
* listing.
*
* @param {Product[]} products
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
* @param {import('@bitgate/runtime').GovernanceRuntime} runtime
* @param {string} profile
* @returns {Array<Product & { moderation: any }>}
*/
@@ -43,7 +43,7 @@ export function governProductFeed(products, runtime, profile) {
* like censorship to sellers.
*
* @param {Product[]} products
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
* @param {import('@bitgate/runtime').GovernanceRuntime} runtime
* @returns {Array<Product & { moderation: any }>}
*/
export function buildMarketplaceGrid(products, runtime) {
@@ -67,7 +67,7 @@ export function buildMarketplaceGrid(products, runtime) {
* checkout is the point where being wrong actually costs someone money.
*
* @param {Product} product
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
* @param {import('@bitgate/runtime').GovernanceRuntime} runtime
* @returns {CheckoutVerdict}
*/
export function checkoutVerdict(product, runtime) {
@@ -90,7 +90,7 @@ export function checkoutVerdict(product, runtime) {
* that can answer "how many people reported me, and for what".
*
* @param {Product} product
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
* @param {import('@bitgate/runtime').GovernanceRuntime} runtime
* @returns {{ visible: boolean, sellable: boolean, reasons: string[], evidence: any }}
*/
export function explainToSeller(product, runtime) {
+4 -4
View File
@@ -1,7 +1,7 @@
// Commerce policy profiles.
//
// This example exists to prove the engine is not video-specific. Nothing here
// touches @nostr-governance/core internals: it is built entirely from the
// touches @bitgate/core internals: it is built entirely from the
// public API, and the core carries none of these numbers.
//
// The interesting property is that one product can simultaneously be visible to
@@ -9,7 +9,7 @@
// blocked from checkout — four different answers from one decision, which a
// single `hidden` boolean could not express.
import { createPolicyDefinition, createRoleDefinition } from "@nostr-governance/core";
import { createPolicyDefinition, createRoleDefinition } from "@bitgate/core";
/**
* Category thresholds for a marketplace.
@@ -18,7 +18,7 @@ import { createPolicyDefinition, createRoleDefinition } from "@nostr-governance/
* blocks checkout, because the cost of a false negative is a compromised buyer,
* while an over-eager `misleading` threshold would just suppress lawful listings.
*
* @type {Record<string, import('@nostr-governance/core').CategoryThresholds>}
* @type {Record<string, import('@bitgate/core').CategoryThresholds>}
*/
export const MARKETPLACE_REPORT_THRESHOLDS = {
scam: {
@@ -137,7 +137,7 @@ export const COMMERCE_ROLES = {
/**
* Role capability map in the shape createAuthorityState expects.
* @type {Record<string, import('@nostr-governance/core').GovernanceCapability[]>}
* @type {Record<string, import('@bitgate/core').GovernanceCapability[]>}
*/
export const COMMERCE_ROLE_CAPABILITIES = Object.fromEntries(
Object.values(COMMERCE_ROLES).map((role) => [role.name, role.capabilities]),
+4 -4
View File
@@ -6,7 +6,7 @@
import { describe, expect, it } from "vitest";
import { createCommands, createGovernanceRuntime, createMemoryTransport } from "@nostr-governance/runtime";
import { createCommands, createBitGate, createMemoryTransport } from "@bitgate/runtime";
import { COMMERCE_POLICY, COMMERCE_ROLE_CAPABILITIES } from "../src/policy.js";
import { productAdapter, reviewAdapter, sellerAdapter } from "../src/adapters.js";
@@ -57,7 +57,7 @@ const RIVAL_PRODUCT = {
};
function setup({ allowlisted = [SELLER, OTHER_SELLER] } = {}) {
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "commerce-example",
namespace: "commerce",
transport: createMemoryTransport(),
@@ -298,7 +298,7 @@ describe("7. moderator capability checks", () => {
});
function commandsAs(pubkey) {
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "commerce-example",
namespace: "commerce",
transport: createMemoryTransport(),
@@ -462,7 +462,7 @@ describe("10. no core changes required", () => {
// Every import in this example resolves through a package entry point; if
// one reached into a package's internals, the reference guard and this
// assertion are what would catch it.
const core = await import("@nostr-governance/core");
const core = await import("@bitgate/core");
for (const name of [
"createApplicationAdapter",
"evaluateObject",
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>BitGate drop-in</title>
</head>
<body>
<h1>Drop-in test</h1>
<!-- Everything below is configured in markup. No setup JavaScript. -->
<bitgate-provider relays="wss://relay.example" root="a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" policy="social">
<bitgate-veil profile="feed" target-user="d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4">
<p id="content">This content is governed.</p>
</bitgate-veil>
<bitgate-status profile="playback" target-user="d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"></bitgate-status>
</bitgate-provider>
<pre id="result">running…</pre>
<script type="module">
import { defineBitGateElements } from "../packages/widget/dist/bitgate.js";
defineBitGateElements();
const out = [];
const provider = document.querySelector("bitgate-provider");
const veil = document.querySelector("bitgate-veil");
// Awaiting the promise avoids racing the ready event, which fires during
// element upgrade — before this script could attach a listener.
provider.ready.then(() => {
out.push("provider ready: " + Boolean(provider.runtime));
out.push("veil adopted runtime: " + (veil.runtime === provider.runtime));
out.push("policy: " + provider.runtime.policies.policy.id);
out.push("target from attribute: " + JSON.stringify(veil.target));
// Deny the creator and confirm the veil reacts with no further wiring.
provider.runtime.admin.setRoles({
root: "a1".repeat(32),
actors: { ["a1".repeat(32)]: ["super_admin"] },
});
provider.runtime.admin.upsertContribution({
actor: "a1".repeat(32),
kind: "user-deny",
targets: [{ type: "user", pubkey: "d4".repeat(32) }],
createdAt: 1,
});
// The social preset's feed profile caps a hide at "restrict", so the
// veil blurs rather than hides. Playback enforces the full decision.
const feed = provider.runtime.evaluate(veil.target, { profile: "feed" });
const playback = provider.runtime.evaluate(veil.target, { profile: "playback" });
out.push("feed visibility: " + feed.visibility.effect + " (blurred, not hidden)");
out.push("playback visibility: " + playback.visibility.effect);
out.push("reasons: " + feed.reasons.map((r) => r.id).join(", "));
out.push("veil is blurred: " + Boolean(veil.shadowRoot.querySelector(".blurred")));
document.getElementById("result").textContent = out.join("\n");
});
</script>
</body>
</html>
+48 -47
View File
@@ -1,11 +1,11 @@
{
"name": "nostr-governance",
"name": "bitgate",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nostr-governance",
"name": "bitgate",
"version": "0.1.0",
"workspaces": [
"packages/*",
@@ -13,6 +13,7 @@
],
"devDependencies": {
"@types/node": "^22.10.0",
"esbuild": "^0.28.2",
"happy-dom": "^20.11.2",
"typescript": "^5.4.5",
"vitest": "^3.2.7"
@@ -22,13 +23,41 @@
}
},
"examples/commerce": {
"name": "@nostr-governance/example-commerce",
"name": "@bitgate/example-commerce",
"version": "0.1.0",
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/runtime": "*"
"@bitgate/core": "*",
"@bitgate/runtime": "*"
}
},
"node_modules/@bitgate/bitvid-compat": {
"resolved": "packages/bitvid-compat",
"link": true
},
"node_modules/@bitgate/core": {
"resolved": "packages/core",
"link": true
},
"node_modules/@bitgate/example-commerce": {
"resolved": "examples/commerce",
"link": true
},
"node_modules/@bitgate/nostr": {
"resolved": "packages/nostr",
"link": true
},
"node_modules/@bitgate/runtime": {
"resolved": "packages/runtime",
"link": true
},
"node_modules/@bitgate/testing": {
"resolved": "packages/testing",
"link": true
},
"node_modules/@bitgate/widget": {
"resolved": "packages/widget",
"link": true
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -495,34 +524,6 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@nostr-governance/bitvid-compat": {
"resolved": "packages/bitvid-compat",
"link": true
},
"node_modules/@nostr-governance/core": {
"resolved": "packages/core",
"link": true
},
"node_modules/@nostr-governance/example-commerce": {
"resolved": "examples/commerce",
"link": true
},
"node_modules/@nostr-governance/nostr": {
"resolved": "packages/nostr",
"link": true
},
"node_modules/@nostr-governance/runtime": {
"resolved": "packages/runtime",
"link": true
},
"node_modules/@nostr-governance/testing": {
"resolved": "packages/testing",
"link": true
},
"node_modules/@nostr-governance/widget": {
"resolved": "packages/widget",
"link": true
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
@@ -1771,45 +1772,45 @@
}
},
"packages/bitvid-compat": {
"name": "@nostr-governance/bitvid-compat",
"name": "@bitgate/bitvid-compat",
"version": "0.1.0",
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/nostr": "*"
"@bitgate/core": "*",
"@bitgate/nostr": "*"
}
},
"packages/core": {
"name": "@nostr-governance/core",
"name": "@bitgate/core",
"version": "0.1.0"
},
"packages/nostr": {
"name": "@nostr-governance/nostr",
"name": "@bitgate/nostr",
"version": "0.1.0",
"dependencies": {
"@nostr-governance/core": "*"
"@bitgate/core": "*"
}
},
"packages/runtime": {
"name": "@nostr-governance/runtime",
"name": "@bitgate/runtime",
"version": "0.1.0",
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/nostr": "*"
"@bitgate/core": "*",
"@bitgate/nostr": "*"
}
},
"packages/testing": {
"name": "@nostr-governance/testing",
"name": "@bitgate/testing",
"version": "0.1.0",
"dependencies": {
"@nostr-governance/core": "*"
"@bitgate/core": "*"
}
},
"packages/widget": {
"name": "@nostr-governance/widget",
"name": "@bitgate/widget",
"version": "0.1.0",
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/runtime": "*"
"@bitgate/core": "*",
"@bitgate/runtime": "*"
}
}
}
+6 -3
View File
@@ -1,5 +1,5 @@
{
"name": "nostr-governance",
"name": "bitgate",
"version": "0.1.0",
"private": true,
"type": "module",
@@ -12,7 +12,9 @@
"build:types": "tsc -p packages/core/tsconfig.types.json && tsc -p packages/nostr/tsconfig.types.json && tsc -p packages/runtime/tsconfig.types.json && tsc -p packages/bitvid-compat/tsconfig.types.json && tsc -p packages/widget/tsconfig.types.json && tsc -p packages/testing/tsconfig.types.json",
"lint": "npm run check:references && npm run check:types",
"check:references": "node scripts/verify-no-reference-imports.mjs",
"check:types": "tsc -p tsconfig.json --noEmit && tsc -p packages/widget/tsconfig.json --noEmit"
"check:types": "tsc -p tsconfig.json --noEmit && tsc -p packages/widget/tsconfig.json --noEmit",
"build:widget": "node packages/widget/build.mjs",
"build": "npm run build:types && npm run build:widget"
},
"engines": {
"node": ">=22"
@@ -25,6 +27,7 @@
"@types/node": "^22.10.0",
"happy-dom": "^20.11.2",
"typescript": "^5.4.5",
"vitest": "^3.2.7"
"vitest": "^3.2.7",
"esbuild": "^0.28.2"
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@nostr-governance/bitvid-compat",
"name": "@bitgate/bitvid-compat",
"version": "0.1.0",
"type": "module",
"main": "src/index.js",
@@ -22,7 +22,7 @@
"test": "vitest run"
},
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/nostr": "*"
"@bitgate/core": "*",
"@bitgate/nostr": "*"
}
}
+5 -5
View File
@@ -1,7 +1,7 @@
// Reference application profile for the extraction's characterization corpus.
//
// These values are one application's configuration, not governance defaults.
// They live outside @nostr-governance/core on purpose: the core engine must
// They live outside @bitgate/core on purpose: the core engine must
// carry no application thresholds, and this file is what proves the generic
// engine can reproduce a real deployment's behavior.
//
@@ -9,7 +9,7 @@
// docs/reference-map.md). Changing a number here should break a conformance
// fixture — that is the point.
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
/** Trusted reports needed before a thumbnail is blurred. */
export const BLUR_THRESHOLD = 3;
@@ -34,7 +34,7 @@ export const TRUSTED_MUTE_WINDOW_SECONDS = TRUSTED_MUTE_WINDOW_DAYS * 24 * 60 *
* autoplay threshold is the withheld autoplay. Spam escalates to a hide at its
* own higher threshold, while other categories never hide on reports alone.
*
* @type {Record<string, import('@nostr-governance/core').CategoryThresholds>}
* @type {Record<string, import('@bitgate/core').CategoryThresholds>}
*/
const REPORT_THRESHOLDS = {
default: {
@@ -54,7 +54,7 @@ const REPORT_THRESHOLDS = {
* Below the hide threshold a trusted mute is a ranking signal only: it must not
* blur, must not block playback, and must not read as an administrative denial.
*
* @type {Record<string, import('@nostr-governance/core').CategoryThresholds>}
* @type {Record<string, import('@bitgate/core').CategoryThresholds>}
*/
const MUTE_THRESHOLDS = {
default: {
@@ -70,7 +70,7 @@ const MUTE_THRESHOLDS = {
* override: discovery feeds decline to hard-hide so that a single stale list
* cannot empty a feed, while playback enforces the full decision.
*
* @type {import('@nostr-governance/core').PolicyDefinition}
* @type {import('@bitgate/core').PolicyDefinition}
*/
export const REFERENCE_POLICY = createPolicyDefinition({
id: "reference-video-app",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@nostr-governance/core",
"name": "@bitgate/core",
"version": "0.1.0",
"type": "module",
"main": "src/index.js",
+9 -1
View File
@@ -1,4 +1,4 @@
// @nostr-governance/core
// @bitgate/core
//
// Pure, headless governance primitives. No I/O, no browser globals, no
// application thresholds. Applications supply a policy definition and a
@@ -85,6 +85,14 @@ export {
collectTargets,
} from "./adapter.js";
export {
SOCIAL_POLICY,
COMMERCE_POLICY,
ADMIN_ONLY_POLICY,
POLICY_PRESETS,
getPolicyPreset,
} from "./presets.js";
export {
canonicalStringify,
hashString,
+249
View File
@@ -0,0 +1,249 @@
// Starter policies.
//
// The engine carries no thresholds of its own, and that stays true: these are
// application policies that happen to ship in the box. They exist so the first
// run does not require authoring a threshold table, not because the engine has
// an opinion.
//
// Every number here is a starting point. Read `docs/integration-guide.md` and
// replace them once you know your own community — a preset that fits everyone
// fits nobody especially well.
import { createPolicyDefinition } from "./policy.js";
const DAY = 24 * 60 * 60;
/**
* Report thresholds for a social feed.
*
* Malware and impersonation escalate fastest because their harm is immediate
* and specific. Broad categories like "spam" need more agreement before they
* do anything visible, since they are the easiest to weaponize against someone
* unpopular.
*
* @type {Record<string, import('./policy.js').CategoryThresholds>}
*/
const SOCIAL_REPORTS = {
malware: { warn: 1, restrict: 1, hide: 2, interactionDeny: 1 },
impersonation: { warn: 1, restrict: 2, hide: 4 },
nudity: { restrict: 2, requireExplicitAction: 2 },
spam: { downrank: 2, restrict: 4, hide: 8 },
harassment: { downrank: 1, warn: 2, restrict: 3, hide: 6 },
default: { downrank: 2, warn: 4, restrict: 6 },
};
/**
* Trusted mutes downrank from the first muter and hide only at scale.
*
* A mute is a personal preference, not an accusation. One person you follow
* muting someone should nudge the ordering, nothing more.
*/
const SOCIAL_MUTES = {
default: { downrank: 1, hide: 12 },
};
/**
* A social-feed policy: timelines, profiles, and playback.
*
* Discovery surfaces decline to hard-hide, so one stale list cannot empty a
* feed; the item is restricted and downranked instead. Playback enforces the
* full decision, because that is where someone has deliberately chosen to
* engage with a specific thing.
*
* @type {import('./policy.js').PolicyDefinition}
*/
export const SOCIAL_POLICY = createPolicyDefinition({
id: "bitgate-social",
name: "Social (starter)",
description: "Starting thresholds for feeds, profiles, and playback. Tune before relying on them.",
version: "1.0.0",
defaultProfile: "feed",
profiles: {
feed: {
name: "feed",
administrativeDeny: { visibility: "hide", interaction: "deny" },
viewerBlock: { visibility: "hide", interaction: "deny" },
allowViewerOverride: true,
bypassHide: true,
bypassHideCeiling: "restrict",
muteWindowSeconds: 60 * DAY,
reports: SOCIAL_REPORTS,
mutes: SOCIAL_MUTES,
},
profile: {
name: "profile",
administrativeDeny: { visibility: "warn", interaction: "require-explicit-action" },
viewerBlock: { visibility: "hide", interaction: "deny" },
allowViewerOverride: true,
muteWindowSeconds: 60 * DAY,
reports: SOCIAL_REPORTS,
mutes: SOCIAL_MUTES,
},
playback: {
name: "playback",
administrativeDeny: { visibility: "deny", interaction: "deny" },
viewerBlock: { visibility: "hide", interaction: "deny" },
allowViewerOverride: true,
exposeEvidence: true,
muteWindowSeconds: 60 * DAY,
reports: SOCIAL_REPORTS,
mutes: SOCIAL_MUTES,
},
},
});
/**
* Report thresholds for a marketplace.
*
* Money changes the calculus: one credible malware report should stop a sale,
* because the cost of being wrong runs one way. Visibility stays more generous
* than transaction throughout — a listing can remain readable while being
* unbuyable.
*
* @type {Record<string, import('./policy.js').CategoryThresholds>}
*/
const COMMERCE_REPORTS = {
malware: { warn: 1, restrict: 1, hide: 2, transactionDeny: 1 },
scam: { downrank: 1, warn: 2, restrict: 3, hide: 5, transactionReview: 2, transactionDeny: 3 },
counterfeit: { downrank: 1, warn: 2, restrict: 4, transactionReview: 3 },
"not-as-described": { downrank: 2, warn: 4, transactionReview: 5 },
misleading: { downrank: 2, warn: 3, restrict: 5, hide: 8 },
default: { downrank: 2, warn: 4 },
};
const COMMERCE_MUTES = {
default: { downrank: 1 },
};
/**
* Checkout cares about one question only: may this sale proceed?
*
* A visibility verdict here would be meaningless — checkout is not rendering
* the listing — and actively confusing, since it would suggest the buyer is
* being shown something they are not. Only transaction gates are declared.
*
* @type {Record<string, import('./policy.js').CategoryThresholds>}
*/
const CHECKOUT_REPORTS = {
malware: { transactionDeny: 1 },
scam: { transactionReview: 2, transactionDeny: 3 },
counterfeit: { transactionReview: 3 },
"not-as-described": { transactionReview: 5 },
};
/**
* A commerce policy: discovery, detail, checkout, and a seller's own view.
*
* The four surfaces exist because one product legitimately has four different
* answers at once — hidden from discovery, inspectable on its detail page,
* unbuyable at checkout, and fully visible with evidence to its own seller.
*
* @type {import('./policy.js').PolicyDefinition}
*/
export const COMMERCE_POLICY = createPolicyDefinition({
id: "bitgate-commerce",
name: "Commerce (starter)",
description: "Starting thresholds for marketplaces. Tune before relying on them.",
version: "1.0.0",
defaultProfile: "browse",
profiles: {
browse: {
name: "browse",
administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" },
viewerBlock: { visibility: "hide", interaction: "deny" },
allowViewerOverride: false,
muteWindowSeconds: 60 * DAY,
reports: COMMERCE_REPORTS,
mutes: COMMERCE_MUTES,
},
detail: {
name: "detail",
administrativeDeny: {
visibility: "restrict",
interaction: "require-explicit-action",
transaction: "deny",
},
viewerBlock: { visibility: "hide", interaction: "deny" },
allowViewerOverride: true,
muteWindowSeconds: 60 * DAY,
reports: COMMERCE_REPORTS,
mutes: COMMERCE_MUTES,
},
checkout: {
name: "checkout",
administrativeDeny: { visibility: "allow", transaction: "deny" },
allowViewerOverride: false,
reports: CHECKOUT_REPORTS,
mutes: {},
},
// A seller must always be able to see their own listing and read why it is
// restricted. Hiding it from them would leave no route to appeal, so this
// surface caps visibility at a warning no matter how strong the evidence.
"seller-dashboard": {
name: "seller-dashboard",
administrativeDeny: { visibility: "warn", interaction: "allow", transaction: "deny" },
allowViewerOverride: false,
exposeEvidence: true,
bypassHide: true,
bypassHideCeiling: "warn",
muteWindowSeconds: 60 * DAY,
reports: COMMERCE_REPORTS,
mutes: COMMERCE_MUTES,
},
},
});
/**
* Administrative denial only — no trust thresholds at all.
*
* For an application that wants an operator deny list and nothing else: no
* report counting, no mute aggregation, no trust graph. Useful as a first step,
* and as an honest option for a community that does not want crowd signals
* affecting what anyone sees.
*
* @type {import('./policy.js').PolicyDefinition}
*/
export const ADMIN_ONLY_POLICY = createPolicyDefinition({
id: "bitgate-admin-only",
name: "Administrative only",
description: "Operator deny lists, with no trust-graph signals.",
version: "1.0.0",
defaultProfile: "default",
profiles: {
default: {
name: "default",
administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" },
viewerBlock: { visibility: "hide", interaction: "deny" },
allowViewerOverride: true,
reports: {},
mutes: {},
},
},
});
/**
* Presets addressable by name, for attribute-driven setup.
* @type {Record<string, import('./policy.js').PolicyDefinition>}
*/
export const POLICY_PRESETS = {
social: SOCIAL_POLICY,
commerce: COMMERCE_POLICY,
"admin-only": ADMIN_ONLY_POLICY,
};
/**
* Resolve a preset by name.
* @param {string} name
* @returns {import('./policy.js').PolicyDefinition|null}
*/
export function getPolicyPreset(name) {
if (typeof name !== "string") {
return null;
}
return POLICY_PRESETS[name.trim().toLowerCase()] ?? null;
}
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import { createAuthorityState } from "../src/authority.js";
import { reduceAdminState } from "../src/adminState.js";
import { createSnapshot, evaluateTarget } from "../src/evaluator.js";
import {
ADMIN_ONLY_POLICY,
COMMERCE_POLICY,
POLICY_PRESETS,
SOCIAL_POLICY,
getPolicyPreset,
} from "../src/presets.js";
const ROOT = "a1".repeat(32);
const MODERATOR = "b2".repeat(32);
const CREATOR = "d4".repeat(32);
const NOW = 1_750_000_000;
const trusted = (index) => index.toString(16).padStart(2, "0").repeat(32);
const TRUSTED = Array.from({ length: 16 }, (_, index) => trusted(index + 1));
const authority = () =>
createAuthorityState({
root: ROOT,
actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] },
});
/** Evaluate a user target with `count` trusted reports in `category`. */
function evaluateWithReports(policy, profile, category, count, contributions = []) {
const auth = authority();
const snapshot = createSnapshot({
authority: auth,
admin: reduceAdminState(contributions, auth),
trust: { contacts: new Set(TRUSTED) },
reports: new Map([
[
`user:${CREATOR}`,
TRUSTED.slice(0, count).map((reporter) => ({ reporter, category, createdAt: NOW - 100 })),
],
]),
});
return evaluateTarget({ type: "user", pubkey: CREATOR }, snapshot, {
surface: profile,
policyProfile: profile,
policy,
now: NOW,
});
}
describe("preset registry", () => {
it("resolves each preset by name", () => {
expect(getPolicyPreset("social")).toBe(SOCIAL_POLICY);
expect(getPolicyPreset("commerce")).toBe(COMMERCE_POLICY);
expect(getPolicyPreset("admin-only")).toBe(ADMIN_ONLY_POLICY);
});
it("is case- and whitespace-insensitive", () => {
expect(getPolicyPreset(" SOCIAL ")).toBe(SOCIAL_POLICY);
});
it("returns null for anything else", () => {
expect(getPolicyPreset("nonsense")).toBeNull();
expect(getPolicyPreset(/** @type {any} */ (null))).toBeNull();
});
it("exposes every preset in the registry", () => {
expect(Object.keys(POLICY_PRESETS).sort()).toEqual(["admin-only", "commerce", "social"]);
});
});
describe("social preset", () => {
it("escalates malware fastest", () => {
const decision = evaluateWithReports(SOCIAL_POLICY, "playback", "malware", 1);
expect(decision.visibility.effect).toBe("restrict");
expect(decision.interaction.effect).toBe("deny");
});
it("requires more agreement before acting on spam", () => {
// One report of spam must not restrict anything: it is the category most
// easily weaponized against an unpopular account.
expect(evaluateWithReports(SOCIAL_POLICY, "playback", "spam", 1).visibility.effect).toBe("allow");
expect(evaluateWithReports(SOCIAL_POLICY, "playback", "spam", 4).visibility.effect).toBe("restrict");
});
it("declines to hard-hide on the feed", () => {
const decision = evaluateWithReports(SOCIAL_POLICY, "feed", "spam", 8);
expect(decision.visibility.effect).toBe("restrict");
expect(decision.reasons.map((reason) => reason.id)).toContain("surface-policy-bypass");
});
it("does hide on playback, where the viewer chose the item", () => {
expect(evaluateWithReports(SOCIAL_POLICY, "playback", "spam", 8).visibility.effect).toBe("hide");
});
it("exposes evidence only on playback", () => {
const playback = evaluateWithReports(SOCIAL_POLICY, "playback", "spam", 4);
const feed = evaluateWithReports(SOCIAL_POLICY, "feed", "spam", 4);
expect(playback.evidence?.trustedReporterPubkeys.length).toBe(4);
expect(feed.evidence?.trustedReporterPubkeys).toEqual([]);
});
it("leaves a clean target entirely alone", () => {
const decision = evaluateWithReports(SOCIAL_POLICY, "feed", "spam", 0);
expect(decision.visibility.effect).toBe("allow");
expect(decision.ranking.effect).toBe("normal");
});
});
describe("commerce preset", () => {
it("blocks checkout on a single malware report", () => {
expect(evaluateWithReports(COMMERCE_POLICY, "checkout", "malware", 1).transaction?.effect).toBe(
"deny",
);
});
it("escalates scam through review before denial", () => {
expect(evaluateWithReports(COMMERCE_POLICY, "checkout", "scam", 2).transaction?.effect).toBe(
"require-review",
);
expect(evaluateWithReports(COMMERCE_POLICY, "checkout", "scam", 3).transaction?.effect).toBe(
"deny",
);
});
it("keeps a denied listing visible to its own seller", () => {
const denial = [
{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] },
];
const dashboard = evaluateWithReports(
COMMERCE_POLICY,
"seller-dashboard",
"scam",
0,
/** @type {any} */ (denial),
);
const browse = evaluateWithReports(
COMMERCE_POLICY,
"browse",
"scam",
0,
/** @type {any} */ (denial),
);
expect(dashboard.visibility.effect).toBe("warn");
expect(browse.visibility.effect).toBe("hide");
expect(dashboard.transaction?.effect).toBe("deny");
});
it("keeps visibility more generous than transaction", () => {
const decision = evaluateWithReports(COMMERCE_POLICY, "checkout", "scam", 3);
expect(decision.visibility.effect).toBe("allow");
expect(decision.transaction?.effect).toBe("deny");
});
it("never hides a listing from its own seller", () => {
// Strong evidence hides it everywhere else, but the seller still needs to
// see it to appeal.
expect(evaluateWithReports(COMMERCE_POLICY, "browse", "scam", 5).visibility.effect).toBe("hide");
expect(
evaluateWithReports(COMMERCE_POLICY, "seller-dashboard", "scam", 5).visibility.effect,
).toBe("warn");
});
it("gives one product different answers per surface from one snapshot", () => {
const byProfile = Object.fromEntries(
["browse", "detail", "checkout", "seller-dashboard"].map((profile) => [
profile,
evaluateWithReports(COMMERCE_POLICY, profile, "scam", 5),
]),
);
expect(byProfile.browse.visibility.effect).toBe("hide");
expect(byProfile["seller-dashboard"].visibility.effect).toBe("warn");
expect(byProfile.checkout.visibility.effect).toBe("allow");
expect(byProfile.checkout.transaction?.effect).toBe("deny");
});
});
describe("admin-only preset", () => {
it("enforces administrative denial", () => {
const decision = evaluateWithReports(ADMIN_ONLY_POLICY, "default", "spam", 0, [
/** @type {any} */ ({
actor: MODERATOR,
kind: "user-deny",
targets: [{ type: "user", pubkey: CREATOR }],
}),
]);
expect(decision.visibility.effect).toBe("hide");
});
it("ignores trust signals entirely", () => {
// Even sixteen trusted reports do nothing: this preset is for operators who
// do not want crowd signals affecting what anyone sees.
const decision = evaluateWithReports(ADMIN_ONLY_POLICY, "default", "malware", 16);
expect(decision.visibility.effect).toBe("allow");
expect(decision.ranking.effect).toBe("normal");
});
});
describe("preset hygiene", () => {
it("gives every preset a version and a named default profile", () => {
for (const [name, policy] of Object.entries(POLICY_PRESETS)) {
expect(policy.version, name).toMatch(/^\d+\.\d+\.\d+$/);
expect(policy.defaultProfile, name).toBeTruthy();
expect(policy.profiles[/** @type {string} */ (policy.defaultProfile)], name).toBeTruthy();
}
});
it("names every profile consistently with its key", () => {
for (const policy of Object.values(POLICY_PRESETS)) {
for (const [key, profile] of Object.entries(policy.profiles)) {
expect(profile.name).toBe(key);
}
}
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
describe("@nostr-governance/core smoke", () => {
describe("@bitgate/core smoke", () => {
it("loads the package entrypoint", async () => {
const mod = await import("../src/index.js");
expect(mod).toBeTypeOf("object");
+2 -2
View File
@@ -1,5 +1,5 @@
{
"name": "@nostr-governance/nostr",
"name": "@bitgate/nostr",
"version": "0.1.0",
"type": "module",
"main": "src/index.js",
@@ -19,6 +19,6 @@
"test": "vitest run"
},
"dependencies": {
"@nostr-governance/core": "*"
"@bitgate/core": "*"
}
}
+10 -10
View File
@@ -4,15 +4,15 @@
// namespaced `d` identifier. The core never learns these details — it receives
// decoded contributions, roles, and policy.
import { normalizeAddress } from "@nostr-governance/core";
import { normalizeAddress } from "@bitgate/core";
import { normalizeEventIdInput, normalizePubkeyInput } from "./nip19.js";
import { getTagValue, getTags } from "./replaceable.js";
/**
* @typedef {import('./replaceable.js').NostrEvent} NostrEvent
* @typedef {import('@nostr-governance/core').Contribution} Contribution
* @typedef {import('@nostr-governance/core').GovernanceCapability} GovernanceCapability
* @typedef {import('@bitgate/core').Contribution} Contribution
* @typedef {import('@bitgate/core').GovernanceCapability} GovernanceCapability
*/
/** Parameterized-replaceable application-data kind used for governance documents. */
@@ -37,7 +37,7 @@ const SCOPE_TO_KIND = {
* @returns {string}
*/
export function canonicalIdentifier(namespace, scope) {
return `${namespace}:governance:${scope}:v${CANONICAL_VERSION}`;
return `${namespace}:bitgate:${scope}:v${CANONICAL_VERSION}`;
}
/**
@@ -49,7 +49,7 @@ export function parseIdentifier(identifier) {
if (typeof identifier !== "string") {
return null;
}
const match = /^([^:]+):governance:([^:]+):v(\d+)$/.exec(identifier.trim());
const match = /^([^:]+):bitgate:([^:]+):v(\d+)$/.exec(identifier.trim());
if (!match) {
return null;
}
@@ -99,7 +99,7 @@ export function decodeContribution(event) {
return null;
}
/** @type {import('@nostr-governance/core').GovernanceTarget[]} */
/** @type {import('@bitgate/core').GovernanceTarget[]} */
const targets = [];
if (kind === "event-deny") {
@@ -139,7 +139,7 @@ export function decodeContribution(event) {
/**
* Decode an `a` coordinate (`kind:pubkey:d`) into an address target.
* @param {string} coordinate
* @returns {import('@nostr-governance/core').AddressTarget|null}
* @returns {import('@bitgate/core').AddressTarget|null}
*/
export function decodeAddressCoordinate(coordinate) {
if (typeof coordinate !== "string") {
@@ -166,7 +166,7 @@ export function decodeAddressCoordinate(coordinate) {
/**
* Encode an address target back into an `a` coordinate.
* @param {import('@nostr-governance/core').AddressTarget} target
* @param {import('@bitgate/core').AddressTarget} target
* @returns {string}
*/
export function encodeAddressCoordinate(target) {
@@ -286,7 +286,7 @@ export function encodeContribution(contribution, namespace) {
const tags = [
["d", canonicalIdentifier(namespace, contribution.kind)],
["v", CANONICAL_VERSION],
["client", "nostr-governance"],
["client", "bitgate"],
["scope", contribution.kind],
];
@@ -320,7 +320,7 @@ export function encodeRoles(roster, namespace) {
const tags = [
["d", canonicalIdentifier(namespace, "roles")],
["v", CANONICAL_VERSION],
["client", "nostr-governance"],
["client", "bitgate"],
["scope", "roles"],
];
+1 -1
View File
@@ -1,4 +1,4 @@
// @nostr-governance/nostr
// @bitgate/nostr
//
// Codecs translating Nostr events into the core's vocabulary. Event formats
// live here so the core never learns which kind stores roles, which `d`
+2 -2
View File
@@ -17,7 +17,7 @@ import { getTagValue, getTags } from "./replaceable.js";
/**
* @typedef {import('./replaceable.js').NostrEvent} NostrEvent
* @typedef {import('@nostr-governance/core').Contribution} Contribution
* @typedef {import('@bitgate/core').Contribution} Contribution
*/
/** Legacy people-list kind used by the reference application. */
@@ -85,7 +85,7 @@ export function decodeLegacyList(event) {
return null;
}
/** @type {import('@nostr-governance/core').GovernanceTarget[]} */
/** @type {import('@bitgate/core').GovernanceTarget[]} */
const targets = [];
if (kind === "event-deny") {
+1 -1
View File
@@ -1,6 +1,6 @@
// bech32 decoding for Nostr identifiers.
//
// Implemented here rather than pulled from a dependency so that @nostr-governance/core
// Implemented here rather than pulled from a dependency so that @bitgate/core
// can stay dependency-free and hex-only: decoding happens at the edge, in the
// codecs, and the core only ever sees normalized hex.
+1 -1
View File
@@ -9,7 +9,7 @@ import { getTags } from "./replaceable.js";
/**
* @typedef {import('./replaceable.js').NostrEvent} NostrEvent
* @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget
* @typedef {import('@bitgate/core').GovernanceTarget} GovernanceTarget
*/
/** NIP-56 report kind. */
+2 -2
View File
@@ -37,7 +37,7 @@ const event = (parts) => ({
describe("canonical identifiers", () => {
it("builds a namespaced identifier", () => {
expect(canonicalIdentifier("bitroad", "address-deny")).toBe(
"bitroad:governance:address-deny:v1",
"bitroad:bitgate:address-deny:v1",
);
});
@@ -292,6 +292,6 @@ describe("encoders", () => {
it("stamps the client and version tags", () => {
const template = encodeContribution({ actor: MODERATOR, kind: "user-deny", targets: [] }, "app");
expect(template.tags).toContainEqual(["v", "1"]);
expect(template.tags).toContainEqual(["client", "nostr-governance"]);
expect(template.tags).toContainEqual(["client", "bitgate"]);
});
});
+1 -1
View File
@@ -170,7 +170,7 @@ describe("decodeMixedContributions", () => {
const legacy = event({ tags: [["d", "bitvid:admin:blacklist"], ["p", CREATOR]] });
const canonical = event({
kind: 30078,
tags: [["d", "app:governance:user-deny:v1"], ["p", CREATOR]],
tags: [["d", "app:bitgate:user-deny:v1"], ["p", CREATOR]],
});
const contributions = decodeMixedContributions([legacy, canonical], decodeContribution);
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@nostr-governance/runtime",
"name": "@bitgate/runtime",
"version": "0.1.0",
"type": "module",
"main": "src/index.js",
@@ -19,7 +19,7 @@
"test": "vitest run"
},
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/nostr": "*"
"@bitgate/core": "*",
"@bitgate/nostr": "*"
}
}
+13 -13
View File
@@ -11,8 +11,8 @@ import {
hasCapability,
isProtectedActor,
isValidTarget,
} from "@nostr-governance/core";
import { encodeContribution, encodeReport, encodeRoles } from "@nostr-governance/nostr";
} from "@bitgate/core";
import { encodeContribution, encodeReport, encodeRoles } from "@bitgate/nostr";
/**
* Stable error codes. Applications match on these rather than on message text.
@@ -61,7 +61,7 @@ function failure(code, message) {
return { ok: false, code, message };
}
/** @type {Record<string, import('@nostr-governance/core').GovernanceCapability>} */
/** @type {Record<string, import('@bitgate/core').GovernanceCapability>} */
const KIND_CAPABILITY = {
"user-allow": "contribute-user-allow",
"user-deny": "contribute-user-deny",
@@ -131,8 +131,8 @@ export class GovernanceCommands {
/**
* Publish a contribution list.
*
* @param {import('@nostr-governance/core').Contribution["kind"]} kind
* @param {import('@nostr-governance/core').GovernanceTarget[]} targets
* @param {import('@bitgate/core').Contribution["kind"]} kind
* @param {import('@bitgate/core').GovernanceTarget[]} targets
* @param {Object} [options]
* @param {string} [options.source] - Community list marker
* @returns {Promise<CommandResult>}
@@ -259,7 +259,7 @@ export class GovernanceCommands {
/**
* Publish a policy document. Requires `manage-policy`.
* @param {import('@nostr-governance/core').PolicyDefinition} policy
* @param {import('@bitgate/core').PolicyDefinition} policy
* @returns {Promise<CommandResult>}
*/
async setPolicy(policy) {
@@ -281,9 +281,9 @@ export class GovernanceCommands {
kind: 30078,
content: JSON.stringify(policy),
tags: [
["d", `${this.runtime.namespace}:governance:policy:v1`],
["d", `${this.runtime.namespace}:bitgate:policy:v1`],
["v", "1"],
["client", "nostr-governance"],
["client", "bitgate"],
["scope", "policy"],
],
});
@@ -307,9 +307,9 @@ export class GovernanceCommands {
}
const tags = [
["d", `${this.runtime.namespace}:governance:community-sources:v1`],
["d", `${this.runtime.namespace}:bitgate:community-sources:v1`],
["v", "1"],
["client", "nostr-governance"],
["client", "bitgate"],
["scope", "community-sources"],
];
for (const source of sources ?? []) {
@@ -325,7 +325,7 @@ export class GovernanceCommands {
* Reporting needs no capability: any account may report, and whether the
* report counts is decided by the viewer's trust graph at evaluation time.
*
* @param {import('@nostr-governance/core').GovernanceTarget} target
* @param {import('@bitgate/core').GovernanceTarget} target
* @param {string} category
* @param {string} [content]
* @returns {Promise<CommandResult>}
@@ -349,7 +349,7 @@ export class GovernanceCommands {
* Set a viewer override. Viewer-local, so no capability is required and
* nothing is published.
*
* @param {import('@nostr-governance/core').GovernanceTarget} target
* @param {import('@bitgate/core').GovernanceTarget} target
* @param {string} visibility
* @param {Object} [options]
* @param {string} [options.reason]
@@ -366,7 +366,7 @@ export class GovernanceCommands {
/**
* Clear a viewer override.
* @param {import('@nostr-governance/core').GovernanceTarget} target
* @param {import('@bitgate/core').GovernanceTarget} target
* @returns {CommandResult}
*/
clearOverride(target) {
+4 -2
View File
@@ -1,4 +1,4 @@
// @nostr-governance/runtime
// @bitgate/runtime
//
// Stateful orchestration: stores, transport wiring, viewer lifecycle,
// evaluation, and commands. Transport, signer, and storage are injected, so the
@@ -6,6 +6,8 @@
export { Emitter } from "./emitter.js";
export { createRelayTransport } from "./relayTransport.js";
export {
storageKey,
createMemoryStorage,
@@ -26,7 +28,7 @@ export {
DEFAULT_CHUNK_SIZE,
GovernanceRuntime,
chunk,
createGovernanceRuntime,
createBitGate,
} from "./runtime.js";
export { ERROR_CODES, GovernanceCommands, createCommands } from "./commands.js";
+2 -2
View File
@@ -5,7 +5,7 @@
// and so an application can boot a working runtime before wiring real relays.
/**
* @typedef {import('@nostr-governance/nostr').NostrEvent} NostrEvent
* @typedef {import('@bitgate/nostr').NostrEvent} NostrEvent
*/
/**
@@ -73,7 +73,7 @@ export function storageKey({
viewerPubkey,
}) {
const segments = [
"nostr-governance",
"bitgate",
applicationId,
namespace,
rootFingerprint,
+408
View File
@@ -0,0 +1,408 @@
// WebSocket relay transport.
//
// The runtime depends on no particular relay pool, and an application with one
// should keep using it. This exists so that nobody has to write a transport
// just to get started: it is the difference between "supply an adapter" and
// "pass some relay URLs".
//
// Deliberately small. It speaks enough of the Nostr protocol to load governance
// state, subscribe to reports and mute lists, and publish administrative
// events. It is not a general-purpose relay client and does not try to be.
/**
* @typedef {import('@bitgate/nostr').NostrEvent} NostrEvent
* @typedef {import('./interfaces.js').GovernanceTransport} GovernanceTransport
* @typedef {import('./interfaces.js').SubscriptionHandlers} SubscriptionHandlers
*/
/** Milliseconds to wait for a relay's stored events before resolving `list`. */
const DEFAULT_LIST_TIMEOUT = 8_000;
/** Milliseconds between reconnection attempts, before backoff. */
const DEFAULT_RECONNECT_DELAY = 1_000;
/** Ceiling for reconnect backoff. */
const MAX_RECONNECT_DELAY = 30_000;
let subscriptionCounter = 0;
/**
* Create a transport backed by plain WebSockets.
*
* @param {string[]} urls - Relay URLs (`wss://…`)
* @param {Object} [options]
* @param {typeof WebSocket} [options.WebSocketImpl] - Injected for tests or Node
* @param {number} [options.listTimeout] - Milliseconds to wait for stored events
* @param {number} [options.reconnectDelay] - Initial reconnect delay
* @param {(message: string, detail?: unknown) => void} [options.onDiagnostic]
* @returns {GovernanceTransport & { close: () => void, relays: string[] }}
*/
export function createRelayTransport(urls, options = {}) {
const relays = (Array.isArray(urls) ? urls : [])
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean);
if (relays.length === 0) {
throw new Error("createRelayTransport requires at least one relay URL");
}
const SocketImpl = options.WebSocketImpl ?? globalThis.WebSocket;
if (typeof SocketImpl !== "function") {
throw new Error("No WebSocket implementation available; pass options.WebSocketImpl");
}
const listTimeout = options.listTimeout ?? DEFAULT_LIST_TIMEOUT;
const baseReconnectDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
const diagnostic = options.onDiagnostic ?? (() => {});
/** @type {Map<string, { socket: any, ready: boolean, queue: string[], attempts: number, timer: any }>} */
const connections = new Map();
// OK frames answer a publish, not a subscription, so they are dispatched
// through their own listener set. Keeping them here rather than rebinding a
// socket's onmessage avoids stacking wrappers across repeated publishes.
/** @type {Set<(url: string, frame: any[]) => void>} */
const okListeners = new Set();
/** @type {Map<string, { filters: object[], handlers: SubscriptionHandlers, relays: Set<string> }>} */
const subscriptions = new Map();
let closed = false;
/**
* @param {string} url
* @param {string} payload
*/
function send(url, payload) {
const connection = connections.get(url);
if (!connection) {
return;
}
if (connection.ready) {
try {
connection.socket.send(payload);
} catch (error) {
diagnostic("relay send failed", { url, error: String(error) });
}
return;
}
// REQ frames are never queued: every live subscription is re-issued from
// the subscription map when the socket opens, so queueing one too would
// send it twice. Everything else (EVENT, CLOSE) is queued rather than
// dropped, since nothing else would re-send it.
if (!payload.startsWith('["REQ"')) {
connection.queue.push(payload);
}
}
/** @param {string} url */
function connect(url) {
if (closed) {
return;
}
/** @type {any} */
const existing = connections.get(url) ?? { socket: null, ready: false, queue: [], attempts: 0, timer: null };
connections.set(url, existing);
let socket;
try {
socket = new SocketImpl(url);
} catch (error) {
diagnostic("relay connect threw", { url, error: String(error) });
scheduleReconnect(url);
return;
}
existing.socket = socket;
existing.ready = false;
socket.onopen = () => {
existing.ready = true;
existing.attempts = 0;
// Re-open every live subscription: a reconnect that dropped them would
// leave the UI silently stale rather than visibly broken.
for (const [id, subscription] of subscriptions) {
subscription.relays.add(url);
send(url, JSON.stringify(["REQ", id, ...subscription.filters]));
}
const queued = existing.queue.splice(0);
for (const payload of queued) {
send(url, payload);
}
};
socket.onmessage = (message) => {
let frame;
try {
frame = JSON.parse(typeof message?.data === "string" ? message.data : "");
} catch {
return;
}
if (!Array.isArray(frame)) {
return;
}
const [verb, id, payload] = frame;
if (verb === "OK") {
for (const listener of Array.from(okListeners)) {
listener(url, frame);
}
return;
}
if (verb === "NOTICE") {
diagnostic("relay notice", { url, message: id });
return;
}
const subscription = subscriptions.get(id);
if (!subscription) {
return;
}
if (verb === "EVENT" && payload) {
subscription.handlers.onEvent?.(payload);
} else if (verb === "EOSE") {
subscription.handlers.onEose?.();
} else if (verb === "CLOSED") {
diagnostic("relay closed subscription", { url, id, reason: payload });
}
};
socket.onerror = (error) => {
diagnostic("relay socket error", { url, error: String(error?.message ?? error) });
};
socket.onclose = () => {
existing.ready = false;
for (const subscription of subscriptions.values()) {
subscription.relays.delete(url);
}
scheduleReconnect(url);
};
}
/** @param {string} url */
function scheduleReconnect(url) {
if (closed) {
return;
}
const connection = connections.get(url);
if (!connection || connection.timer) {
return;
}
connection.attempts += 1;
const delay = Math.min(baseReconnectDelay * 2 ** (connection.attempts - 1), MAX_RECONNECT_DELAY);
connection.timer = setTimeout(() => {
connection.timer = null;
connect(url);
}, delay);
// Never let a reconnect timer hold a Node process open.
connection.timer?.unref?.();
}
for (const url of relays) {
connect(url);
}
return {
relays,
/**
* Collect stored events matching the filters.
*
* Resolves on EOSE from every relay, or when the timeout elapses —
* whichever comes first. A single slow relay must not stall a page load,
* and results already received are returned rather than discarded.
*
* @param {object[]} filters
* @param {{ timeout?: number }} [listOptions]
* @returns {Promise<NostrEvent[]>}
*/
async list(filters, listOptions = {}) {
const id = `bg-list-${(subscriptionCounter += 1)}`;
/** @type {Map<string, NostrEvent>} */
const collected = new Map();
return new Promise((resolve) => {
let settled = false;
let pendingEose = relays.length;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
subscriptions.delete(id);
for (const url of relays) {
send(url, JSON.stringify(["CLOSE", id]));
}
resolve([...collected.values()]);
};
const timer = setTimeout(finish, listOptions.timeout ?? listTimeout);
timer?.unref?.();
subscriptions.set(id, {
filters,
relays: new Set(),
handlers: {
onEvent: (event) => {
// Relays overlap heavily; dedupe by id so a caller does not have to.
if (event?.id && !collected.has(event.id)) {
collected.set(event.id, event);
}
},
onEose: () => {
pendingEose -= 1;
if (pendingEose <= 0) {
finish();
}
},
},
});
for (const url of relays) {
send(url, JSON.stringify(["REQ", id, ...filters]));
}
});
},
/**
* Open a live subscription across every relay.
* @param {object[]} filters
* @param {SubscriptionHandlers} handlers
* @returns {{ close: () => void }}
*/
subscribe(filters, handlers) {
const id = `bg-sub-${(subscriptionCounter += 1)}`;
/** @type {Set<string>} */
const seen = new Set();
subscriptions.set(id, {
filters,
relays: new Set(),
handlers: {
onEvent: (event) => {
if (!event?.id || seen.has(event.id)) {
return;
}
seen.add(event.id);
handlers.onEvent?.(event);
},
onEose: handlers.onEose,
onError: handlers.onError,
},
});
const payload = JSON.stringify(["REQ", id, ...filters]);
for (const url of relays) {
send(url, payload);
}
return {
close() {
subscriptions.delete(id);
const closePayload = JSON.stringify(["CLOSE", id]);
for (const url of relays) {
send(url, closePayload);
}
},
};
},
/**
* Publish an event to every relay.
*
* Resolves once every relay has answered or the timeout elapses. Partial
* acceptance is success: one relay accepting means the event exists on the
* network, and the failures stay on the result for diagnostics.
*
* @param {NostrEvent} event
* @param {{ timeout?: number }} [publishOptions]
* @returns {Promise<import('./interfaces.js').PublishResult>}
*/
async publish(event, publishOptions = {}) {
/** @type {string[]} */
const accepted = [];
/** @type {Array<{ relay: string, error: string }>} */
const failed = [];
return new Promise((resolve) => {
let settled = false;
const answered = new Set();
/** @param {string} url @param {any[]} frame */
const listener = (url, frame) => {
if (frame[1] !== event.id || answered.has(url)) {
return;
}
answered.add(url);
if (frame[2] === true) {
accepted.push(url);
} else {
failed.push({ relay: url, error: String(frame[3] ?? "rejected") });
}
if (answered.size >= relays.length) {
finish();
}
};
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
okListeners.delete(listener);
// Relays that never answered are reported as timed out rather than
// silently omitted, so diagnostics show the full picture.
for (const url of relays) {
if (!answered.has(url)) {
failed.push({ relay: url, error: "timeout" });
}
}
resolve({ ok: accepted.length > 0, accepted, failed });
};
const timer = setTimeout(finish, publishOptions.timeout ?? listTimeout);
timer?.unref?.();
okListeners.add(listener);
send(relays[0], JSON.stringify(["EVENT", event]));
for (const url of relays.slice(1)) {
send(url, JSON.stringify(["EVENT", event]));
}
});
},
/** Close every socket and stop reconnecting. */
close() {
closed = true;
subscriptions.clear();
for (const connection of connections.values()) {
if (connection.timer) {
clearTimeout(connection.timer);
connection.timer = null;
}
try {
connection.socket?.close?.();
} catch {
// Closing an already-dead socket is not an error worth surfacing.
}
}
connections.clear();
},
};
}
+14 -14
View File
@@ -14,7 +14,7 @@ import {
normalizePolicyDefinition,
normalizePubkey,
serializeAdminState,
} from "@nostr-governance/core";
} from "@bitgate/core";
import {
CANONICAL_KIND,
LEGACY_KIND,
@@ -28,9 +28,9 @@ import {
decodeRoles,
selectReplaceable,
verifyEvents,
} from "@nostr-governance/nostr";
} from "@bitgate/nostr";
import { snapshotFingerprint } from "@nostr-governance/core";
import { snapshotFingerprint } from "@bitgate/core";
import { Emitter } from "./emitter.js";
import { createMemoryStorage, createNullSigner, storageKey } from "./interfaces.js";
@@ -44,15 +44,15 @@ import {
} from "./stores.js";
/**
* @typedef {import('@nostr-governance/nostr').NostrEvent} NostrEvent
* @typedef {import('@bitgate/nostr').NostrEvent} NostrEvent
* @typedef {import('./interfaces.js').GovernanceTransport} GovernanceTransport
* @typedef {import('./interfaces.js').GovernanceSigner} GovernanceSigner
* @typedef {import('./interfaces.js').GovernanceStorage} GovernanceStorage
* @typedef {import('@nostr-governance/core').PolicyDefinition} PolicyDefinition
* @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget
* @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision
* @typedef {import('@nostr-governance/core').GovernanceSnapshot} GovernanceSnapshot
* @typedef {import('@nostr-governance/core').ViewerState} ViewerState
* @typedef {import('@bitgate/core').PolicyDefinition} PolicyDefinition
* @typedef {import('@bitgate/core').GovernanceTarget} GovernanceTarget
* @typedef {import('@bitgate/core').GovernanceDecision} GovernanceDecision
* @typedef {import('@bitgate/core').GovernanceSnapshot} GovernanceSnapshot
* @typedef {import('@bitgate/core').ViewerState} ViewerState
*/
/** How many targets go into one relay filter chunk. */
@@ -109,7 +109,7 @@ export class GovernanceRuntime extends Emitter {
* @param {() => number} [options.now] - Injected clock, unix seconds
* @param {number} [options.chunkSize]
* @param {number} [options.muteWindowSeconds]
* @param {import('@nostr-governance/nostr').SignatureVerifier} [options.verifySignature]
* @param {import('@bitgate/nostr').SignatureVerifier} [options.verifySignature]
* @param {string} [options.root] - Root administrator pubkey, from deployment config
*/
constructor({
@@ -859,7 +859,7 @@ export class GovernanceRuntime extends Emitter {
/**
* Whether an actor holds a capability under the current roster.
* @param {string} pubkey
* @param {import('@nostr-governance/core').GovernanceCapability} capability
* @param {import('@bitgate/core').GovernanceCapability} capability
* @returns {boolean}
*/
can(pubkey, capability) {
@@ -869,7 +869,7 @@ export class GovernanceRuntime extends Emitter {
/**
* Every capability an actor holds under the current roster.
* @param {string} pubkey
* @returns {import('@nostr-governance/core').GovernanceCapability[]}
* @returns {import('@bitgate/core').GovernanceCapability[]}
*/
capabilitiesOf(pubkey) {
return getActorCapabilities(pubkey, this.admin.authority);
@@ -877,7 +877,7 @@ export class GovernanceRuntime extends Emitter {
/**
* What the current viewer is allowed to do.
* @returns {import('@nostr-governance/core').GovernanceCapability[]}
* @returns {import('@bitgate/core').GovernanceCapability[]}
*/
viewerCapabilities() {
return this.viewerPubkey ? this.capabilitiesOf(this.viewerPubkey) : [];
@@ -942,6 +942,6 @@ export class GovernanceRuntime extends Emitter {
* @param {ConstructorParameters<typeof GovernanceRuntime>[0]} options
* @returns {GovernanceRuntime}
*/
export function createGovernanceRuntime(options) {
export function createBitGate(options) {
return new GovernanceRuntime(options);
}
+9 -9
View File
@@ -11,17 +11,17 @@ import {
normalizePubkey,
reduceAdminState,
serializeAdminState,
} from "@nostr-governance/core";
import { selectReplaceable, toMuteRecords } from "@nostr-governance/nostr";
} from "@bitgate/core";
import { selectReplaceable, toMuteRecords } from "@bitgate/nostr";
import { Emitter } from "./emitter.js";
/**
* @typedef {import('@nostr-governance/core').PolicyDefinition} PolicyDefinition
* @typedef {import('@nostr-governance/core').Contribution} Contribution
* @typedef {import('@nostr-governance/core').AuthorityState} AuthorityState
* @typedef {import('@nostr-governance/core').AdminState} AdminState
* @typedef {import('@nostr-governance/nostr').NostrEvent} NostrEvent
* @typedef {import('@bitgate/core').PolicyDefinition} PolicyDefinition
* @typedef {import('@bitgate/core').Contribution} Contribution
* @typedef {import('@bitgate/core').AuthorityState} AuthorityState
* @typedef {import('@bitgate/core').AdminState} AdminState
* @typedef {import('@bitgate/nostr').NostrEvent} NostrEvent
*/
/**
@@ -248,7 +248,7 @@ export class ReportStore extends Emitter {
* The target is identified by `targetKey`; a decoded report's own `target`
* field is accepted but not required, since it would be redundant.
*
* @param {{ reporter: string, category: string, createdAt?: number, target?: import('@nostr-governance/core').GovernanceTarget }} report
* @param {{ reporter: string, category: string, createdAt?: number, target?: import('@bitgate/core').GovernanceTarget }} report
* @param {string} targetKey
*/
ingest(report, targetKey) {
@@ -349,7 +349,7 @@ export class TrustedMuteStore extends Emitter {
* A newer list fully replaces the older one; an out-of-order delivery of an
* older list is ignored rather than resurrecting removed entries.
*
* @param {import('@nostr-governance/nostr').DecodedMuteList} list
* @param {import('@bitgate/nostr').DecodedMuteList} list
*/
replaceList(list) {
if (!list?.owner) {
+6 -6
View File
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { REPORT_KIND, encodeRoles } from "@nostr-governance/nostr";
import { createPolicyDefinition } from "@bitgate/core";
import { REPORT_KIND, encodeRoles } from "@bitgate/nostr";
import { ERROR_CODES, createCommands } from "../src/commands.js";
import { createGovernanceRuntime } from "../src/runtime.js";
import { createBitGate } from "../src/runtime.js";
import { createMemoryTransport } from "../src/interfaces.js";
const ROOT = "a1".repeat(32);
@@ -45,7 +45,7 @@ function signerFor(pubkey) {
*/
function setup({ actor = MODERATOR, signer, transport } = {}) {
const memory = transport ?? createMemoryTransport();
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "test",
namespace: "app",
transport: memory,
@@ -177,7 +177,7 @@ describe("argument validation", () => {
describe("signer failures", () => {
it("reports a missing signer", async () => {
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "test",
namespace: "app",
transport: createMemoryTransport(),
@@ -244,7 +244,7 @@ describe("contribution round-trip", () => {
const { runtime, commands, transport } = setup();
await commands.denyUser(CREATOR);
const fresh = createGovernanceRuntime({
const fresh = createBitGate({
applicationId: "test",
namespace: "app",
transport: createMemoryTransport(),
+11 -11
View File
@@ -3,15 +3,15 @@
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
import {
CANONICAL_KIND,
MUTE_LIST_KIND,
encodeContribution,
encodeRoles,
} from "@nostr-governance/nostr";
} from "@bitgate/nostr";
import { createGovernanceRuntime } from "../src/runtime.js";
import { createBitGate } from "../src/runtime.js";
import { createMemoryStorage, createMemoryTransport } from "../src/interfaces.js";
const ROOT = "a1".repeat(32);
@@ -66,11 +66,11 @@ const denyEvent = () =>
* @param {Object} [options]
* @param {import('../src/interfaces.js').GovernanceStorage} [options.storage]
* @param {any} [options.transport]
* @param {import('@nostr-governance/nostr').SignatureVerifier} [options.verifySignature]
* @param {import('@bitgate/nostr').SignatureVerifier} [options.verifySignature]
* @param {string} [options.root]
*/
function makeRuntime({ storage, transport, verifySignature, root = ROOT } = {}) {
return createGovernanceRuntime({
return createBitGate({
applicationId: "test",
namespace: "app",
transport: transport ?? createMemoryTransport([rolesEvent(), denyEvent()]),
@@ -92,7 +92,7 @@ describe("persistence", () => {
expect(writes).toHaveBeenCalled();
const [key] = writes.mock.calls[0];
expect(key).toMatch(/^nostr-governance:test:app:[0-9a-f]{16}:v1:admin$/);
expect(key).toMatch(/^bitgate:test:app:[0-9a-f]{16}:v1:admin$/);
});
it("restores administrative state without touching a relay", async () => {
@@ -257,7 +257,7 @@ describe("trusted mute list subscriptions", () => {
it("subscribes by author, chunked", () => {
const transport = createMemoryTransport();
const subscribe = vi.spyOn(transport, "subscribe");
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "t",
namespace: "app",
transport,
@@ -275,7 +275,7 @@ describe("trusted mute list subscriptions", () => {
it("ingests a delivered mute list into evaluation", () => {
const transport = createMemoryTransport();
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "t",
namespace: "app",
transport,
@@ -331,13 +331,13 @@ describe("community sources", () => {
});
const merged = await runtime.loadCommunitySources([
{ curator: CURATOR, identifier: "app:governance:user-deny:v1", kind: CANONICAL_KIND },
{ curator: CURATOR, identifier: "app:bitgate:user-deny:v1", kind: CANONICAL_KIND },
]);
expect(merged).toBe(1);
expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true);
expect(runtime.admin.state.communitySources.get(`user:${CREATOR}`)).toEqual([
`${CURATOR}:app:governance:user-deny:v1`,
`${CURATOR}:app:bitgate:user-deny:v1`,
]);
});
@@ -357,7 +357,7 @@ describe("community sources", () => {
runtime.admin.setRoles({ root: ROOT, actors: { [CURATOR]: ["curator"] } });
await runtime.loadCommunitySources([
{ curator: CURATOR, identifier: "app:governance:event-deny:v1", kind: CANONICAL_KIND },
{ curator: CURATOR, identifier: "app:bitgate:event-deny:v1", kind: CANONICAL_KIND },
]);
expect(runtime.admin.state.eventDeny.size).toBe(0);
@@ -0,0 +1,391 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRelayTransport } from "../src/relayTransport.js";
const NOW = 1_750_000_000;
/** Every socket built during a test, so assertions can drive them. */
let sockets = [];
/** A scriptable WebSocket stand-in. */
class FakeSocket {
constructor(url) {
this.url = url;
this.sent = [];
this.closed = false;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
sockets.push(this);
}
send(payload) {
this.sent.push(JSON.parse(payload));
}
close() {
this.closed = true;
}
/** Simulate the relay accepting the connection. */
open() {
this.onopen?.();
}
/** Simulate a frame arriving from the relay. */
deliver(frame) {
this.onmessage?.({ data: JSON.stringify(frame) });
}
/** Simulate the connection dropping. */
drop() {
this.onclose?.();
}
/** Frames of a given verb that this socket was asked to send. */
sentOf(verb) {
return this.sent.filter((frame) => frame[0] === verb);
}
}
const event = (id) => ({
id,
pubkey: "a1".repeat(32),
kind: 1,
created_at: NOW,
tags: [],
content: "",
});
function makeTransport(urls = ["wss://one.example"], options = {}) {
return createRelayTransport(urls, { WebSocketImpl: /** @type {any} */ (FakeSocket), ...options });
}
afterEach(() => {
sockets = [];
vi.useRealTimers();
});
describe("construction", () => {
it("requires at least one relay", () => {
expect(() => makeTransport([])).toThrow(/at least one relay/);
expect(() => makeTransport([" ", ""])).toThrow(/at least one relay/);
});
it("requires a usable WebSocket implementation", () => {
// Node 22 has a global WebSocket, so passing null correctly falls back to
// it. The guard is for environments that have neither.
expect(() =>
createRelayTransport(["wss://one.example"], { WebSocketImpl: /** @type {any} */ (42) }),
).toThrow(/WebSocket implementation/);
});
it("falls back to the global WebSocket when none is injected", () => {
expect(() => createRelayTransport(["wss://one.example"])).not.toThrow();
});
it("opens one socket per relay", () => {
makeTransport(["wss://one.example", "wss://two.example"]);
expect(sockets.map((socket) => socket.url)).toEqual([
"wss://one.example",
"wss://two.example",
]);
});
it("trims and drops blank relay URLs", () => {
const transport = makeTransport([" wss://one.example ", ""]);
expect(transport.relays).toEqual(["wss://one.example"]);
});
});
describe("connection lifecycle", () => {
it("issues a subscription exactly once when it opens", () => {
const transport = makeTransport();
transport.subscribe([{ kinds: [1] }], {});
// Nothing can be sent yet — the socket has not opened.
expect(sockets[0].sent).toHaveLength(0);
sockets[0].open();
// Re-issued from the subscription map, not also flushed from the queue.
expect(sockets[0].sentOf("REQ")).toHaveLength(1);
});
it("queues a publish made before the socket opens", async () => {
const transport = makeTransport();
const published = event("bb".repeat(32));
const pending = transport.publish(published);
expect(sockets[0].sentOf("EVENT")).toHaveLength(0);
sockets[0].open();
expect(sockets[0].sentOf("EVENT")).toHaveLength(1);
sockets[0].deliver(["OK", published.id, true, ""]);
expect((await pending).ok).toBe(true);
});
it("re-opens live subscriptions after a reconnect", () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example"], { reconnectDelay: 10 });
sockets[0].open();
transport.subscribe([{ kinds: [1] }], {});
expect(sockets[0].sentOf("REQ")).toHaveLength(1);
sockets[0].drop();
vi.advanceTimersByTime(20);
// A fresh socket was created and the subscription re-issued on open.
expect(sockets).toHaveLength(2);
sockets[1].open();
expect(sockets[1].sentOf("REQ")).toHaveLength(1);
});
it("backs off between reconnect attempts", () => {
vi.useFakeTimers();
makeTransport(["wss://one.example"], { reconnectDelay: 100 });
sockets[0].drop();
vi.advanceTimersByTime(100);
expect(sockets).toHaveLength(2);
// Second failure waits longer than the first.
sockets[1].drop();
vi.advanceTimersByTime(100);
expect(sockets).toHaveLength(2);
vi.advanceTimersByTime(100);
expect(sockets).toHaveLength(3);
});
it("stops reconnecting once closed", () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example"], { reconnectDelay: 10 });
transport.close();
sockets[0].drop();
vi.advanceTimersByTime(500);
expect(sockets).toHaveLength(1);
});
it("closes every socket on close", () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
transport.close();
expect(sockets.every((socket) => socket.closed)).toBe(true);
});
});
describe("list", () => {
it("resolves with stored events once every relay sends EOSE", async () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
sockets.forEach((socket) => socket.open());
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].deliver(["EVENT", id, event("aa".repeat(32))]);
sockets[0].deliver(["EOSE", id]);
sockets[1].deliver(["EOSE", id]);
expect(await pending).toHaveLength(1);
});
it("deduplicates events served by more than one relay", async () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
sockets.forEach((socket) => socket.open());
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
const duplicate = event("aa".repeat(32));
sockets[0].deliver(["EVENT", id, duplicate]);
sockets[1].deliver(["EVENT", id, duplicate]);
sockets[0].deliver(["EOSE", id]);
sockets[1].deliver(["EOSE", id]);
expect(await pending).toHaveLength(1);
});
it("returns what it has when a relay never answers", async () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example", "wss://slow.example"], {
listTimeout: 1_000,
});
sockets.forEach((socket) => socket.open());
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].deliver(["EVENT", id, event("aa".repeat(32))]);
sockets[0].deliver(["EOSE", id]);
// The second relay stays silent; the timeout must not discard what arrived.
await vi.advanceTimersByTimeAsync(1_000);
expect(await pending).toHaveLength(1);
});
it("closes the subscription when it resolves", async () => {
const transport = makeTransport();
sockets[0].open();
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].deliver(["EOSE", id]);
await pending;
expect(sockets[0].sentOf("CLOSE")[0][1]).toBe(id);
});
it("ignores malformed frames", async () => {
const transport = makeTransport();
sockets[0].open();
const pending = transport.list([{ kinds: [1] }]);
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].onmessage?.({ data: "not json" });
sockets[0].onmessage?.({ data: JSON.stringify({ not: "an array" }) });
sockets[0].deliver(["EOSE", id]);
expect(await pending).toEqual([]);
});
});
describe("subscribe", () => {
it("delivers events to the handler", () => {
const transport = makeTransport();
sockets[0].open();
const onEvent = vi.fn();
transport.subscribe([{ kinds: [1] }], { onEvent });
const id = sockets[0].sentOf("REQ")[0][1];
sockets[0].deliver(["EVENT", id, event("aa".repeat(32))]);
expect(onEvent).toHaveBeenCalledTimes(1);
});
it("deduplicates across relays", () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
sockets.forEach((socket) => socket.open());
const onEvent = vi.fn();
transport.subscribe([{ kinds: [1] }], { onEvent });
const id = sockets[0].sentOf("REQ")[0][1];
const duplicate = event("aa".repeat(32));
sockets[0].deliver(["EVENT", id, duplicate]);
sockets[1].deliver(["EVENT", id, duplicate]);
expect(onEvent).toHaveBeenCalledTimes(1);
});
it("reports EOSE", () => {
const transport = makeTransport();
sockets[0].open();
const onEose = vi.fn();
transport.subscribe([{ kinds: [1] }], { onEose });
sockets[0].deliver(["EOSE", sockets[0].sentOf("REQ")[0][1]]);
expect(onEose).toHaveBeenCalled();
});
it("stops delivering after close", () => {
const transport = makeTransport();
sockets[0].open();
const onEvent = vi.fn();
const handle = transport.subscribe([{ kinds: [1] }], { onEvent });
const id = sockets[0].sentOf("REQ")[0][1];
handle.close();
sockets[0].deliver(["EVENT", id, event("aa".repeat(32))]);
expect(onEvent).not.toHaveBeenCalled();
expect(sockets[0].sentOf("CLOSE")).toHaveLength(1);
});
});
describe("publish", () => {
it("succeeds when a relay accepts", async () => {
const transport = makeTransport();
sockets[0].open();
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, true, ""]);
const result = await pending;
expect(result.ok).toBe(true);
expect(result.accepted).toEqual(["wss://one.example"]);
});
it("treats partial acceptance as success and keeps failures visible", async () => {
const transport = makeTransport(["wss://one.example", "wss://two.example"]);
sockets.forEach((socket) => socket.open());
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, true, ""]);
sockets[1].deliver(["OK", published.id, false, "blocked: spam"]);
const result = await pending;
expect(result.ok).toBe(true);
expect(result.accepted).toEqual(["wss://one.example"]);
expect(result.failed).toEqual([{ relay: "wss://two.example", error: "blocked: spam" }]);
});
it("fails when every relay rejects", async () => {
const transport = makeTransport();
sockets[0].open();
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, false, "rejected"]);
const result = await pending;
expect(result.ok).toBe(false);
expect(result.failed).toHaveLength(1);
});
it("reports silent relays as timed out rather than omitting them", async () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example", "wss://quiet.example"], {
listTimeout: 500,
});
sockets.forEach((socket) => socket.open());
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, true, ""]);
await vi.advanceTimersByTimeAsync(500);
const result = await pending;
expect(result.ok).toBe(true);
expect(result.failed).toEqual([{ relay: "wss://quiet.example", error: "timeout" }]);
});
it("ignores OK frames for a different event", async () => {
vi.useFakeTimers();
const transport = makeTransport(["wss://one.example"], { listTimeout: 200 });
sockets[0].open();
const published = event("bb".repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", "cc".repeat(32), true, ""]);
await vi.advanceTimersByTimeAsync(200);
const result = await pending;
expect(result.ok).toBe(false);
});
it("does not stack handlers across repeated publishes", async () => {
const transport = makeTransport();
sockets[0].open();
for (const id of ["aa", "bb", "cc"]) {
const published = event(id.repeat(32));
const pending = transport.publish(published);
sockets[0].deliver(["OK", published.id, true, ""]);
expect((await pending).ok).toBe(true);
}
expect(sockets[0].sentOf("EVENT")).toHaveLength(3);
});
});
+12 -12
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
import {
CANONICAL_KIND,
MUTE_LIST_KIND,
@@ -8,9 +8,9 @@ import {
canonicalIdentifier,
encodeContribution,
encodeRoles,
} from "@nostr-governance/nostr";
} from "@bitgate/nostr";
import { GovernanceRuntime, chunk, createGovernanceRuntime } from "../src/runtime.js";
import { GovernanceRuntime, chunk, createBitGate } from "../src/runtime.js";
import { createMemoryTransport, storageKey } from "../src/interfaces.js";
const ROOT = "a1".repeat(32);
@@ -37,8 +37,8 @@ const POLICY = createPolicyDefinition({
});
/**
* @param {Partial<import('@nostr-governance/nostr').NostrEvent>} parts
* @returns {import('@nostr-governance/nostr').NostrEvent}
* @param {Partial<import('@bitgate/nostr').NostrEvent>} parts
* @returns {import('@bitgate/nostr').NostrEvent}
*/
const event = (parts) => ({
id: "00".repeat(32),
@@ -57,7 +57,7 @@ const rolesEvent = () =>
...encodeRoles({ actors: { [MODERATOR]: ["moderator"] }, protectedActors: [] }, "app"),
});
/** @param {import('@nostr-governance/core').GovernanceTarget} [target] */
/** @param {import('@bitgate/core').GovernanceTarget} [target] */
const denyEvent = (target = { type: "user", pubkey: CREATOR }) =>
event({
id: "bb".repeat(32),
@@ -66,7 +66,7 @@ const denyEvent = (target = { type: "user", pubkey: CREATOR }) =>
function makeRuntime(events = []) {
const transport = createMemoryTransport(events);
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "test-app",
namespace: "app",
transport,
@@ -100,7 +100,7 @@ describe("storageKey", () => {
schemaVersion: "v1",
scope: "admin",
}),
).toBe("nostr-governance:bitroad:bitroad:abcd:v1:admin");
).toBe("bitgate:bitroad:bitroad:abcd:v1:admin");
});
it("appends the viewer for viewer-scoped state", () => {
@@ -260,7 +260,7 @@ describe("loadAdministrativeState", () => {
it("can restrict the query to known authors", async () => {
const transport = createMemoryTransport([rolesEvent(), denyEvent()]);
const listSpy = vi.spyOn(transport, "list");
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "a",
namespace: "app",
transport,
@@ -277,7 +277,7 @@ describe("active targets and subscriptions", () => {
it("chunks report subscriptions rather than opening one per target", () => {
const transport = createMemoryTransport();
const subscribeSpy = vi.spyOn(transport, "subscribe");
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "a",
namespace: "app",
transport,
@@ -299,7 +299,7 @@ describe("active targets and subscriptions", () => {
it("separates event and user filters", () => {
const transport = createMemoryTransport();
const subscribeSpy = vi.spyOn(transport, "subscribe");
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "a",
namespace: "app",
transport,
@@ -411,7 +411,7 @@ describe("evaluation", () => {
runtime.ingestEvent(rolesEvent());
runtime.ingestEvent(denyEvent());
/** @type {import('@nostr-governance/core').GovernanceTarget} */
/** @type {import('@bitgate/core').GovernanceTarget} */
const target = { type: "user", pubkey: CREATOR };
const many = runtime.evaluateMany([target], { profile: "feed" });
expect(many.get(`user:${CREATOR}`)).toEqual(runtime.evaluate(target, { profile: "feed" }));
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
import { Emitter } from "../src/emitter.js";
import {
@@ -18,7 +18,7 @@ const CREATOR = "d4".repeat(32);
const OTHER = "e5".repeat(32);
const DAY = 86_400;
/** @param {string} pubkey @returns {import("@nostr-governance/core").UserTarget} */
/** @param {string} pubkey @returns {import("@bitgate/core").UserTarget} */
const user = (pubkey) => ({ type: "user", pubkey });
const roster = {
+2 -2
View File
@@ -1,5 +1,5 @@
{
"name": "@nostr-governance/testing",
"name": "@bitgate/testing",
"version": "0.1.0",
"type": "module",
"private": true,
@@ -16,6 +16,6 @@
"test": "vitest run"
},
"dependencies": {
"@nostr-governance/core": "*"
"@bitgate/core": "*"
}
}
+7 -7
View File
@@ -1,4 +1,4 @@
// Conformance harness for @nostr-governance
// Conformance harness for BitGate
//
// Turns a JSON fixture into a snapshot, evaluates it, and compares the decision
// against the fixture's expectation. Expectations are partial by design: a
@@ -11,7 +11,7 @@ import {
createViewerState,
evaluateTarget,
reduceAdminState,
} from "@nostr-governance/core";
} from "@bitgate/core";
/**
* @typedef {Object} FixtureExpectation
@@ -78,8 +78,8 @@ export function buildSnapshot(fixture) {
/**
* Evaluate a fixture against a policy definition.
* @param {Object} fixture
* @param {import('@nostr-governance/core').PolicyDefinition} policy
* @returns {import('@nostr-governance/core').GovernanceDecision}
* @param {import('@bitgate/core').PolicyDefinition} policy
* @returns {import('@bitgate/core').GovernanceDecision}
*/
export function evaluateFixture(fixture, policy) {
const { snapshot, viewer } = buildSnapshot(fixture);
@@ -93,7 +93,7 @@ export function evaluateFixture(fixture, policy) {
/**
* Compare a decision against a fixture expectation.
* @param {import('@nostr-governance/core').GovernanceDecision} decision
* @param {import('@bitgate/core').GovernanceDecision} decision
* @param {FixtureExpectation} expectation
* @returns {string[]} Human-readable mismatches; empty when the fixture passes
*/
@@ -187,8 +187,8 @@ export function diffExpectation(decision, expectation) {
/**
* Run one fixture end to end.
* @param {Object} fixture
* @param {import('@nostr-governance/core').PolicyDefinition} policy
* @returns {{ passed: boolean, mismatches: string[], decision: import('@nostr-governance/core').GovernanceDecision }}
* @param {import('@bitgate/core').PolicyDefinition} policy
* @returns {{ passed: boolean, mismatches: string[], decision: import('@bitgate/core').GovernanceDecision }}
*/
export function runConformanceCase(fixture, policy) {
const decision = evaluateFixture(fixture, policy);
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
import { buildSnapshot, diffExpectation, evaluateFixture, runConformanceCase } from "../src/conformance.js";
+81 -22
View File
@@ -1,7 +1,66 @@
# @nostr-governance/widget
# @bitgate/widget
Drop-in custom elements for governance UI. Framework-agnostic, no build step
required for consumers, and built strictly on the published API.
Drop-in custom elements. Framework-agnostic, no bundler required by consumers,
and built strictly on the published API.
## Install
```bash
npm install && npm run build:widget
```
Copy `packages/widget/dist/bitgate.js` (and its `.map`) onto your own origin.
The bundle is deliberately not committed: build it from reviewed source rather
than trusting a prebuilt minified blob for a moderation widget.
## The one-element version
```html
<script type="module" src="/vendor/bitgate/bitgate.js"></script>
<bitgate-provider relays="wss://relay.example" root="<root-pubkey>" policy="social">
<bitgate-veil profile="feed" target-user="<author-pubkey>">
<img src="thumbnail.jpg" alt="" />
</bitgate-veil>
</bitgate-provider>
```
`<bitgate-provider>` builds the runtime, loads administrative state, and shares
both with every descendant. Nothing else to wire.
### Provider attributes
| Attribute | Meaning |
| --- | --- |
| `relays` | Comma-separated relay URLs (required) |
| `root` | Root administrator pubkey, from your deployment config |
| `policy` | `social`, `commerce`, or `admin-only` (default) |
| `viewer` | Viewer pubkey, if known at render time |
| `application` / `namespace` | Storage and event namespacing |
### Target attributes
Any target-bearing element reads its target from markup:
```html
<bitgate-veil target-user="<pubkey>">…</bitgate-veil>
<bitgate-veil target-event="<id>" target-author="<pubkey>">…</bitgate-veil>
<bitgate-veil target-address="30078:<pubkey>:sku-001">…</bitgate-veil>
```
Assigning `.target` in JavaScript still works and takes precedence.
### With BitLogin
```js
const provider = document.querySelector("bitgate-provider");
await provider.ready;
await provider.useSigner(window.nostr);
```
Await `ready` rather than listening for `bitgate:ready` — the event fires during
element upgrade, which is before a script that runs after parsing could attach a
listener.
## The rule these elements follow
@@ -17,28 +76,28 @@ number. Keeping that line is the whole reason the engine was extracted.
| Element | Purpose |
| --- | --- |
| `<governance-veil>` | Wraps content and applies the decision: blur on `restrict`, disclosure on `hide`, warning on `warn` |
| `<governance-report>` | Report dialog. Requires no capability — anyone may report |
| `<governance-status>` | Readout of effect, reasons, and evidence |
| `<bitgate-veil>` | Wraps content and applies the decision: blur on `restrict`, disclosure on `hide`, warning on `warn` |
| `<bitgate-report>` | Report dialog. Requires no capability — anyone may report |
| `<bitgate-status>` | Readout of effect, reasons, and evidence |
### Moderator-facing
| Element | Purpose |
| --- | --- |
| `<governance-capabilities>` | What the signed-in account may actually do |
| `<governance-action>` | A capability-gated button that explains refusals |
| `<governance-admin-panel>` | Effective state, contributor attribution, and controls |
| `<bitgate-capabilities>` | What the signed-in account may actually do |
| `<bitgate-action>` | A capability-gated button that explains refusals |
| `<bitgate-admin-panel>` | Effective state, contributor attribution, and controls |
## Usage
```html
<script type="module">
import { createGovernanceRuntime, createCommands } from "@nostr-governance/runtime";
import { defineGovernanceElements } from "@nostr-governance/widget";
import { createBitGate, createCommands } from "@bitgate/runtime";
import { defineBitGateElements } from "@bitgate/widget";
defineGovernanceElements();
defineBitGateElements();
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "my-app",
namespace: "myapp",
root: ROOT_PUBKEY,
@@ -49,14 +108,14 @@ number. Keeping that line is the whole reason the engine was extracted.
await runtime.loadAdministrativeState();
const veil = document.querySelector("governance-veil");
const veil = document.querySelector("bitgate-veil");
veil.runtime = runtime;
veil.target = { type: "event", id: eventId, author: authorPubkey };
</script>
<governance-veil profile="feed">
<bitgate-veil profile="feed">
<img src="thumbnail.jpg" alt="" />
</governance-veil>
</bitgate-veil>
```
Elements receive a runtime; they never construct one. A widget that owned relay
@@ -69,10 +128,10 @@ All events are `composed` so they cross shadow boundaries.
| Event | Fired when |
| --- | --- |
| `governance:revealed` | The viewer chose to show hidden content |
| `governance:reported` / `governance:report-failed` | A report was published or refused |
| `governance:action-completed` / `:action-refused` / `:action-failed` | An action resolved |
| `governance:account-restricted` | The admin panel restricted an account |
| `bitgate:revealed` | The viewer chose to show hidden content |
| `bitgate:reported` / `bitgate:report-failed` | A report was published or refused |
| `bitgate:action-completed` / `:action-refused` / `:action-failed` | An action resolved |
| `bitgate:account-restricted` | The admin panel restricted an account |
## Wording
@@ -106,11 +165,11 @@ the same check independently — the UI is an explanation, never the gate.
`--gov-surface`, `--gov-warn`, `--gov-danger`, `--gov-radius`) or target parts:
```css
governance-veil::part(veil) { border: 1px solid red; }
bitgate-veil::part(veil) { border: 1px solid red; }
```
## Environment
`defineGovernanceElements()` is safe to call repeatedly and safe to import in
`defineBitGateElements()` is safe to call repeatedly and safe to import in
Node — registration is a no-op without a DOM, so a server-rendering host can
import the helpers without guarding.
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/**
* Bundle the widget into a single self-contained file.
*
* This exists because bare module specifiers (`@bitgate/core`) do not resolve
* in a browser. Node and the test runner resolve them through node_modules, so
* the source works everywhere except the one place it most needs to: a plain
* static page. Without this step a consumer would need their own bundler or a
* hand-written import map, which is exactly the friction the widget is meant
* to remove.
*
* The output is a self-hosted artifact. Consumers copy `dist/bitgate.js` onto
* their own origin and load it with a single script tag — they never fetch it
* from someone else's deployment.
*
* Usage: node packages/widget/build.mjs [--watch]
*/
import { build, context } from "esbuild";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
/** @type {import("esbuild").BuildOptions} */
const options = {
entryPoints: [join(HERE, "src", "index.js")],
outfile: join(HERE, "dist", "bitgate.js"),
bundle: true,
format: "esm",
platform: "browser",
target: ["es2022"],
// Sourcemaps ship alongside: a moderator console that misbehaves in
// production is worth being able to debug, and the cost is one extra file.
sourcemap: true,
minify: true,
legalComments: "inline",
banner: {
js: "/*! BitGate widget — GPL-3.0-or-later — https://github.com/PR0M3TH3AN/Nostr-Governance */",
},
};
const watch = process.argv.includes("--watch");
if (watch) {
const ctx = await context(options);
await ctx.watch();
console.log("bitgate widget: watching…");
} else {
const result = await build(options);
if (result.errors.length) {
process.exit(1);
}
console.log(`✓ Built ${options.outfile}`);
}
+9 -5
View File
@@ -1,10 +1,13 @@
{
"name": "@nostr-governance/widget",
"name": "@bitgate/widget",
"version": "0.1.0",
"type": "module",
"main": "src/index.js",
"types": "dist/types/index.d.ts",
"files": ["src", "dist"],
"files": [
"src",
"dist"
],
"exports": {
".": {
"types": "./dist/types/index.d.ts",
@@ -13,10 +16,11 @@
"./dist/types/*": "./dist/types/*"
},
"scripts": {
"test": "vitest run"
"test": "vitest run",
"build": "node build.mjs"
},
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/runtime": "*"
"@bitgate/core": "*",
"@bitgate/runtime": "*"
}
}
+10 -10
View File
@@ -14,8 +14,8 @@
import { GovernanceElement, defineElement, escapeHtml, shortenKey } from "./base.js";
/**
* @typedef {import('@nostr-governance/runtime').GovernanceCommands} GovernanceCommands
* @typedef {import('@nostr-governance/core').GovernanceCapability} GovernanceCapability
* @typedef {import('@bitgate/runtime').GovernanceCommands} GovernanceCommands
* @typedef {import('@bitgate/core').GovernanceCapability} GovernanceCapability
*/
/** Human wording for capabilities, used when explaining a refusal. */
@@ -32,7 +32,7 @@ export const CAPABILITY_LABELS = {
};
/**
* `<governance-capabilities>` — what the signed-in moderator may do.
* `<bitgate-capabilities>` — what the signed-in moderator may do.
*
* Worth rendering explicitly: in the previous generation of this system,
* moderator status was a UI permission with no cryptographic meaning. Showing
@@ -81,7 +81,7 @@ export class GovernanceCapabilities extends GovernanceElement {
}
/**
* `<governance-action>` — a single capability-gated action button.
* `<bitgate-action>` — a single capability-gated action button.
*
* When the actor lacks the capability the button is disabled *and* labelled
* with what is missing, which is the difference between "broken" and "not
@@ -167,7 +167,7 @@ export class GovernanceAction extends GovernanceElement {
}
/**
* `<governance-admin-panel>` — effective administrative state plus controls.
* `<bitgate-admin-panel>` — effective administrative state plus controls.
*
* Shows what is currently in force and who contributed it, so a moderator can
* see that a denial came from a community list rather than from their own team.
@@ -298,12 +298,12 @@ export class GovernanceAdminPanel extends GovernanceElement {
*/
export function defineAdminElements() {
const registered = [];
if (defineElement("governance-capabilities", GovernanceCapabilities)) {
registered.push("governance-capabilities");
if (defineElement("bitgate-capabilities", GovernanceCapabilities)) {
registered.push("bitgate-capabilities");
}
if (defineElement("governance-action", GovernanceAction)) registered.push("governance-action");
if (defineElement("governance-admin-panel", GovernanceAdminPanel)) {
registered.push("governance-admin-panel");
if (defineElement("bitgate-action", GovernanceAction)) registered.push("bitgate-action");
if (defineElement("bitgate-admin-panel", GovernanceAdminPanel)) {
registered.push("bitgate-admin-panel");
}
return registered;
}
+115 -4
View File
@@ -10,9 +10,9 @@
// one governance runtime across its whole page.
/**
* @typedef {import('@nostr-governance/runtime').GovernanceRuntime} GovernanceRuntime
* @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision
* @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget
* @typedef {import('@bitgate/runtime').GovernanceRuntime} GovernanceRuntime
* @typedef {import('@bitgate/core').GovernanceDecision} GovernanceDecision
* @typedef {import('@bitgate/core').GovernanceTarget} GovernanceTarget
*/
/**
@@ -90,6 +90,73 @@ export function shortenKey(pubkey) {
return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
}
/**
* Ask the nearest provider for its runtime and commands.
*
* Declared here rather than imported from ./provider.js to keep the module
* graph acyclic: provider.js already depends on this file.
*
* @param {Element} element
* @returns {{ runtime: any, commands: any }|null}
*/
export function requestContext(element) {
/** @type {{ context: any }} */
const detail = { context: null };
element.dispatchEvent(
new CustomEvent("bitgate:context-request", { detail, bubbles: true, composed: true }),
);
return detail.context;
}
/**
* Parse a governance target from element attributes.
*
* Lets markup name a target without JavaScript:
* target-event / target-author / target-kind
* target-user
* target-address (a `kind:pubkey:d` coordinate)
*
* @param {Element} element
* @returns {import('@bitgate/core').GovernanceTarget|null}
*/
export function targetFromAttributes(element) {
const user = element.getAttribute("target-user");
if (user) {
return { type: "user", pubkey: user.trim() };
}
const eventId = element.getAttribute("target-event");
if (eventId) {
/** @type {any} */
const target = { type: "event", id: eventId.trim() };
const author = element.getAttribute("target-author");
if (author) {
target.author = author.trim();
}
const kind = element.getAttribute("target-kind");
if (kind && Number.isFinite(Number(kind))) {
target.kind = Number(kind);
}
return target;
}
const address = element.getAttribute("target-address");
if (address) {
const first = address.indexOf(":");
const second = address.indexOf(":", first + 1);
if (first > 0 && second > first) {
return {
type: "address",
kind: address.slice(0, first),
pubkey: address.slice(first + 1, second),
identifier: address.slice(second + 1),
};
}
}
return null;
}
/**
* Base element: owns a shadow root, a runtime reference, and change
* subscription lifecycle.
@@ -104,6 +171,7 @@ export class GovernanceElement extends globalThis.HTMLElement {
this._runtime = null;
/** @type {(() => void)|null} */
this._unsubscribe = null;
this._awaitingProvider = false;
}
/**
@@ -129,12 +197,55 @@ export class GovernanceElement extends globalThis.HTMLElement {
}
connectedCallback() {
// Adopt the nearest provider's runtime when one was not assigned directly.
// This is what lets a page configure BitGate entirely in markup.
if (!this._runtime) {
this.adoptContext();
}
if (this._runtime && !this._unsubscribe) {
this._subscribe();
}
this.render();
}
/**
* Find a `<bitgate-provider>` ancestor and take its runtime and commands.
*
* A provider that is still starting answers with nothing; the element waits
* for `bitgate:ready` rather than polling.
*
* @returns {boolean} Whether a runtime was adopted
*/
adoptContext() {
const context = requestContext(this);
if (context?.runtime) {
this.runtime = context.runtime;
if (context.commands && "commands" in this) {
/** @type {any} */ (this).commands = context.commands;
}
return true;
}
if (!this._awaitingProvider) {
this._awaitingProvider = true;
globalThis.document?.addEventListener(
"bitgate:ready",
(event) => {
const detail = /** @type {CustomEvent} */ (event).detail;
if (!detail?.runtime || this._runtime) {
return;
}
this.runtime = detail.runtime;
if (detail.commands && "commands" in this) {
/** @type {any} */ (this).commands = detail.commands;
}
},
{ once: true },
);
}
return false;
}
disconnectedCallback() {
// Elements are routinely moved or removed during rendering; leaving a
// subscription behind would keep a detached element re-rendering forever.
@@ -155,7 +266,7 @@ export class GovernanceElement extends globalThis.HTMLElement {
*/
emit(name, detail) {
this.dispatchEvent(
new CustomEvent(`governance:${name}`, { detail, bubbles: true, composed: true }),
new CustomEvent(`bitgate:${name}`, { detail, bubbles: true, composed: true }),
);
}
+13 -3
View File
@@ -1,4 +1,4 @@
// @nostr-governance/widget
// @bitgate/widget
//
// Custom elements built strictly on the published API. They render decisions
// and issue commands; they compute no policy. If an element here ever needs a
@@ -14,9 +14,18 @@ export {
canRegisterElements,
defineElement,
escapeHtml,
requestContext,
shortenKey,
targetFromAttributes,
} from "./base.js";
export {
BitGateError,
BitGateProvider,
CONTEXT_REQUEST,
defineProviderElements,
} from "./provider.js";
export {
DEFAULT_REASON_TEXT,
GovernanceReport,
@@ -35,6 +44,7 @@ export {
} from "./admin.js";
import { defineAdminElements } from "./admin.js";
import { defineProviderElements } from "./provider.js";
import { defineViewerElements } from "./viewer.js";
/**
@@ -46,6 +56,6 @@ import { defineViewerElements } from "./viewer.js";
*
* @returns {string[]} Element names newly registered by this call
*/
export function defineGovernanceElements() {
return [...defineViewerElements(), ...defineAdminElements()];
export function defineBitGateElements() {
return [...defineProviderElements(), ...defineViewerElements(), ...defineAdminElements()];
}
+314
View File
@@ -0,0 +1,314 @@
// `<bitgate-provider>` — attribute-driven setup.
//
// This is what makes BitGate droppable into a static page. Without it, a host
// has to construct a runtime in JavaScript and assign it to every element;
// with it, the markup carries the configuration and descendants find the
// runtime themselves.
//
// Descendants discover the provider by firing a bubbling request event rather
// than walking up the DOM. Walking up breaks across shadow boundaries, which is
// exactly where a host is most likely to place these elements.
import { createBitGate, createCommands, createRelayTransport } from "@bitgate/runtime";
import { getPolicyPreset } from "@bitgate/core";
import { GovernanceElement, defineElement, escapeHtml } from "./base.js";
/** Event a descendant fires to locate its provider. */
export const CONTEXT_REQUEST = "bitgate:context-request";
/**
* Ask the nearest provider for its runtime and commands.
*
* Returns null when no provider is present, which is a legitimate state: an
* element may be configured directly in JavaScript instead.
*
* @param {Element} element
* @returns {{ runtime: any, commands: any }|null}
*/
export function requestContext(element) {
/** @type {{ context: any }} */
const detail = { context: null };
element.dispatchEvent(new CustomEvent(CONTEXT_REQUEST, { detail, bubbles: true, composed: true }));
return detail.context;
}
export class BitGateProvider extends globalThis.HTMLElement {
static get observedAttributes() {
return ["relays", "root", "policy", "profile", "application", "namespace", "viewer"];
}
constructor() {
super();
/** @type {any} */
this.runtime = null;
/** @type {any} */
this.commands = null;
/** @type {any} */
this.transport = null;
/** @type {Error|null} */
this.error = null;
this._ready = false;
// A promise as well as an event. The event fires during element upgrade,
// which for a page that calls defineBitGateElements() after parsing is
// *before* it can attach a listener — so an event alone would be a race
// that silently loses. Awaiting `ready` always works.
/** @type {Promise<{ runtime: any, commands: any }>} */
this.ready = new Promise((resolve, reject) => {
this._resolveReady = resolve;
this._rejectReady = reject;
});
// Nothing may await this before start() runs; keep Node and browsers from
// reporting an unhandled rejection in that window.
this.ready.catch(() => {});
/** @type {Promise<void>|null} */
this.loaded = null;
// Answer descendants' context requests. Registered on the host element so
// it catches events from composed shadow trees too.
this.addEventListener(CONTEXT_REQUEST, (event) => {
const custom = /** @type {CustomEvent} */ (event);
if (!custom.detail || custom.detail.context) {
return;
}
custom.detail.context = this.runtime ? { runtime: this.runtime, commands: this.commands } : null;
event.stopPropagation();
});
}
connectedCallback() {
if (!this._ready) {
this._ready = true;
void this.start();
}
}
disconnectedCallback() {
this.stop();
}
attributeChangedCallback(name, before, after) {
// Only rebuild once connected and actually changed; the initial attribute
// pass would otherwise construct several runtimes during parsing.
if (this._ready && before !== after) {
this.stop();
void this.start();
}
}
/** @returns {string[]} */
get relayUrls() {
return (this.getAttribute("relays") ?? "")
.split(",")
.map((url) => url.trim())
.filter(Boolean);
}
/**
* Build the runtime and load administrative state.
*
* Failures are surfaced as an event and a `data-error` attribute rather than
* thrown: a page whose relays are unreachable should still render, showing
* whatever cached state exists.
*/
async start() {
try {
const relays = this.relayUrls;
const policyName = this.getAttribute("policy") ?? "admin-only";
const policy = getPolicyPreset(policyName);
if (!policy) {
throw new Error(
`Unknown policy preset "${policyName}". Use social, commerce, admin-only, or set .policy directly.`,
);
}
if (relays.length === 0) {
throw new Error("bitgate-provider requires a relays attribute");
}
this.transport = createRelayTransport(relays);
this.runtime = createBitGate({
applicationId: this.getAttribute("application") ?? globalThis.location?.hostname ?? "bitgate",
namespace: this.getAttribute("namespace") ?? "bitgate",
root: this.getAttribute("root") ?? undefined,
transport: this.transport,
policy,
storage: createLocalStorage(),
now: () => Math.floor(Date.now() / 1000),
});
this.commands = createCommands(this.runtime);
const viewer = this.getAttribute("viewer");
if (viewer) {
this.runtime.setViewer(viewer);
}
this.error = null;
this.removeAttribute("data-error");
this._resolveReady?.({ runtime: this.runtime, commands: this.commands });
this.dispatchEvent(
new CustomEvent("bitgate:ready", {
detail: { runtime: this.runtime, commands: this.commands },
bubbles: true,
composed: true,
}),
);
await this.runtime.loadAdministrativeState();
this.dispatchEvent(new CustomEvent("bitgate:loaded", { bubbles: true, composed: true }));
} catch (error) {
this.error = /** @type {Error} */ (error);
this.setAttribute("data-error", String(error));
this._rejectReady?.(error);
this.dispatchEvent(
new CustomEvent("bitgate:error", {
detail: { error: String(error) },
bubbles: true,
composed: true,
}),
);
}
}
/** Tear down the runtime and close relay sockets. */
stop() {
// A restart needs a fresh promise; the old one has already settled.
this.ready = new Promise((resolve, reject) => {
this._resolveReady = resolve;
this._rejectReady = reject;
});
this.ready.catch(() => {});
this.runtime?.destroy?.();
this.transport?.close?.();
this.runtime = null;
this.commands = null;
this.transport = null;
this._ready = false;
}
/**
* Attach a signer, typically BitLogin's `window.nostr` provider.
*
* Also sets the viewer, since a signer that cannot say who it is would leave
* the trust graph empty and every capability check failing.
*
* @param {{ getPublicKey: () => Promise<string>, signEvent: (event: any) => Promise<any> }} signer
* @returns {Promise<string>} The viewer pubkey
*/
async useSigner(signer) {
if (!this.runtime) {
throw new Error("bitgate-provider is not ready");
}
this.runtime.signer = signer;
const pubkey = await signer.getPublicKey();
this.runtime.setViewer(pubkey);
return pubkey;
}
}
/**
* Storage backed by `localStorage`, falling back to memory where it is
* unavailable or blocked (private browsing, sandboxed frames).
* @returns {import('@bitgate/runtime').GovernanceStorage}
*/
function createLocalStorage() {
/** @type {Map<string, unknown>} */
const fallback = new Map();
const available = (() => {
try {
const probe = "__bitgate_probe__";
globalThis.localStorage?.setItem(probe, "1");
globalThis.localStorage?.removeItem(probe);
return Boolean(globalThis.localStorage);
} catch {
return false;
}
})();
return {
async read(key) {
if (!available) {
return fallback.has(key) ? fallback.get(key) : null;
}
try {
const raw = globalThis.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
},
async write(key, value) {
if (!available) {
fallback.set(key, value);
return;
}
try {
globalThis.localStorage.setItem(key, JSON.stringify(value));
} catch {
// A full or blocked quota degrades to no persistence, never to a throw
// that would break rendering.
fallback.set(key, value);
}
},
async remove(key) {
fallback.delete(key);
try {
globalThis.localStorage?.removeItem(key);
} catch {
// Nothing to do.
}
},
};
}
/**
* `<bitgate-error>` — renders a provider's setup failure.
*
* Optional, but worth having: a misconfigured `relays` attribute otherwise
* produces a page that silently governs nothing.
*/
export class BitGateError extends GovernanceElement {
connectedCallback() {
super.connectedCallback();
this._listener = (event) => {
this._error = event.detail?.error;
this.render();
};
globalThis.document?.addEventListener("bitgate:error", this._listener);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._listener) {
globalThis.document?.removeEventListener("bitgate:error", this._listener);
}
}
render() {
if (!this.shadowRoot) {
return;
}
if (!this._error) {
this.renderHtml("");
return;
}
this.renderHtml(
`<p class="reason" part="error" role="status">BitGate: ${escapeHtml(this._error)}</p>`,
);
}
}
/**
* Register the provider elements.
* @returns {string[]}
*/
export function defineProviderElements() {
const registered = [];
if (defineElement("bitgate-provider", BitGateProvider)) registered.push("bitgate-provider");
if (defineElement("bitgate-error", BitGateError)) registered.push("bitgate-error");
return registered;
}
+51 -26
View File
@@ -4,11 +4,17 @@
// no thresholds, no precedence, no "if reports > n". If one of these needs a
// number, the number belongs in a policy profile.
import { GovernanceElement, defineElement, escapeHtml, shortenKey } from "./base.js";
import {
GovernanceElement,
defineElement,
escapeHtml,
shortenKey,
targetFromAttributes,
} from "./base.js";
/**
* @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision
* @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget
* @typedef {import('@bitgate/core').GovernanceDecision} GovernanceDecision
* @typedef {import('@bitgate/core').GovernanceTarget} GovernanceTarget
*/
/**
@@ -50,7 +56,7 @@ export function describeReasons(decision, overrides = {}) {
}
/**
* `<governance-veil>` — wraps content and applies a decision to it.
* `<bitgate-veil>` — wraps content and applies a decision to it.
*
* Slotted content is always in the DOM; the element controls whether it is
* shown, blurred, or replaced. Hidden content is removed from the accessibility
@@ -58,12 +64,12 @@ export function describeReasons(decision, overrides = {}) {
* out something the viewer was not meant to see.
*
* Usage:
* <governance-veil><img src="..."></governance-veil>
* <bitgate-veil><img src="..."></bitgate-veil>
* veil.runtime = runtime; veil.target = { type: "event", id, author };
*/
export class GovernanceVeil extends GovernanceElement {
static get observedAttributes() {
return ["profile"];
return ["profile", "target-user", "target-event", "target-author", "target-kind", "target-address"];
}
constructor() {
@@ -76,7 +82,8 @@ export class GovernanceVeil extends GovernanceElement {
}
get target() {
return this._target;
// An explicitly assigned target wins; otherwise read it from markup.
return this._target ?? targetFromAttributes(this);
}
set target(target) {
@@ -95,11 +102,12 @@ export class GovernanceVeil extends GovernanceElement {
/** The decision currently applied, or null when not evaluable. */
get decision() {
if (!this.runtime || !this._target) {
const target = this.target;
if (!this.runtime || !target) {
return null;
}
try {
return this.runtime.evaluate(this._target, { profile: this.profile });
return this.runtime.evaluate(target, { profile: this.profile });
} catch {
return null;
}
@@ -136,7 +144,7 @@ export class GovernanceVeil extends GovernanceElement {
`);
this.$("#reveal")?.addEventListener("click", () => {
this._revealed = true;
this.emit("revealed", { target: this._target, decision });
this.emit("revealed", { target: this.target, decision });
this.render();
});
return;
@@ -173,14 +181,14 @@ export class GovernanceVeil extends GovernanceElement {
this.$("#reveal")?.addEventListener("click", () => {
this._revealed = true;
this.emit("revealed", { target: this._target, decision });
this.emit("revealed", { target: this.target, decision });
this.render();
});
}
}
/**
* `<governance-report>` — a report dialog.
* `<bitgate-report>` — a report dialog.
*
* Reporting requires no capability: anyone may report, and whether the report
* counts is decided later by each viewer's own trust graph. The element makes
@@ -193,7 +201,7 @@ export class GovernanceReport extends GovernanceElement {
this._target = null;
/** @type {string[]} */
this.categories = ["spam", "nudity", "profanity", "illegal", "malware", "impersonation", "other"];
/** @type {import('@nostr-governance/runtime').GovernanceCommands|null} */
/** @type {import('@bitgate/runtime').GovernanceCommands|null} */
this.commands = null;
/**
* The in-flight submission, or null when idle.
@@ -203,8 +211,12 @@ export class GovernanceReport extends GovernanceElement {
this._status = "";
}
static get observedAttributes() {
return ["target-user", "target-event", "target-author", "target-kind", "target-address"];
}
get target() {
return this._target;
return this._target ?? targetFromAttributes(this);
}
set target(target) {
@@ -213,6 +225,10 @@ export class GovernanceReport extends GovernanceElement {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
if (!this.shadowRoot) {
return;
@@ -237,7 +253,7 @@ export class GovernanceReport extends GovernanceElement {
sees depends on their own trust graph.
</p>
<div class="row">
<button type="submit" id="submit" ${this._target && this.commands ? "" : "disabled"}>
<button type="submit" id="submit" ${this.target && this.commands ? "" : "disabled"}>
Submit report
</button>
<span class="reason" id="status">${escapeHtml(this._status)}</span>
@@ -252,7 +268,8 @@ export class GovernanceReport extends GovernanceElement {
}
async _submit() {
if (!this._target || !this.commands) {
const target = this.target;
if (!target || !this.commands) {
return;
}
@@ -262,18 +279,18 @@ export class GovernanceReport extends GovernanceElement {
this._status = "Publishing…";
this.render();
const result = await this.commands.report(this._target, category, details);
const result = await this.commands.report(target, category, details);
// Partial relay acceptance is success: the event exists on the network.
this._status = result.ok ? "Report published" : `Failed: ${result.code}`;
this.emit(result.ok ? "reported" : "report-failed", { target: this._target, category, result });
this.emit(result.ok ? "reported" : "report-failed", { target, category, result });
this.render();
this.pending = null;
}
}
/**
* `<governance-status>` — a compact readout of why something was affected.
* `<bitgate-status>` — a compact readout of why something was affected.
*
* Shows counts when the profile exposes evidence, and contributor identities
* only when it does. A surface that redacts evidence gets "reported by people
@@ -289,11 +306,11 @@ export class GovernanceStatus extends GovernanceElement {
}
static get observedAttributes() {
return ["profile"];
return ["profile", "target-user", "target-event", "target-author", "target-kind", "target-address"];
}
get target() {
return this._target;
return this._target ?? targetFromAttributes(this);
}
set target(target) {
@@ -313,19 +330,27 @@ export class GovernanceStatus extends GovernanceElement {
if (!this.shadowRoot) {
return;
}
if (!this.runtime || !this._target) {
const target = this.target;
if (!this.runtime || !target) {
this.renderHtml("");
return;
}
let decision;
try {
decision = this.runtime.evaluate(this._target, { profile: this.profile });
decision = this.runtime.evaluate(target, { profile: this.profile });
} catch {
this.renderHtml("");
return;
}
// A runtime that returns nothing usable must not take the page down with
// it; rendering no status is the correct degradation.
if (!decision?.reasons) {
this.renderHtml("");
return;
}
const reasons = describeReasons(decision, this.reasonText);
const evidence = decision.evidence;
const reporters = evidence?.trustedReporterPubkeys ?? [];
@@ -380,8 +405,8 @@ export class GovernanceStatus extends GovernanceElement {
*/
export function defineViewerElements() {
const registered = [];
if (defineElement("governance-veil", GovernanceVeil)) registered.push("governance-veil");
if (defineElement("governance-report", GovernanceReport)) registered.push("governance-report");
if (defineElement("governance-status", GovernanceStatus)) registered.push("governance-status");
if (defineElement("bitgate-veil", GovernanceVeil)) registered.push("bitgate-veil");
if (defineElement("bitgate-report", GovernanceReport)) registered.push("bitgate-report");
if (defineElement("bitgate-status", GovernanceStatus)) registered.push("bitgate-status");
return registered;
}
+39 -39
View File
@@ -2,15 +2,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
import {
createCommands,
createGovernanceRuntime,
createBitGate,
createMemoryTransport,
} from "@nostr-governance/runtime";
} from "@bitgate/runtime";
import { CAPABILITY_LABELS } from "../src/admin.js";
import { defineGovernanceElements } from "../src/index.js";
import { defineBitGateElements } from "../src/index.js";
import { shortenKey } from "../src/base.js";
const ROOT = "a1".repeat(32);
@@ -26,10 +26,10 @@ const POLICY = createPolicyDefinition({
profiles: { feed: { name: "feed", reports: {}, mutes: {} } },
});
defineGovernanceElements();
defineBitGateElements();
function makeRuntime(viewer) {
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "widget-test",
namespace: "widget",
transport: createMemoryTransport(),
@@ -71,21 +71,21 @@ beforeEach(() => {
document.body.innerHTML = "";
});
describe("<governance-capabilities>", () => {
describe("<bitgate-capabilities>", () => {
it("says when nobody is signed in", () => {
const panel = mount("governance-capabilities", makeRuntime());
const panel = mount("bitgate-capabilities", makeRuntime());
expect(panel.shadowRoot.textContent).toContain("Not signed in");
});
it("lists what a moderator holds", () => {
const panel = mount("governance-capabilities", makeRuntime(MODERATOR));
const panel = mount("bitgate-capabilities", makeRuntime(MODERATOR));
const text = panel.shadowRoot.textContent;
expect(text).toContain(CAPABILITY_LABELS["contribute-user-deny"]);
expect(text).toContain(shortenKey(MODERATOR));
});
it("shows capabilities an actor lacks rather than omitting them", () => {
const panel = mount("governance-capabilities", makeRuntime(CURATOR));
const panel = mount("bitgate-capabilities", makeRuntime(CURATOR));
// A curator holds only contribute-user-deny, but every capability is
// listed so the boundary is legible.
for (const label of Object.values(CAPABILITY_LABELS)) {
@@ -94,7 +94,7 @@ describe("<governance-capabilities>", () => {
});
it("marks held capabilities distinctly from unheld ones", () => {
const panel = mount("governance-capabilities", makeRuntime(CURATOR));
const panel = mount("bitgate-capabilities", makeRuntime(CURATOR));
const items = [...panel.shadowRoot.querySelectorAll("li")];
const held = items.filter((item) => item.textContent.includes("✓"));
expect(held).toHaveLength(1);
@@ -102,12 +102,12 @@ describe("<governance-capabilities>", () => {
});
it("tells an account with no authority that it has none", () => {
const panel = mount("governance-capabilities", makeRuntime(STRANGER));
const panel = mount("bitgate-capabilities", makeRuntime(STRANGER));
expect(panel.shadowRoot.textContent).toContain("no governance capabilities");
});
it("grants the root everything", () => {
const panel = mount("governance-capabilities", makeRuntime(ROOT));
const panel = mount("bitgate-capabilities", makeRuntime(ROOT));
const held = [...panel.shadowRoot.querySelectorAll("li")].filter((item) =>
item.textContent.includes("✓"),
);
@@ -115,9 +115,9 @@ describe("<governance-capabilities>", () => {
});
});
describe("<governance-action>", () => {
describe("<bitgate-action>", () => {
it("enables the control when the actor holds the capability", () => {
const action = mount("governance-action", makeRuntime(MODERATOR), {
const action = mount("bitgate-action", makeRuntime(MODERATOR), {
capability: "contribute-user-deny",
label: "Restrict",
});
@@ -125,7 +125,7 @@ describe("<governance-action>", () => {
});
it("disables it and explains why when they do not", () => {
const action = mount("governance-action", makeRuntime(CURATOR), {
const action = mount("bitgate-action", makeRuntime(CURATOR), {
capability: "contribute-event-deny",
label: "Restrict post",
});
@@ -136,7 +136,7 @@ describe("<governance-action>", () => {
});
it("keeps the control visible rather than hiding it", () => {
const action = mount("governance-action", makeRuntime(CURATOR), {
const action = mount("bitgate-action", makeRuntime(CURATOR), {
capability: "manage-roles",
label: "Edit roles",
});
@@ -145,14 +145,14 @@ describe("<governance-action>", () => {
});
it("runs the action and announces completion", async () => {
const action = mount("governance-action", makeRuntime(MODERATOR), {
const action = mount("bitgate-action", makeRuntime(MODERATOR), {
capability: "contribute-user-deny",
label: "Restrict",
});
action.action = vi.fn().mockResolvedValue({ ok: true });
const completed = vi.fn();
action.addEventListener("governance:action-completed", completed);
action.addEventListener("bitgate:action-completed", completed);
action.shadowRoot.querySelector("#run").click();
await action.pending;
expect(completed).toHaveBeenCalled();
@@ -161,14 +161,14 @@ describe("<governance-action>", () => {
});
it("reports a refusal with its stable code", async () => {
const action = mount("governance-action", makeRuntime(MODERATOR), {
const action = mount("bitgate-action", makeRuntime(MODERATOR), {
capability: "contribute-user-deny",
label: "Restrict",
});
action.action = vi.fn().mockResolvedValue({ ok: false, code: "protected-target" });
const refused = vi.fn();
action.addEventListener("governance:action-refused", refused);
action.addEventListener("bitgate:action-refused", refused);
action.shadowRoot.querySelector("#run").click();
await action.pending;
expect(refused).toHaveBeenCalled();
@@ -177,13 +177,13 @@ describe("<governance-action>", () => {
});
it("reports a thrown failure without breaking the element", async () => {
const action = mount("governance-action", makeRuntime(MODERATOR), {
const action = mount("bitgate-action", makeRuntime(MODERATOR), {
capability: "contribute-user-deny",
});
action.action = vi.fn().mockRejectedValue(new Error("relay down"));
const failed = vi.fn();
action.addEventListener("governance:action-failed", failed);
action.addEventListener("bitgate:action-failed", failed);
action.shadowRoot.querySelector("#run").click();
await action.pending;
expect(failed).toHaveBeenCalled();
@@ -193,7 +193,7 @@ describe("<governance-action>", () => {
it("re-evaluates permission when the roster changes", () => {
const runtime = makeRuntime(MODERATOR);
const action = mount("governance-action", runtime, {
const action = mount("bitgate-action", runtime, {
capability: "contribute-user-deny",
});
expect(action.shadowRoot.querySelector("#run").disabled).toBe(false);
@@ -203,7 +203,7 @@ describe("<governance-action>", () => {
});
});
describe("<governance-admin-panel>", () => {
describe("<bitgate-admin-panel>", () => {
/**
* @param {any} runtime
* @param {Object} [options]
@@ -220,13 +220,13 @@ describe("<governance-admin-panel>", () => {
}
it("reports when no runtime is attached", () => {
const panel = /** @type {any} */ (document.createElement("governance-admin-panel"));
const panel = /** @type {any} */ (document.createElement("bitgate-admin-panel"));
document.body.append(panel);
expect(panel.shadowRoot.textContent).toContain("No runtime attached");
});
it("shows the roster summary", () => {
const panel = mount("governance-admin-panel", makeRuntime(MODERATOR));
const panel = mount("bitgate-admin-panel", makeRuntime(MODERATOR));
expect(panel.shadowRoot.textContent).toContain(shortenKey(ROOT));
expect(panel.shadowRoot.textContent).toContain("actor(s)");
});
@@ -234,27 +234,27 @@ describe("<governance-admin-panel>", () => {
it("lists restricted accounts", () => {
const runtime = makeRuntime(MODERATOR);
denyCreator(runtime);
const panel = mount("governance-admin-panel", runtime);
const panel = mount("bitgate-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain(shortenKey(CREATOR));
});
it("distinguishes a community list from a moderator action", () => {
const runtime = makeRuntime(MODERATOR);
denyCreator(runtime, { source: "curated-list" });
const panel = mount("governance-admin-panel", runtime);
const panel = mount("bitgate-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain("community list");
});
it("attributes a direct action to a moderator", () => {
const runtime = makeRuntime(MODERATOR);
denyCreator(runtime);
const panel = mount("governance-admin-panel", runtime);
const panel = mount("bitgate-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain("moderator");
expect(panel.shadowRoot.textContent).not.toContain("community list");
});
it("disables restriction for an actor without the capability", () => {
const panel = mount("governance-admin-panel", makeRuntime(STRANGER));
const panel = mount("bitgate-admin-panel", makeRuntime(STRANGER));
expect(panel.shadowRoot.querySelector("#deny").disabled).toBe(true);
expect(panel.shadowRoot.textContent).toContain("Requires permission");
});
@@ -264,12 +264,12 @@ describe("<governance-admin-panel>", () => {
const commands = createCommands(runtime);
vi.spyOn(commands, "denyUser").mockResolvedValue({ ok: true });
const panel = mount("governance-admin-panel", runtime);
const panel = mount("bitgate-admin-panel", runtime);
panel.commands = commands;
panel.shadowRoot.querySelector("#pubkey").value = CREATOR;
const restricted = vi.fn();
panel.addEventListener("governance:account-restricted", restricted);
panel.addEventListener("bitgate:account-restricted", restricted);
panel.shadowRoot.querySelector("#deny").click();
await panel.pending;
expect(restricted).toHaveBeenCalled();
@@ -282,12 +282,12 @@ describe("<governance-admin-panel>", () => {
const commands = createCommands(runtime);
vi.spyOn(commands, "denyUser").mockResolvedValue({ ok: false, code: "protected-target" });
const panel = mount("governance-admin-panel", runtime);
const panel = mount("bitgate-admin-panel", runtime);
panel.commands = commands;
panel.shadowRoot.querySelector("#pubkey").value = ROOT;
const refused = vi.fn();
panel.addEventListener("governance:action-refused", refused);
panel.addEventListener("bitgate:action-refused", refused);
panel.shadowRoot.querySelector("#deny").click();
await panel.pending;
expect(refused).toHaveBeenCalled();
@@ -298,13 +298,13 @@ describe("<governance-admin-panel>", () => {
it("warns when serving cached state", () => {
const runtime = makeRuntime(MODERATOR);
runtime.stale = true;
const panel = mount("governance-admin-panel", runtime);
const panel = mount("bitgate-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain("relays unreachable");
});
it("updates when administrative state changes", () => {
const runtime = makeRuntime(MODERATOR);
const panel = mount("governance-admin-panel", runtime);
const panel = mount("bitgate-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain("Restricted accounts (0)");
denyCreator(runtime);
@@ -315,7 +315,7 @@ describe("<governance-admin-panel>", () => {
describe("element registration", () => {
it("is idempotent", () => {
// Elements were registered at module load; a second call must not throw.
expect(() => defineGovernanceElements()).not.toThrow();
expect(defineGovernanceElements()).toEqual([]);
expect(() => defineBitGateElements()).not.toThrow();
expect(defineBitGateElements()).toEqual([]);
});
});
+275
View File
@@ -0,0 +1,275 @@
// @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);
});
});
+32 -32
View File
@@ -2,15 +2,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createPolicyDefinition } from "@bitgate/core";
import {
createCommands,
createGovernanceRuntime,
createBitGate,
createMemoryTransport,
} from "@nostr-governance/runtime";
} from "@bitgate/runtime";
import { DEFAULT_REASON_TEXT, describeReasons } from "../src/viewer.js";
import { defineGovernanceElements } from "../src/index.js";
import { defineBitGateElements } from "../src/index.js";
import { escapeHtml, shortenKey } from "../src/base.js";
const ROOT = "a1".repeat(32);
@@ -48,10 +48,10 @@ const POLICY = createPolicyDefinition({
},
});
defineGovernanceElements();
defineBitGateElements();
function makeRuntime() {
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "widget-test",
namespace: "widget",
transport: createMemoryTransport(),
@@ -140,10 +140,10 @@ describe("describeReasons", () => {
});
});
describe("<governance-veil>", () => {
describe("<bitgate-veil>", () => {
it("renders content untouched when nothing objects", () => {
const runtime = makeRuntime();
const veil = mount("governance-veil", {
const veil = mount("bitgate-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
});
@@ -154,7 +154,7 @@ describe("<governance-veil>", () => {
it("hides content behind a disclosure when the decision hides", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
const veil = mount("bitgate-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
@@ -167,7 +167,7 @@ describe("<governance-veil>", () => {
it("explains why rather than vanishing silently", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
const veil = mount("bitgate-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
@@ -178,7 +178,7 @@ describe("<governance-veil>", () => {
it("keeps hidden content out of the accessibility tree", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
const veil = mount("bitgate-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
@@ -191,14 +191,14 @@ describe("<governance-veil>", () => {
it("reveals on request and announces it", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
const veil = mount("bitgate-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
const revealed = vi.fn();
veil.addEventListener("governance:revealed", revealed);
veil.addEventListener("bitgate:revealed", revealed);
veil.shadowRoot.querySelector("#reveal").click();
expect(revealed).toHaveBeenCalled();
@@ -208,7 +208,7 @@ describe("<governance-veil>", () => {
it("offers no reveal when the profile forbids overrides", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
const veil = mount("bitgate-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "locked",
@@ -224,7 +224,7 @@ describe("<governance-veil>", () => {
{ reporter: TRUSTED, category: "spam", createdAt: NOW },
`user:${CREATOR}`,
);
const veil = mount("governance-veil", {
const veil = mount("bitgate-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "detail",
@@ -236,7 +236,7 @@ describe("<governance-veil>", () => {
it("re-renders when governance state changes", () => {
const runtime = makeRuntime();
const veil = mount("governance-veil", {
const veil = mount("bitgate-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
@@ -249,7 +249,7 @@ describe("<governance-veil>", () => {
it("stops re-rendering once detached", () => {
const runtime = makeRuntime();
const veil = mount("governance-veil", {
const veil = mount("bitgate-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
@@ -261,27 +261,27 @@ describe("<governance-veil>", () => {
});
it("renders plainly without a runtime", () => {
const veil = mount("governance-veil", {});
const veil = mount("bitgate-veil", {});
expect(veil.shadowRoot.querySelector("slot")).not.toBeNull();
});
it("tolerates an invalid target instead of throwing", () => {
const runtime = makeRuntime();
const veil = mount("governance-veil", { runtime });
const veil = mount("bitgate-veil", { runtime });
expect(() => {
veil.target = { type: "user", pubkey: "not-a-key" };
}).not.toThrow();
});
});
describe("<governance-report>", () => {
describe("<bitgate-report>", () => {
it("disables submission with no target or commands", () => {
const report = mount("governance-report", { runtime: makeRuntime() });
const report = mount("bitgate-report", { runtime: makeRuntime() });
expect(report.shadowRoot.querySelector("#submit").disabled).toBe(true);
});
it("states plainly that reports are public", () => {
const report = mount("governance-report", { runtime: makeRuntime() });
const report = mount("bitgate-report", { runtime: makeRuntime() });
expect(report.shadowRoot.textContent).toContain("public");
});
@@ -290,12 +290,12 @@ describe("<governance-report>", () => {
const commands = createCommands(runtime);
vi.spyOn(commands, "report").mockResolvedValue({ ok: true, accepted: ["wss://a"] });
const report = mount("governance-report", { runtime });
const report = mount("bitgate-report", { runtime });
report.commands = commands;
report.target = { type: "event", id: EVENT_ID };
const reported = vi.fn();
report.addEventListener("governance:reported", reported);
report.addEventListener("bitgate:reported", reported);
report.shadowRoot.querySelector("#category").value = "spam";
report.shadowRoot.querySelector("form").dispatchEvent(new Event("submit"));
@@ -311,12 +311,12 @@ describe("<governance-report>", () => {
const commands = createCommands(runtime);
vi.spyOn(commands, "report").mockResolvedValue({ ok: false, code: "invalid-target" });
const report = mount("governance-report", { runtime });
const report = mount("bitgate-report", { runtime });
report.commands = commands;
report.target = { type: "event", id: EVENT_ID };
const failed = vi.fn();
report.addEventListener("governance:report-failed", failed);
report.addEventListener("bitgate:report-failed", failed);
report.shadowRoot.querySelector("form").dispatchEvent(new Event("submit"));
await report.pending;
expect(failed).toHaveBeenCalled();
@@ -325,11 +325,11 @@ describe("<governance-report>", () => {
});
});
describe("<governance-status>", () => {
describe("<bitgate-status>", () => {
it("reports the effect and reasons", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const status = mount("governance-status", {
const status = mount("bitgate-status", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
@@ -340,7 +340,7 @@ describe("<governance-status>", () => {
});
it("says so when there is nothing to report", () => {
const status = mount("governance-status", {
const status = mount("bitgate-status", {
runtime: makeRuntime(),
target: { type: "user", pubkey: CREATOR },
profile: "feed",
@@ -354,7 +354,7 @@ describe("<governance-status>", () => {
{ reporter: TRUSTED, category: "spam", createdAt: NOW },
`user:${CREATOR}`,
);
const status = mount("governance-status", {
const status = mount("bitgate-status", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
@@ -370,7 +370,7 @@ describe("<governance-status>", () => {
{ reporter: TRUSTED, category: "spam", createdAt: NOW },
`user:${CREATOR}`,
);
const status = mount("governance-status", {
const status = mount("bitgate-status", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "detail",
@@ -381,7 +381,7 @@ describe("<governance-status>", () => {
});
it("renders no status without a target", () => {
const status = mount("governance-status", { runtime: makeRuntime() });
const status = mount("bitgate-status", { runtime: makeRuntime() });
expect(status.shadowRoot.querySelector("[part='status']")).toBeNull();
});
});
+2 -2
View File
@@ -9,8 +9,8 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { runConformanceCase } from "@nostr-governance/testing/conformance";
import { REFERENCE_POLICY } from "@nostr-governance/bitvid-compat/profile";
import { runConformanceCase } from "@bitgate/testing/conformance";
import { REFERENCE_POLICY } from "@bitgate/bitvid-compat/profile";
const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
const CASES_DIR = join(ROOT, "fixtures", "bitvid", "cases");
+5 -5
View File
@@ -8,8 +8,8 @@
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import { createGovernanceRuntime, createMemoryTransport } from "@nostr-governance/runtime";
import { createPolicyDefinition } from "@bitgate/core";
import { createBitGate, createMemoryTransport } from "@bitgate/runtime";
const AUTHOR_COUNT = 500;
const TARGET_COUNT = 5_000;
@@ -18,9 +18,9 @@ const NOW = 1_750_000_000;
const hex = (prefix, index) => `${prefix}${index.toString(16).padStart(10, "0")}`.padEnd(64, "0").slice(0, 64);
/** @param {number} index @returns {import('@nostr-governance/core').GovernanceTarget} */
/** @param {number} index @returns {import('@bitgate/core').GovernanceTarget} */
const userTarget = (index) => ({ type: "user", pubkey: hex("a", index) });
/** @param {number} index @param {number} authorIndex @returns {import('@nostr-governance/core').GovernanceTarget} */
/** @param {number} index @param {number} authorIndex @returns {import('@bitgate/core').GovernanceTarget} */
const eventTarget = (index, authorIndex) => ({
type: "event",
id: hex("c", index),
@@ -55,7 +55,7 @@ const POLICY = createPolicyDefinition({
/** Build a runtime loaded with a large, realistic governance state. */
function buildLargeRuntime() {
const transport = createMemoryTransport();
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "perf",
namespace: "perf",
transport,
+8 -8
View File
@@ -19,20 +19,20 @@ import {
isValidTarget,
normalizeAddress,
reduceAdminState,
} from "@nostr-governance/core";
} from "@bitgate/core";
import {
MAX_POLICY_BYTES,
canonicalIdentifier,
decodePolicy,
decodeReport,
verifyEvents,
} from "@nostr-governance/nostr";
} from "@bitgate/nostr";
import {
ERROR_CODES,
createCommands,
createGovernanceRuntime,
createBitGate,
createMemoryTransport,
} from "@nostr-governance/runtime";
} from "@bitgate/runtime";
const ROOT = "a1".repeat(32);
const MODERATOR = "b2".repeat(32);
@@ -75,7 +75,7 @@ const event = (parts) => ({
});
function runtimeWithSigner(pubkey) {
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "sec",
namespace: "sec",
transport: createMemoryTransport(),
@@ -206,7 +206,7 @@ describe("9. ignore unauthorized contributor events", () => {
});
it("stops honoring an actor's contributions the moment their role is revoked", () => {
/** @type {import('@nostr-governance/core').Contribution[]} */
/** @type {import('@bitgate/core').Contribution[]} */
const contributions = [
{ actor: MODERATOR, kind: "user-deny", targets: [{ type: "user", pubkey: CREATOR }] },
];
@@ -419,7 +419,7 @@ describe("18-19. transient relay failure and diagnostics", () => {
describe("20. fail closed when authority cannot be confirmed", () => {
it("refuses an administrative command with no signer", async () => {
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "sec",
namespace: "sec",
transport: createMemoryTransport(),
@@ -430,7 +430,7 @@ describe("20. fail closed when authority cannot be confirmed", () => {
});
it("refuses every administrative command when the roster is empty", async () => {
const runtime = createGovernanceRuntime({
const runtime = createBitGate({
applicationId: "sec",
namespace: "sec",
transport: createMemoryTransport(),