Files
bitgate/packages/runtime/src/commands.js
T
thePR0M3TH3AN 3220889f01 feat(nostr): NIP-32 labelling — the interop surface
BitGate read standard events but published a private vocabulary, making it an
island. NIP-32 (kind 1985) is the shared moderation vocabulary; BitGate now
reads and writes it. See ADR 0007.

The design keeps flexibility central, since the goal is a reusable building
block. A label is (namespace, value, target) and carries no built-in meaning —
"deny", "nsfw", and "MIT" are the same kind of object. What a label means is a
policy decision an application supplies via labelMapping { namespace,
denyValues, allowValues }. The codec itself is vocabulary-free; decodeLabels
returns every label regardless of namespace for generic use.

A matching label becomes a contribution authored by the labeller, gated
identically: it denies someone only if the labeller holds the capability in the
roster, exactly like a community source. An untrusted labeller's deny does
nothing, and labels fail closed without verification like any administrative
event.

- consume: ingestEvent maps kind 1985; loadLabels(labellers) fetches them
- emit: commands.publishLabel(target, value), gated by the same capability as
  denying, so the shared form is no weaker than the private one
- the widget bundle re-exports the label API for the drop-in path

Validated against 40 real kind:1985 events from public relays (ISO-639-1
language labels among them): all decoded with targets and namespaces, zero
throws. 897 tests; cold gates green; audit clean.
2026-08-10 10:01:34 -04:00

441 lines
13 KiB
JavaScript

