mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-11-04 04:21:00 +00:00
feat: rename to BitGate and close the drop-in gap
Renames the project and makes it as easy to adopt as BitLogin, which was the stated bar and which it did not previously meet. Rename (ADR 0005): @nostr-governance/* -> @bitgate/*, governance-* elements -> bitgate-*, createGovernanceRuntime -> createBitGate. "Governance" reads as DAO voting to this audience and named a category rather than a product; BitGate names the benefit and joins the bit- family that BitVid and BitRoad compose with. Domain types keep their names — the product is BitGate, the subject matter is still governance. The migration plan is left as written; this ADR records the change rather than rewriting history. Drop-in distribution (ADR 0006): adopting BitGate meant supplying a transport, authoring a policy, and wiring every element in JavaScript. Now: - createRelayTransport(urls) — WebSocket transport with reconnection, backoff, subscription replay, and cross-relay dedupe - policy presets (social, commerce, admin-only) addressable by name; the engine still carries no thresholds of its own - <bitgate-provider> — configuration in markup, with descendants finding the runtime through a bubbling context event rather than DOM walking, which breaks across shadow boundaries - npm run build:widget — a self-contained dist/bitgate.js That last one came from running it in a browser rather than from a test. Bare specifiers resolve in Node and vitest but not in a browser, so the widget source worked everywhere except a plain static page — the one place it exists to serve. The bundle is not committed: consumers build from reviewed source. Also found by running it: the provider's ready event fires during element upgrade, before a page that defines elements after parsing can attach a listener, so it now exposes `ready` as a promise too. Four bugs fixed along the way, three found by new tests and one by the browser: admin state stayed silent when a roster change altered capabilities but no denials; the relay transport sent every subscription twice on connect; the commerce preset computed a meaningless visibility verdict at checkout and could hide a listing from its own seller; <bitgate-status> threw on an unusable decision. 759 tests; lint, typecheck, and both builds clean.
This commit is contained in:
1 parent
7f70a64d61
commit
adeba151b8
63 files changed
+2774
-401
No files matched your search
@@ -14,8 +14,8 @@
|
||||
import { GovernanceElement, defineElement, escapeHtml, shortenKey } from "./base.js";
|
||||
|
||||
/**
|
||||
* @typedef {import('@nostr-governance/runtime').GovernanceCommands} GovernanceCommands
|
||||
* @typedef {import('@nostr-governance/core').GovernanceCapability} GovernanceCapability
|
||||
* @typedef {import('@bitgate/runtime').GovernanceCommands} GovernanceCommands
|
||||
* @typedef {import('@bitgate/core').GovernanceCapability} GovernanceCapability
|
||||
*/
|
||||
|
||||
/** Human wording for capabilities, used when explaining a refusal. */
|
||||
@@ -32,7 +32,7 @@ export const CAPABILITY_LABELS = {
|
||||
};
|
||||
|
||||
/**
|
||||
* `<governance-capabilities>` — what the signed-in moderator may do.
|
||||
* `<bitgate-capabilities>` — what the signed-in moderator may do.
|
||||
*
|
||||
* Worth rendering explicitly: in the previous generation of this system,
|
||||
* moderator status was a UI permission with no cryptographic meaning. Showing
|
||||
@@ -81,7 +81,7 @@ export class GovernanceCapabilities extends GovernanceElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* `<governance-action>` — a single capability-gated action button.
|
||||
* `<bitgate-action>` — a single capability-gated action button.
|
||||
*
|
||||
* When the actor lacks the capability the button is disabled *and* labelled
|
||||
* with what is missing, which is the difference between "broken" and "not
|
||||
@@ -167,7 +167,7 @@ export class GovernanceAction extends GovernanceElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* `<governance-admin-panel>` — effective administrative state plus controls.
|
||||
* `<bitgate-admin-panel>` — effective administrative state plus controls.
|
||||
*
|
||||
* Shows what is currently in force and who contributed it, so a moderator can
|
||||
* see that a denial came from a community list rather than from their own team.
|
||||
@@ -298,12 +298,12 @@ export class GovernanceAdminPanel extends GovernanceElement {
|
||||
*/
|
||||
export function defineAdminElements() {
|
||||
const registered = [];
|
||||
if (defineElement("governance-capabilities", GovernanceCapabilities)) {
|
||||
registered.push("governance-capabilities");
|
||||
if (defineElement("bitgate-capabilities", GovernanceCapabilities)) {
|
||||
registered.push("bitgate-capabilities");
|
||||
}
|
||||
if (defineElement("governance-action", GovernanceAction)) registered.push("governance-action");
|
||||
if (defineElement("governance-admin-panel", GovernanceAdminPanel)) {
|
||||
registered.push("governance-admin-panel");
|
||||
if (defineElement("bitgate-action", GovernanceAction)) registered.push("bitgate-action");
|
||||
if (defineElement("bitgate-admin-panel", GovernanceAdminPanel)) {
|
||||
registered.push("bitgate-admin-panel");
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
+115
-4
@@ -10,9 +10,9 @@
|
||||
// one governance runtime across its whole page.
|
||||
|
||||
/**
|
||||
* @typedef {import('@nostr-governance/runtime').GovernanceRuntime} GovernanceRuntime
|
||||
* @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision
|
||||
* @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget
|
||||
* @typedef {import('@bitgate/runtime').GovernanceRuntime} GovernanceRuntime
|
||||
* @typedef {import('@bitgate/core').GovernanceDecision} GovernanceDecision
|
||||
* @typedef {import('@bitgate/core').GovernanceTarget} GovernanceTarget
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -90,6 +90,73 @@ export function shortenKey(pubkey) {
|
||||
return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the nearest provider for its runtime and commands.
|
||||
*
|
||||
* Declared here rather than imported from ./provider.js to keep the module
|
||||
* graph acyclic: provider.js already depends on this file.
|
||||
*
|
||||
* @param {Element} element
|
||||
* @returns {{ runtime: any, commands: any }|null}
|
||||
*/
|
||||
export function requestContext(element) {
|
||||
/** @type {{ context: any }} */
|
||||
const detail = { context: null };
|
||||
element.dispatchEvent(
|
||||
new CustomEvent("bitgate:context-request", { detail, bubbles: true, composed: true }),
|
||||
);
|
||||
return detail.context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a governance target from element attributes.
|
||||
*
|
||||
* Lets markup name a target without JavaScript:
|
||||
* target-event / target-author / target-kind
|
||||
* target-user
|
||||
* target-address (a `kind:pubkey:d` coordinate)
|
||||
*
|
||||
* @param {Element} element
|
||||
* @returns {import('@bitgate/core').GovernanceTarget|null}
|
||||
*/
|
||||
export function targetFromAttributes(element) {
|
||||
const user = element.getAttribute("target-user");
|
||||
if (user) {
|
||||
return { type: "user", pubkey: user.trim() };
|
||||
}
|
||||
|
||||
const eventId = element.getAttribute("target-event");
|
||||
if (eventId) {
|
||||
/** @type {any} */
|
||||
const target = { type: "event", id: eventId.trim() };
|
||||
const author = element.getAttribute("target-author");
|
||||
if (author) {
|
||||
target.author = author.trim();
|
||||
}
|
||||
const kind = element.getAttribute("target-kind");
|
||||
if (kind && Number.isFinite(Number(kind))) {
|
||||
target.kind = Number(kind);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
const address = element.getAttribute("target-address");
|
||||
if (address) {
|
||||
const first = address.indexOf(":");
|
||||
const second = address.indexOf(":", first + 1);
|
||||
if (first > 0 && second > first) {
|
||||
return {
|
||||
type: "address",
|
||||
kind: address.slice(0, first),
|
||||
pubkey: address.slice(first + 1, second),
|
||||
identifier: address.slice(second + 1),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base element: owns a shadow root, a runtime reference, and change
|
||||
* subscription lifecycle.
|
||||
@@ -104,6 +171,7 @@ export class GovernanceElement extends globalThis.HTMLElement {
|
||||
this._runtime = null;
|
||||
/** @type {(() => void)|null} */
|
||||
this._unsubscribe = null;
|
||||
this._awaitingProvider = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,12 +197,55 @@ export class GovernanceElement extends globalThis.HTMLElement {
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Adopt the nearest provider's runtime when one was not assigned directly.
|
||||
// This is what lets a page configure BitGate entirely in markup.
|
||||
if (!this._runtime) {
|
||||
this.adoptContext();
|
||||
}
|
||||
if (this._runtime && !this._unsubscribe) {
|
||||
this._subscribe();
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a `<bitgate-provider>` ancestor and take its runtime and commands.
|
||||
*
|
||||
* A provider that is still starting answers with nothing; the element waits
|
||||
* for `bitgate:ready` rather than polling.
|
||||
*
|
||||
* @returns {boolean} Whether a runtime was adopted
|
||||
*/
|
||||
adoptContext() {
|
||||
const context = requestContext(this);
|
||||
if (context?.runtime) {
|
||||
this.runtime = context.runtime;
|
||||
if (context.commands && "commands" in this) {
|
||||
/** @type {any} */ (this).commands = context.commands;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this._awaitingProvider) {
|
||||
this._awaitingProvider = true;
|
||||
globalThis.document?.addEventListener(
|
||||
"bitgate:ready",
|
||||
(event) => {
|
||||
const detail = /** @type {CustomEvent} */ (event).detail;
|
||||
if (!detail?.runtime || this._runtime) {
|
||||
return;
|
||||
}
|
||||
this.runtime = detail.runtime;
|
||||
if (detail.commands && "commands" in this) {
|
||||
/** @type {any} */ (this).commands = detail.commands;
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// Elements are routinely moved or removed during rendering; leaving a
|
||||
// subscription behind would keep a detached element re-rendering forever.
|
||||
@@ -155,7 +266,7 @@ export class GovernanceElement extends globalThis.HTMLElement {
|
||||
*/
|
||||
emit(name, detail) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(`governance:${name}`, { detail, bubbles: true, composed: true }),
|
||||
new CustomEvent(`bitgate:${name}`, { detail, bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @nostr-governance/widget
|
||||
// @bitgate/widget
|
||||
//
|
||||
// Custom elements built strictly on the published API. They render decisions
|
||||
// and issue commands; they compute no policy. If an element here ever needs a
|
||||
@@ -14,9 +14,18 @@ export {
|
||||
canRegisterElements,
|
||||
defineElement,
|
||||
escapeHtml,
|
||||
requestContext,
|
||||
shortenKey,
|
||||
targetFromAttributes,
|
||||
} from "./base.js";
|
||||
|
||||
export {
|
||||
BitGateError,
|
||||
BitGateProvider,
|
||||
CONTEXT_REQUEST,
|
||||
defineProviderElements,
|
||||
} from "./provider.js";
|
||||
|
||||
export {
|
||||
DEFAULT_REASON_TEXT,
|
||||
GovernanceReport,
|
||||
@@ -35,6 +44,7 @@ export {
|
||||
} from "./admin.js";
|
||||
|
||||
import { defineAdminElements } from "./admin.js";
|
||||
import { defineProviderElements } from "./provider.js";
|
||||
import { defineViewerElements } from "./viewer.js";
|
||||
|
||||
/**
|
||||
@@ -46,6 +56,6 @@ import { defineViewerElements } from "./viewer.js";
|
||||
*
|
||||
* @returns {string[]} Element names newly registered by this call
|
||||
*/
|
||||
export function defineGovernanceElements() {
|
||||
return [...defineViewerElements(), ...defineAdminElements()];
|
||||
export function defineBitGateElements() {
|
||||
return [...defineProviderElements(), ...defineViewerElements(), ...defineAdminElements()];
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// `<bitgate-provider>` — attribute-driven setup.
|
||||
//
|
||||
// This is what makes BitGate droppable into a static page. Without it, a host
|
||||
// has to construct a runtime in JavaScript and assign it to every element;
|
||||
// with it, the markup carries the configuration and descendants find the
|
||||
// runtime themselves.
|
||||
//
|
||||
// Descendants discover the provider by firing a bubbling request event rather
|
||||
// than walking up the DOM. Walking up breaks across shadow boundaries, which is
|
||||
// exactly where a host is most likely to place these elements.
|
||||
|
||||
import { createBitGate, createCommands, createRelayTransport } from "@bitgate/runtime";
|
||||
import { getPolicyPreset } from "@bitgate/core";
|
||||
|
||||
import { GovernanceElement, defineElement, escapeHtml } from "./base.js";
|
||||
|
||||
/** Event a descendant fires to locate its provider. */
|
||||
export const CONTEXT_REQUEST = "bitgate:context-request";
|
||||
|
||||
/**
|
||||
* Ask the nearest provider for its runtime and commands.
|
||||
*
|
||||
* Returns null when no provider is present, which is a legitimate state: an
|
||||
* element may be configured directly in JavaScript instead.
|
||||
*
|
||||
* @param {Element} element
|
||||
* @returns {{ runtime: any, commands: any }|null}
|
||||
*/
|
||||
export function requestContext(element) {
|
||||
/** @type {{ context: any }} */
|
||||
const detail = { context: null };
|
||||
element.dispatchEvent(new CustomEvent(CONTEXT_REQUEST, { detail, bubbles: true, composed: true }));
|
||||
return detail.context;
|
||||
}
|
||||
|
||||
export class BitGateProvider extends globalThis.HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["relays", "root", "policy", "profile", "application", "namespace", "viewer"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {any} */
|
||||
this.runtime = null;
|
||||
/** @type {any} */
|
||||
this.commands = null;
|
||||
/** @type {any} */
|
||||
this.transport = null;
|
||||
/** @type {Error|null} */
|
||||
this.error = null;
|
||||
this._ready = false;
|
||||
|
||||
// A promise as well as an event. The event fires during element upgrade,
|
||||
// which for a page that calls defineBitGateElements() after parsing is
|
||||
// *before* it can attach a listener — so an event alone would be a race
|
||||
// that silently loses. Awaiting `ready` always works.
|
||||
/** @type {Promise<{ runtime: any, commands: any }>} */
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
this._resolveReady = resolve;
|
||||
this._rejectReady = reject;
|
||||
});
|
||||
// Nothing may await this before start() runs; keep Node and browsers from
|
||||
// reporting an unhandled rejection in that window.
|
||||
this.ready.catch(() => {});
|
||||
|
||||
/** @type {Promise<void>|null} */
|
||||
this.loaded = null;
|
||||
|
||||
// Answer descendants' context requests. Registered on the host element so
|
||||
// it catches events from composed shadow trees too.
|
||||
this.addEventListener(CONTEXT_REQUEST, (event) => {
|
||||
const custom = /** @type {CustomEvent} */ (event);
|
||||
if (!custom.detail || custom.detail.context) {
|
||||
return;
|
||||
}
|
||||
custom.detail.context = this.runtime ? { runtime: this.runtime, commands: this.commands } : null;
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (!this._ready) {
|
||||
this._ready = true;
|
||||
void this.start();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, before, after) {
|
||||
// Only rebuild once connected and actually changed; the initial attribute
|
||||
// pass would otherwise construct several runtimes during parsing.
|
||||
if (this._ready && before !== after) {
|
||||
this.stop();
|
||||
void this.start();
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {string[]} */
|
||||
get relayUrls() {
|
||||
return (this.getAttribute("relays") ?? "")
|
||||
.split(",")
|
||||
.map((url) => url.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the runtime and load administrative state.
|
||||
*
|
||||
* Failures are surfaced as an event and a `data-error` attribute rather than
|
||||
* thrown: a page whose relays are unreachable should still render, showing
|
||||
* whatever cached state exists.
|
||||
*/
|
||||
async start() {
|
||||
try {
|
||||
const relays = this.relayUrls;
|
||||
const policyName = this.getAttribute("policy") ?? "admin-only";
|
||||
const policy = getPolicyPreset(policyName);
|
||||
|
||||
if (!policy) {
|
||||
throw new Error(
|
||||
`Unknown policy preset "${policyName}". Use social, commerce, admin-only, or set .policy directly.`,
|
||||
);
|
||||
}
|
||||
if (relays.length === 0) {
|
||||
throw new Error("bitgate-provider requires a relays attribute");
|
||||
}
|
||||
|
||||
this.transport = createRelayTransport(relays);
|
||||
this.runtime = createBitGate({
|
||||
applicationId: this.getAttribute("application") ?? globalThis.location?.hostname ?? "bitgate",
|
||||
namespace: this.getAttribute("namespace") ?? "bitgate",
|
||||
root: this.getAttribute("root") ?? undefined,
|
||||
transport: this.transport,
|
||||
policy,
|
||||
storage: createLocalStorage(),
|
||||
now: () => Math.floor(Date.now() / 1000),
|
||||
});
|
||||
this.commands = createCommands(this.runtime);
|
||||
|
||||
const viewer = this.getAttribute("viewer");
|
||||
if (viewer) {
|
||||
this.runtime.setViewer(viewer);
|
||||
}
|
||||
|
||||
this.error = null;
|
||||
this.removeAttribute("data-error");
|
||||
this._resolveReady?.({ runtime: this.runtime, commands: this.commands });
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("bitgate:ready", {
|
||||
detail: { runtime: this.runtime, commands: this.commands },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.runtime.loadAdministrativeState();
|
||||
this.dispatchEvent(new CustomEvent("bitgate:loaded", { bubbles: true, composed: true }));
|
||||
} catch (error) {
|
||||
this.error = /** @type {Error} */ (error);
|
||||
this.setAttribute("data-error", String(error));
|
||||
this._rejectReady?.(error);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("bitgate:error", {
|
||||
detail: { error: String(error) },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the runtime and close relay sockets. */
|
||||
stop() {
|
||||
// A restart needs a fresh promise; the old one has already settled.
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
this._resolveReady = resolve;
|
||||
this._rejectReady = reject;
|
||||
});
|
||||
this.ready.catch(() => {});
|
||||
|
||||
this.runtime?.destroy?.();
|
||||
this.transport?.close?.();
|
||||
this.runtime = null;
|
||||
this.commands = null;
|
||||
this.transport = null;
|
||||
this._ready = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a signer, typically BitLogin's `window.nostr` provider.
|
||||
*
|
||||
* Also sets the viewer, since a signer that cannot say who it is would leave
|
||||
* the trust graph empty and every capability check failing.
|
||||
*
|
||||
* @param {{ getPublicKey: () => Promise<string>, signEvent: (event: any) => Promise<any> }} signer
|
||||
* @returns {Promise<string>} The viewer pubkey
|
||||
*/
|
||||
async useSigner(signer) {
|
||||
if (!this.runtime) {
|
||||
throw new Error("bitgate-provider is not ready");
|
||||
}
|
||||
this.runtime.signer = signer;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
this.runtime.setViewer(pubkey);
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage backed by `localStorage`, falling back to memory where it is
|
||||
* unavailable or blocked (private browsing, sandboxed frames).
|
||||
* @returns {import('@bitgate/runtime').GovernanceStorage}
|
||||
*/
|
||||
function createLocalStorage() {
|
||||
/** @type {Map<string, unknown>} */
|
||||
const fallback = new Map();
|
||||
|
||||
const available = (() => {
|
||||
try {
|
||||
const probe = "__bitgate_probe__";
|
||||
globalThis.localStorage?.setItem(probe, "1");
|
||||
globalThis.localStorage?.removeItem(probe);
|
||||
return Boolean(globalThis.localStorage);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
async read(key) {
|
||||
if (!available) {
|
||||
return fallback.has(key) ? fallback.get(key) : null;
|
||||
}
|
||||
try {
|
||||
const raw = globalThis.localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async write(key, value) {
|
||||
if (!available) {
|
||||
fallback.set(key, value);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
globalThis.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// A full or blocked quota degrades to no persistence, never to a throw
|
||||
// that would break rendering.
|
||||
fallback.set(key, value);
|
||||
}
|
||||
},
|
||||
async remove(key) {
|
||||
fallback.delete(key);
|
||||
try {
|
||||
globalThis.localStorage?.removeItem(key);
|
||||
} catch {
|
||||
// Nothing to do.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `<bitgate-error>` — renders a provider's setup failure.
|
||||
*
|
||||
* Optional, but worth having: a misconfigured `relays` attribute otherwise
|
||||
* produces a page that silently governs nothing.
|
||||
*/
|
||||
export class BitGateError extends GovernanceElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._listener = (event) => {
|
||||
this._error = event.detail?.error;
|
||||
this.render();
|
||||
};
|
||||
globalThis.document?.addEventListener("bitgate:error", this._listener);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._listener) {
|
||||
globalThis.document?.removeEventListener("bitgate:error", this._listener);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shadowRoot) {
|
||||
return;
|
||||
}
|
||||
if (!this._error) {
|
||||
this.renderHtml("");
|
||||
return;
|
||||
}
|
||||
this.renderHtml(
|
||||
`<p class="reason" part="error" role="status">BitGate: ${escapeHtml(this._error)}</p>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the provider elements.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function defineProviderElements() {
|
||||
const registered = [];
|
||||
if (defineElement("bitgate-provider", BitGateProvider)) registered.push("bitgate-provider");
|
||||
if (defineElement("bitgate-error", BitGateError)) registered.push("bitgate-error");
|
||||
return registered;
|
||||
}
|
||||
@@ -4,11 +4,17 @@
|
||||
// no thresholds, no precedence, no "if reports > n". If one of these needs a
|
||||
// number, the number belongs in a policy profile.
|
||||
|
||||
import { GovernanceElement, defineElement, escapeHtml, shortenKey } from "./base.js";
|
||||
import {
|
||||
GovernanceElement,
|
||||
defineElement,
|
||||
escapeHtml,
|
||||
shortenKey,
|
||||
targetFromAttributes,
|
||||
} from "./base.js";
|
||||
|
||||
/**
|
||||
* @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision
|
||||
* @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget
|
||||
* @typedef {import('@bitgate/core').GovernanceDecision} GovernanceDecision
|
||||
* @typedef {import('@bitgate/core').GovernanceTarget} GovernanceTarget
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -50,7 +56,7 @@ export function describeReasons(decision, overrides = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `<governance-veil>` — wraps content and applies a decision to it.
|
||||
* `<bitgate-veil>` — wraps content and applies a decision to it.
|
||||
*
|
||||
* Slotted content is always in the DOM; the element controls whether it is
|
||||
* shown, blurred, or replaced. Hidden content is removed from the accessibility
|
||||
@@ -58,12 +64,12 @@ export function describeReasons(decision, overrides = {}) {
|
||||
* out something the viewer was not meant to see.
|
||||
*
|
||||
* Usage:
|
||||
* <governance-veil><img src="..."></governance-veil>
|
||||
* <bitgate-veil><img src="..."></bitgate-veil>
|
||||
* veil.runtime = runtime; veil.target = { type: "event", id, author };
|
||||
*/
|
||||
export class GovernanceVeil extends GovernanceElement {
|
||||
static get observedAttributes() {
|
||||
return ["profile"];
|
||||
return ["profile", "target-user", "target-event", "target-author", "target-kind", "target-address"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -76,7 +82,8 @@ export class GovernanceVeil extends GovernanceElement {
|
||||
}
|
||||
|
||||
get target() {
|
||||
return this._target;
|
||||
// An explicitly assigned target wins; otherwise read it from markup.
|
||||
return this._target ?? targetFromAttributes(this);
|
||||
}
|
||||
|
||||
set target(target) {
|
||||
@@ -95,11 +102,12 @@ export class GovernanceVeil extends GovernanceElement {
|
||||
|
||||
/** The decision currently applied, or null when not evaluable. */
|
||||
get decision() {
|
||||
if (!this.runtime || !this._target) {
|
||||
const target = this.target;
|
||||
if (!this.runtime || !target) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return this.runtime.evaluate(this._target, { profile: this.profile });
|
||||
return this.runtime.evaluate(target, { profile: this.profile });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -136,7 +144,7 @@ export class GovernanceVeil extends GovernanceElement {
|
||||
`);
|
||||
this.$("#reveal")?.addEventListener("click", () => {
|
||||
this._revealed = true;
|
||||
this.emit("revealed", { target: this._target, decision });
|
||||
this.emit("revealed", { target: this.target, decision });
|
||||
this.render();
|
||||
});
|
||||
return;
|
||||
@@ -173,14 +181,14 @@ export class GovernanceVeil extends GovernanceElement {
|
||||
|
||||
this.$("#reveal")?.addEventListener("click", () => {
|
||||
this._revealed = true;
|
||||
this.emit("revealed", { target: this._target, decision });
|
||||
this.emit("revealed", { target: this.target, decision });
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `<governance-report>` — a report dialog.
|
||||
* `<bitgate-report>` — a report dialog.
|
||||
*
|
||||
* Reporting requires no capability: anyone may report, and whether the report
|
||||
* counts is decided later by each viewer's own trust graph. The element makes
|
||||
@@ -193,7 +201,7 @@ export class GovernanceReport extends GovernanceElement {
|
||||
this._target = null;
|
||||
/** @type {string[]} */
|
||||
this.categories = ["spam", "nudity", "profanity", "illegal", "malware", "impersonation", "other"];
|
||||
/** @type {import('@nostr-governance/runtime').GovernanceCommands|null} */
|
||||
/** @type {import('@bitgate/runtime').GovernanceCommands|null} */
|
||||
this.commands = null;
|
||||
/**
|
||||
* The in-flight submission, or null when idle.
|
||||
@@ -203,8 +211,12 @@ export class GovernanceReport extends GovernanceElement {
|
||||
this._status = "";
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["target-user", "target-event", "target-author", "target-kind", "target-address"];
|
||||
}
|
||||
|
||||
get target() {
|
||||
return this._target;
|
||||
return this._target ?? targetFromAttributes(this);
|
||||
}
|
||||
|
||||
set target(target) {
|
||||
@@ -213,6 +225,10 @@ export class GovernanceReport extends GovernanceElement {
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shadowRoot) {
|
||||
return;
|
||||
@@ -237,7 +253,7 @@ export class GovernanceReport extends GovernanceElement {
|
||||
sees depends on their own trust graph.
|
||||
</p>
|
||||
<div class="row">
|
||||
<button type="submit" id="submit" ${this._target && this.commands ? "" : "disabled"}>
|
||||
<button type="submit" id="submit" ${this.target && this.commands ? "" : "disabled"}>
|
||||
Submit report
|
||||
</button>
|
||||
<span class="reason" id="status">${escapeHtml(this._status)}</span>
|
||||
@@ -252,7 +268,8 @@ export class GovernanceReport extends GovernanceElement {
|
||||
}
|
||||
|
||||
async _submit() {
|
||||
if (!this._target || !this.commands) {
|
||||
const target = this.target;
|
||||
if (!target || !this.commands) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,18 +279,18 @@ export class GovernanceReport extends GovernanceElement {
|
||||
this._status = "Publishing…";
|
||||
this.render();
|
||||
|
||||
const result = await this.commands.report(this._target, category, details);
|
||||
const result = await this.commands.report(target, category, details);
|
||||
|
||||
// Partial relay acceptance is success: the event exists on the network.
|
||||
this._status = result.ok ? "Report published" : `Failed: ${result.code}`;
|
||||
this.emit(result.ok ? "reported" : "report-failed", { target: this._target, category, result });
|
||||
this.emit(result.ok ? "reported" : "report-failed", { target, category, result });
|
||||
this.render();
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `<governance-status>` — a compact readout of why something was affected.
|
||||
* `<bitgate-status>` — a compact readout of why something was affected.
|
||||
*
|
||||
* Shows counts when the profile exposes evidence, and contributor identities
|
||||
* only when it does. A surface that redacts evidence gets "reported by people
|
||||
@@ -289,11 +306,11 @@ export class GovernanceStatus extends GovernanceElement {
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["profile"];
|
||||
return ["profile", "target-user", "target-event", "target-author", "target-kind", "target-address"];
|
||||
}
|
||||
|
||||
get target() {
|
||||
return this._target;
|
||||
return this._target ?? targetFromAttributes(this);
|
||||
}
|
||||
|
||||
set target(target) {
|
||||
@@ -313,19 +330,27 @@ export class GovernanceStatus extends GovernanceElement {
|
||||
if (!this.shadowRoot) {
|
||||
return;
|
||||
}
|
||||
if (!this.runtime || !this._target) {
|
||||
const target = this.target;
|
||||
if (!this.runtime || !target) {
|
||||
this.renderHtml("");
|
||||
return;
|
||||
}
|
||||
|
||||
let decision;
|
||||
try {
|
||||
decision = this.runtime.evaluate(this._target, { profile: this.profile });
|
||||
decision = this.runtime.evaluate(target, { profile: this.profile });
|
||||
} catch {
|
||||
this.renderHtml("");
|
||||
return;
|
||||
}
|
||||
|
||||
// A runtime that returns nothing usable must not take the page down with
|
||||
// it; rendering no status is the correct degradation.
|
||||
if (!decision?.reasons) {
|
||||
this.renderHtml("");
|
||||
return;
|
||||
}
|
||||
|
||||
const reasons = describeReasons(decision, this.reasonText);
|
||||
const evidence = decision.evidence;
|
||||
const reporters = evidence?.trustedReporterPubkeys ?? [];
|
||||
@@ -380,8 +405,8 @@ export class GovernanceStatus extends GovernanceElement {
|
||||
*/
|
||||
export function defineViewerElements() {
|
||||
const registered = [];
|
||||
if (defineElement("governance-veil", GovernanceVeil)) registered.push("governance-veil");
|
||||
if (defineElement("governance-report", GovernanceReport)) registered.push("governance-report");
|
||||
if (defineElement("governance-status", GovernanceStatus)) registered.push("governance-status");
|
||||
if (defineElement("bitgate-veil", GovernanceVeil)) registered.push("bitgate-veil");
|
||||
if (defineElement("bitgate-report", GovernanceReport)) registered.push("bitgate-report");
|
||||
if (defineElement("bitgate-status", GovernanceStatus)) registered.push("bitgate-status");
|
||||
return registered;
|
||||
}
|
||||
Reference in new issue
Block a user