mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
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.
229 lines
7.2 KiB
JavaScript
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"]);
|
|
});
|
|
});
|