From adeba151b874839345b13fc30db1ec9fe9c6238d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:58:45 -0400 Subject: [PATCH] feat: rename to BitGate and close the drop-in gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - — 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; threw on an unusable decision. 759 tests; lint, typecheck, and both builds clean. --- CHANGELOG.md | 49 ++- README.md | 91 ++-- .../0003-generic-engine-application-policy.md | 2 +- docs/adr/0004-hex-only-core.md | 4 +- docs/adr/0005-rename-to-bitgate.md | 52 +++ docs/adr/0006-drop-in-distribution.md | 50 +++ docs/checkpoints.md | 2 +- docs/integration-guide.md | 12 +- docs/license-and-provenance.md | 10 +- examples/commerce/README.md | 2 +- examples/commerce/demo.html | 26 +- examples/commerce/package.json | 6 +- examples/commerce/src/adapters.js | 10 +- examples/commerce/src/marketplace.js | 10 +- examples/commerce/src/policy.js | 8 +- examples/commerce/test/proof-of-fit.test.js | 8 +- examples/dropin.html | 60 +++ package-lock.json | 95 ++-- package.json | 9 +- packages/bitvid-compat/package.json | 6 +- packages/bitvid-compat/src/profile.js | 10 +- packages/core/package.json | 2 +- packages/core/src/index.js | 10 +- packages/core/src/presets.js | 249 +++++++++++ packages/core/test/presets.test.js | 217 ++++++++++ packages/core/test/smoke.test.js | 2 +- packages/nostr/package.json | 4 +- packages/nostr/src/canonical.js | 20 +- packages/nostr/src/index.js | 2 +- packages/nostr/src/legacy.js | 4 +- packages/nostr/src/nip19.js | 2 +- packages/nostr/src/reports.js | 2 +- packages/nostr/test/canonical.test.js | 4 +- packages/nostr/test/legacy.test.js | 2 +- packages/runtime/package.json | 6 +- packages/runtime/src/commands.js | 26 +- packages/runtime/src/index.js | 6 +- packages/runtime/src/interfaces.js | 4 +- packages/runtime/src/relayTransport.js | 408 ++++++++++++++++++ packages/runtime/src/runtime.js | 28 +- packages/runtime/src/stores.js | 18 +- packages/runtime/test/commands.test.js | 12 +- packages/runtime/test/persistence.test.js | 22 +- packages/runtime/test/relayTransport.test.js | 391 +++++++++++++++++ packages/runtime/test/runtime.test.js | 24 +- packages/runtime/test/stores.test.js | 4 +- packages/testing/package.json | 4 +- packages/testing/src/conformance.js | 14 +- packages/testing/test/conformance.test.js | 2 +- packages/widget/README.md | 103 ++++- packages/widget/build.mjs | 54 +++ packages/widget/package.json | 14 +- packages/widget/src/admin.js | 20 +- packages/widget/src/base.js | 119 ++++- packages/widget/src/index.js | 16 +- packages/widget/src/provider.js | 314 ++++++++++++++ packages/widget/src/viewer.js | 77 ++-- packages/widget/test/admin.test.js | 78 ++-- packages/widget/test/provider.test.js | 275 ++++++++++++ packages/widget/test/viewer.test.js | 64 +-- tests/conformance/bitvid-fixtures.test.js | 4 +- tests/performance/large-feed.test.js | 10 +- tests/security/requirements.test.js | 16 +- 63 files changed, 2774 insertions(+), 401 deletions(-) create mode 100644 docs/adr/0005-rename-to-bitgate.md create mode 100644 docs/adr/0006-drop-in-distribution.md create mode 100644 examples/dropin.html create mode 100644 packages/core/src/presets.js create mode 100644 packages/core/test/presets.test.js create mode 100644 packages/runtime/src/relayTransport.js create mode 100644 packages/runtime/test/relayTransport.test.js create mode 100644 packages/widget/build.mjs create mode 100644 packages/widget/src/provider.js create mode 100644 packages/widget/test/provider.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 81fe347..9abac6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +- **``** — 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. +- `` 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 diff --git a/README.md b/README.md index afb0cb5..a65bf92 100644 --- a/README.md +++ b/README.md @@ -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 + + + + + + + +``` + +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 - + ``` ```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; diff --git a/docs/adr/0003-generic-engine-application-policy.md b/docs/adr/0003-generic-engine-application-policy.md index 99f34cc..3833da5 100644 --- a/docs/adr/0003-generic-engine-application-policy.md +++ b/docs/adr/0003-generic-engine-application-policy.md @@ -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 diff --git a/docs/adr/0004-hex-only-core.md b/docs/adr/0004-hex-only-core.md index 9395646..67a6cb4 100644 --- a/docs/adr/0004-hex-only-core.md +++ b/docs/adr/0004-hex-only-core.md @@ -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 diff --git a/docs/adr/0005-rename-to-bitgate.md b/docs/adr/0005-rename-to-bitgate.md new file mode 100644 index 0000000..8b02672 --- /dev/null +++ b/docs/adr/0005-rename-to-bitgate.md @@ -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. diff --git a/docs/adr/0006-drop-in-distribution.md b/docs/adr/0006-drop-in-distribution.md new file mode 100644 index 0000000..1e47d02 --- /dev/null +++ b/docs/adr/0006-drop-in-distribution.md @@ -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. **``** — 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. diff --git a/docs/checkpoints.md b/docs/checkpoints.md index bf7aa90..56a2268 100644 --- a/docs/checkpoints.md +++ b/docs/checkpoints.md @@ -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 diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 8182d1f..b13eb0d 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -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); ``` diff --git a/docs/license-and-provenance.md b/docs/license-and-provenance.md index 85e2ae8..0f8b57f 100644 --- a/docs/license-and-provenance.md +++ b/docs/license-and-provenance.md @@ -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 diff --git a/examples/commerce/README.md b/examples/commerce/README.md index 939e2ce..16f6f06 100644 --- a/examples/commerce/README.md +++ b/examples/commerce/README.md @@ -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 diff --git a/examples/commerce/demo.html b/examples/commerce/demo.html index e9cf0cb..6936835 100644 --- a/examples/commerce/demo.html +++ b/examples/commerce/demo.html @@ -3,7 +3,7 @@ - nostr-governance widget demo + BitGate widget demo -

