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

90 lines
2.7 KiB
JavaScript

import { describe, expect, it } from "vitest";
import {
CONTACT_LIST_KIND,
decodeContactList,
encodeContactList,
} from "../src/contacts.js";
const OWNER = "a1".repeat(32);
const ALICE = "b2".repeat(32);
const BOB = "c3".repeat(32);
const NOW = 1_750_000_000;
const event = (parts) => ({
id: "00".repeat(32),
pubkey: OWNER,
kind: CONTACT_LIST_KIND,
created_at: NOW,
tags: [],
content: "",
...parts,
});
describe("decodeContactList", () => {
it("reads followed pubkeys", () => {
const list = decodeContactList(event({ tags: [["p", ALICE], ["p", BOB]] }));
expect(list?.owner).toBe(OWNER);
expect(list?.contacts).toEqual([ALICE, BOB]);
});
it("captures relay hints from the third tag position", () => {
const list = decodeContactList(
event({ tags: [["p", ALICE, "wss://alicerelay.com/", "alice"]] }),
);
expect(list?.relayHints.get(ALICE)).toBe("wss://alicerelay.com/");
});
it("tolerates missing relay and petname", () => {
const list = decodeContactList(event({ tags: [["p", ALICE, "", ""]] }));
expect(list?.contacts).toEqual([ALICE]);
expect(list?.relayHints.size).toBe(0);
});
it("deduplicates repeated follows", () => {
const list = decodeContactList(event({ tags: [["p", ALICE], ["p", ALICE]] }));
expect(list?.contacts).toEqual([ALICE]);
});
it("skips malformed pubkeys", () => {
const list = decodeContactList(event({ tags: [["p", "junk"], ["p", ALICE]] }));
expect(list?.contacts).toEqual([ALICE]);
});
it("ignores content, which NIP-02 does not use", () => {
const list = decodeContactList(
event({ tags: [["p", ALICE]], content: '{"wss://legacy.example":{"read":true}}' }),
);
expect(list?.contacts).toEqual([ALICE]);
});
it("rejects the wrong kind and a malformed owner", () => {
expect(decodeContactList(event({ kind: 10000 }))).toBeNull();
expect(decodeContactList(event({ pubkey: "nope" }))).toBeNull();
});
it("returns an empty list for someone following nobody", () => {
expect(decodeContactList(event({}))?.contacts).toEqual([]);
});
});
describe("encodeContactList", () => {
it("accepts bare pubkeys", () => {
expect(encodeContactList([ALICE]).tags).toEqual([["p", ALICE]]);
});
it("includes relay and petname when given", () => {
const template = encodeContactList([{ pubkey: ALICE, relay: "wss://r.example", petname: "alice" }]);
expect(template.tags).toEqual([["p", ALICE, "wss://r.example", "alice"]]);
});
it("round-trips", () => {
const template = encodeContactList([ALICE, BOB]);
expect(decodeContactList(event(template))?.contacts).toEqual([ALICE, BOB]);
});
it("skips malformed entries", () => {
expect(encodeContactList(["junk", ALICE]).tags).toEqual([["p", ALICE]]);
});
});