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