nostr-governance widget

+

BitGate widget demo

Every element below reads from one shared runtime. Toggle governance state and watch each surface reach its own conclusion from the same evidence.

-

Viewer surface — <governance-veil>

- +

Viewer surface — <bitgate-veil>

+

Widget — 1000 sats

-
+
@@ -61,8 +61,8 @@
-

Status readout — <governance-status>

- +

Status readout — <bitgate-status>

+
@@ -72,20 +72,20 @@ listings but not sellers. Note that the refused control stays visible and says what it needs.

- - + +
+ + diff --git a/package-lock.json b/package-lock.json index 4dfc628..205026c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": "*" } } } diff --git a/package.json b/package.json index 3b13be5..eaf3bed 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/bitvid-compat/package.json b/packages/bitvid-compat/package.json index 775abb9..2cbc84c 100644 --- a/packages/bitvid-compat/package.json +++ b/packages/bitvid-compat/package.json @@ -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": "*" } } diff --git a/packages/bitvid-compat/src/profile.js b/packages/bitvid-compat/src/profile.js index cc7f552..d97408c 100644 --- a/packages/bitvid-compat/src/profile.js +++ b/packages/bitvid-compat/src/profile.js @@ -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} + * @type {Record} */ 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} + * @type {Record} */ 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", diff --git a/packages/core/package.json b/packages/core/package.json index e3dcbe2..f9e246b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,5 +1,5 @@ { - "name": "@nostr-governance/core", + "name": "@bitgate/core", "version": "0.1.0", "type": "module", "main": "src/index.js", diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 28258fa..cea1a8d 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -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, diff --git a/packages/core/src/presets.js b/packages/core/src/presets.js new file mode 100644 index 0000000..a2be6ae --- /dev/null +++ b/packages/core/src/presets.js @@ -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} + */ +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} + */ +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} + */ +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} + */ +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; +} diff --git a/packages/core/test/presets.test.js b/packages/core/test/presets.test.js new file mode 100644 index 0000000..2562b87 --- /dev/null +++ b/packages/core/test/presets.test.js @@ -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); + } + } + }); +}); diff --git a/packages/core/test/smoke.test.js b/packages/core/test/smoke.test.js index 0979082..472d9c0 100644 --- a/packages/core/test/smoke.test.js +++ b/packages/core/test/smoke.test.js @@ -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"); diff --git a/packages/nostr/package.json b/packages/nostr/package.json index 00b9766..7f44c12 100644 --- a/packages/nostr/package.json +++ b/packages/nostr/package.json @@ -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": "*" } } diff --git a/packages/nostr/src/canonical.js b/packages/nostr/src/canonical.js index 49fbb00..550f0a0 100644 --- a/packages/nostr/src/canonical.js +++ b/packages/nostr/src/canonical.js @@ -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"], ]; diff --git a/packages/nostr/src/index.js b/packages/nostr/src/index.js index cc609cc..c28150c 100644 --- a/packages/nostr/src/index.js +++ b/packages/nostr/src/index.js @@ -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` diff --git a/packages/nostr/src/legacy.js b/packages/nostr/src/legacy.js index a6bd937..4ecc84c 100644 --- a/packages/nostr/src/legacy.js +++ b/packages/nostr/src/legacy.js @@ -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") { diff --git a/packages/nostr/src/nip19.js b/packages/nostr/src/nip19.js index be57c8e..77eecca 100644 --- a/packages/nostr/src/nip19.js +++ b/packages/nostr/src/nip19.js @@ -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. diff --git a/packages/nostr/src/reports.js b/packages/nostr/src/reports.js index dd88734..8b2ac69 100644 --- a/packages/nostr/src/reports.js +++ b/packages/nostr/src/reports.js @@ -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. */ diff --git a/packages/nostr/test/canonical.test.js b/packages/nostr/test/canonical.test.js index c973c77..34da35e 100644 --- a/packages/nostr/test/canonical.test.js +++ b/packages/nostr/test/canonical.test.js @@ -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"]); }); }); diff --git a/packages/nostr/test/legacy.test.js b/packages/nostr/test/legacy.test.js index 55c9b41..5b1dba9 100644 --- a/packages/nostr/test/legacy.test.js +++ b/packages/nostr/test/legacy.test.js @@ -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); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 5731be6..ad9d49f 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -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": "*" } } diff --git a/packages/runtime/src/commands.js b/packages/runtime/src/commands.js index 2b46c49..7332196 100644 --- a/packages/runtime/src/commands.js +++ b/packages/runtime/src/commands.js @@ -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} */ +/** @type {Record} */ 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} @@ -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} */ 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} @@ -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) { diff --git a/packages/runtime/src/index.js b/packages/runtime/src/index.js index bc198db..e4da251 100644 --- a/packages/runtime/src/index.js +++ b/packages/runtime/src/index.js @@ -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"; diff --git a/packages/runtime/src/interfaces.js b/packages/runtime/src/interfaces.js index 984439b..1f65698 100644 --- a/packages/runtime/src/interfaces.js +++ b/packages/runtime/src/interfaces.js @@ -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, diff --git a/packages/runtime/src/relayTransport.js b/packages/runtime/src/relayTransport.js new file mode 100644 index 0000000..ff2b101 --- /dev/null +++ b/packages/runtime/src/relayTransport.js @@ -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} */ + 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 }>} */ + 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} + */ + async list(filters, listOptions = {}) { + const id = `bg-list-${(subscriptionCounter += 1)}`; + /** @type {Map} */ + 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} */ + 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} + */ + 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(); + }, + }; +} diff --git a/packages/runtime/src/runtime.js b/packages/runtime/src/runtime.js index daff0f4..01d8a6a 100644 --- a/packages/runtime/src/runtime.js +++ b/packages/runtime/src/runtime.js @@ -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[0]} options * @returns {GovernanceRuntime} */ -export function createGovernanceRuntime(options) { +export function createBitGate(options) { return new GovernanceRuntime(options); } diff --git a/packages/runtime/src/stores.js b/packages/runtime/src/stores.js index d00f066..5c52b23 100644 --- a/packages/runtime/src/stores.js +++ b/packages/runtime/src/stores.js @@ -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) { diff --git a/packages/runtime/test/commands.test.js b/packages/runtime/test/commands.test.js index 7eccac7..ad36698 100644 --- a/packages/runtime/test/commands.test.js +++ b/packages/runtime/test/commands.test.js @@ -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(), diff --git a/packages/runtime/test/persistence.test.js b/packages/runtime/test/persistence.test.js index 3749c55..598095f 100644 --- a/packages/runtime/test/persistence.test.js +++ b/packages/runtime/test/persistence.test.js @@ -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); diff --git a/packages/runtime/test/relayTransport.test.js b/packages/runtime/test/relayTransport.test.js new file mode 100644 index 0000000..ec0e5da --- /dev/null +++ b/packages/runtime/test/relayTransport.test.js @@ -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); + }); +}); diff --git a/packages/runtime/test/runtime.test.js b/packages/runtime/test/runtime.test.js index b7561f9..ddc7371 100644 --- a/packages/runtime/test/runtime.test.js +++ b/packages/runtime/test/runtime.test.js @@ -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} parts - * @returns {import('@nostr-governance/nostr').NostrEvent} + * @param {Partial} 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" })); diff --git a/packages/runtime/test/stores.test.js b/packages/runtime/test/stores.test.js index 9dcc547..b1a8145 100644 --- a/packages/runtime/test/stores.test.js +++ b/packages/runtime/test/stores.test.js @@ -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 = { diff --git a/packages/testing/package.json b/packages/testing/package.json index 81e18fb..76790f8 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -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": "*" } } diff --git a/packages/testing/src/conformance.js b/packages/testing/src/conformance.js index de95150..553c41b 100644 --- a/packages/testing/src/conformance.js +++ b/packages/testing/src/conformance.js @@ -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); diff --git a/packages/testing/test/conformance.test.js b/packages/testing/test/conformance.test.js index 9b02332..8de4b93 100644 --- a/packages/testing/test/conformance.test.js +++ b/packages/testing/test/conformance.test.js @@ -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"; diff --git a/packages/widget/README.md b/packages/widget/README.md index f398eea..c824642 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -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 + + + + + + + +``` + +`` 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 +… +… +… +``` + +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 | | --- | --- | -| `` | Wraps content and applies the decision: blur on `restrict`, disclosure on `hide`, warning on `warn` | -| `` | Report dialog. Requires no capability — anyone may report | -| `` | Readout of effect, reasons, and evidence | +| `` | Wraps content and applies the decision: blur on `restrict`, disclosure on `hide`, warning on `warn` | +| `` | Report dialog. Requires no capability — anyone may report | +| `` | Readout of effect, reasons, and evidence | ### Moderator-facing | Element | Purpose | | --- | --- | -| `` | What the signed-in account may actually do | -| `` | A capability-gated button that explains refusals | -| `` | Effective state, contributor attribution, and controls | +| `` | What the signed-in account may actually do | +| `` | A capability-gated button that explains refusals | +| `` | Effective state, contributor attribution, and controls | ## Usage ```html - + - + ``` 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. diff --git a/packages/widget/build.mjs b/packages/widget/build.mjs new file mode 100644 index 0000000..d49ed86 --- /dev/null +++ b/packages/widget/build.mjs @@ -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}`); +} diff --git a/packages/widget/package.json b/packages/widget/package.json index c9d0315..7509c64 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -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": "*" } } diff --git a/packages/widget/src/admin.js b/packages/widget/src/admin.js index 0155b38..3a347ea 100644 --- a/packages/widget/src/admin.js +++ b/packages/widget/src/admin.js @@ -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 = { }; /** - * `` — what the signed-in moderator may do. + * `` — 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 { } /** - * `` — a single capability-gated action button. + * `` — 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 { } /** - * `` — effective administrative state plus controls. + * `` — 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; } diff --git a/packages/widget/src/base.js b/packages/widget/src/base.js index 8885b2d..4e347fb 100644 --- a/packages/widget/src/base.js +++ b/packages/widget/src/base.js @@ -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 `` 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 }), ); } diff --git a/packages/widget/src/index.js b/packages/widget/src/index.js index 4d2676a..fe89e37 100644 --- a/packages/widget/src/index.js +++ b/packages/widget/src/index.js @@ -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()]; } diff --git a/packages/widget/src/provider.js b/packages/widget/src/provider.js new file mode 100644 index 0000000..07329dc --- /dev/null +++ b/packages/widget/src/provider.js @@ -0,0 +1,314 @@ +// `` — 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|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, signEvent: (event: any) => Promise }} signer + * @returns {Promise} 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} */ + 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. + } + }, + }; +} + +/** + * `` — 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( + `

