mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
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:
1 parent
da2c1cd923
commit
6957594c36
18 files changed
+2207
-11
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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><governance-veil></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><governance-status></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>
|
||||||
Generated
+107
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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, """)
|
||||||
|
.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 = `<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;
|
||||||
|
}
|
||||||
@@ -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()];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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&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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"test/**/*.js"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user