Files
bitgate/packages/nostr/test/labels.test.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

229 lines
7.2 KiB
JavaScript

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"]);
});
});