BitGate: ${escapeHtml(this._error)}

`, + ); + } +} + +/** + * 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; +} diff --git a/packages/widget/src/viewer.js b/packages/widget/src/viewer.js index 4731aad..5e5b74a 100644 --- a/packages/widget/src/viewer.js +++ b/packages/widget/src/viewer.js @@ -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 = {}) { } /** - * `` — wraps content and applies a decision to it. + * `` — 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: - * + * * 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(); }); } } /** - * `` — a report dialog. + * `` — 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.

- ${escapeHtml(this._status)} @@ -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; } } /** - * `` — a compact readout of why something was affected. + * `` — 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; } diff --git a/packages/widget/test/admin.test.js b/packages/widget/test/admin.test.js index 859c1f5..6886486 100644 --- a/packages/widget/test/admin.test.js +++ b/packages/widget/test/admin.test.js @@ -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("", () => { +describe("", () => { 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("", () => { }); 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("", () => { }); 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("", () => { }); }); -describe("", () => { +describe("", () => { 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { 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("", () => { }); }); -describe("", () => { +describe("", () => { /** * @param {any} runtime * @param {Object} [options] @@ -220,13 +220,13 @@ describe("", () => { } 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("", () => { 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("", () => { 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("", () => { 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("", () => { 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("", () => { 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([]); }); }); diff --git a/packages/widget/test/provider.test.js b/packages/widget/test/provider.test.js new file mode 100644 index 0000000..c1cea77 --- /dev/null +++ b/packages/widget/test/provider.test.js @@ -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} [attributes] + * @param {string} [children] + * @returns {Promise} + */ +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("", () => { + 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( + {}, + `

