mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-11-02 03:21:01 +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.
159 lines
4.9 KiB
JavaScript
159 lines
4.9 KiB
JavaScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
RELAY_LIST_KIND,
|
|
decodeRelayList,
|
|
encodeRelayList,
|
|
groupAuthorsByWriteRelay,
|
|
normalizeRelayUrl,
|
|
} from "../src/relayList.js";
|
|
|
|
const ALICE = "a1".repeat(32);
|
|
const BOB = "b2".repeat(32);
|
|
const CAROL = "c3".repeat(32);
|
|
const NOW = 1_750_000_000;
|
|
|
|
const event = (parts) => ({
|
|
id: "00".repeat(32),
|
|
pubkey: ALICE,
|
|
kind: RELAY_LIST_KIND,
|
|
created_at: NOW,
|
|
tags: [],
|
|
content: "",
|
|
...parts,
|
|
});
|
|
|
|
describe("normalizeRelayUrl", () => {
|
|
it("strips trailing slashes and lowercases", () => {
|
|
expect(normalizeRelayUrl("WSS://Relay.Example/")).toBe("wss://relay.example");
|
|
});
|
|
|
|
it("accepts ws and wss only", () => {
|
|
expect(normalizeRelayUrl("ws://relay.example")).toBe("ws://relay.example");
|
|
expect(normalizeRelayUrl("https://relay.example")).toBe("");
|
|
expect(normalizeRelayUrl("relay.example")).toBe("");
|
|
});
|
|
|
|
it("returns empty for non-strings", () => {
|
|
expect(normalizeRelayUrl(null)).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("decodeRelayList", () => {
|
|
it("treats an unmarked relay as both read and write", () => {
|
|
const list = decodeRelayList(event({ tags: [["r", "wss://both.example"]] }));
|
|
expect(list?.read).toEqual(["wss://both.example"]);
|
|
expect(list?.write).toEqual(["wss://both.example"]);
|
|
});
|
|
|
|
it("honours read and write markers", () => {
|
|
const list = decodeRelayList(
|
|
event({
|
|
tags: [
|
|
["r", "wss://out.example", "write"],
|
|
["r", "wss://in.example", "read"],
|
|
],
|
|
}),
|
|
);
|
|
expect(list?.write).toEqual(["wss://out.example"]);
|
|
expect(list?.read).toEqual(["wss://in.example"]);
|
|
});
|
|
|
|
it("decodes the NIP-65 example event", () => {
|
|
const list = decodeRelayList(
|
|
event({
|
|
tags: [
|
|
["r", "wss://alicerelay.example.com"],
|
|
["r", "wss://brando-relay.com"],
|
|
["r", "wss://expensive-relay.example2.com", "write"],
|
|
["r", "wss://nostr-relay.example.com", "read"],
|
|
],
|
|
}),
|
|
);
|
|
expect(list?.write).toEqual([
|
|
"wss://alicerelay.example.com",
|
|
"wss://brando-relay.com",
|
|
"wss://expensive-relay.example2.com",
|
|
]);
|
|
expect(list?.read).toEqual([
|
|
"wss://alicerelay.example.com",
|
|
"wss://brando-relay.com",
|
|
"wss://nostr-relay.example.com",
|
|
]);
|
|
});
|
|
|
|
it("skips malformed relay URLs", () => {
|
|
const list = decodeRelayList(
|
|
event({ tags: [["r", "not-a-relay"], ["r", "wss://good.example"]] }),
|
|
);
|
|
expect(list?.write).toEqual(["wss://good.example"]);
|
|
});
|
|
|
|
it("rejects the wrong kind and malformed authors", () => {
|
|
expect(decodeRelayList(event({ kind: 10000 }))).toBeNull();
|
|
expect(decodeRelayList(event({ pubkey: "nope" }))).toBeNull();
|
|
});
|
|
|
|
it("round-trips through the encoder", () => {
|
|
const template = encodeRelayList({
|
|
both: ["wss://both.example"],
|
|
write: ["wss://out.example"],
|
|
read: ["wss://in.example"],
|
|
});
|
|
const list = decodeRelayList(event(template));
|
|
expect(list?.write).toEqual(["wss://both.example", "wss://out.example"]);
|
|
expect(list?.read).toEqual(["wss://both.example", "wss://in.example"]);
|
|
});
|
|
});
|
|
|
|
describe("groupAuthorsByWriteRelay", () => {
|
|
const lists = new Map([
|
|
[ALICE, { pubkey: ALICE, updatedAt: NOW, read: [], write: ["wss://one.example"] }],
|
|
[BOB, { pubkey: BOB, updatedAt: NOW, read: [], write: ["wss://one.example", "wss://two.example"] }],
|
|
]);
|
|
|
|
it("groups authors by the relays they write to", () => {
|
|
const grouped = groupAuthorsByWriteRelay([ALICE, BOB], lists);
|
|
expect(grouped.get("wss://one.example")?.sort()).toEqual([ALICE, BOB].sort());
|
|
expect(grouped.get("wss://two.example")).toEqual([BOB]);
|
|
});
|
|
|
|
it("falls back for authors with no published list", () => {
|
|
// Dropping them would silently exclude everyone who has not published a
|
|
// kind:10002, which is a large share of accounts.
|
|
const grouped = groupAuthorsByWriteRelay([CAROL], lists, {
|
|
fallback: ["wss://default.example"],
|
|
});
|
|
expect(grouped.get("wss://default.example")).toEqual([CAROL]);
|
|
});
|
|
|
|
it("caps how many relays are queried per author", () => {
|
|
const many = new Map([
|
|
[
|
|
ALICE,
|
|
{
|
|
pubkey: ALICE,
|
|
updatedAt: NOW,
|
|
read: [],
|
|
write: ["wss://a.example", "wss://b.example", "wss://c.example", "wss://d.example"],
|
|
},
|
|
],
|
|
]);
|
|
const grouped = groupAuthorsByWriteRelay([ALICE], many, { maxRelaysPerAuthor: 2 });
|
|
expect(grouped.size).toBe(2);
|
|
});
|
|
|
|
it("deduplicates an author repeated in the input", () => {
|
|
const grouped = groupAuthorsByWriteRelay([ALICE, ALICE], lists);
|
|
expect(grouped.get("wss://one.example")).toEqual([ALICE]);
|
|
});
|
|
|
|
it("skips malformed pubkeys", () => {
|
|
expect(groupAuthorsByWriteRelay(["junk"], lists).size).toBe(0);
|
|
});
|
|
|
|
it("returns nothing when there is nowhere to look", () => {
|
|
expect(groupAuthorsByWriteRelay([CAROL], lists).size).toBe(0);
|
|
});
|
|
});
|