From 6957594c36155f61635d4de69655708910589eb6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:14:27 -0400 Subject: [PATCH] feat(widget): plug-and-play governance UI as custom elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows bitlogin's layering: the engine stays headless and the widget is a consumer of the published API, never a dependency of it. Building it was also a test of that API — an element needing something unexposed would have been a finding, the way the commerce example was. Viewer elements: governance-veil (blur on restrict, disclosure on hide, warning on warn), governance-report, governance-status. Moderator elements: governance-capabilities, governance-action, governance-admin-panel. Deliberate behaviors: - hidden content leaves the accessibility tree rather than being visually covered, so a screen reader does not read out what the viewer must not see - a hidden decision renders a disclosure, not nothing: content that silently vanishes is indistinguishable from a bug and leaves no route to appeal - refused moderator actions stay visible and name the missing capability. A hidden button teaches nothing about the authority model, and the command layer enforces the same check independently — the UI is an explanation, never the gate - no element computes policy; wording maps from stable reason identifiers and is overridable per application Found while building it: GovernanceAdminStore emitted only when reduced denial state changed, so revoking a role that denied nobody announced nothing, leaving capability-gated UI and the decision cache stale. The root tsconfig deliberately omits the DOM lib — that absence is what keeps core, nostr, and runtime from reaching for browser globals — so the widget carries its own DOM-aware config rather than relaxing the guarantee globally. 687 tests; lint, typecheck, and the declaration build pass from a cold checkout. --- CHANGELOG.md | 14 + README.md | 25 ++ examples/commerce/demo.html | 190 +++++++++++++ package-lock.json | 107 ++++++++ package.json | 5 +- packages/runtime/src/stores.js | 33 ++- packages/runtime/test/stores.test.js | 34 +++ packages/widget/README.md | 116 ++++++++ packages/widget/package.json | 22 ++ packages/widget/src/admin.js | 295 ++++++++++++++++++++ packages/widget/src/base.js | 195 ++++++++++++++ packages/widget/src/index.js | 51 ++++ packages/widget/src/viewer.js | 381 ++++++++++++++++++++++++++ packages/widget/test/admin.test.js | 316 ++++++++++++++++++++++ packages/widget/test/viewer.test.js | 385 +++++++++++++++++++++++++++ packages/widget/tsconfig.json | 19 ++ packages/widget/tsconfig.types.json | 14 + tsconfig.json | 16 +- 18 files changed, 2207 insertions(+), 11 deletions(-) create mode 100644 examples/commerce/demo.html create mode 100644 packages/widget/README.md create mode 100644 packages/widget/package.json create mode 100644 packages/widget/src/admin.js create mode 100644 packages/widget/src/base.js create mode 100644 packages/widget/src/index.js create mode 100644 packages/widget/src/viewer.js create mode 100644 packages/widget/test/admin.test.js create mode 100644 packages/widget/test/viewer.test.js create mode 100644 packages/widget/tsconfig.json create mode 100644 packages/widget/tsconfig.types.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9130506..1338e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,14 @@ 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 + 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 + resolution, root-policy application, import/export, and capability queries. - GPL-3.0-or-later `LICENSE`, `README`, and an integration guide. ### Fixed @@ -59,6 +67,12 @@ public API changes with it. governed by several targets lost the evidence that drove its verdict. - `OverrideStore.clear()` shadowed the inherited listener `clear()`, leaking subscribers on teardown. Now `clearOverrides()`. +- `GovernanceAdminStore` emitted only when reduced denial state changed, so + revoking a role that happened to deny nobody announced nothing — leaving + capability-gated UI and the decision cache stale. It now also emits on + authority changes. +- `ReportStore.ingest` required a `target` field it never read, since the + target is identified by the key argument. - Evaluation was quadratic in state size: both the snapshot and its fingerprint were rebuilt for every target, so each call walked every report and mute list. A 5,000-target pass took ~47s. The runtime now memoizes the snapshot diff --git a/README.md b/README.md index 095026d..afb0cb5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ equivalent to following the operator's seed list. | `@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. | ## Quick start @@ -116,6 +117,30 @@ address denial that survives republication, malware-driven checkout blocking, capability-scoped moderator roles, community curator ingestion, and seller-facing explanations. See [`docs/integration-guide.md`](docs/integration-guide.md). +## Plug-and-play UI + +`@nostr-governance/widget` ships custom elements that render decisions and issue +commands — and compute no policy of their own: + +```html + +``` + +```js +import { defineGovernanceElements } from "@nostr-governance/widget"; +defineGovernanceElements(); + +const veil = document.querySelector("governance-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. +See [`packages/widget/README.md`](packages/widget/README.md) and the runnable +[`examples/commerce/demo.html`](examples/commerce/demo.html). + ## Authority model Root-authorized contributors: the root publishes the role roster, each actor diff --git a/examples/commerce/demo.html b/examples/commerce/demo.html new file mode 100644 index 0000000..e9cf0cb --- /dev/null +++ b/examples/commerce/demo.html @@ -0,0 +1,190 @@ + + + + + + nostr-governance widget demo + + + +

nostr-governance widget

+

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

+ +
+

Viewer surface — <governance-veil>

+ +
+ +

Widget — 1000 sats

+
+
+ +
+ + + +
+
+ +
+

Same product, four surfaces

+

+ One snapshot, four profiles. This is the state a single + hidden boolean cannot express. +

+
+
+ +
+

Status readout — <governance-status>

+ +
+ +
+

Moderator console

+

+ Signed in as a listing moderator, who may restrict + listings but not sellers. Note that the refused control stays visible + and says what it needs. +

+ + +
+ + + + diff --git a/package-lock.json b/package-lock.json index 108e891..8efde46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ ], "devDependencies": { "@types/node": "^22.10.0", + "happy-dom": "^20.11.2", "typescript": "^5.4.5", "vitest": "^1.6.0" }, @@ -463,6 +464,10 @@ "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.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", @@ -837,6 +842,23 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", @@ -960,6 +982,19 @@ "node": "*" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1065,6 +1100,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1176,6 +1224,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/happy-dom": { + "version": "20.11.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz", + "integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -1833,6 +1900,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1866,6 +1943,28 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", @@ -1912,6 +2011,14 @@ "dependencies": { "@nostr-governance/core": "*" } + }, + "packages/widget": { + "name": "@nostr-governance/widget", + "version": "0.1.0", + "dependencies": { + "@nostr-governance/core": "*", + "@nostr-governance/runtime": "*" + } } } } diff --git a/package.json b/package.json index 1b3d92f..564ecf8 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "test:unit": "vitest run --passWithNoTests", "test:conformance": "vitest run tests/conformance --passWithNoTests", "typecheck": "tsc -p tsconfig.json --noEmit", - "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/testing/tsconfig.types.json", + "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": "npm run typecheck" + "check:types": "tsc -p tsconfig.json --noEmit && tsc -p packages/widget/tsconfig.json --noEmit" }, "engines": { "node": ">=22" @@ -23,6 +23,7 @@ ], "devDependencies": { "@types/node": "^22.10.0", + "happy-dom": "^20.11.2", "typescript": "^5.4.5", "vitest": "^1.6.0" } diff --git a/packages/runtime/src/stores.js b/packages/runtime/src/stores.js index 7f50302..d00f066 100644 --- a/packages/runtime/src/stores.js +++ b/packages/runtime/src/stores.js @@ -38,6 +38,8 @@ export class GovernanceAdminStore extends Emitter { this.state = createEmptyAdminState(); /** @type {string} */ this.fingerprint = fingerprint(serializeAdminState(this.state)); + /** @type {string} */ + this.authorityFingerprint = this.#computeAuthorityFingerprint(); } /** @@ -53,6 +55,15 @@ export class GovernanceAdminStore extends Emitter { this.#recompute(); } + #computeAuthorityFingerprint() { + return fingerprint({ + root: this.authority.root ?? "", + actors: this.authority.actors, + roles: this.authority.roles, + protectedActors: this.authority.protectedActors, + }); + } + /** * Replace every contribution. * @param {Contribution[]} contributions @@ -104,11 +115,22 @@ export class GovernanceAdminStore extends Emitter { #recompute() { const next = reduceAdminState(this.contributions, this.authority); const nextFingerprint = fingerprint(serializeAdminState(next)); + const nextAuthority = this.#computeAuthorityFingerprint(); + this.state = next; - if (nextFingerprint !== this.fingerprint) { - this.fingerprint = nextFingerprint; - this.emit("change", { fingerprint: nextFingerprint }); + // A roster change can alter capabilities and protected actors without + // changing a single denial entry. Emitting only on reduced state would + // leave capability-gated UI and the decision cache stale after a + // revocation that happened to deny nobody. + const changed = + nextFingerprint !== this.fingerprint || nextAuthority !== this.authorityFingerprint; + + this.fingerprint = nextFingerprint; + this.authorityFingerprint = nextAuthority; + + if (changed) { + this.emit("change", { fingerprint: nextFingerprint, authorityFingerprint: nextAuthority }); } } } @@ -223,7 +245,10 @@ export class ReportStore extends Emitter { * structural: re-ingesting the same report cannot inflate a count, which * matters because relays routinely deliver duplicates. * - * @param {{ reporter: string, target: import('@nostr-governance/core').GovernanceTarget, category: string, createdAt?: number }} report + * 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 {string} targetKey */ ingest(report, targetKey) { diff --git a/packages/runtime/test/stores.test.js b/packages/runtime/test/stores.test.js index bba4c78..9dcc547 100644 --- a/packages/runtime/test/stores.test.js +++ b/packages/runtime/test/stores.test.js @@ -382,3 +382,37 @@ describe("OverrideStore", () => { expect(handler).toHaveBeenCalledTimes(3); }); }); + +describe("GovernanceAdminStore authority changes", () => { + it("emits when a role is revoked even if no denial changes", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + const handler = vi.fn(); + store.on("change", handler); + + // No contributions exist, so effective denial state is empty before and + // after. The capability change must still be announced. + store.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("emits when protected actors change", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + const handler = vi.fn(); + store.on("change", handler); + + store.setRoles({ ...roster, protectedActors: [CREATOR] }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("stays quiet when the roster is set to the same thing", () => { + const store = new GovernanceAdminStore(); + store.setRoles(roster); + const handler = vi.fn(); + store.on("change", handler); + + store.setRoles(roster); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/widget/README.md b/packages/widget/README.md new file mode 100644 index 0000000..f398eea --- /dev/null +++ b/packages/widget/README.md @@ -0,0 +1,116 @@ +# @nostr-governance/widget + +Drop-in custom elements for governance UI. Framework-agnostic, no build step +required for consumers, and built strictly on the published API. + +## The rule these elements follow + +**They render decisions and issue commands. They compute no policy.** + +If an element here ever needed to compare a count to a threshold, that would +mean the policy definition is missing a profile — not that the widget needs a +number. Keeping that line is the whole reason the engine was extracted. + +## Elements + +### Viewer-facing + +| Element | Purpose | +| --- | --- | +| `` | Wraps content and applies the decision: blur on `restrict`, disclosure on `hide`, warning on `warn` | +| `` | Report dialog. Requires no capability — anyone may report | +| `` | Readout of effect, reasons, and evidence | + +### Moderator-facing + +| Element | Purpose | +| --- | --- | +| `` | What the signed-in account may actually do | +| `` | A capability-gated button that explains refusals | +| `` | Effective state, contributor attribution, and controls | + +## Usage + +```html + + + + + +``` + +Elements receive a runtime; they never construct one. A widget that owned relay +connections would make it impossible to share one governance runtime across a +page. + +## Events + +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 | + +## Wording + +The engine emits stable reason identifiers, never prose, so applications phrase +them for their own audience: + +```js +veil.reasonText = { + "admin-user-deny": "This seller is suspended", + "trusted-report-threshold": "Flagged by several buyers you follow", +}; +``` + +## Design decisions worth knowing + +**Hidden content is removed from the accessibility tree**, not merely covered. +A screen reader must not read out something the viewer was not meant to see. + +**A hidden decision renders a disclosure, not nothing.** Content that silently +vanishes is indistinguishable from a bug and leaves no route to appeal. Where +the profile permits an override, a "Show anyway" control appears; where it does +not, the reason still does. + +**Refused actions stay visible and explain themselves.** A moderator who cannot +see that they lack a capability will assume the tool is broken, and a hidden +button teaches nothing about the authority model. The command layer enforces +the same check independently — the UI is an explanation, never the gate. + +**Styling is yours.** Elements expose `part` attributes and inherit `color` and +`font` from the host. Override the CSS custom properties (`--gov-fg`, +`--gov-surface`, `--gov-warn`, `--gov-danger`, `--gov-radius`) or target parts: + +```css +governance-veil::part(veil) { border: 1px solid red; } +``` + +## Environment + +`defineGovernanceElements()` is safe to call repeatedly and safe to import in +Node — registration is a no-op without a DOM, so a server-rendering host can +import the helpers without guarding. diff --git a/packages/widget/package.json b/packages/widget/package.json new file mode 100644 index 0000000..c9d0315 --- /dev/null +++ b/packages/widget/package.json @@ -0,0 +1,22 @@ +{ + "name": "@nostr-governance/widget", + "version": "0.1.0", + "type": "module", + "main": "src/index.js", + "types": "dist/types/index.d.ts", + "files": ["src", "dist"], + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./src/index.js" + }, + "./dist/types/*": "./dist/types/*" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@nostr-governance/core": "*", + "@nostr-governance/runtime": "*" + } +} diff --git a/packages/widget/src/admin.js b/packages/widget/src/admin.js new file mode 100644 index 0000000..2d0a98b --- /dev/null +++ b/packages/widget/src/admin.js @@ -0,0 +1,295 @@ +// Moderator console elements. +// +// Two rules govern this file. +// +// First, the UI shows *why* an action is unavailable rather than hiding the +// control. A moderator who cannot see that they lack a capability will assume +// the tool is broken, and a hidden button teaches nothing about the authority +// model. The command layer enforces the same check independently, so the UI is +// an explanation, never the gate. +// +// Second, nothing here computes policy. The console displays effective state +// and issues commands; every accept/reject decision belongs to the runtime. + +import { GovernanceElement, defineElement, escapeHtml, shortenKey } from "./base.js"; + +/** + * @typedef {import('@nostr-governance/runtime').GovernanceCommands} GovernanceCommands + * @typedef {import('@nostr-governance/core').GovernanceCapability} GovernanceCapability + */ + +/** Human wording for capabilities, used when explaining a refusal. */ +export const CAPABILITY_LABELS = { + "manage-roles": "manage roles", + "manage-policy": "manage policy", + "manage-community-sources": "manage community sources", + "contribute-user-allow": "approve accounts", + "contribute-user-deny": "restrict accounts", + "contribute-event-deny": "restrict posts", + "contribute-address-deny": "restrict listings", + "contribute-trust-seed": "manage trust seeds", + "review-evidence": "review evidence", +}; + +/** + * `` — 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 + * the resolved capability set makes the difference visible. + */ +export class GovernanceCapabilities extends GovernanceElement { + render() { + if (!this.shadowRoot) { + return; + } + if (!this.runtime) { + this.renderHtml(""); + return; + } + + const viewer = this.runtime.viewerPubkey; + const held = new Set(this.runtime.viewerCapabilities()); + + if (!viewer) { + this.renderHtml('

Not signed in.

'); + return; + } + + const rows = Object.entries(CAPABILITY_LABELS) + .map(([capability, label]) => { + const has = held.has(/** @type {GovernanceCapability} */ (capability)); + return `
  • + + ${escapeHtml(label)} +
  • `; + }) + .join(""); + + this.renderHtml(` + +
    +

    Signed in as ${escapeHtml(shortenKey(viewer))}

    +
      ${rows}
    + ${held.size === 0 ? '

    This account holds no governance capabilities.

    ' : ""} +
    + `); + } +} + +/** + * `` — 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 + * yours to do". + */ +export class GovernanceAction extends GovernanceElement { + static get observedAttributes() { + return ["capability", "label"]; + } + + constructor() { + super(); + /** @type {GovernanceCommands|null} */ + this.commands = null; + /** @type {(() => Promise)|null} */ + this.action = null; + this._status = ""; + } + + attributeChangedCallback() { + this.render(); + } + + get capability() { + return /** @type {GovernanceCapability} */ (this.getAttribute("capability") ?? ""); + } + + render() { + if (!this.shadowRoot) { + return; + } + + const capability = this.capability; + const label = this.getAttribute("label") ?? "Apply"; + const viewer = this.runtime?.viewerPubkey ?? ""; + const permitted = Boolean(this.runtime && viewer && this.runtime.can(viewer, capability)); + const missing = CAPABILITY_LABELS[capability] ?? capability; + + this.renderHtml(` +
    + + ${ + permitted + ? "" + : `Requires permission to ${escapeHtml(missing)}` + } + ${escapeHtml(this._status)} +
    + `); + + this.$("#run")?.addEventListener("click", () => void this._run()); + } + + async _run() { + if (!this.action) { + return; + } + this._status = "Working…"; + this.render(); + + try { + const result = await this.action(); + // Commands return a result object rather than throwing on refusal, so a + // refusal is reported with its stable error code. + this._status = result?.ok === false ? `Refused: ${result.code}` : "Done"; + this.emit(result?.ok === false ? "action-refused" : "action-completed", { result }); + } catch (error) { + this._status = `Failed: ${String(error)}`; + this.emit("action-failed", { error: String(error) }); + } + + this.render(); + } +} + +/** + * `` — 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. + */ +export class GovernanceAdminPanel extends GovernanceElement { + constructor() { + super(); + /** @type {GovernanceCommands|null} */ + this.commands = null; + this._status = ""; + } + + render() { + if (!this.shadowRoot) { + return; + } + if (!this.runtime) { + this.renderHtml('

    No runtime attached.

    '); + return; + } + + const state = this.runtime.admin.state; + const authority = this.runtime.admin.authority; + const viewer = this.runtime.viewerPubkey; + const canDenyUser = Boolean(viewer && this.runtime.can(viewer, "contribute-user-deny")); + + const denyRows = Array.from(state.userDeny) + .map((key) => { + const pubkey = key.slice("user:".length); + const sources = state.communitySources.get(key) ?? []; + const origin = sources.length + ? `community list (${escapeHtml(shortenKey(sources[0]))})` + : "moderator"; + return ` + ${escapeHtml(shortenKey(pubkey))} + ${origin} + `; + }) + .join(""); + + this.renderHtml(` + +
    + ${ + this.runtime.stale + ? '

    Showing cached state — relays unreachable.

    ' + : "" + } + +
    +

    Roster

    +

    + Root: ${escapeHtml(authority.root ? shortenKey(authority.root) : "not configured")} · + ${escapeHtml(Object.keys(authority.actors).length)} actor(s) · + ${escapeHtml(authority.protectedActors.length)} protected +

    +
    + +
    +

    Restricted accounts (${escapeHtml(state.userDeny.size)})

    + ${ + denyRows + ? `${denyRows}
    ` + : '

    None.

    ' + } +
    + +
    +

    Restrict an account

    +
    + + +
    + ${ + canDenyUser + ? "" + : '

    Requires permission to restrict accounts.

    ' + } + ${escapeHtml(this._status)} +
    + +
    +

    Other state

    +

    + ${escapeHtml(state.eventDeny.size)} restricted post(s) · + ${escapeHtml(state.addressDeny.size)} restricted listing(s) · + ${escapeHtml(state.userAllow.size)} approved account(s) · + ${escapeHtml(state.trustSeeds.size)} trust seed(s) +

    +
    +
    + `); + + this.$("#deny")?.addEventListener("click", () => void this._deny()); + } + + async _deny() { + const input = /** @type {HTMLInputElement} */ (this.$("#pubkey")); + const pubkey = input?.value?.trim() ?? ""; + if (!pubkey || !this.commands) { + return; + } + + this._status = "Publishing…"; + this.render(); + + const result = await this.commands.denyUser(pubkey); + this._status = result.ok ? "Restricted" : `Refused: ${result.code}`; + this.emit(result.ok ? "account-restricted" : "action-refused", { pubkey, result }); + this.render(); + } +} + +/** + * Register the moderator elements. + * @returns {string[]} Element names newly registered + */ +export function defineAdminElements() { + const registered = []; + if (defineElement("governance-capabilities", GovernanceCapabilities)) { + registered.push("governance-capabilities"); + } + if (defineElement("governance-action", GovernanceAction)) registered.push("governance-action"); + if (defineElement("governance-admin-panel", GovernanceAdminPanel)) { + registered.push("governance-admin-panel"); + } + return registered; +} diff --git a/packages/widget/src/base.js b/packages/widget/src/base.js new file mode 100644 index 0000000..8885b2d --- /dev/null +++ b/packages/widget/src/base.js @@ -0,0 +1,195 @@ +// Shared base for governance custom elements. +// +// Every element here follows one rule: it renders decisions and issues +// commands, and computes no policy of its own. The moment a component compares +// a count to a threshold, the duplication this project exists to remove has +// come back in the view layer. +// +// Elements receive a runtime rather than constructing one. A widget that owned +// relay connections would make it impossible for a host application to share +// 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 + */ + +/** + * Whether custom elements can be defined in this environment. + * + * The package is importable in Node so that a server-rendering host can pull in + * the helpers without a DOM; only registration requires a browser. + * + * @returns {boolean} + */ +export function canRegisterElements() { + return typeof globalThis.HTMLElement === "function" && typeof globalThis.customElements === "object"; +} + +/** Base styles shared by every element, scoped inside each shadow root. */ +export const BASE_STYLES = ` + :host { + --gov-fg: currentColor; + --gov-muted: color-mix(in srgb, currentColor 60%, transparent); + --gov-border: color-mix(in srgb, currentColor 20%, transparent); + --gov-surface: color-mix(in srgb, canvas 96%, currentColor); + --gov-warn: #b45309; + --gov-danger: #b91c1c; + --gov-radius: 8px; + display: block; + color: var(--gov-fg); + font: inherit; + } + :host([hidden]) { display: none; } + button { + font: inherit; + color: inherit; + background: var(--gov-surface); + border: 1px solid var(--gov-border); + border-radius: var(--gov-radius); + padding: 0.4em 0.8em; + cursor: pointer; + } + button:hover:not(:disabled) { border-color: var(--gov-fg); } + button:disabled { opacity: 0.5; cursor: not-allowed; } + .reason { + color: var(--gov-muted); + font-size: 0.875em; + } + .row { display: flex; gap: 0.5em; align-items: center; flex-wrap: wrap; } + .stack { display: grid; gap: 0.75em; } +`; + +/** + * Escape text for interpolation into element markup. + * + * Reason identifiers and pubkeys are the only untrusted-ish values these + * elements render, but they arrive from relays, so they are escaped rather + * than trusted to be well-formed. + * + * @param {unknown} value + * @returns {string} + */ +export function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Shorten a pubkey for display without pretending to resolve a profile. + * @param {string} pubkey + * @returns {string} + */ +export function shortenKey(pubkey) { + const value = String(pubkey ?? ""); + return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value; +} + +/** + * Base element: owns a shadow root, a runtime reference, and change + * subscription lifecycle. + * + * Subclasses implement `render()` and may implement `template()`. + */ +export class GovernanceElement extends globalThis.HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + /** @type {GovernanceRuntime|null} */ + this._runtime = null; + /** @type {(() => void)|null} */ + this._unsubscribe = null; + } + + /** + * The governance runtime this element reads from. + * @returns {GovernanceRuntime|null} + */ + get runtime() { + return this._runtime; + } + + set runtime(runtime) { + if (this._runtime === runtime) { + return; + } + this._unsubscribe?.(); + this._unsubscribe = null; + this._runtime = runtime ?? null; + + if (this._runtime && this.isConnected) { + this._subscribe(); + } + this.render(); + } + + connectedCallback() { + if (this._runtime && !this._unsubscribe) { + this._subscribe(); + } + this.render(); + } + + disconnectedCallback() { + // Elements are routinely moved or removed during rendering; leaving a + // subscription behind would keep a detached element re-rendering forever. + this._unsubscribe?.(); + this._unsubscribe = null; + } + + _subscribe() { + this._unsubscribe = /** @type {GovernanceRuntime} */ (this._runtime).on("change", () => + this.render(), + ); + } + + /** + * Emit a namespaced, composed event so hosts can react across shadow bounds. + * @param {string} name + * @param {any} detail + */ + emit(name, detail) { + this.dispatchEvent( + new CustomEvent(`governance:${name}`, { detail, bubbles: true, composed: true }), + ); + } + + /** Replace shadow content. */ + renderHtml(html) { + /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `${html}`; + } + + /** @param {string} selector */ + $(selector) { + return /** @type {ShadowRoot} */ (this.shadowRoot).querySelector(selector); + } + + /** Subclass hook. */ + render() {} +} + +/** + * Register an element, tolerating repeat registration. + * + * A page that loads the widget bundle twice should not throw; the first + * definition wins. + * + * @param {string} name + * @param {CustomElementConstructor} constructor + * @returns {boolean} Whether this call performed the registration + */ +export function defineElement(name, constructor) { + if (!canRegisterElements()) { + return false; + } + if (globalThis.customElements.get(name)) { + return false; + } + globalThis.customElements.define(name, constructor); + return true; +} diff --git a/packages/widget/src/index.js b/packages/widget/src/index.js new file mode 100644 index 0000000..4d2676a --- /dev/null +++ b/packages/widget/src/index.js @@ -0,0 +1,51 @@ +// @nostr-governance/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 +// threshold, that is a signal the policy definition is missing a profile — +// not that the widget needs a number. +// +// Elements receive a runtime rather than creating one, so a host application +// can share a single governance runtime across an entire page. + +export { + BASE_STYLES, + GovernanceElement, + canRegisterElements, + defineElement, + escapeHtml, + shortenKey, +} from "./base.js"; + +export { + DEFAULT_REASON_TEXT, + GovernanceReport, + GovernanceStatus, + GovernanceVeil, + defineViewerElements, + describeReasons, +} from "./viewer.js"; + +export { + CAPABILITY_LABELS, + GovernanceAction, + GovernanceAdminPanel, + GovernanceCapabilities, + defineAdminElements, +} from "./admin.js"; + +import { defineAdminElements } from "./admin.js"; +import { defineViewerElements } from "./viewer.js"; + +/** + * Register every governance element. + * + * Safe to call more than once 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. + * + * @returns {string[]} Element names newly registered by this call + */ +export function defineGovernanceElements() { + return [...defineViewerElements(), ...defineAdminElements()]; +} diff --git a/packages/widget/src/viewer.js b/packages/widget/src/viewer.js new file mode 100644 index 0000000..bcde5e4 --- /dev/null +++ b/packages/widget/src/viewer.js @@ -0,0 +1,381 @@ +// Viewer-facing governance elements. +// +// These render a decision the engine already made. They never decide anything: +// 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"; + +/** + * @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision + * @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget + */ + +/** + * Default wording for reason identifiers. + * + * Deliberately overridable: the engine emits stable identifiers precisely so + * that applications can phrase them for their own audience. A marketplace says + * "this seller is suspended"; a video app says "hidden by moderators". + * + * @type {Record} + */ +export const DEFAULT_REASON_TEXT = { + "viewer-block": "You blocked this account", + "viewer-mute": "You muted this account", + "viewer-override": "You chose to show this", + "admin-user-deny": "Restricted by moderators", + "admin-event-deny": "This post was restricted by moderators", + "admin-address-deny": "This listing was restricted by moderators", + "community-user-deny": "Restricted by a community list", + "trusted-report": "Reported by people you follow", + "trusted-report-threshold": "Reported by enough people you follow", + "trusted-mute": "Muted by people you follow", + "trusted-mute-threshold": "Muted by enough people you follow", + "allowlist-miss": "This account is not approved here", + "protected-target": "This account cannot be restricted", + "surface-policy-bypass": "Shown here despite restrictions", + "policy-disabled": "Moderation is off for this view", +}; + +/** + * Turn reason identifiers into display text. + * @param {GovernanceDecision} decision + * @param {Record} [overrides] + * @returns {string[]} + */ +export function describeReasons(decision, overrides = {}) { + const table = { ...DEFAULT_REASON_TEXT, ...overrides }; + return decision.reasons.map((reason) => table[reason.id] ?? reason.id); +} + +/** + * `` — 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 + * tree rather than merely visually covered, so a screen reader does not read + * out something the viewer was not meant to see. + * + * Usage: + * + * veil.runtime = runtime; veil.target = { type: "event", id, author }; + */ +export class GovernanceVeil extends GovernanceElement { + static get observedAttributes() { + return ["profile"]; + } + + constructor() { + super(); + /** @type {GovernanceTarget|null} */ + this._target = null; + /** @type {Record} */ + this.reasonText = {}; + this._revealed = false; + } + + get target() { + return this._target; + } + + set target(target) { + this._target = target ?? null; + this._revealed = false; + this.render(); + } + + get profile() { + return this.getAttribute("profile") ?? undefined; + } + + attributeChangedCallback() { + this.render(); + } + + /** The decision currently applied, or null when not evaluable. */ + get decision() { + if (!this.runtime || !this._target) { + return null; + } + try { + return this.runtime.evaluate(this._target, { profile: this.profile }); + } catch { + return null; + } + } + + render() { + if (!this.shadowRoot) { + return; + } + + const decision = this.decision; + if (!decision) { + this.renderHtml(""); + return; + } + + const effect = decision.visibility.effect; + const reasons = describeReasons(decision, this.reasonText); + const reasonLine = reasons.length ? `

    ${escapeHtml(reasons[0])}

    ` : ""; + + // A hidden decision the viewer may override renders a disclosure rather + // than nothing at all: silently vanishing content is indistinguishable + // from a bug, and leaves no way to appeal. + const hidden = effect === "hide" || effect === "deny"; + if (hidden && !this._revealed) { + const canReveal = decision.visibility.overridable && effect !== "deny"; + this.renderHtml(` +
    +

    Content hidden

    + ${reasonLine} + ${canReveal ? '
    ' : ""} + +
    + `); + this.$("#reveal")?.addEventListener("click", () => { + this._revealed = true; + this.emit("revealed", { target: this._target, decision }); + this.render(); + }); + return; + } + + const blurred = effect === "restrict" && !this._revealed; + const warned = effect === "warn"; + + this.renderHtml(` + +
    +
    + ${ + blurred + ? `
    + ${reasonLine} +
    + +
    +
    ` + : "" + } +
    + ${warned && reasons.length ? `

    ${escapeHtml(reasons[0])}

    ` : ""} + `); + + this.$("#reveal")?.addEventListener("click", () => { + this._revealed = true; + this.emit("revealed", { target: this._target, decision }); + this.render(); + }); + } +} + +/** + * `` — 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 + * that explicit rather than implying a report is an enforcement action. + */ +export class GovernanceReport extends GovernanceElement { + constructor() { + super(); + /** @type {GovernanceTarget|null} */ + this._target = null; + /** @type {string[]} */ + this.categories = ["spam", "nudity", "profanity", "illegal", "malware", "impersonation", "other"]; + /** @type {import('@nostr-governance/runtime').GovernanceCommands|null} */ + this.commands = null; + this._status = ""; + } + + get target() { + return this._target; + } + + set target(target) { + this._target = target ?? null; + this._status = ""; + this.render(); + } + + render() { + if (!this.shadowRoot) { + return; + } + + const options = this.categories + .map((category) => ``) + .join(""); + + this.renderHtml(` +
    + + +

    + Reports are public Nostr events. Whether a report affects what someone + sees depends on their own trust graph. +

    +
    + + ${escapeHtml(this._status)} +
    +
    + `); + + this.$("form")?.addEventListener("submit", (fromEvent) => { + fromEvent.preventDefault(); + void this._submit(); + }); + } + + async _submit() { + if (!this._target || !this.commands) { + return; + } + + const category = /** @type {HTMLSelectElement} */ (this.$("#category"))?.value ?? ""; + const details = /** @type {HTMLTextAreaElement} */ (this.$("#details"))?.value ?? ""; + + this._status = "Publishing…"; + this.render(); + + const result = await this.commands.report(this._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.render(); + } +} + +/** + * `` — 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 + * you follow" and no names. + */ +export class GovernanceStatus extends GovernanceElement { + constructor() { + super(); + /** @type {GovernanceTarget|null} */ + this._target = null; + /** @type {Record} */ + this.reasonText = {}; + } + + static get observedAttributes() { + return ["profile"]; + } + + get target() { + return this._target; + } + + set target(target) { + this._target = target ?? null; + this.render(); + } + + get profile() { + return this.getAttribute("profile") ?? undefined; + } + + attributeChangedCallback() { + this.render(); + } + + render() { + if (!this.shadowRoot) { + return; + } + if (!this.runtime || !this._target) { + this.renderHtml(""); + return; + } + + let decision; + try { + decision = this.runtime.evaluate(this._target, { profile: this.profile }); + } catch { + this.renderHtml(""); + return; + } + + const reasons = describeReasons(decision, this.reasonText); + const evidence = decision.evidence; + const reporters = evidence?.trustedReporterPubkeys ?? []; + const muters = evidence?.trustedMuterPubkeys ?? []; + + this.renderHtml(` +
    +
    + ${escapeHtml(decision.visibility.effect)} + ${ + decision.ranking.effect === "downrank" + ? `downranked (${escapeHtml(decision.ranking.weight)})` + : "" + } + ${ + decision.transaction + ? `transaction: ${escapeHtml(decision.transaction.effect)}` + : "" + } +
    + ${ + reasons.length + ? `
      ${reasons.map((text) => `
    • ${escapeHtml(text)}
    • `).join("")}
    ` + : '

    No governance signals

    ' + } + ${ + evidence + ? `

    + ${escapeHtml(evidence.trustedReportTotal)} trusted report(s), + ${escapeHtml(evidence.trustedMuteTotal)} trusted mute(s) +

    ` + : "" + } + ${ + reporters.length + ? `

    Reported by: ${reporters.map((key) => escapeHtml(shortenKey(key))).join(", ")}

    ` + : "" + } + ${ + muters.length + ? `

    Muted by: ${muters.map((key) => escapeHtml(shortenKey(key))).join(", ")}

    ` + : "" + } +
    + `); + } +} + +/** + * Register the viewer elements. + * @returns {string[]} Element names newly registered + */ +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"); + return registered; +} diff --git a/packages/widget/test/admin.test.js b/packages/widget/test/admin.test.js new file mode 100644 index 0000000..2bac0cd --- /dev/null +++ b/packages/widget/test/admin.test.js @@ -0,0 +1,316 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createPolicyDefinition } from "@nostr-governance/core"; +import { + createCommands, + createGovernanceRuntime, + createMemoryTransport, +} from "@nostr-governance/runtime"; + +import { CAPABILITY_LABELS } from "../src/admin.js"; +import { defineGovernanceElements } from "../src/index.js"; +import { shortenKey } from "../src/base.js"; + +const ROOT = "a1".repeat(32); +const MODERATOR = "b2".repeat(32); +const CURATOR = "c3".repeat(32); +const CREATOR = "d4".repeat(32); +const STRANGER = "f6".repeat(32); +const NOW = 1_750_000_000; + +const POLICY = createPolicyDefinition({ + id: "widget", + version: "1.0.0", + profiles: { feed: { name: "feed", reports: {}, mutes: {} } }, +}); + +defineGovernanceElements(); + +function makeRuntime(viewer) { + const runtime = createGovernanceRuntime({ + applicationId: "widget-test", + namespace: "widget", + transport: createMemoryTransport(), + policy: POLICY, + now: () => NOW, + root: ROOT, + }); + runtime.admin.setRoles({ + root: ROOT, + actors: { + [ROOT]: ["super_admin"], + [MODERATOR]: ["moderator"], + [CURATOR]: ["curator"], + }, + }); + if (viewer) { + runtime.setViewer(viewer); + } + return runtime; +} + +/** + * @param {string} tag + * @param {any} runtime + * @param {Record} [attributes] + * @returns {any} + */ +function mount(tag, runtime, attributes = {}) { + const element = /** @type {any} */ (document.createElement(tag)); + for (const [name, value] of Object.entries(attributes)) { + element.setAttribute(name, value); + } + document.body.append(element); + element.runtime = runtime; + return element; +} + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +describe("", () => { + it("says when nobody is signed in", () => { + const panel = mount("governance-capabilities", makeRuntime()); + expect(panel.shadowRoot.textContent).toContain("Not signed in"); + }); + + it("lists what a moderator holds", () => { + const panel = mount("governance-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)); + // 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)) { + expect(panel.shadowRoot.textContent).toContain(label); + } + }); + + it("marks held capabilities distinctly from unheld ones", () => { + const panel = mount("governance-capabilities", makeRuntime(CURATOR)); + const items = [...panel.shadowRoot.querySelectorAll("li")]; + const held = items.filter((item) => item.textContent.includes("✓")); + expect(held).toHaveLength(1); + expect(held[0].textContent).toContain(CAPABILITY_LABELS["contribute-user-deny"]); + }); + + it("tells an account with no authority that it has none", () => { + const panel = mount("governance-capabilities", makeRuntime(STRANGER)); + expect(panel.shadowRoot.textContent).toContain("no governance capabilities"); + }); + + it("grants the root everything", () => { + const panel = mount("governance-capabilities", makeRuntime(ROOT)); + const held = [...panel.shadowRoot.querySelectorAll("li")].filter((item) => + item.textContent.includes("✓"), + ); + expect(held).toHaveLength(Object.keys(CAPABILITY_LABELS).length); + }); +}); + +describe("", () => { + it("enables the control when the actor holds the capability", () => { + const action = mount("governance-action", makeRuntime(MODERATOR), { + capability: "contribute-user-deny", + label: "Restrict", + }); + expect(action.shadowRoot.querySelector("#run").disabled).toBe(false); + }); + + it("disables it and explains why when they do not", () => { + const action = mount("governance-action", makeRuntime(CURATOR), { + capability: "contribute-event-deny", + label: "Restrict post", + }); + + expect(action.shadowRoot.querySelector("#run").disabled).toBe(true); + expect(action.shadowRoot.textContent).toContain("Requires permission to"); + expect(action.shadowRoot.textContent).toContain(CAPABILITY_LABELS["contribute-event-deny"]); + }); + + it("keeps the control visible rather than hiding it", () => { + const action = mount("governance-action", makeRuntime(CURATOR), { + capability: "manage-roles", + label: "Edit roles", + }); + expect(action.shadowRoot.querySelector("#run")).not.toBeNull(); + expect(action.shadowRoot.textContent).toContain("Edit roles"); + }); + + it("runs the action and announces completion", async () => { + const action = mount("governance-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.shadowRoot.querySelector("#run").click(); + await vi.waitFor(() => expect(completed).toHaveBeenCalled()); + + expect(action.shadowRoot.textContent).toContain("Done"); + }); + + it("reports a refusal with its stable code", async () => { + const action = mount("governance-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.shadowRoot.querySelector("#run").click(); + await vi.waitFor(() => expect(refused).toHaveBeenCalled()); + + expect(action.shadowRoot.textContent).toContain("protected-target"); + }); + + it("reports a thrown failure without breaking the element", async () => { + const action = mount("governance-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.shadowRoot.querySelector("#run").click(); + await vi.waitFor(() => expect(failed).toHaveBeenCalled()); + + expect(action.shadowRoot.textContent).toContain("relay down"); + }); + + it("re-evaluates permission when the roster changes", () => { + const runtime = makeRuntime(MODERATOR); + const action = mount("governance-action", runtime, { + capability: "contribute-user-deny", + }); + expect(action.shadowRoot.querySelector("#run").disabled).toBe(false); + + runtime.admin.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } }); + expect(action.shadowRoot.querySelector("#run").disabled).toBe(true); + }); +}); + +describe("", () => { + /** + * @param {any} runtime + * @param {Object} [options] + * @param {string} [options.source] + */ + function denyCreator(runtime, { source } = {}) { + runtime.admin.upsertContribution({ + actor: source ? CURATOR : MODERATOR, + kind: "user-deny", + targets: [{ type: "user", pubkey: CREATOR }], + createdAt: NOW, + ...(source ? { source } : {}), + }); + } + + it("reports when no runtime is attached", () => { + const panel = /** @type {any} */ (document.createElement("governance-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)); + expect(panel.shadowRoot.textContent).toContain(shortenKey(ROOT)); + expect(panel.shadowRoot.textContent).toContain("actor(s)"); + }); + + it("lists restricted accounts", () => { + const runtime = makeRuntime(MODERATOR); + denyCreator(runtime); + const panel = mount("governance-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); + 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); + 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)); + expect(panel.shadowRoot.querySelector("#deny").disabled).toBe(true); + expect(panel.shadowRoot.textContent).toContain("Requires permission"); + }); + + it("issues the command through the runtime", async () => { + const runtime = makeRuntime(MODERATOR); + const commands = createCommands(runtime); + vi.spyOn(commands, "denyUser").mockResolvedValue({ ok: true }); + + const panel = mount("governance-admin-panel", runtime); + panel.commands = commands; + panel.shadowRoot.querySelector("#pubkey").value = CREATOR; + + const restricted = vi.fn(); + panel.addEventListener("governance:account-restricted", restricted); + panel.shadowRoot.querySelector("#deny").click(); + await vi.waitFor(() => expect(restricted).toHaveBeenCalled()); + + expect(commands.denyUser).toHaveBeenCalledWith(CREATOR); + }); + + it("surfaces a refusal from the command layer", async () => { + const runtime = makeRuntime(MODERATOR); + const commands = createCommands(runtime); + vi.spyOn(commands, "denyUser").mockResolvedValue({ ok: false, code: "protected-target" }); + + const panel = mount("governance-admin-panel", runtime); + panel.commands = commands; + panel.shadowRoot.querySelector("#pubkey").value = ROOT; + + const refused = vi.fn(); + panel.addEventListener("governance:action-refused", refused); + panel.shadowRoot.querySelector("#deny").click(); + await vi.waitFor(() => expect(refused).toHaveBeenCalled()); + + expect(panel.shadowRoot.textContent).toContain("protected-target"); + }); + + it("warns when serving cached state", () => { + const runtime = makeRuntime(MODERATOR); + runtime.stale = true; + const panel = mount("governance-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); + expect(panel.shadowRoot.textContent).toContain("Restricted accounts (0)"); + + denyCreator(runtime); + expect(panel.shadowRoot.textContent).toContain("Restricted accounts (1)"); + }); +}); + +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([]); + }); +}); diff --git a/packages/widget/test/viewer.test.js b/packages/widget/test/viewer.test.js new file mode 100644 index 0000000..b9776df --- /dev/null +++ b/packages/widget/test/viewer.test.js @@ -0,0 +1,385 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createPolicyDefinition } from "@nostr-governance/core"; +import { + createCommands, + createGovernanceRuntime, + createMemoryTransport, +} from "@nostr-governance/runtime"; + +import { DEFAULT_REASON_TEXT, describeReasons } from "../src/viewer.js"; +import { defineGovernanceElements } from "../src/index.js"; +import { escapeHtml, shortenKey } from "../src/base.js"; + +const ROOT = "a1".repeat(32); +const MODERATOR = "b2".repeat(32); +const CREATOR = "d4".repeat(32); +const TRUSTED = "01".repeat(32); +const EVENT_ID = "1b".repeat(32); +const NOW = 1_750_000_000; + +const POLICY = createPolicyDefinition({ + id: "widget", + version: "1.0.0", + defaultProfile: "feed", + profiles: { + feed: { + name: "feed", + administrativeDeny: { visibility: "hide", interaction: "deny" }, + reports: { spam: { warn: 1, restrict: 2 } }, + mutes: {}, + }, + locked: { + name: "locked", + administrativeDeny: { visibility: "deny", interaction: "deny" }, + allowViewerOverride: false, + reports: {}, + mutes: {}, + }, + detail: { + name: "detail", + administrativeDeny: { visibility: "restrict", interaction: "deny" }, + exposeEvidence: true, + reports: { spam: { restrict: 1 } }, + mutes: {}, + }, + }, +}); + +defineGovernanceElements(); + +function makeRuntime() { + const runtime = createGovernanceRuntime({ + applicationId: "widget-test", + namespace: "widget", + transport: createMemoryTransport(), + policy: POLICY, + now: () => NOW, + root: ROOT, + }); + runtime.admin.setRoles({ + root: ROOT, + actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] }, + }); + runtime.trust.setContacts([TRUSTED]); + return runtime; +} + +function denyCreator(runtime) { + runtime.admin.upsertContribution({ + actor: MODERATOR, + kind: "user-deny", + targets: [{ type: "user", pubkey: CREATOR }], + createdAt: NOW, + }); +} + +/** + * Mount an element with content and wait for its initial render. + * @param {string} tag + * @param {Object} [options] + * @param {any} [options.runtime] + * @param {any} [options.target] + * @param {string} [options.profile] + * @param {string} [options.inner] + * @returns {any} + */ +function mount(tag, { runtime, target, profile, inner = "

    content

    " } = {}) { + const element = /** @type {any} */ (document.createElement(tag)); + if (profile) { + element.setAttribute("profile", profile); + } + element.innerHTML = inner; + document.body.append(element); + if (runtime) element.runtime = runtime; + if (target) element.target = target; + return element; +} + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +describe("helpers", () => { + it("escapes markup", () => { + expect(escapeHtml('')).not.toContain(" { + expect(escapeHtml(null)).toBe(""); + expect(escapeHtml(undefined)).toBe(""); + }); + + it("shortens long keys and leaves short ones alone", () => { + expect(shortenKey(CREATOR)).toMatch(/^d4d4d4d4…d4d4$/); + expect(shortenKey("short")).toBe("short"); + }); +}); + +describe("describeReasons", () => { + it("maps stable identifiers to wording", () => { + const decision = { reasons: [{ id: "admin-user-deny" }] }; + expect(describeReasons(/** @type {any} */ (decision))).toEqual([ + DEFAULT_REASON_TEXT["admin-user-deny"], + ]); + }); + + it("lets an application override the wording", () => { + const decision = { reasons: [{ id: "admin-user-deny" }] }; + expect( + describeReasons(/** @type {any} */ (decision), { "admin-user-deny": "Seller suspended" }), + ).toEqual(["Seller suspended"]); + }); + + it("falls back to the raw identifier for unknown reasons", () => { + const decision = { reasons: [{ id: "future-reason" }] }; + expect(describeReasons(/** @type {any} */ (decision))).toEqual(["future-reason"]); + }); +}); + +describe("", () => { + it("renders content untouched when nothing objects", () => { + const runtime = makeRuntime(); + const veil = mount("governance-veil", { + runtime, + target: { type: "user", pubkey: CREATOR }, + }); + expect(veil.shadowRoot.querySelector("slot")).not.toBeNull(); + expect(veil.shadowRoot.textContent).not.toContain("Content hidden"); + }); + + it("hides content behind a disclosure when the decision hides", () => { + const runtime = makeRuntime(); + denyCreator(runtime); + const veil = mount("governance-veil", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "feed", + }); + + expect(veil.shadowRoot.textContent).toContain("Content hidden"); + expect(veil.shadowRoot.querySelector("#reveal")).not.toBeNull(); + }); + + it("explains why rather than vanishing silently", () => { + const runtime = makeRuntime(); + denyCreator(runtime); + const veil = mount("governance-veil", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "feed", + }); + expect(veil.shadowRoot.textContent).toContain(DEFAULT_REASON_TEXT["admin-user-deny"]); + }); + + it("keeps hidden content out of the accessibility tree", () => { + const runtime = makeRuntime(); + denyCreator(runtime); + const veil = mount("governance-veil", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "feed", + }); + const wrapper = veil.shadowRoot.querySelector("[aria-hidden='true']"); + expect(wrapper).not.toBeNull(); + expect(wrapper.hasAttribute("hidden")).toBe(true); + }); + + it("reveals on request and announces it", () => { + const runtime = makeRuntime(); + denyCreator(runtime); + const veil = mount("governance-veil", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "feed", + }); + + const revealed = vi.fn(); + veil.addEventListener("governance:revealed", revealed); + veil.shadowRoot.querySelector("#reveal").click(); + + expect(revealed).toHaveBeenCalled(); + expect(veil.shadowRoot.textContent).not.toContain("Content hidden"); + }); + + it("offers no reveal when the profile forbids overrides", () => { + const runtime = makeRuntime(); + denyCreator(runtime); + const veil = mount("governance-veil", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "locked", + }); + + expect(veil.shadowRoot.textContent).toContain("Content hidden"); + expect(veil.shadowRoot.querySelector("#reveal")).toBeNull(); + }); + + it("blurs rather than hides on a restrict decision", () => { + const runtime = makeRuntime(); + runtime.reports.ingest( + { reporter: TRUSTED, category: "spam", createdAt: NOW }, + `user:${CREATOR}`, + ); + const veil = mount("governance-veil", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "detail", + }); + + expect(veil.shadowRoot.querySelector(".blurred")).not.toBeNull(); + expect(veil.shadowRoot.textContent).not.toContain("Content hidden"); + }); + + it("re-renders when governance state changes", () => { + const runtime = makeRuntime(); + const veil = mount("governance-veil", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "feed", + }); + expect(veil.shadowRoot.textContent).not.toContain("Content hidden"); + + denyCreator(runtime); + expect(veil.shadowRoot.textContent).toContain("Content hidden"); + }); + + it("stops re-rendering once detached", () => { + const runtime = makeRuntime(); + const veil = mount("governance-veil", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "feed", + }); + veil.remove(); + + denyCreator(runtime); + expect(veil.shadowRoot.textContent).not.toContain("Content hidden"); + }); + + it("renders plainly without a runtime", () => { + const veil = mount("governance-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 }); + expect(() => { + veil.target = { type: "user", pubkey: "not-a-key" }; + }).not.toThrow(); + }); +}); + +describe("", () => { + it("disables submission with no target or commands", () => { + const report = mount("governance-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() }); + expect(report.shadowRoot.textContent).toContain("public"); + }); + + it("publishes a report and announces success", async () => { + const runtime = makeRuntime(); + const commands = createCommands(runtime); + vi.spyOn(commands, "report").mockResolvedValue({ ok: true, accepted: ["wss://a"] }); + + const report = mount("governance-report", { runtime }); + report.commands = commands; + report.target = { type: "event", id: EVENT_ID }; + + const reported = vi.fn(); + report.addEventListener("governance:reported", reported); + + report.shadowRoot.querySelector("#category").value = "spam"; + report.shadowRoot.querySelector("form").dispatchEvent(new Event("submit")); + await vi.waitFor(() => expect(reported).toHaveBeenCalled()); + + expect(commands.report).toHaveBeenCalledWith({ type: "event", id: EVENT_ID }, "spam", ""); + expect(report.shadowRoot.textContent).toContain("Report published"); + }); + + it("surfaces the stable error code on refusal", async () => { + const runtime = makeRuntime(); + const commands = createCommands(runtime); + vi.spyOn(commands, "report").mockResolvedValue({ ok: false, code: "invalid-target" }); + + const report = mount("governance-report", { runtime }); + report.commands = commands; + report.target = { type: "event", id: EVENT_ID }; + + const failed = vi.fn(); + report.addEventListener("governance:report-failed", failed); + report.shadowRoot.querySelector("form").dispatchEvent(new Event("submit")); + await vi.waitFor(() => expect(failed).toHaveBeenCalled()); + + expect(report.shadowRoot.textContent).toContain("invalid-target"); + }); +}); + +describe("", () => { + it("reports the effect and reasons", () => { + const runtime = makeRuntime(); + denyCreator(runtime); + const status = mount("governance-status", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "feed", + }); + + expect(status.shadowRoot.textContent).toContain("hide"); + expect(status.shadowRoot.textContent).toContain(DEFAULT_REASON_TEXT["admin-user-deny"]); + }); + + it("says so when there is nothing to report", () => { + const status = mount("governance-status", { + runtime: makeRuntime(), + target: { type: "user", pubkey: CREATOR }, + profile: "feed", + }); + expect(status.shadowRoot.textContent).toContain("No governance signals"); + }); + + it("withholds contributor identities on a redacting profile", () => { + const runtime = makeRuntime(); + runtime.reports.ingest( + { reporter: TRUSTED, category: "spam", createdAt: NOW }, + `user:${CREATOR}`, + ); + const status = mount("governance-status", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "feed", + }); + + expect(status.shadowRoot.textContent).toContain("1 trusted report"); + expect(status.shadowRoot.textContent).not.toContain("Reported by:"); + }); + + it("shows contributor identities when the profile exposes evidence", () => { + const runtime = makeRuntime(); + runtime.reports.ingest( + { reporter: TRUSTED, category: "spam", createdAt: NOW }, + `user:${CREATOR}`, + ); + const status = mount("governance-status", { + runtime, + target: { type: "user", pubkey: CREATOR }, + profile: "detail", + }); + + expect(status.shadowRoot.textContent).toContain("Reported by:"); + expect(status.shadowRoot.textContent).toContain(shortenKey(TRUSTED)); + }); + + it("renders no status without a target", () => { + const status = mount("governance-status", { runtime: makeRuntime() }); + expect(status.shadowRoot.querySelector("[part='status']")).toBeNull(); + }); +}); diff --git a/packages/widget/tsconfig.json b/packages/widget/tsconfig.json new file mode 100644 index 0000000..d346ef4 --- /dev/null +++ b/packages/widget/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "noEmit": true + }, + "include": [ + "src/**/*.js", + "test/**/*.js" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/widget/tsconfig.types.json b/packages/widget/tsconfig.types.json new file mode 100644 index 0000000..7b9ec76 --- /dev/null +++ b/packages/widget/tsconfig.types.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist/types", + "rootDir": "src" + }, + "include": [ + "src/**/*.js" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 0cd758f..a89df9b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,9 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "strict": true, "skipLibCheck": true, "noEmit": true, @@ -11,7 +13,10 @@ "checkJs": true, "noImplicitAny": false, "resolveJsonModule": true, - "types": ["node", "vitest/globals"] + "types": [ + "node", + "vitest/globals" + ] }, "include": [ "packages/**/src/**/*.js", @@ -20,10 +25,11 @@ "vitest.config.js" ], "exclude": [ - "node_modules", - "dist", - "coverage", "**/dist/**", + "coverage", + "dist", + "node_modules", + "packages/widget/**", "scripts/**" ] }