content

`, + ); + 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( + {}, + ``, + ); + 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" }, + `

content

`, + ); + + 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( + {}, + ``, + ); + 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); + }); +}); diff --git a/packages/widget/test/viewer.test.js b/packages/widget/test/viewer.test.js index 8e7be36..0aef4bc 100644 --- a/packages/widget/test/viewer.test.js +++ b/packages/widget/test/viewer.test.js @@ -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("", () => { +describe("", () => { 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("", () => { 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("", () => { 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("", () => { 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("", () => { 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("", () => { 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("", () => { { 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("", () => { 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("", () => { 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("", () => { }); 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("", () => { +describe("", () => { 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("", () => { 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("", () => { 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("", () => { }); }); -describe("", () => { +describe("", () => { 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("", () => { }); 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("", () => { { 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("", () => { { 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("", () => { }); 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(); }); }); diff --git a/tests/conformance/bitvid-fixtures.test.js b/tests/conformance/bitvid-fixtures.test.js index 5ce6c9c..ff83a3c 100644 --- a/tests/conformance/bitvid-fixtures.test.js +++ b/tests/conformance/bitvid-fixtures.test.js @@ -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"); diff --git a/tests/performance/large-feed.test.js b/tests/performance/large-feed.test.js index e5dbf32..b2f786a 100644 --- a/tests/performance/large-feed.test.js +++ b/tests/performance/large-feed.test.js @@ -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, diff --git a/tests/security/requirements.test.js b/tests/security/requirements.test.js index 544165b..5e3d404 100644 --- a/tests/security/requirements.test.js +++ b/tests/security/requirements.test.js @@ -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(),