Files
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

1.9 KiB

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:

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:

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:

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.