feat(widget): plug-and-play governance UI as custom elements

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.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-09 19:14:27 -04:00
1 parent da2c1cd923
commit 6957594c36
18 files changed
+2207 -11

No files matched your search

+14
View File
@@ -43,6 +43,14 @@ public API changes with it.
bounded subscription counts, and targeted invalidation. bounded subscription counts, and targeted invalidation.
- **Security requirements suite** (`tests/security/`) covering the enumerated - **Security requirements suite** (`tests/security/`) covering the enumerated
requirements testable at this layer. 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. - GPL-3.0-or-later `LICENSE`, `README`, and an integration guide.
### Fixed ### Fixed
@@ -59,6 +67,12 @@ public API changes with it.
governed by several targets lost the evidence that drove its verdict. governed by several targets lost the evidence that drove its verdict.
- `OverrideStore.clear()` shadowed the inherited listener `clear()`, leaking - `OverrideStore.clear()` shadowed the inherited listener `clear()`, leaking
subscribers on teardown. Now `clearOverrides()`. 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 - 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 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 list. A 5,000-target pass took ~47s. The runtime now memoizes the snapshot
+25
View File
@@ -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/nostr` | Codecs: canonical v1, NIP-56, NIP-51, legacy lists, bech32. |
| `@nostr-governance/runtime` | Stores, transport orchestration, commands. | | `@nostr-governance/runtime` | Stores, transport orchestration, commands. |
| `@nostr-governance/testing` | Conformance harness. | | `@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. | | `@nostr-governance/bitvid-compat` | Reference application profile for the characterization corpus. |
## Quick start ## Quick start
@@ -116,6 +117,30 @@ address denial that survives republication, malware-driven checkout blocking,
capability-scoped moderator roles, community curator ingestion, and capability-scoped moderator roles, community curator ingestion, and
seller-facing explanations. See [`docs/integration-guide.md`](docs/integration-guide.md). 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
<governance-veil profile="feed"><img src="thumbnail.jpg" alt="" /></governance-veil>
```
```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 ## Authority model
Root-authorized contributors: the root publishes the role roster, each actor Root-authorized contributors: the root publishes the role roster, each actor
+190
View File
@@ -0,0 +1,190 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>nostr-governance widget demo</title>
<style>
:root { color-scheme: light dark; }
body {
font: 16px/1.5 system-ui, sans-serif;
max-width: 60rem;
margin: 0 auto;
padding: 2rem 1rem 6rem;
}
h1 { margin-bottom: 0.25rem; }
.lede { color: color-mix(in srgb, currentColor 65%, transparent); margin-top: 0; }
section {
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
border-radius: 10px;
padding: 1rem 1.25rem;
margin: 1.5rem 0;
}
h2 { font-size: 1.05rem; margin-top: 0; }
.card { max-width: 22rem; border-radius: 8px; overflow: hidden; }
.card img { display: block; width: 100%; background: #8884; aspect-ratio: 16/9; }
.controls { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; }
button { font: inherit; padding: 0.4em 0.8em; border-radius: 8px; cursor: pointer; }
code { font-size: 0.9em; }
</style>
</head>
<body>
<h1>nostr-governance widget</h1>
<p class="lede">
Every element below reads from one shared runtime. Toggle governance state
and watch each surface reach its own conclusion from the same evidence.
</p>
<section>
<h2>Viewer surface — <code>&lt;governance-veil&gt;</code></h2>
<governance-veil id="veil" profile="public-marketplace">
<div class="card">
<img alt="" />
<p><strong>Widget</strong> — 1000 sats</p>
</div>
</governance-veil>
<div class="controls">
<button id="report">Add a trusted scam report</button>
<button id="deny">Restrict the seller</button>
<button id="reset">Reset</button>
</div>
</section>
<section>
<h2>Same product, four surfaces</h2>
<p class="lede">
One snapshot, four profiles. This is the state a single
<code>hidden</code> boolean cannot express.
</p>
<div id="surfaces"></div>
</section>
<section>
<h2>Status readout — <code>&lt;governance-status&gt;</code></h2>
<governance-status id="status" profile="seller-dashboard"></governance-status>
</section>
<section>
<h2>Moderator console</h2>
<p class="lede">
Signed in as a <strong>listing moderator</strong>, who may restrict
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>
</section>
<script type="module">
import {
createCommands,
createGovernanceRuntime,
createMemoryTransport,
} from "../../packages/runtime/src/index.js";
import { defineGovernanceElements } from "../../packages/widget/src/index.js";
import { COMMERCE_POLICY, COMMERCE_ROLE_CAPABILITIES } from "./src/policy.js";
defineGovernanceElements();
const key = (seed) => seed.repeat(64).slice(0, 64);
const ROOT = key("a1");
const LISTING_MOD = key("b2");
const SELLER_MOD = key("c3");
const SELLER = key("d4");
const NOW = Math.floor(Date.now() / 1000);
const trusted = (i) => key(i.toString(16).padStart(2, "0"));
const runtime = createGovernanceRuntime({
applicationId: "commerce-demo",
namespace: "commerce",
root: ROOT,
transport: createMemoryTransport(),
policy: COMMERCE_POLICY,
now: () => NOW,
});
function resetRoster() {
runtime.admin.setRoles({
root: ROOT,
roles: COMMERCE_ROLE_CAPABILITIES,
actors: {
[ROOT]: ["super_admin"],
[LISTING_MOD]: ["listing_moderator"],
[SELLER_MOD]: ["seller_moderator"],
},
});
runtime.admin.upsertContribution({
actor: ROOT,
kind: "user-allow",
targets: [{ type: "user", pubkey: SELLER }],
createdAt: NOW,
});
}
resetRoster();
// Signed in as a listing moderator: they may restrict listings, not sellers.
runtime.setViewer(LISTING_MOD);
runtime.trust.setContacts(Array.from({ length: 8 }, (_, i) => trusted(i + 1)));
const commands = createCommands(runtime);
const target = { type: "address", kind: "30078", pubkey: SELLER, identifier: "sku-001" };
const targetKey = `address:30078:${SELLER}:sku-001`;
for (const [id, node] of [
["veil", document.getElementById("veil")],
["status", document.getElementById("status")],
]) {
node.runtime = runtime;
node.target = target;
}
const caps = document.getElementById("caps");
const panel = document.getElementById("panel");
caps.runtime = runtime;
panel.runtime = runtime;
panel.commands = commands;
const PROFILES = ["public-marketplace", "product-detail", "checkout", "seller-dashboard"];
const surfaces = document.getElementById("surfaces");
function renderSurfaces() {
surfaces.innerHTML = PROFILES.map((profile) => {
const decision = runtime.evaluate(target, { profile });
return `<p><code>${profile}</code> — visibility:
<strong>${decision.visibility.effect}</strong>, transaction:
<strong>${decision.transaction?.effect ?? "n/a"}</strong>, rank:
<strong>${decision.ranking.effect}</strong></p>`;
}).join("");
}
runtime.on("change", renderSurfaces);
renderSurfaces();
let reportCount = 0;
document.getElementById("report").addEventListener("click", () => {
reportCount += 1;
runtime.reports.ingest(
{ reporter: trusted(reportCount), category: "scam", createdAt: NOW },
targetKey,
);
});
document.getElementById("deny").addEventListener("click", () => {
runtime.admin.upsertContribution({
actor: SELLER_MOD,
kind: "user-deny",
targets: [{ type: "user", pubkey: SELLER }],
createdAt: NOW + reportCount,
});
});
document.getElementById("reset").addEventListener("click", () => {
reportCount = 0;
runtime.reports.clearTarget(targetKey);
runtime.admin.setContributions([]);
resetRoster();
});
</script>
</body>
</html>
+107
View File
@@ -13,6 +13,7 @@
], ],
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"happy-dom": "^20.11.2",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vitest": "^1.6.0" "vitest": "^1.6.0"
}, },
@@ -463,6 +464,10 @@
"resolved": "packages/testing", "resolved": "packages/testing",
"link": true "link": true
}, },
"node_modules/@nostr-governance/widget": {
"resolved": "packages/widget",
"link": true
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.3", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", "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" "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": { "node_modules/@vitest/expect": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@@ -960,6 +982,19 @@
"node": "*" "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": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "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": "^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": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1176,6 +1224,25 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/human-signals": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -1866,6 +1943,28 @@
"node": ">=8" "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": { "node_modules/yocto-queue": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
@@ -1912,6 +2011,14 @@
"dependencies": { "dependencies": {
"@nostr-governance/core": "*" "@nostr-governance/core": "*"
} }
},
"packages/widget": {
"name": "@nostr-governance/widget",
"version": "0.1.0",
"dependencies": {
"@nostr-governance/core": "*",
"@nostr-governance/runtime": "*"
}
} }
} }
} }
+3 -2
View File
@@ -9,10 +9,10 @@
"test:unit": "vitest run --passWithNoTests", "test:unit": "vitest run --passWithNoTests",
"test:conformance": "vitest run tests/conformance --passWithNoTests", "test:conformance": "vitest run tests/conformance --passWithNoTests",
"typecheck": "tsc -p tsconfig.json --noEmit", "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", "lint": "npm run check:references && npm run check:types",
"check:references": "node scripts/verify-no-reference-imports.mjs", "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": { "engines": {
"node": ">=22" "node": ">=22"
@@ -23,6 +23,7 @@
], ],
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"happy-dom": "^20.11.2",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vitest": "^1.6.0" "vitest": "^1.6.0"
} }
+29 -4
View File
@@ -38,6 +38,8 @@ export class GovernanceAdminStore extends Emitter {
this.state = createEmptyAdminState(); this.state = createEmptyAdminState();
/** @type {string} */ /** @type {string} */
this.fingerprint = fingerprint(serializeAdminState(this.state)); this.fingerprint = fingerprint(serializeAdminState(this.state));
/** @type {string} */
this.authorityFingerprint = this.#computeAuthorityFingerprint();
} }
/** /**
@@ -53,6 +55,15 @@ export class GovernanceAdminStore extends Emitter {
this.#recompute(); this.#recompute();
} }
#computeAuthorityFingerprint() {
return fingerprint({
root: this.authority.root ?? "",
actors: this.authority.actors,
roles: this.authority.roles,
protectedActors: this.authority.protectedActors,
});
}
/** /**
* Replace every contribution. * Replace every contribution.
* @param {Contribution[]} contributions * @param {Contribution[]} contributions
@@ -104,11 +115,22 @@ export class GovernanceAdminStore extends Emitter {
#recompute() { #recompute() {
const next = reduceAdminState(this.contributions, this.authority); const next = reduceAdminState(this.contributions, this.authority);
const nextFingerprint = fingerprint(serializeAdminState(next)); const nextFingerprint = fingerprint(serializeAdminState(next));
const nextAuthority = this.#computeAuthorityFingerprint();
this.state = next; this.state = next;
if (nextFingerprint !== this.fingerprint) { // A roster change can alter capabilities and protected actors without
this.fingerprint = nextFingerprint; // changing a single denial entry. Emitting only on reduced state would
this.emit("change", { fingerprint: nextFingerprint }); // 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 * structural: re-ingesting the same report cannot inflate a count, which
* matters because relays routinely deliver duplicates. * 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 * @param {string} targetKey
*/ */
ingest(report, targetKey) { ingest(report, targetKey) {
+34
View File
@@ -382,3 +382,37 @@ describe("OverrideStore", () => {
expect(handler).toHaveBeenCalledTimes(3); 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();
});
});
+116
View File
@@ -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 |
| --- | --- |
| `<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 |
### 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 |
## Usage
```html
<script type="module">
import { createGovernanceRuntime, createCommands } from "@nostr-governance/runtime";
import { defineGovernanceElements } from "@nostr-governance/widget";
defineGovernanceElements();
const runtime = createGovernanceRuntime({
applicationId: "my-app",
namespace: "myapp",
root: ROOT_PUBKEY,
transport: myRelayAdapter,
policy: myPolicy,
now: () => Math.floor(Date.now() / 1000),
});
await runtime.loadAdministrativeState();
const veil = document.querySelector("governance-veil");
veil.runtime = runtime;
veil.target = { type: "event", id: eventId, author: authorPubkey };
</script>
<governance-veil profile="feed">
<img src="thumbnail.jpg" alt="" />
</governance-veil>
```
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.
+22
View File
@@ -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": "*"
}
}
+295
View File
@@ -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",
};
/**
* `<governance-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
* 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('<p class="reason">Not signed in.</p>');
return;
}
const rows = Object.entries(CAPABILITY_LABELS)
.map(([capability, label]) => {
const has = held.has(/** @type {GovernanceCapability} */ (capability));
return `<li part="capability">
<span aria-hidden="true">${has ? "✓" : "·"}</span>
<span${has ? "" : ' class="reason"'}>${escapeHtml(label)}</span>
</li>`;
})
.join("");
this.renderHtml(`
<style>
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.25em; }
li { display: grid; grid-template-columns: 1.2em 1fr; align-items: baseline; }
</style>
<div class="stack" part="capabilities">
<p class="reason">Signed in as ${escapeHtml(shortenKey(viewer))}</p>
<ul>${rows}</ul>
${held.size === 0 ? '<p class="reason">This account holds no governance capabilities.</p>' : ""}
</div>
`);
}
}
/**
* `<governance-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
* yours to do".
*/
export class GovernanceAction extends GovernanceElement {
static get observedAttributes() {
return ["capability", "label"];
}
constructor() {
super();
/** @type {GovernanceCommands|null} */
this.commands = null;
/** @type {(() => Promise<any>)|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(`
<div class="row" part="action">
<button type="button" id="run" ${permitted ? "" : "disabled"}>${escapeHtml(label)}</button>
${
permitted
? ""
: `<span class="reason" part="explanation">Requires permission to ${escapeHtml(missing)}</span>`
}
<span class="reason" id="status">${escapeHtml(this._status)}</span>
</div>
`);
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();
}
}
/**
* `<governance-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.
*/
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('<p class="reason">No runtime attached.</p>');
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 `<tr>
<td><code>${escapeHtml(shortenKey(pubkey))}</code></td>
<td class="reason">${origin}</td>
</tr>`;
})
.join("");
this.renderHtml(`
<style>
table { border-collapse: collapse; width: 100%; }
td, th { text-align: left; padding: 0.3em 0.5em; border-bottom: 1px solid var(--gov-border); }
code { font-size: 0.9em; }
h3 { margin: 0; font-size: 1em; }
.stale { color: var(--gov-warn); }
</style>
<div class="stack" part="panel">
${
this.runtime.stale
? '<p class="stale" part="stale">Showing cached state — relays unreachable.</p>'
: ""
}
<section class="stack">
<h3>Roster</h3>
<p class="reason">
Root: ${escapeHtml(authority.root ? shortenKey(authority.root) : "not configured")} ·
${escapeHtml(Object.keys(authority.actors).length)} actor(s) ·
${escapeHtml(authority.protectedActors.length)} protected
</p>
</section>
<section class="stack">
<h3>Restricted accounts (${escapeHtml(state.userDeny.size)})</h3>
${
denyRows
? `<table part="deny-table"><tbody>${denyRows}</tbody></table>`
: '<p class="reason">None.</p>'
}
</section>
<section class="stack">
<h3>Restrict an account</h3>
<div class="row">
<input id="pubkey" type="text" placeholder="hex pubkey" size="24" part="input" />
<button type="button" id="deny" ${canDenyUser ? "" : "disabled"}>Restrict</button>
</div>
${
canDenyUser
? ""
: '<p class="reason">Requires permission to restrict accounts.</p>'
}
<span class="reason" id="status">${escapeHtml(this._status)}</span>
</section>
<section class="stack">
<h3>Other state</h3>
<p class="reason">
${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)
</p>
</section>
</div>
`);
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;
}
+195
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* 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 = `<style>${BASE_STYLES}</style>${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;
}
+51
View File
@@ -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()];
}
+381
View File
@@ -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<string, string>}
*/
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<string, string>} [overrides]
* @returns {string[]}
*/
export function describeReasons(decision, overrides = {}) {
const table = { ...DEFAULT_REASON_TEXT, ...overrides };
return decision.reasons.map((reason) => table[reason.id] ?? reason.id);
}
/**
* `<governance-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
* tree rather than merely visually covered, so a screen reader does not read
* out something the viewer was not meant to see.
*
* Usage:
* <governance-veil><img src="..."></governance-veil>
* 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<string, string>} */
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("<slot></slot>");
return;
}
const effect = decision.visibility.effect;
const reasons = describeReasons(decision, this.reasonText);
const reasonLine = reasons.length ? `<p class="reason">${escapeHtml(reasons[0])}</p>` : "";
// 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(`
<div class="veil stack" part="veil">
<p part="notice">Content hidden</p>
${reasonLine}
${canReveal ? '<div class="row"><button type="button" id="reveal">Show anyway</button></div>' : ""}
<div hidden aria-hidden="true"><slot></slot></div>
</div>
`);
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(`
<style>
.blurred { filter: blur(18px); pointer-events: none; user-select: none; }
.overlay { position: relative; }
.overlay .controls {
position: absolute; inset: 0; display: grid; place-content: center;
gap: 0.5em; text-align: center; padding: 1em;
}
.warn { color: var(--gov-warn); }
</style>
<div class="overlay" part="container">
<div class="${blurred ? "blurred" : ""}" part="content"><slot></slot></div>
${
blurred
? `<div class="controls">
${reasonLine}
<div class="row" style="justify-content:center">
<button type="button" id="reveal">Show anyway</button>
</div>
</div>`
: ""
}
</div>
${warned && reasons.length ? `<p class="reason warn" part="warning">${escapeHtml(reasons[0])}</p>` : ""}
`);
this.$("#reveal")?.addEventListener("click", () => {
this._revealed = true;
this.emit("revealed", { target: this._target, decision });
this.render();
});
}
}
/**
* `<governance-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
* 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) => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`)
.join("");
this.renderHtml(`
<form class="stack" part="form">
<label class="stack">
<span>Reason</span>
<select id="category" part="select">${options}</select>
</label>
<label class="stack">
<span>Details <span class="reason">(optional, public)</span></span>
<textarea id="details" rows="3" part="textarea"></textarea>
</label>
<p class="reason">
Reports are public Nostr events. Whether a report affects what someone
sees depends on their own trust graph.
</p>
<div class="row">
<button type="submit" id="submit" ${this._target && this.commands ? "" : "disabled"}>
Submit report
</button>
<span class="reason" id="status">${escapeHtml(this._status)}</span>
</div>
</form>
`);
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();
}
}
/**
* `<governance-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
* you follow" and no names.
*/
export class GovernanceStatus extends GovernanceElement {
constructor() {
super();
/** @type {GovernanceTarget|null} */
this._target = null;
/** @type {Record<string, string>} */
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(`
<div class="stack" part="status">
<div class="row">
<strong part="effect">${escapeHtml(decision.visibility.effect)}</strong>
${
decision.ranking.effect === "downrank"
? `<span class="reason">downranked (${escapeHtml(decision.ranking.weight)})</span>`
: ""
}
${
decision.transaction
? `<span class="reason">transaction: ${escapeHtml(decision.transaction.effect)}</span>`
: ""
}
</div>
${
reasons.length
? `<ul part="reasons">${reasons.map((text) => `<li>${escapeHtml(text)}</li>`).join("")}</ul>`
: '<p class="reason">No governance signals</p>'
}
${
evidence
? `<p class="reason">
${escapeHtml(evidence.trustedReportTotal)} trusted report(s),
${escapeHtml(evidence.trustedMuteTotal)} trusted mute(s)
</p>`
: ""
}
${
reporters.length
? `<p class="reason">Reported by: ${reporters.map((key) => escapeHtml(shortenKey(key))).join(", ")}</p>`
: ""
}
${
muters.length
? `<p class="reason">Muted by: ${muters.map((key) => escapeHtml(shortenKey(key))).join(", ")}</p>`
: ""
}
</div>
`);
}
}
/**
* 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;
}
+316
View File
@@ -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<string, string>} [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("<governance-capabilities>", () => {
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("<governance-action>", () => {
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("<governance-admin-panel>", () => {
/**
* @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([]);
});
});
+385
View File
@@ -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 = "<p>content</p>" } = {}) {
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('<img src=x onerror="alert(1)">')).not.toContain("<img");
expect(escapeHtml("a&b")).toBe("a&amp;b");
});
it("handles nullish input", () => {
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("<governance-veil>", () => {
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("<governance-report>", () => {
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("<governance-status>", () => {
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();
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"noEmit": true
},
"include": [
"src/**/*.js",
"test/**/*.js"
],
"exclude": [
"node_modules",
"dist"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist/types",
"rootDir": "src"
},
"include": [
"src/**/*.js"
]
}
+11 -5
View File
@@ -3,7 +3,9 @@
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"target": "ES2022", "target": "ES2022",
"lib": ["ES2022"], "lib": [
"ES2022"
],
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
@@ -11,7 +13,10 @@
"checkJs": true, "checkJs": true,
"noImplicitAny": false, "noImplicitAny": false,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["node", "vitest/globals"] "types": [
"node",
"vitest/globals"
]
}, },
"include": [ "include": [
"packages/**/src/**/*.js", "packages/**/src/**/*.js",
@@ -20,10 +25,11 @@
"vitest.config.js" "vitest.config.js"
], ],
"exclude": [ "exclude": [
"node_modules",
"dist",
"coverage",
"**/dist/**", "**/dist/**",
"coverage",
"dist",
"node_modules",
"packages/widget/**",
"scripts/**" "scripts/**"
] ]
} }