diff --git a/README.md b/README.md index 84b86f0..361a201 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/docs/adr/0007-nip32-labelling.md b/docs/adr/0007-nip32-labelling.md new file mode 100644 index 0000000..9ebee15 --- /dev/null +++ b/docs/adr/0007-nip32-labelling.md @@ -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. diff --git a/docs/labels.md b/docs/labels.md new file mode 100644 index 0000000..266ae79 --- /dev/null +++ b/docs/labels.md @@ -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. diff --git a/docs/nips.md b/docs/nips.md index 1c3c352..31e8203 100644 --- a/docs/nips.md +++ b/docs/nips.md @@ -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 diff --git a/package-lock.json b/package-lock.json index a157d44..2146f08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1857,6 +1857,7 @@ "version": "0.1.0", "dependencies": { "@bitgate/core": "*", + "@bitgate/nostr": "*", "@bitgate/runtime": "*", "@bitgate/verify": "*" } diff --git a/packages/nostr/src/index.js b/packages/nostr/src/index.js index 9550a65..d9260bd 100644 --- a/packages/nostr/src/index.js +++ b/packages/nostr/src/index.js @@ -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 */ diff --git a/packages/nostr/src/labels.js b/packages/nostr/src/labels.js new file mode 100644 index 0000000..db76b52 --- /dev/null +++ b/packages/nostr/src/labels.js @@ -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} [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, + }); +} diff --git a/packages/nostr/test/labels.test.js b/packages/nostr/test/labels.test.js new file mode 100644 index 0000000..a2bf623 --- /dev/null +++ b/packages/nostr/test/labels.test.js @@ -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"]); + }); +}); diff --git a/packages/runtime/src/commands.js b/packages/runtime/src/commands.js index 40d17e5..d0bc26e 100644 --- a/packages/runtime/src/commands.js +++ b/packages/runtime/src/commands.js @@ -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} + */ + 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. diff --git a/packages/runtime/src/runtime.js b/packages/runtime/src/runtime.js index 0bcd0e1..0ae64ca 100644 --- a/packages/runtime/src/runtime.js +++ b/packages/runtime/src/runtime.js @@ -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 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. * diff --git a/packages/runtime/test/labels.test.js b/packages/runtime/test/labels.test.js new file mode 100644 index 0000000..c263b38 --- /dev/null +++ b/packages/runtime/test/labels.test.js @@ -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(); + }); +}); diff --git a/packages/widget/package.json b/packages/widget/package.json index ee532a4..a6daf52 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -22,6 +22,7 @@ "dependencies": { "@bitgate/core": "*", "@bitgate/runtime": "*", - "@bitgate/verify": "*" + "@bitgate/verify": "*", + "@bitgate/nostr": "*" } } diff --git a/packages/widget/src/index.js b/packages/widget/src/index.js index b21201c..0e84329 100644 --- a/packages/widget/src/index.js +++ b/packages/widget/src/index.js @@ -32,6 +32,14 @@ export { collectTargets, } from "@bitgate/core"; +export { + LABEL_KIND, + decodeLabels, + labelsToContributions, + encodeLabel, + contributionToLabel, +} from "@bitgate/nostr"; + export { BASE_STYLES, GovernanceElement,