Files
bitgate/packages/nostr/src/relayList.js
T
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

178 lines
5.2 KiB
JavaScript

// NIP-65 relay list metadata (kind 10002).
//
// This is what makes trusted mutes actually work. Each contact publishes their
// mute list to *their own* write relays, so querying a fixed relay set finds
// only the subset who happen to write where you read. Without reading relay
// lists, a trust graph is silently under-fed and the counts come out low —
// which looks like "nobody muted them" rather than like a bug.
import { normalizePubkeyInput } from "./nip19.js";
import { getTags } from "./replaceable.js";
/**
* @typedef {import('./replaceable.js').NostrEvent} NostrEvent
*/
/** NIP-65 relay list metadata kind. */
export const RELAY_LIST_KIND = 10002;
/**
* @typedef {Object} RelayList
* @property {string} pubkey - Whose list this is
* @property {number} updatedAt
* @property {string[]} read - Relays they read from (where to find mentions of them)
* @property {string[]} write - Relays they write to (where to find their events)
*/
/**
* Normalize a relay URL for comparison.
*
* Relays are commonly written with and without a trailing slash, and casing
* varies. Treating those as different URLs would open duplicate connections to
* the same relay.
*
* @param {unknown} value
* @returns {string}
*/
export function normalizeRelayUrl(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!/^wss?:\/\//i.test(trimmed)) {
return "";
}
return trimmed.replace(/\/+$/, "").toLowerCase();
}
/**
* Decode a NIP-65 relay list.
*
* An `r` tag with no marker means both read and write, so an unmarked relay
* appears in both lists rather than neither.
*
* @param {NostrEvent} event
* @returns {RelayList|null}
*/
export function decodeRelayList(event) {
if (!event || event.kind !== RELAY_LIST_KIND) {
return null;
}
const pubkey = normalizePubkeyInput(event.pubkey);
if (!pubkey) {
return null;
}
/** @type {Set<string>} */
const read = new Set();
/** @type {Set<string>} */
const write = new Set();
for (const tag of getTags(event, "r")) {
const url = normalizeRelayUrl(tag[1]);
if (!url) {
continue;
}
const marker = typeof tag[2] === "string" ? tag[2].trim().toLowerCase() : "";
if (marker === "read") {
read.add(url);
} else if (marker === "write") {
write.add(url);
} else {
read.add(url);
write.add(url);
}
}
return {
pubkey,
updatedAt: Number.isFinite(event.created_at) ? event.created_at : 0,
read: Array.from(read),
write: Array.from(write),
};
}
/**
* Group authors by the relays their events should be fetched from.
*
* Returns a map of relay URL to the authors who write there, which is exactly
* the shape a batched outbox query needs: one subscription per relay carrying
* every author that relay can answer for.
*
* Authors with no known relay list fall back to the caller's default relays —
* omitting them entirely would quietly drop people who simply have not
* published a kind:10002.
*
* @param {string[]} authors
* @param {Map<string, RelayList>} relayLists - By author pubkey
* @param {Object} [options]
* @param {string[]} [options.fallback] - Relays for authors with no list
* @param {number} [options.maxRelaysPerAuthor] - Cap on relays queried per author
* @returns {Map<string, string[]>} Relay URL to author pubkeys
*/
export function groupAuthorsByWriteRelay(authors, relayLists, options = {}) {
const fallback = (options.fallback ?? []).map(normalizeRelayUrl).filter(Boolean);
// Some accounts advertise a dozen relays. Querying all of them multiplies
// connections for diminishing returns, so take the first few and rely on
// relay overlap for the rest.
const maxPerAuthor = options.maxRelaysPerAuthor ?? 3;
/** @type {Map<string, Set<string>>} */
const byRelay = new Map();
for (const author of authors ?? []) {
const pubkey = normalizePubkeyInput(author);
if (!pubkey) {
continue;
}
const list = relayLists.get(pubkey);
const relays = list?.write?.length ? list.write.slice(0, maxPerAuthor) : fallback;
for (const relay of relays) {
const url = normalizeRelayUrl(relay);
if (!url) {
continue;
}
if (!byRelay.has(url)) {
byRelay.set(url, new Set());
}
/** @type {Set<string>} */ (byRelay.get(url)).add(pubkey);
}
}
/** @type {Map<string, string[]>} */
const grouped = new Map();
for (const [relay, pubkeys] of byRelay) {
grouped.set(relay, Array.from(pubkeys));
}
return grouped;
}
/**
* Encode a relay list as an unsigned event template.
* @param {{ read?: string[], write?: string[], both?: string[] }} relays
* @returns {{ kind: number, content: string, tags: string[][] }}
*/
export function encodeRelayList(relays) {
/** @type {string[][]} */
const tags = [];
for (const url of relays.both ?? []) {
const normalized = normalizeRelayUrl(url);
if (normalized) tags.push(["r", normalized]);
}
for (const url of relays.read ?? []) {
const normalized = normalizeRelayUrl(url);
if (normalized) tags.push(["r", normalized, "read"]);
}
for (const url of relays.write ?? []) {
const normalized = normalizeRelayUrl(url);
if (normalized) tags.push(["r", normalized, "write"]);
}
return { kind: RELAY_LIST_KIND, content: "", tags };
}