// Public governance commands.
//
// Every command checks authority before signing. An unauthorized actor must not
// be able to produce an accepted mutation, so the capability check happens
// locally before the signer is ever invoked — a relay is not an access-control
// mechanism, and a client that skipped this check would publish an event that
// merely fails to take effect, which is far harder to diagnose.
import {
getTargetKey,
hasCapability,
isProtectedActor,
isValidTarget,
} from "@bitgate/core";
import { encodeContribution, encodeLabel, encodeReport, encodeRoles } from "@bitgate/nostr";
/**
* Stable error codes. Applications match on these rather than on message text.
*/
export const ERROR_CODES = Object.freeze({
NO_SIGNER: "no-signer-configured",
NOT_AUTHORIZED: "not-authorized",
INVALID_TARGET: "invalid-target",
PROTECTED_TARGET: "protected-target",
PUBLISH_FAILED: "publish-failed",
INVALID_ARGUMENT: "invalid-argument",
});
/**
* The signed event a command published.
*
* Described structurally rather than imported so that this package's public
* types stay nameable without reaching into another package's internal modules.
*
* @typedef {Object} SignedEvent
* @property {string} id
* @property {string} pubkey
* @property {number} kind
* @property {number} created_at
* @property {string[][]} tags
* @property {string} content
* @property {string} [sig]
*/
/**
* @typedef {Object} CommandResult
* @property {boolean} ok
* @property {string} [code] - One of ERROR_CODES when ok is false
* @property {string} [message]
* @property {SignedEvent} [event]
* @property {string[]} [accepted] - Relays that accepted
* @property {Array<{ relay: string, error: string }>} [failed]
*/
/**
* @param {string} code
* @param {string} message
* @returns {CommandResult}
*/
function failure(code, message) {
return { ok: false, code, message };
}
/** @type {Record<string, import('@bitgate/core').GovernanceCapability>} */
const KIND_CAPABILITY = {
"user-allow": "contribute-user-allow",
"user-deny": "contribute-user-deny",
"event-deny": "contribute-event-deny",
"address-deny": "contribute-address-deny",
"trust-seed": "contribute-trust-seed",
};
export class GovernanceCommands {
/**
* @param {import('./runtime.js').GovernanceRuntime} runtime
*/
constructor(runtime) {
this.runtime = runtime;
}
/**
* Sign and publish an event template.
*
* A partial relay acceptance still succeeds: the first acceptance means the
* event exists on the network. The remaining relay results stay on the result
* so diagnostics can surface them.
*
* @param {{ kind: number, content: string, tags: string[][] }} template
* @returns {Promise<CommandResult>}
*/
async #publish(template) {
let signed;
try {
signed = await this.runtime.signer.signEvent({
...template,
created_at: this.runtime.now(),
});
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
try {
const result = await this.runtime.transport.publish(signed);
if (!result?.ok) {
return {
...failure(ERROR_CODES.PUBLISH_FAILED, "no relay accepted the event"),
event: signed,
accepted: result?.accepted ?? [],
failed: result?.failed ?? [],
};
}
return {
ok: true,
event: signed,
accepted: result.accepted ?? [],
failed: result.failed ?? [],
};
} catch (error) {
return { ...failure(ERROR_CODES.PUBLISH_FAILED, String(error)), event: signed };
}
}
/**
* Resolve the acting pubkey from the signer.
* @returns {Promise<string>}
*/
async #actor() {
return this.runtime.signer.getPublicKey();
}
/**
* Publish a contribution list.
*
* @param {import('@bitgate/core').Contribution["kind"]} kind
* @param {import('@bitgate/core').GovernanceTarget[]} targets
* @param {Object} [options]
* @param {string} [options.source] - Community list marker
* @returns {Promise<CommandResult>}
*/
async contribute(kind, targets, { source } = {}) {
const capability = KIND_CAPABILITY[kind];
if (!capability) {
return failure(ERROR_CODES.INVALID_ARGUMENT, `unknown contribution kind: ${kind}`);
}
if (!Array.isArray(targets) || targets.length === 0) {
return failure(ERROR_CODES.INVALID_ARGUMENT, "targets must be a non-empty array");
}
for (const target of targets) {
if (!isValidTarget(target)) {
return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target));
}
}
let actor;
try {
actor = await this.#actor();
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
const authority = this.runtime.admin.authority;
if (!hasCapability(actor, capability, authority)) {
return failure(ERROR_CODES.NOT_AUTHORIZED, `actor lacks ${capability}`);
}
// Protected actors are rejected at the command boundary as well as during
// reduction, so an operator sees the refusal instead of publishing a list
// entry that silently never takes effect.
if (kind === "user-deny") {
for (const target of targets) {
if (target.type === "user" && isProtectedActor(target.pubkey, authority)) {
return failure(ERROR_CODES.PROTECTED_TARGET, target.pubkey);
}
}
}
const template = encodeContribution(
{ actor, kind, targets, ...(source ? { source } : {}) },
this.runtime.namespace,
);
return this.#publish(template);
}
/**
* Deny a user.
* @param {string} pubkey
* @param {Object} [options]
* @returns {Promise<CommandResult>}
*/
async denyUser(pubkey, options) {
return this.contribute("user-deny", [{ type: "user", pubkey }], options);
}
/**
* Allow a user.
* @param {string} pubkey
* @returns {Promise<CommandResult>}
*/
async allowUser(pubkey) {
return this.contribute("user-allow", [{ type: "user", pubkey }]);
}
/**
* Deny an exact event.
* @param {string} id
* @returns {Promise<CommandResult>}
*/
async denyEvent(id) {
return this.contribute("event-deny", [{ type: "event", id }]);
}
/**
* Deny an address coordinate.
* @param {string} kind
* @param {string} pubkey
* @param {string} identifier
* @returns {Promise<CommandResult>}
*/
async denyAddress(kind, pubkey, identifier) {
return this.contribute("address-deny", [{ type: "address", kind, pubkey, identifier }]);
}
/**
* Add trust seeds.
* @param {string[]} pubkeys
* @returns {Promise<CommandResult>}
*/
async addTrustSeeds(pubkeys) {
return this.contribute(
"trust-seed",
(pubkeys ?? []).map((pubkey) => ({ type: "user", pubkey })),
);
}
/**
* Publish the role roster. Requires `manage-roles`.
* @param {Object} roster
* @param {Record<string, string[]>} [roster.actors]
* @param {Record<string, string[]>} [roster.capabilities]
* @param {string[]} [roster.protectedActors]
* @returns {Promise<CommandResult>}
*/
async setRoles(roster) {
let actor;
try {
actor = await this.#actor();
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
if (!hasCapability(actor, "manage-roles", this.runtime.admin.authority)) {
return failure(ERROR_CODES.NOT_AUTHORIZED, "actor lacks manage-roles");
}
return this.#publish(encodeRoles(roster ?? {}, this.runtime.namespace));
}
/**
* Publish a policy document. Requires `manage-policy`.
* @param {import('@bitgate/core').PolicyDefinition} policy
* @returns {Promise<CommandResult>}
*/
async setPolicy(policy) {
let actor;
try {
actor = await this.#actor();
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
if (!hasCapability(actor, "manage-policy", this.runtime.admin.authority)) {
return failure(ERROR_CODES.NOT_AUTHORIZED, "actor lacks manage-policy");
}
if (!policy || typeof policy !== "object") {
return failure(ERROR_CODES.INVALID_ARGUMENT, "policy must be an object");
}
return this.#publish({
kind: 30078,
content: JSON.stringify(policy),
tags: [
["d", `${this.runtime.namespace}:governance:policy:v1`],
["v", "1"],
["client", "bitgate"],
["scope", "policy"],
],
});
}
/**
* Register a community source. Requires `manage-community-sources`.
* @param {Array<{ curator: string, identifier: string, kind: number }>} sources
* @returns {Promise<CommandResult>}
*/
async setCommunitySources(sources) {
let actor;
try {
actor = await this.#actor();
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
if (!hasCapability(actor, "manage-community-sources", this.runtime.admin.authority)) {
return failure(ERROR_CODES.NOT_AUTHORIZED, "actor lacks manage-community-sources");
}
const tags = [
["d", `${this.runtime.namespace}:governance:community-sources:v1`],
["v", "1"],
["client", "bitgate"],
["scope", "community-sources"],
];
for (const source of sources ?? []) {
tags.push(["a", `${source.kind}:${source.curator}:${source.identifier}`]);
}
return this.#publish({ kind: 30078, content: "", tags });
}
/**
* Publish a NIP-56 report.
*
* Reporting needs no capability: any account may report, and whether the
* report counts is decided by the viewer's trust graph at evaluation time.
*
* @param {import('@bitgate/core').GovernanceTarget} target
* @param {string} category
* @param {string} [content]
* @returns {Promise<CommandResult>}
*/
async report(target, category, content = "") {
if (!isValidTarget(target)) {
return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target));
}
let template;
try {
template = encodeReport(target, category, content);
} catch (error) {
return failure(ERROR_CODES.INVALID_ARGUMENT, String(error));
}
return this.#publish(template);
}
/**
* Publish a NIP-32 label for a target.
*
* Requires the same capability as denying the target directly, because a
* "deny" label from an authorized moderator has the same effect — it would be
* incoherent to gate the private form and not the shared one. The value and
* namespace are free, so an application can publish its own richer vocabulary
* (`"nsfw"`, `"scam"`) and decide elsewhere what each means.
*
* @param {import('@bitgate/core').GovernanceTarget} target
* @param {string} value - The label value, e.g. "deny" or "nsfw"
* @param {Object} [options]
* @param {string} [options.namespace]
* @param {string} [options.content] - Explanation
* @returns {Promise<CommandResult>}
*/
async publishLabel(target, value, { namespace, content = "" } = {}) {
if (!isValidTarget(target)) {
return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target));
}
const capability =
target.type === "user"
? "contribute-user-deny"
: target.type === "event"
? "contribute-event-deny"
: "contribute-address-deny";
let actor;
try {
actor = await this.#actor();
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
if (!hasCapability(actor, capability, this.runtime.admin.authority)) {
return failure(ERROR_CODES.NOT_AUTHORIZED, `actor lacks ${capability}`);
}
let template;
try {
template = encodeLabel({
value,
namespace: namespace ?? this.runtime.labelMapping?.namespace,
targets: [target],
content,
});
} catch (error) {
return failure(ERROR_CODES.INVALID_ARGUMENT, String(error));
}
return this.#publish(template);
}
/**
* Set a viewer override. Viewer-local, so no capability is required and
* nothing is published.
*
* @param {import('@bitgate/core').GovernanceTarget} target
* @param {string} visibility
* @param {Object} [options]
* @param {string} [options.reason]
* @param {number} [options.expiresAt]
* @returns {CommandResult}
*/
setOverride(target, visibility, { reason, expiresAt } = {}) {
if (!isValidTarget(target)) {
return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target));
}
this.runtime.overrides.set(getTargetKey(target), { visibility, reason, expiresAt });
return { ok: true };
}
/**
* Clear a viewer override.
* @param {import('@bitgate/core').GovernanceTarget} target
* @returns {CommandResult}
*/
clearOverride(target) {
if (!isValidTarget(target)) {
return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target));
}
this.runtime.overrides.remove(getTargetKey(target));
return { ok: true };
}
}
/**
* @param {import('./runtime.js').GovernanceRuntime} runtime
* @returns {GovernanceCommands}
*/
export function createCommands(runtime) {
return new GovernanceCommands(runtime);
}