Files
bitgate/packages/nostr/test/mutes.test.js
thePR0M3TH3AN 7a45f1cf3e feat(nostr): outbox-model fetching, private mutes, contacts, NIP-56 vocabulary
Closes the four spec gaps from the audit, verified against the NIPs repo.

NIP-65 (kind 10002) — the one that was actually breaking things. Mute lists are
published by each contact to their own write relays, so querying a fixed relay
set finds only the subset who happen to write where this deployment reads. The
trust graph was silently under-fed and counts came out low, which looks like
"nobody muted them" rather than like a bug. Adds a relay-list codec, per-author
grouping with a fallback for accounts that have published no list, and
on-demand relay connections in the transport so an outbox query can reach
relays that were never configured.

NIP-51 private entries — the viewer's own private mutes are now decrypted
through their signer (NIP-44) and applied as viewer mutes. Someone who mutes
privately previously got no effect at all. The privacy boundary is asymmetric
and enforced at the codec: another account's private list is never decoded,
and the check lives there rather than in the caller's discipline.

NIP-02 (kind 3) — the runtime can build the trust graph itself, so
<bitgate-provider> no longer needs a host to supply a follow list. Set
trust="manual" to opt out.

NIP-56 vocabulary — SOCIAL_POLICY declared thresholds for "harassment", which
is not a NIP-56 type, so no standard client could ever trigger it. Replaced
with the defined vocabulary and asserted in tests. Commerce keeps its
marketplace-specific categories, now documented as deliberate, with defaults so
unclassified reports are never silently dropped.

Two bugs found by the new tests:
- checkout had no default threshold, so a flood of "other" reports did nothing
  at the one place money changes hands. Now earns review, never auto-denial.
- describe() spread diagnostics after a derived field, so a stale zero shadowed
  the live relay-list count.

Adds docs/nips.md recording coverage, deliberate choices, and remaining gaps —
NIP-32 labelling being the largest.

811 tests.
2026-08-09 20:52:21 -04:00

192 lines
6.1 KiB
JavaScript

