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.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-10 10:01:34 -04:00
1 parent 7cc5756c15
commit 3220889f01
13 files changed
+983 -11

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ equivalent to following the operator's seed list.
| Package | Purpose |
| --- | --- |
| `@bitgate/core` | Pure evaluation. No I/O, no browser globals, no dependencies. |
| `@bitgate/nostr` | Codecs: canonical v1, NIP-56, NIP-51, legacy lists, bech32. |
| `@bitgate/nostr` | Codecs: canonical v1, NIP-56, NIP-51, NIP-32, NIP-65, NIP-02, bech32. |
| `@bitgate/runtime` | Stores, transport orchestration, commands. |
| `@bitgate/verify` | Optional NIP-01 signature verification, so the core stays dependency-free. |
| `@bitgate/testing` | Conformance harness. |
+54
View File
@@ -0,0 +1,54 @@
# ADR 0007 — NIP-32 labelling as the interop surface
**Status:** accepted
## Context
BitGate read two standard event kinds (NIP-56 reports, NIP-51 mutes) but
published its moderation output as a private vocabulary — contribution lists
under a `bitgate`-namespaced `d` identifier. Other clients could not act on a
BitGate deployment's moderator decisions, and BitGate could not act on anyone
else's. For a project whose stated goal is being a reusable building block,
that made it an island.
NIP-32 (kind 1985) is the shared vocabulary for moderation labels: an `L`
namespace, an `l` value, and `e`/`p`/`a` targets.
## Decision
Treat a label as **just another wire format for a contribution**, subject to
the identical capability gate.
A NIP-32 label is `(namespace, value, target)`. A BitGate contribution is
`(kind, target)` that only takes effect if its author holds the matching
capability. The bridge is `labelsToContributions(labels, mapping)`, where the
application supplies the namespace it trusts and the label values that mean
deny or allow. A matching label becomes a contribution authored by the
labeller, with a `source` marker, and reduces exactly like a community source:
it denies someone only if the labeller holds the capability in the roster.
The codec itself carries no vocabulary. "MIT", "nsfw", and "spam" are all
labels; whether any of them means denial is a policy decision that lives in the
caller's `labelMapping`, not in the codec. This is what keeps it a building
block: an application brings its own namespace and its own words.
- **Consume:** `ingestEvent` maps kind 1985 through the configured
`labelMapping`; `loadLabels(labellers)` fetches them.
- **Emit:** `commands.publishLabel(target, value)`, gated by the same
capability as denying the target, so the shared form is no weaker than the
private one.
## Consequences
A BitGate deployment can honour a third-party labeller by adding them to the
roster with a contribution capability — no new trust mechanism, because labels
reuse the one that already exists. Other clients can read a deployment's
`deny` labels. The two directions round-trip.
Because labels are administrative state, they fail closed without signature
verification, like every other administrative document.
`r` (relay) and `t` (topic) label targets are dropped rather than mapped: they
have no place in an allow/deny model, and representing them as something else
would be worse than ignoring them. An application that cares about topic labels
can consume the codec's generic `decodeLabels` output directly.
+62
View File
@@ -0,0 +1,62 @@
# Interoperable moderation with NIP-32
BitGate reads and writes NIP-32 labels (kind 1985), so a deployment's
moderators can be honoured by other Nostr clients, and BitGate can act on
labels published by others.
## The model
A label is `(namespace, value, target)`. It carries no built-in meaning — a
`"deny"` label and an `"nsfw"` label are the same kind of object. What a label
means to *your* application is a policy decision you supply:
```js
createBitGate({
namespace: "myapp",
labelMapping: {
namespace: "com.myapp.moderation", // only labels in this namespace map
denyValues: ["deny", "spam", "scam"], // your vocabulary for "hide this"
allowValues: ["trusted"],
},
…
});
```
A label matching that mapping becomes a **contribution**, authored by the
labeller. It denies someone only if the labeller holds the capability in your
roster — exactly like a community source. An untrusted labeller's `deny` does
nothing.
## Consuming a third-party labeller
Add the labeller to the roster with a contribution capability, then fetch their
labels:
```js
runtime.admin.setRoles({
root: ROOT,
roles: { external_labeller: ["contribute-user-deny", "contribute-event-deny"] },
actors: { [ROOT]: ["super_admin"], [LABELLER_PUBKEY]: ["external_labeller"] },
});
await runtime.loadLabels([LABELLER_PUBKEY]);
```
## Publishing labels
A moderator publishes a label with the same capability that lets them deny
directly:
```js
await commands.publishLabel({ type: "user", pubkey }, "deny");
await commands.publishLabel({ type: "event", id }, "nsfw", { content: "graphic" });
```
Other clients that understand your namespace can then act on it.
## Generic use
The codec is not BitGate-specific. `decodeLabels(event)` returns every label
(namespace, value, targets) regardless of vocabulary, so an application can
consume topic or licence labels directly without going through the allow/deny
mapping.
+1 -8
View File
@@ -11,7 +11,7 @@ this table for anything load-bearing.
| **NIP-01** | — | Event shape, replaceable selection with a deterministic same-timestamp tiebreak, and signature verification via `@bitgate/verify` (id recomputed, then schnorr-checked) |
| **NIP-02** | 3 | The viewer's follow list *is* the trust graph. Relay hints in the third tag position are captured as an outbox fallback. |
| **NIP-19** | — | bech32 `npub`/`note` decoding at the codec edge, so the core stays hex-only |
| **NIP-32** | 1985 | **Not yet** — see gaps below |
| **NIP-32** | 1985 | Labels, both directions. Consumed as capability-gated contributions via a configurable namespace/vocabulary; emitted with `publishLabel`. See [ADR 0007](adr/0007-nip32-labelling.md). |
| **NIP-51** | 10000 | Mute lists. Public `p` entries always; the viewer's *own* private entries decrypted through their signer. |
| **NIP-44** | — | Decryption of the viewer's own private mute entries, delegated to the signer |
| **NIP-56** | 1984 | Reports. The report type is read from the 3rd tag position, with fallbacks for clients that place it elsewhere. |
@@ -40,13 +40,6 @@ injected verifier before anything authoritative is accepted.
## Known gaps
**NIP-32 labelling (kind 1985) — the interop gap.** BitGate's contribution
lists are a private vocabulary. NIP-32 is the standard way to publish
moderation labels (`L` namespace, `l` value, targeting `e`/`p`/`a`), and our
user/event/address denials map onto it almost exactly. Emitting NIP-32 would
let other clients honour these moderators; consuming it would let BitGate
subscribe to third-party label sets. This is the highest-value remaining work.
**Trust is binary.** `resolveTrustSet` is "in the viewer's follows, or not".
Most things called web-of-trust weight by hop distance — follows-of-follows at
reduced weight. Adding depth naively would be wrong: a two-hop reporter would
+1
View File
@@ -1857,6 +1857,7 @@
"version": "0.1.0",
"dependencies": {
"@bitgate/core": "*",
"@bitgate/nostr": "*",
"@bitgate/runtime": "*",
"@bitgate/verify": "*"
}
+11
View File
@@ -70,6 +70,15 @@ export {
encodeContactList,
} from "./contacts.js";
export {
LABEL_KIND,
labelTargets,
decodeLabels,
labelsToContributions,
encodeLabel,
contributionToLabel,
} from "./labels.js";
export {
LEGACY_KIND,
LEGACY_IDENTIFIERS,
@@ -89,4 +98,6 @@ export {
* @typedef {import('./mutes.js').DecodedMuteList} DecodedMuteList
* @typedef {import('./relayList.js').RelayList} RelayList
* @typedef {import('./contacts.js').ContactList} ContactList
* @typedef {import('./labels.js').Label} Label
* @typedef {import('./labels.js').LabelMapping} LabelMapping
*/
+259
View File
@@ -0,0 +1,259 @@
// NIP-32 labelling (kind 1985).
//
// This is BitGate's interop surface. Contribution lists are a private
// vocabulary; NIP-32 is the shared one. Emitting labels lets other clients
// honour this deployment's moderators, and consuming them lets BitGate treat a
// third-party labeller as a moderation source.
//
// The codec is deliberately general. A label is (namespace, value, target) and
// says nothing about denial on its own — "MIT", "nsfw", and "spam" are all
// labels. What a given label *means* to an application is a policy decision, so
// the mapping to BitGate's allow/deny model is data-driven and lives in the
// caller's configuration, not baked in here.
import { normalizeEventIdInput, normalizePubkeyInput } from "./nip19.js";
import { getTags } from "./replaceable.js";
import { decodeAddressCoordinate } from "./canonical.js";
/**
* @typedef {import('./replaceable.js').NostrEvent} NostrEvent
* @typedef {import('@bitgate/core').GovernanceTarget} GovernanceTarget
* @typedef {import('@bitgate/core').Contribution} Contribution
*/
/** NIP-32 label kind. */
export const LABEL_KIND = 1985;
/**
* @typedef {Object} Label
* @property {string} namespace - The `L` value, or "" for an unnamespaced label
* @property {string} value - The `l` value
* @property {GovernanceTarget[]} targets - Everything the label points at
* @property {string} labeller - Pubkey that published the label
* @property {string} content - Free-text explanation, per NIP-32
* @property {number} createdAt
*/
/**
* Read the governance targets a label event points at.
*
* Supports `e`, `p`, and `a` — the target types BitGate governs. `r` (relays)
* and `t` (topics) are valid NIP-32 targets but have no place in an allow/deny
* model, so they are skipped rather than misrepresented as something they are
* not.
*
* @param {NostrEvent} event
* @returns {GovernanceTarget[]}
*/
export function labelTargets(event) {
/** @type {GovernanceTarget[]} */
const targets = [];
for (const tag of getTags(event, "p")) {
const pubkey = normalizePubkeyInput(tag[1]);
if (pubkey) {
targets.push({ type: "user", pubkey });
}
}
for (const tag of getTags(event, "e")) {
const id = normalizeEventIdInput(tag[1]);
if (id) {
targets.push({ type: "event", id });
}
}
for (const tag of getTags(event, "a")) {
const target = decodeAddressCoordinate(tag[1]);
if (target) {
targets.push(target);
}
}
return targets;
}
/**
* Decode a kind 1985 label event into its individual labels.
*
* One event can carry several `l` values across namespaces; each becomes its
* own {@link Label} so a consumer can filter by namespace and value
* independently. A label with no matching `L` mark keeps namespace "".
*
* @param {NostrEvent} event
* @returns {Label[]}
*/
export function decodeLabels(event) {
if (!event || event.kind !== LABEL_KIND) {
return [];
}
const labeller = normalizePubkeyInput(event.pubkey);
if (!labeller) {
return [];
}
const namespaces = new Set(getTags(event, "L").map((tag) => (typeof tag[1] === "string" ? tag[1] : "")));
const targets = labelTargets(event);
const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0;
const content = typeof event.content === "string" ? event.content : "";
/** @type {Label[]} */
const labels = [];
for (const tag of getTags(event, "l")) {
const value = typeof tag[1] === "string" ? tag[1].trim() : "";
if (!value) {
continue;
}
// Per NIP-32 the mark is the third element and references an `L` value.
// Honour it only when it matches a declared namespace; an unmatched mark is
// treated as unnamespaced rather than silently trusted.
const mark = typeof tag[2] === "string" ? tag[2].trim() : "";
const namespace = mark && namespaces.has(mark) ? mark : "";
labels.push({ namespace, value, targets, labeller, content, createdAt });
}
return labels;
}
/**
* @typedef {Object} LabelMapping
* @property {string} [namespace] - Only labels in this namespace map; "" matches unnamespaced
* @property {string[]} [denyValues] - Label values that mean "deny the target"
* @property {string[]} [allowValues] - Label values that mean "allow the target"
*/
/**
* Turn decoded labels into BitGate contributions.
*
* This is where an application's vocabulary is applied. Only labels in the
* configured namespace whose value is in `denyValues`/`allowValues` produce a
* contribution; everything else is ignored, because most labels are
* categorisation rather than moderation.
*
* The resulting contributions are *not* privileged by being labels. They still
* pass through the same capability gate as any contribution when reduced, so a
* label only denies someone if its author holds the capability — exactly like a
* community source. The `source` marker records which labeller it came from.
*
* @param {Label[]} labels
* @param {LabelMapping} [mapping]
* @returns {Contribution[]}
*/
export function labelsToContributions(labels, mapping = {}) {
const namespace = mapping.namespace ?? "";
const denyValues = new Set((mapping.denyValues ?? ["deny"]).map((value) => value.toLowerCase()));
const allowValues = new Set((mapping.allowValues ?? []).map((value) => value.toLowerCase()));
/** @type {Contribution[]} */
const contributions = [];
for (const label of labels ?? []) {
if (label.namespace !== namespace) {
continue;
}
const value = label.value.toLowerCase();
const isDeny = denyValues.has(value);
const isAllow = allowValues.has(value);
if (!isDeny && !isAllow) {
continue;
}
// A label points at mixed target types; each maps to the contribution kind
// that fits it. Allow is only meaningful for users, matching the allowlist
// model, so non-user allow targets are dropped.
for (const target of label.targets) {
/** @type {Contribution["kind"]|null} */
let kind = null;
if (isAllow) {
kind = target.type === "user" ? "user-allow" : null;
} else if (target.type === "user") {
kind = "user-deny";
} else if (target.type === "event") {
kind = "event-deny";
} else if (target.type === "address") {
kind = "address-deny";
}
if (!kind) {
continue;
}
contributions.push({
actor: label.labeller,
kind,
targets: [target],
createdAt: label.createdAt,
source: `label:${namespace || "-"}:${label.labeller}`,
});
}
}
return contributions;
}
/**
* Encode a label event template.
*
* @param {Object} label
* @param {string} label.value - The `l` value
* @param {string} [label.namespace] - The `L` namespace; omitted for an unnamespaced label
* @param {GovernanceTarget[]} label.targets
* @param {string} [label.content] - Explanation
* @param {Record<string, string>} [label.relayHints] - Target key to relay hint
* @returns {{ kind: number, content: string, tags: string[][] }}
*/
export function encodeLabel({ value, namespace, targets, content = "", relayHints = {} }) {
if (typeof value !== "string" || !value.trim()) {
throw new Error("A label requires a non-empty value");
}
/** @type {string[][]} */
const tags = [];
if (namespace && namespace.trim()) {
tags.push(["L", namespace.trim()]);
tags.push(["l", value.trim(), namespace.trim()]);
} else {
tags.push(["l", value.trim()]);
}
for (const target of targets ?? []) {
if (target.type === "user") {
const hint = relayHints[`user:${target.pubkey}`];
tags.push(hint ? ["p", target.pubkey, hint] : ["p", target.pubkey]);
} else if (target.type === "event") {
const hint = relayHints[`event:${target.id}`];
tags.push(hint ? ["e", target.id, hint] : ["e", target.id]);
} else if (target.type === "address") {
tags.push(["a", `${target.kind}:${target.pubkey}:${target.identifier}`]);
}
}
return { kind: LABEL_KIND, content, tags };
}
/**
* Encode a BitGate contribution as a NIP-32 label, for publishing.
*
* The inverse of {@link labelsToContributions}: a deny/allow contribution
* becomes a label other clients can read. The label value defaults to the
* verb, so a plain consumer sees a `deny`/`allow` label in the given namespace.
*
* @param {Contribution} contribution
* @param {Object} options
* @param {string} options.namespace
* @param {string} [options.denyValue] - Label value used for denials
* @param {string} [options.allowValue] - Label value used for allowances
* @param {string} [options.content]
* @returns {{ kind: number, content: string, tags: string[][] }}
*/
export function contributionToLabel(contribution, { namespace, denyValue = "deny", allowValue = "allow", content = "" }) {
const value = contribution.kind === "user-allow" ? allowValue : denyValue;
return encodeLabel({
value,
namespace,
targets: contribution.targets ?? [],
content,
});
}
+228
View File
@@ -0,0 +1,228 @@
import { describe, expect, it } from "vitest";
import {
LABEL_KIND,
contributionToLabel,
decodeLabels,
encodeLabel,
labelTargets,
labelsToContributions,
} from "../src/labels.js";
const LABELLER = "b2".repeat(32);
const USER = "d4".repeat(32);
const EVENT_ID = "1b".repeat(32);
const NOW = 1_750_000_000;
const event = (parts) => ({
id: "00".repeat(32),
pubkey: LABELLER,
kind: LABEL_KIND,
created_at: NOW,
tags: [],
content: "",
...parts,
});
describe("decodeLabels", () => {
it("decodes the NIP-32 example shape", () => {
const labels = decodeLabels(
event({
tags: [
["L", "license"],
["l", "MIT", "license"],
["e", EVENT_ID, "wss://relay.example"],
],
content: "explanation",
}),
);
expect(labels).toHaveLength(1);
expect(labels[0]).toMatchObject({ namespace: "license", value: "MIT", content: "explanation" });
expect(labels[0].targets).toEqual([{ type: "event", id: EVENT_ID }]);
});
it("splits several l values into separate labels", () => {
const labels = decodeLabels(
event({
tags: [
["L", "com.app.mod"],
["l", "deny", "com.app.mod"],
["l", "nsfw"],
["p", USER],
],
}),
);
expect(labels.map((label) => ({ ns: label.namespace, v: label.value }))).toEqual([
{ ns: "com.app.mod", v: "deny" },
{ ns: "", v: "nsfw" },
]);
});
it("treats an l mark that matches no L as unnamespaced", () => {
const labels = decodeLabels(event({ tags: [["l", "deny", "unlisted"], ["p", USER]] }));
expect(labels[0].namespace).toBe("");
});
it("reads all governed target types", () => {
const targets = labelTargets(
event({
tags: [
["p", USER],
["e", EVENT_ID],
["a", `30023:${USER}:sku`],
],
}),
);
expect(targets.map((t) => t.type)).toEqual(["user", "event", "address"]);
});
it("skips relay and topic targets, which have no allow/deny meaning", () => {
const targets = labelTargets(event({ tags: [["r", "wss://x"], ["t", "politics"], ["p", USER]] }));
expect(targets).toEqual([{ type: "user", pubkey: USER }]);
});
it("rejects the wrong kind and a malformed author", () => {
expect(decodeLabels(event({ kind: 1 }))).toEqual([]);
expect(decodeLabels(event({ pubkey: "nope", tags: [["l", "deny"]] }))).toEqual([]);
});
it("ignores empty label values", () => {
expect(decodeLabels(event({ tags: [["l", " "], ["p", USER]] }))).toEqual([]);
});
});
describe("labelsToContributions", () => {
const labels = () =>
decodeLabels(
event({
tags: [
["L", "com.app.mod"],
["l", "deny", "com.app.mod"],
["l", "nsfw", "com.app.mod"],
["p", USER],
["e", EVENT_ID],
],
}),
);
it("maps a deny label to a deny contribution per target", () => {
const contributions = labelsToContributions(labels(), {
namespace: "com.app.mod",
denyValues: ["deny"],
});
expect(contributions.map((c) => c.kind).sort()).toEqual(["event-deny", "user-deny"]);
expect(contributions.every((c) => c.actor === LABELLER)).toBe(true);
});
it("records the labeller as the source", () => {
const [contribution] = labelsToContributions(labels(), {
namespace: "com.app.mod",
denyValues: ["deny"],
});
expect(contribution.source).toBe(`label:com.app.mod:${LABELLER}`);
});
it("ignores labels outside the configured namespace", () => {
expect(labelsToContributions(labels(), { namespace: "other", denyValues: ["deny"] })).toEqual([]);
});
it("ignores label values not in the vocabulary", () => {
// "nsfw" is a categorisation label, not a denial, unless the app says so.
const contributions = labelsToContributions(labels(), {
namespace: "com.app.mod",
denyValues: ["deny"],
});
expect(contributions).toHaveLength(2);
});
it("lets an application define its own deny vocabulary", () => {
const contributions = labelsToContributions(labels(), {
namespace: "com.app.mod",
denyValues: ["nsfw"],
});
expect(contributions).toHaveLength(2);
expect(contributions.every((c) => c.kind.endsWith("-deny"))).toBe(true);
});
it("maps allow only for users", () => {
const allowLabels = decodeLabels(
event({
tags: [["L", "ns"], ["l", "allow", "ns"], ["p", USER], ["e", EVENT_ID]],
}),
);
const contributions = labelsToContributions(allowLabels, {
namespace: "ns",
allowValues: ["allow"],
});
expect(contributions).toEqual([
expect.objectContaining({ kind: "user-allow", targets: [{ type: "user", pubkey: USER }] }),
]);
});
it("is case-insensitive on the value", () => {
const upper = decodeLabels(event({ tags: [["L", "ns"], ["l", "DENY", "ns"], ["p", USER]] }));
expect(labelsToContributions(upper, { namespace: "ns", denyValues: ["deny"] })).toHaveLength(1);
});
});
describe("encodeLabel and round-trip", () => {
it("encodes a namespaced label", () => {
const template = encodeLabel({ value: "deny", namespace: "ns", targets: [{ type: "user", pubkey: USER }] });
expect(template.tags).toEqual([["L", "ns"], ["l", "deny", "ns"], ["p", USER]]);
});
it("encodes an unnamespaced label without an L tag", () => {
const template = encodeLabel({ value: "spam", targets: [{ type: "user", pubkey: USER }] });
expect(template.tags).toEqual([["l", "spam"], ["p", USER]]);
});
it("includes relay hints when given", () => {
const template = encodeLabel({
value: "deny",
namespace: "ns",
targets: [{ type: "user", pubkey: USER }],
relayHints: { [`user:${USER}`]: "wss://r.example" },
});
expect(template.tags).toContainEqual(["p", USER, "wss://r.example"]);
});
it("encodes an address target", () => {
const template = encodeLabel({
value: "deny",
namespace: "ns",
targets: [{ type: "address", kind: "30023", pubkey: USER, identifier: "sku" }],
});
expect(template.tags).toContainEqual(["a", `30023:${USER}:sku`]);
});
it("rejects an empty value", () => {
expect(() => encodeLabel({ value: " ", targets: [] })).toThrow(/non-empty/);
});
it("round-trips a deny back to a contribution", () => {
const template = encodeLabel({ value: "deny", namespace: "ns", targets: [{ type: "user", pubkey: USER }] });
const contributions = labelsToContributions(decodeLabels(event(template)), {
namespace: "ns",
denyValues: ["deny"],
});
expect(contributions).toEqual([
expect.objectContaining({ kind: "user-deny", targets: [{ type: "user", pubkey: USER }] }),
]);
});
it("encodes a contribution as a label", () => {
const template = contributionToLabel(
{ actor: LABELLER, kind: "user-deny", targets: [{ type: "user", pubkey: USER }] },
{ namespace: "ns" },
);
expect(template.tags).toEqual([["L", "ns"], ["l", "deny", "ns"], ["p", USER]]);
});
it("encodes an allow contribution with the allow value", () => {
const template = contributionToLabel(
{ actor: LABELLER, kind: "user-allow", targets: [{ type: "user", pubkey: USER }] },
{ namespace: "ns", allowValue: "trusted" },
);
expect(template.tags).toContainEqual(["l", "trusted", "ns"]);
});
});
+54 -1
View File
@@ -12,7 +12,7 @@ import {
isProtectedActor,
isValidTarget,
} from "@bitgate/core";
import { encodeContribution, encodeReport, encodeRoles } from "@bitgate/nostr";
import { encodeContribution, encodeLabel, encodeReport, encodeRoles } from "@bitgate/nostr";
/**
* Stable error codes. Applications match on these rather than on message text.
@@ -345,6 +345,59 @@ export class GovernanceCommands {
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.
+73
View File
@@ -24,6 +24,7 @@ import {
REPORT_KIND,
decodeContactList,
decodeContribution,
decodeLabels,
decodeLegacyList,
decodeMuteList,
decodePolicy,
@@ -32,8 +33,10 @@ import {
decodeReport,
decodeRoles,
groupAuthorsByWriteRelay,
labelsToContributions,
selectReplaceable,
verifyEvents,
LABEL_KIND,
} from "@bitgate/nostr";
import { snapshotFingerprint } from "@bitgate/core";
@@ -143,6 +146,8 @@ export class GovernanceRuntime extends Emitter {
* @param {boolean} [options.trustUnsignedEvents] - Accept administrative events
* without a verifier. Development and tests only.
* @param {string} [options.root] - Root administrator pubkey, from deployment config
* @param {import('@bitgate/nostr').LabelMapping} [options.labelMapping] - Which NIP-32
* label namespace and values to treat as moderation
* @param {number} [options.maxCachedDecisions] - Ceiling on cached decisions
* @param {number} [options.maxReportTargets] - Ceiling on targets held by the report store
* @param {number} [options.maxMuteLists] - Ceiling on mute lists held
@@ -160,6 +165,7 @@ export class GovernanceRuntime extends Emitter {
verifySignature,
trustUnsignedEvents,
root,
labelMapping,
maxCachedDecisions = 5_000,
maxReportTargets = 20_000,
maxMuteLists = 5_000,
@@ -197,6 +203,18 @@ export class GovernanceRuntime extends Emitter {
/** @type {string[]} */
this.defaultRelays = Array.isArray(transport.relays) ? [...transport.relays] : [];
// Which NIP-32 label namespace and vocabulary this deployment reads as
// moderation. Defaults to the app namespace with a plain "deny"/"allow"
// vocabulary, so BitGate reads its own emitted labels; an application
// consuming a third-party labeller overrides it. A label only ever denies
// when its author holds the capability, exactly like any contribution.
/** @type {import('@bitgate/nostr').LabelMapping} */
this.labelMapping = labelMapping ?? {
namespace: namespace,
denyValues: ["deny"],
allowValues: ["allow"],
};
this.admin = new GovernanceAdminStore();
this.trust = new TrustGraphStore();
this.reports = new ReportStore({ maxTargets: maxReportTargets });
@@ -437,6 +455,23 @@ export class GovernanceRuntime extends Emitter {
}
}
if (event.kind === LABEL_KIND) {
// NIP-32 labels are a shared wire format for the same allow/deny model.
// They map to contributions and are gated identically: a label denies
// someone only if its author holds the capability at reduce time.
const contributions = labelsToContributions(decodeLabels(event), this.labelMapping);
if (!contributions.length) {
return false;
}
if (!this.#adminIngestAllowed()) {
return false;
}
for (const contribution of contributions) {
this.admin.upsertContribution(contribution);
}
return true;
}
this.diagnostics.unknownEvents += 1;
return false;
}
@@ -820,6 +855,44 @@ export class GovernanceRuntime extends Emitter {
return learned;
}
/**
* Load NIP-32 labels published by a set of labellers.
*
* Labels only take effect for labellers the roster grants a contribution
* capability, so this is safe to point at any labeller: an untrusted one's
* labels reduce to nothing. Fetched from the configured relays.
*
* @param {string[]} labellers - Pubkeys whose labels to read
* @returns {Promise<number>} Number of label events ingested
*/
async loadLabels(labellers) {
const authors = Array.from(
new Set((labellers ?? []).map((key) => normalizePubkey(key)).filter(Boolean)),
);
if (!authors.length) {
return 0;
}
let ingested = 0;
for (const chunkOfAuthors of chunk(authors, this.chunkSize)) {
let events;
try {
events = await this.transport.list([{ kinds: [LABEL_KIND], authors: chunkOfAuthors }]);
} catch (error) {
this.emit("stale", { reason: "labels-unreachable", error: String(error) });
continue;
}
for (const event of selectReplaceable(await this.#verified(events)).values()) {
if (this.ingestEvent(event, { verified: true })) {
ingested += 1;
}
}
}
return ingested;
}
/**
* Load the viewer's follow list and adopt it as the trust graph.
*
+229
View File
@@ -0,0 +1,229 @@
// NIP-32 label ingestion in the runtime.
//
// The property that matters most: a label is a contribution, so it is subject
// to the identical capability gate. An untrusted labeller's "deny" must do
// nothing.
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@bitgate/core";
import { LABEL_KIND, encodeLabel } from "@bitgate/nostr";
import { createBitGate, createCommands, createMemoryTransport } from "@bitgate/runtime";
const ROOT = "a1".repeat(32);
const MODERATOR = "b2".repeat(32);
const STRANGER = "f6".repeat(32);
const VICTIM = "d4".repeat(32);
const NOW = 1_750_000_000;
const POLICY = createPolicyDefinition({
id: "labels",
version: "1.0.0",
defaultProfile: "feed",
profiles: {
feed: {
name: "feed",
administrativeDeny: { visibility: "hide", interaction: "deny" },
reports: {},
mutes: {},
},
},
});
function makeRuntime(options = {}) {
const runtime = createBitGate({
applicationId: "labels",
namespace: "app",
root: ROOT,
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
trustUnsignedEvents: true,
labelMapping: { namespace: "app.mod", denyValues: ["deny"], allowValues: ["allow"] },
...options,
});
runtime.admin.setRoles({
root: ROOT,
actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] },
});
return runtime;
}
const labelEvent = (pubkey, value, target, id = "01") => ({
id: id.repeat(32),
pubkey,
created_at: NOW,
...encodeLabel({ value, namespace: "app.mod", targets: [target] }),
});
describe("label ingestion", () => {
it("denies a user when an authorized moderator labels them", () => {
const runtime = makeRuntime();
const accepted = runtime.ingestEvent(labelEvent(MODERATOR, "deny", { type: "user", pubkey: VICTIM }));
expect(accepted).toBe(true);
expect(runtime.admin.state.userDeny.has(`user:${VICTIM}`)).toBe(true);
expect(runtime.evaluate({ type: "user", pubkey: VICTIM }, { profile: "feed" }).visibility.effect).toBe(
"hide",
);
});
it("does nothing when the labeller lacks the capability", () => {
// The whole safety story: a label is not privileged. A stranger's deny
// label reduces to nothing because they hold no capability.
const runtime = makeRuntime();
runtime.ingestEvent(labelEvent(STRANGER, "deny", { type: "user", pubkey: VICTIM }));
expect(runtime.admin.state.userDeny.has(`user:${VICTIM}`)).toBe(false);
});
it("stops honouring a labeller's labels once their role is revoked", () => {
const runtime = makeRuntime();
runtime.ingestEvent(labelEvent(MODERATOR, "deny", { type: "user", pubkey: VICTIM }));
expect(runtime.admin.state.userDeny.size).toBe(1);
runtime.admin.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
expect(runtime.admin.state.userDeny.size).toBe(0);
});
it("ignores labels outside the configured namespace", () => {
const runtime = makeRuntime({ labelMapping: { namespace: "other", denyValues: ["deny"] } });
expect(runtime.ingestEvent(labelEvent(MODERATOR, "deny", { type: "user", pubkey: VICTIM }))).toBe(
false,
);
expect(runtime.admin.state.userDeny.size).toBe(0);
});
it("records the labeller as a community source", () => {
const runtime = makeRuntime();
runtime.ingestEvent(labelEvent(MODERATOR, "deny", { type: "user", pubkey: VICTIM }));
expect(runtime.admin.state.communitySources.get(`user:${VICTIM}`)).toBeDefined();
});
it("maps an event-deny label", () => {
const runtime = makeRuntime();
const eventId = "1b".repeat(32);
runtime.ingestEvent(labelEvent(MODERATOR, "deny", { type: "event", id: eventId }));
expect(runtime.admin.state.eventDeny.has(`event:${eventId}`)).toBe(true);
});
it("fails closed without a verifier, like any administrative event", () => {
const runtime = createBitGate({
applicationId: "labels",
namespace: "app",
root: ROOT,
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
labelMapping: { namespace: "app.mod", denyValues: ["deny"] },
});
runtime.admin.setRoles({ root: ROOT, actors: { [MODERATOR]: ["moderator"] } });
expect(runtime.ingestEvent(labelEvent(MODERATOR, "deny", { type: "user", pubkey: VICTIM }))).toBe(
false,
);
});
});
describe("loadLabels", () => {
it("fetches and applies a labeller's labels", async () => {
const transport = createMemoryTransport([
labelEvent(MODERATOR, "deny", { type: "user", pubkey: VICTIM }),
]);
const runtime = createBitGate({
applicationId: "labels",
namespace: "app",
root: ROOT,
transport,
policy: POLICY,
now: () => NOW,
trustUnsignedEvents: true,
labelMapping: { namespace: "app.mod", denyValues: ["deny"] },
});
runtime.admin.setRoles({ root: ROOT, actors: { [MODERATOR]: ["moderator"] } });
expect(await runtime.loadLabels([MODERATOR])).toBe(1);
expect(runtime.admin.state.userDeny.has(`user:${VICTIM}`)).toBe(true);
});
it("does nothing for an empty labeller list", async () => {
expect(await makeRuntime().loadLabels([])).toBe(0);
});
});
describe("publishLabel command", () => {
function signerFor(pubkey) {
return {
async getPublicKey() {
return pubkey;
},
async signEvent(template) {
return { ...template, id: "ff".repeat(32), pubkey, sig: "00".repeat(64) };
},
};
}
function commandsAs(pubkey) {
const runtime = createBitGate({
applicationId: "labels",
namespace: "app",
root: ROOT,
transport: createMemoryTransport(),
signer: signerFor(pubkey),
policy: POLICY,
now: () => NOW,
labelMapping: { namespace: "app.mod", denyValues: ["deny"] },
});
runtime.admin.setRoles({ root: ROOT, actors: { [MODERATOR]: ["moderator"] } });
return { runtime, commands: createCommands(runtime) };
}
it("publishes a label for an authorized moderator", async () => {
const { commands } = commandsAs(MODERATOR);
const result = await commands.publishLabel({ type: "user", pubkey: VICTIM }, "deny");
expect(result.ok).toBe(true);
expect(result.event?.kind).toBe(LABEL_KIND);
});
it("refuses a labeller who lacks the capability", async () => {
const { commands } = commandsAs(STRANGER);
const result = await commands.publishLabel({ type: "user", pubkey: VICTIM }, "deny");
expect(result.code).toBe("not-authorized");
});
it("defaults the namespace to the runtime's label mapping", async () => {
const { commands } = commandsAs(MODERATOR);
const result = await commands.publishLabel({ type: "user", pubkey: VICTIM }, "deny");
expect(result.event?.tags).toContainEqual(["L", "app.mod"]);
});
it("rejects a malformed target", async () => {
const { commands } = commandsAs(MODERATOR);
const result = await commands.publishLabel(
/** @type {any} */ ({ type: "user", pubkey: "bad" }),
"deny",
);
expect(result.code).toBe("invalid-target");
});
it("round-trips: a published label denies when read back", async () => {
const { commands, runtime } = commandsAs(MODERATOR);
const result = await commands.publishLabel({ type: "user", pubkey: VICTIM }, "deny");
const fresh = createBitGate({
applicationId: "labels",
namespace: "app",
root: ROOT,
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
trustUnsignedEvents: true,
labelMapping: { namespace: "app.mod", denyValues: ["deny"] },
});
fresh.admin.setRoles({ root: ROOT, actors: { [MODERATOR]: ["moderator"] } });
fresh.ingestEvent(/** @type {any} */ ({ ...result.event, pubkey: MODERATOR }));
expect(fresh.admin.state.userDeny.has(`user:${VICTIM}`)).toBe(true);
expect(runtime).toBeDefined();
});
});
+2 -1
View File
@@ -22,6 +22,7 @@
"dependencies": {
"@bitgate/core": "*",
"@bitgate/runtime": "*",
"@bitgate/verify": "*"
"@bitgate/verify": "*",
"@bitgate/nostr": "*"
}
}
+8
View File
@@ -32,6 +32,14 @@ export {
collectTargets,
} from "@bitgate/core";
export {
LABEL_KIND,
decodeLabels,
labelsToContributions,
encodeLabel,
contributionToLabel,
} from "@bitgate/nostr";
export {
BASE_STYLES,
GovernanceElement,