mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-11-03 12:01:02 +00:00
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.
192 lines
6.1 KiB
JavaScript
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([]);
|
|
});
|
|
});
|