import { describe, expect, it } from "vitest";
import {
MUTE_LIST_KIND,
decodeMuteList,
decodePrivateMuteEntries,
encodeMuteList,
extractMuteCategory,
toMuteRecords,
} from "../src/mutes.js";
const OWNER = "a1".repeat(32);
const OTHER_OWNER = "b2".repeat(32);
const CREATOR = "d4".repeat(32);
/**
* @param {Partial<import('../src/replaceable.js').NostrEvent>} parts
* @returns {import('../src/replaceable.js').NostrEvent}
*/
const event = (parts) => ({
id: "00".repeat(32),
pubkey: OWNER,
kind: MUTE_LIST_KIND,
created_at: 1000,
tags: [],
content: "",
...parts,
});
describe("extractMuteCategory", () => {
it("reads index 3", () => {
expect(extractMuteCategory(["p", CREATOR, "wss://relay.example", "Spam"])).toBe("spam");
});
it("falls back to index 2 when it is not a relay", () => {
expect(extractMuteCategory(["p", CREATOR, "harassment"])).toBe("harassment");
});
it("ignores a relay hint at index 2", () => {
expect(extractMuteCategory(["p", CREATOR, "wss://relay.example"])).toBe("");
});
it("returns empty for a bare tag", () => {
expect(extractMuteCategory(["p", CREATOR])).toBe("");
expect(extractMuteCategory(/** @type {any} */ (null))).toBe("");
});
});
describe("decodeMuteList", () => {
it("decodes public entries", () => {
const list = decodeMuteList(event({ tags: [["p", CREATOR]] }));
expect(list?.owner).toBe(OWNER);
expect(list?.entries).toEqual([{ pubkey: CREATOR }]);
});
it("carries the list timestamp for window expiry", () => {
expect(decodeMuteList(event({ created_at: 555, tags: [["p", CREATOR]] }))?.updatedAt).toBe(555);
});
it("attaches categories", () => {
const list = decodeMuteList(event({ tags: [["p", CREATOR, "", "spam"]] }));
expect(list?.entries[0]).toEqual({ pubkey: CREATOR, category: "spam" });
});
it("deduplicates repeated pubkeys", () => {
const list = decodeMuteList(event({ tags: [["p", CREATOR], ["p", CREATOR]] }));
expect(list?.entries).toHaveLength(1);
});
it("skips malformed pubkeys", () => {
const list = decodeMuteList(event({ tags: [["p", "short"], ["p", CREATOR]] }));
expect(list?.entries).toHaveLength(1);
});
it("flags encrypted entries without decoding them", () => {
const list = decodeMuteList(event({ content: "encrypted-blob", tags: [["p", CREATOR]] }));
expect(list?.hasEncryptedEntries).toBe(true);
expect(list?.entries).toHaveLength(1);
});
it("rejects the wrong kind", () => {
expect(decodeMuteList(event({ kind: 30000 }))).toBeNull();
});
it("rejects a malformed owner", () => {
expect(decodeMuteList(event({ pubkey: "nope" }))).toBeNull();
});
});
describe("toMuteRecords", () => {
it("keys records by governance target", () => {
const lists = [decodeMuteList(event({ tags: [["p", CREATOR]] }))];
const records = toMuteRecords(/** @type {any} */ (lists));
expect(records.get(`user:${CREATOR}`)).toEqual([{ muter: OWNER, updatedAt: 1000 }]);
});
it("collects distinct muters for one target", () => {
const lists = [
decodeMuteList(event({ tags: [["p", CREATOR]] })),
decodeMuteList(event({ pubkey: OTHER_OWNER, tags: [["p", CREATOR]] })),
];
expect(toMuteRecords(/** @type {any} */ (lists)).get(`user:${CREATOR}`)).toHaveLength(2);
});
it("preserves categories", () => {
const lists = [decodeMuteList(event({ tags: [["p", CREATOR, "", "spam"]] }))];
expect(toMuteRecords(/** @type {any} */ (lists)).get(`user:${CREATOR}`)?.[0].category).toBe("spam");
});
it("tolerates an empty input", () => {
expect(toMuteRecords([]).size).toBe(0);
});
});
describe("encodeMuteList", () => {
it("round-trips entries", () => {
const template = encodeMuteList([{ pubkey: CREATOR, category: "spam" }]);
const list = decodeMuteList(event(template));
expect(list?.entries[0]).toEqual({ pubkey: CREATOR, category: "spam" });
});
it("encodes a bare entry without a category slot", () => {
expect(encodeMuteList([{ pubkey: CREATOR }]).tags).toEqual([["p", CREATOR]]);
});
});
describe("decodePrivateMuteEntries", () => {
const PRIVATE = JSON.stringify([["p", CREATOR], ["p", OTHER_OWNER, "", "spam"], ["t", "topic"]]);
/** A signer stand-in: returns the plaintext for the owner's own ciphertext. */
const decrypt = async (pubkey, ciphertext) => {
if (pubkey !== OWNER || ciphertext !== "cipher") {
throw new Error("cannot decrypt");
}
return PRIVATE;
};
it("decodes the viewer's own private entries", async () => {
const entries = await decodePrivateMuteEntries(event({ content: "cipher" }), {
viewerPubkey: OWNER,
decrypt,
});
expect(entries).toEqual([{ pubkey: CREATOR }, { pubkey: OTHER_OWNER, category: "spam" }]);
});
it("refuses to decrypt someone else's list", async () => {
// The privacy boundary lives here rather than in the caller's discipline.
const attempted = await decodePrivateMuteEntries(
event({ pubkey: OTHER_OWNER, content: "cipher" }),
{ viewerPubkey: OWNER, decrypt },
);
expect(attempted).toEqual([]);
});
it("ignores non-p tags in the private payload", async () => {
const entries = await decodePrivateMuteEntries(event({ content: "cipher" }), {
viewerPubkey: OWNER,
decrypt,
});
expect(entries.every((entry) => typeof entry.pubkey === "string")).toBe(true);
});
it("degrades to nothing when decryption fails", async () => {
const entries = await decodePrivateMuteEntries(event({ content: "garbage" }), {
viewerPubkey: OWNER,
decrypt,
});
expect(entries).toEqual([]);
});
it("degrades to nothing without a decrypt function", async () => {
expect(
await decodePrivateMuteEntries(event({ content: "cipher" }), { viewerPubkey: OWNER }),
).toEqual([]);
});
it("returns nothing when there is no private half", async () => {
expect(
await decodePrivateMuteEntries(event({ content: "" }), { viewerPubkey: OWNER, decrypt }),
).toEqual([]);
});
it("rejects the wrong kind", async () => {
expect(
await decodePrivateMuteEntries(event({ kind: 30000, content: "cipher" }), {
viewerPubkey: OWNER,
decrypt,
}),
).toEqual([]);
});
});