From 7a45f1cf3e4f005a92951f19fd74286a0dc9b9bb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:52:21 -0400 Subject: [PATCH] feat(nostr): outbox-model fetching, private mutes, contacts, NIP-56 vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- docs/nips.md | 63 +++++ packages/core/src/presets.js | 27 ++- packages/core/test/presets.test.js | 31 +++ packages/nostr/src/contacts.js | 107 +++++++++ packages/nostr/src/index.js | 17 ++ packages/nostr/src/mutes.js | 82 ++++++- packages/nostr/src/relayList.js | 177 ++++++++++++++ packages/nostr/test/contacts.test.js | 89 +++++++ packages/nostr/test/mutes.test.js | 67 ++++++ packages/nostr/test/relayList.test.js | 158 +++++++++++++ packages/runtime/src/interfaces.js | 25 +- packages/runtime/src/relayTransport.js | 45 +++- packages/runtime/src/runtime.js | 200 ++++++++++++++++ packages/runtime/test/outbox.test.js | 311 +++++++++++++++++++++++++ packages/widget/src/provider.js | 17 +- 15 files changed, 1399 insertions(+), 17 deletions(-) create mode 100644 docs/nips.md create mode 100644 packages/nostr/src/contacts.js create mode 100644 packages/nostr/src/relayList.js create mode 100644 packages/nostr/test/contacts.test.js create mode 100644 packages/nostr/test/relayList.test.js create mode 100644 packages/runtime/test/outbox.test.js diff --git a/docs/nips.md b/docs/nips.md new file mode 100644 index 0000000..d0f8a48 --- /dev/null +++ b/docs/nips.md @@ -0,0 +1,63 @@ +# Nostr spec coverage + +What BitGate implements, what it deliberately does not, and where the gaps are. +Verified against the NIPs repository; specs move, so re-check before relying on +this table for anything load-bearing. + +## Implemented + +| NIP | Kind | How BitGate uses it | +| --- | --- | --- | +| **NIP-01** | — | Event shape, replaceable and parameterized-replaceable selection with a deterministic same-timestamp tiebreak | +| **NIP-02** | 3 | The viewer's follow list *is* the trust graph. Relay hints in the third tag position are captured as an outbox fallback. | +| **NIP-19** | — | bech32 `npub`/`note` decoding at the codec edge, so the core stays hex-only | +| **NIP-32** | 1985 | **Not yet** — see gaps below | +| **NIP-51** | 10000 | Mute lists. Public `p` entries always; the viewer's *own* private entries decrypted through their signer. | +| **NIP-44** | — | Decryption of the viewer's own private mute entries, delegated to the signer | +| **NIP-56** | 1984 | Reports. The report type is read from the 3rd tag position, with fallbacks for clients that place it elsewhere. | +| **NIP-65** | 10002 | Outbox model: each contact's mute list is fetched from *their* write relays | +| **NIP-78** | 30078 | Canonical governance documents — roles, policy, and contribution lists — under namespaced `d` identifiers | + +## Deliberate choices + +**The viewer's private mutes are decrypted; nobody else's are.** Another +account's private list is none of our business, and the check lives at the +codec boundary rather than relying on callers to behave. A user who mutes +privately should still have their mutes take effect — ignoring them reads as +the product being broken. + +**Report categories follow NIP-56 exactly** in the social preset. Declaring a +threshold for a category no standard client emits is dead configuration. + +**Commerce categories are non-standard on purpose.** `scam`, `counterfeit`, and +`not-as-described` have no NIP-56 equivalent. Reports from generic clients +arrive as `other` and fall through to a `default` threshold, so they are never +silently ignored — including at checkout, where an unclassified complaint earns +review rather than an automatic denial. + +**Relays are transport, never authority.** Signatures are verified through an +injected verifier before anything authoritative is accepted. + +## Known gaps + +**NIP-32 labelling (kind 1985) — the interop gap.** BitGate's contribution +lists are a private vocabulary. NIP-32 is the standard way to publish +moderation labels (`L` namespace, `l` value, targeting `e`/`p`/`a`), and our +user/event/address denials map onto it almost exactly. Emitting NIP-32 would +let other clients honour these moderators; consuming it would let BitGate +subscribe to third-party label sets. This is the highest-value remaining work. + +**Trust is binary.** `resolveTrustSet` is "in the viewer's follows, or not". +Most things called web-of-trust weight by hop distance — follows-of-follows at +reduced weight. Adding depth naively would be wrong: a two-hop reporter would +count the same as someone the viewer actually follows. It needs hop-weighted +counting, which is a change to aggregation, not just to the set. + +**NIP-72 / NIP-29 communities.** The role roster is a parallel invention to +NIP-72's moderator `p` tags. Note NIP-72 is now marked *"unrecommended: try +NIP-29 instead"*, so a compat codec is only worth building against a specific +target application. + +**NIP-86 relay management, NIP-70 protected events.** Relay-side moderation is +a different layer and out of scope; BitGate governs what a client shows, not +what a relay stores. diff --git a/packages/core/src/presets.js b/packages/core/src/presets.js index a2be6ae..8a82bd4 100644 --- a/packages/core/src/presets.js +++ b/packages/core/src/presets.js @@ -16,9 +16,14 @@ const DAY = 24 * 60 * 60; /** * Report thresholds for a social feed. * + * Categories are the NIP-56 vocabulary exactly — `nudity`, `malware`, + * `profanity`, `illegal`, `spam`, `impersonation`, `other`. Inventing a + * category here would be dead configuration: no standard client emits it, so + * the threshold could never fire. + * * Malware and impersonation escalate fastest because their harm is immediate - * and specific. Broad categories like "spam" need more agreement before they - * do anything visible, since they are the easiest to weaponize against someone + * and specific. Broad categories like `spam` need more agreement before doing + * anything visible, since they are the easiest to weaponize against someone * unpopular. * * @type {Record} @@ -26,9 +31,10 @@ const DAY = 24 * 60 * 60; const SOCIAL_REPORTS = { malware: { warn: 1, restrict: 1, hide: 2, interactionDeny: 1 }, impersonation: { warn: 1, restrict: 2, hide: 4 }, + illegal: { warn: 1, restrict: 2, hide: 3 }, nudity: { restrict: 2, requireExplicitAction: 2 }, + profanity: { downrank: 1, warn: 2, restrict: 3, hide: 6 }, spam: { downrank: 2, restrict: 4, hide: 8 }, - harassment: { downrank: 1, warn: 2, restrict: 3, hide: 6 }, default: { downrank: 2, warn: 4, restrict: 6 }, }; @@ -97,6 +103,13 @@ export const SOCIAL_POLICY = createPolicyDefinition({ /** * Report thresholds for a marketplace. * + * `malware`, `illegal`, and `spam` are NIP-56 categories. `scam`, + * `counterfeit`, and `not-as-described` are **not**: they are marketplace + * vocabulary that only a commerce client will emit. That is a deliberate + * trade — commerce harms have no NIP-56 equivalent — but it means reports from + * generic Nostr clients arrive as `other` and fall through to the default + * thresholds. Publish them as NIP-32 labels if you need cross-client meaning. + * * Money changes the calculus: one credible malware report should stop a sale, * because the cost of being wrong runs one way. Visibility stays more generous * than transaction throughout — a listing can remain readable while being @@ -110,6 +123,8 @@ const COMMERCE_REPORTS = { counterfeit: { downrank: 1, warn: 2, restrict: 4, transactionReview: 3 }, "not-as-described": { downrank: 2, warn: 4, transactionReview: 5 }, misleading: { downrank: 2, warn: 3, restrict: 5, hide: 8 }, + illegal: { warn: 1, restrict: 2, hide: 3, transactionDeny: 2 }, + spam: { downrank: 2, restrict: 5 }, default: { downrank: 2, warn: 4 }, }; @@ -128,9 +143,15 @@ const COMMERCE_MUTES = { */ const CHECKOUT_REPORTS = { malware: { transactionDeny: 1 }, + illegal: { transactionDeny: 2 }, scam: { transactionReview: 2, transactionDeny: 3 }, counterfeit: { transactionReview: 3 }, "not-as-described": { transactionReview: 5 }, + // Reports whose category we cannot interpret — `other`, or a vocabulary from + // some client we have never seen — should not silently do nothing at the one + // place money changes hands. They do not auto-deny either: an unclassified + // complaint is not evidence of fraud. Enough of them earns a human look. + default: { transactionReview: 4 }, }; /** diff --git a/packages/core/test/presets.test.js b/packages/core/test/presets.test.js index 2562b87..d00407e 100644 --- a/packages/core/test/presets.test.js +++ b/packages/core/test/presets.test.js @@ -198,6 +198,37 @@ describe("admin-only preset", () => { }); }); +describe("category vocabulary", () => { + // NIP-56 defines exactly these. A threshold for anything else can only fire + // if a bespoke client emits it. + const NIP56 = ["nudity", "malware", "profanity", "illegal", "spam", "impersonation", "other"]; + + it("uses only NIP-56 categories in the social preset", () => { + for (const category of Object.keys(SOCIAL_POLICY.profiles.feed.reports ?? {})) { + if (category === "default") continue; + expect(NIP56, `social preset declares "${category}"`).toContain(category); + } + }); + + it("keeps commerce extensions alongside the standard ones", () => { + const categories = Object.keys(COMMERCE_POLICY.profiles.browse.reports ?? {}); + expect(categories).toContain("malware"); + expect(categories).toContain("illegal"); + // Marketplace-specific vocabulary is deliberate; it is documented as such. + expect(categories).toContain("scam"); + }); + + it("gives every preset a default so unknown categories still land somewhere", () => { + for (const [name, policy] of Object.entries(POLICY_PRESETS)) { + for (const [profileName, profile] of Object.entries(policy.profiles)) { + const reports = profile.reports ?? {}; + if (Object.keys(reports).length === 0) continue; + expect(reports.default, `${name}/${profileName}`).toBeDefined(); + } + } + }); +}); + describe("preset hygiene", () => { it("gives every preset a version and a named default profile", () => { for (const [name, policy] of Object.entries(POLICY_PRESETS)) { diff --git a/packages/nostr/src/contacts.js b/packages/nostr/src/contacts.js new file mode 100644 index 0000000..640aa99 --- /dev/null +++ b/packages/nostr/src/contacts.js @@ -0,0 +1,107 @@ +// NIP-02 follow list (kind 3). +// +// The follow list is the trust graph. BitGate previously required the host +// application to supply it, which is fine for an app that already has one and +// impossible for a page whose only configuration is markup. +// +// Note the deliberate asymmetry with mute lists: a follow list is public by +// design, so there is nothing here to decrypt and no privacy decision to make. + +import { normalizePubkeyInput } from "./nip19.js"; +import { getTags } from "./replaceable.js"; + +/** + * @typedef {import('./replaceable.js').NostrEvent} NostrEvent + */ + +/** NIP-02 follow list kind. */ +export const CONTACT_LIST_KIND = 3; + +/** + * @typedef {Object} ContactList + * @property {string} owner + * @property {number} updatedAt + * @property {string[]} contacts - Followed pubkeys, deduplicated + * @property {Map} relayHints - Contact pubkey to the relay hint they were listed with + */ + +/** + * Decode a NIP-02 follow list. + * + * The relay hint in the third tag position is captured because it is a useful + * fallback for someone who has published no kind:10002 — it is the only hint + * their follower left about where to find them. + * + * `.content` is unused by NIP-02 and is ignored here; some clients have + * historically stored relay JSON in it, but reading that is not part of the + * spec and guessing at it would be worse than not trying. + * + * @param {NostrEvent} event + * @returns {ContactList|null} + */ +export function decodeContactList(event) { + if (!event || event.kind !== CONTACT_LIST_KIND) { + return null; + } + + const owner = normalizePubkeyInput(event.pubkey); + if (!owner) { + return null; + } + + /** @type {string[]} */ + const contacts = []; + /** @type {Set} */ + const seen = new Set(); + /** @type {Map} */ + const relayHints = new Map(); + + for (const tag of getTags(event, "p")) { + const pubkey = normalizePubkeyInput(tag[1]); + if (!pubkey || seen.has(pubkey)) { + continue; + } + seen.add(pubkey); + contacts.push(pubkey); + + const hint = typeof tag[2] === "string" ? tag[2].trim() : ""; + if (hint) { + relayHints.set(pubkey, hint); + } + } + + return { + owner, + updatedAt: Number.isFinite(event.created_at) ? event.created_at : 0, + contacts, + relayHints, + }; +} + +/** + * Encode a follow list as an unsigned event template. + * + * A follow list is a complete replacement, not a delta — publishing a partial + * list unfollows everyone omitted from it. + * + * @param {Array} contacts + * @returns {{ kind: number, content: string, tags: string[][] }} + */ +export function encodeContactList(contacts) { + /** @type {string[][]} */ + const tags = []; + + for (const entry of contacts ?? []) { + const pubkey = normalizePubkeyInput(typeof entry === "string" ? entry : entry?.pubkey); + if (!pubkey) { + continue; + } + if (typeof entry === "string") { + tags.push(["p", pubkey]); + continue; + } + tags.push(["p", pubkey, entry.relay ?? "", entry.petname ?? ""]); + } + + return { kind: CONTACT_LIST_KIND, content: "", tags }; +} diff --git a/packages/nostr/src/index.js b/packages/nostr/src/index.js index c28150c..9550a65 100644 --- a/packages/nostr/src/index.js +++ b/packages/nostr/src/index.js @@ -51,10 +51,25 @@ export { MUTE_LIST_KIND, extractMuteCategory, decodeMuteList, + decodePrivateMuteEntries, toMuteRecords, encodeMuteList, } from "./mutes.js"; +export { + RELAY_LIST_KIND, + normalizeRelayUrl, + decodeRelayList, + groupAuthorsByWriteRelay, + encodeRelayList, +} from "./relayList.js"; + +export { + CONTACT_LIST_KIND, + decodeContactList, + encodeContactList, +} from "./contacts.js"; + export { LEGACY_KIND, LEGACY_IDENTIFIERS, @@ -72,4 +87,6 @@ export { * @typedef {import('./replaceable.js').SignatureVerifier} SignatureVerifier * @typedef {import('./reports.js').DecodedReport} DecodedReport * @typedef {import('./mutes.js').DecodedMuteList} DecodedMuteList + * @typedef {import('./relayList.js').RelayList} RelayList + * @typedef {import('./contacts.js').ContactList} ContactList */ diff --git a/packages/nostr/src/mutes.js b/packages/nostr/src/mutes.js index 988f152..d07db24 100644 --- a/packages/nostr/src/mutes.js +++ b/packages/nostr/src/mutes.js @@ -1,9 +1,14 @@ // NIP-51 mute list codec. // -// A mute list is a replaceable kind:10000 event whose public entries are `p` -// tags. Encrypted entries live in `content` and are deliberately not decoded -// here: a private mute is private, and the governance engine has no business -// reading another account's encrypted list even when it could. +// A mute list is a replaceable kind:10000 event. Public entries are `p` tags; +// private entries are a JSON array of the same shape, NIP-44 encrypted into +// `content` to the author's own key. +// +// The privacy rule here is asymmetric on purpose. Another account's private +// mutes are none of our business and are never decoded — only counted as +// present. The *viewer's own* private mutes are theirs, they hold the key, and +// ignoring them means a user who mutes privately gets no effect at all, which +// reads as the product being broken. import { normalizePubkeyInput } from "./nip19.js"; import { getTags } from "./replaceable.js"; @@ -58,6 +63,75 @@ export function extractMuteCategory(tag) { * @property {boolean} hasEncryptedEntries - Whether private entries were present but not read */ +/** + * Decode the private half of a mute list. + * + * NIP-51 stores private entries as a JSON array mirroring the tag structure, + * encrypted to the author's own key. Decryption is delegated to a signer so + * this package needs no crypto implementation and no access to a secret key. + * + * Refuses outright when the list is not the viewer's own: the caller could + * technically pass any list, and the check belongs where the boundary is, not + * in the caller's discipline. + * + * @param {NostrEvent} event + * @param {Object} options + * @param {string} options.viewerPubkey - The signed-in viewer + * @param {(pubkey: string, ciphertext: string) => Promise} [options.decrypt] - NIP-44 decrypt + * @returns {Promise>} + */ +export async function decodePrivateMuteEntries(event, { viewerPubkey, decrypt }) { + if (!event || event.kind !== MUTE_LIST_KIND) { + return []; + } + + const owner = normalizePubkeyInput(event.pubkey); + const viewer = normalizePubkeyInput(viewerPubkey); + if (!owner || !viewer || owner !== viewer) { + return []; + } + + const content = typeof event.content === "string" ? event.content.trim() : ""; + if (!content || typeof decrypt !== "function") { + return []; + } + + let tags; + try { + const plaintext = await decrypt(owner, content); + tags = JSON.parse(plaintext); + } catch { + // A list encrypted with a key we do not hold, or written by a client we do + // not understand, degrades to "no private entries" rather than throwing. + return []; + } + + if (!Array.isArray(tags)) { + return []; + } + + /** @type {Array<{ pubkey: string, category?: string }>} */ + const entries = []; + /** @type {Set} */ + const seen = new Set(); + + for (const tag of tags) { + if (!Array.isArray(tag) || tag[0] !== "p") { + continue; + } + const pubkey = normalizePubkeyInput(tag[1]); + if (!pubkey || seen.has(pubkey)) { + continue; + } + seen.add(pubkey); + + const category = extractMuteCategory(tag); + entries.push(category ? { pubkey, category } : { pubkey }); + } + + return entries; +} + /** * Decode a NIP-51 mute list. * @param {NostrEvent} event diff --git a/packages/nostr/src/relayList.js b/packages/nostr/src/relayList.js new file mode 100644 index 0000000..b168253 --- /dev/null +++ b/packages/nostr/src/relayList.js @@ -0,0 +1,177 @@ +// 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} */ + const read = new Set(); + /** @type {Set} */ + 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} 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} 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>} */ + 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} */ (byRelay.get(url)).add(pubkey); + } + } + + /** @type {Map} */ + 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 }; +} diff --git a/packages/nostr/test/contacts.test.js b/packages/nostr/test/contacts.test.js new file mode 100644 index 0000000..bf6a919 --- /dev/null +++ b/packages/nostr/test/contacts.test.js @@ -0,0 +1,89 @@ +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]]); + }); +}); diff --git a/packages/nostr/test/mutes.test.js b/packages/nostr/test/mutes.test.js index 16bb134..b83242d 100644 --- a/packages/nostr/test/mutes.test.js +++ b/packages/nostr/test/mutes.test.js @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { MUTE_LIST_KIND, decodeMuteList, + decodePrivateMuteEntries, encodeMuteList, extractMuteCategory, toMuteRecords, @@ -122,3 +123,69 @@ describe("encodeMuteList", () => { 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([]); + }); +}); diff --git a/packages/nostr/test/relayList.test.js b/packages/nostr/test/relayList.test.js new file mode 100644 index 0000000..e87bd73 --- /dev/null +++ b/packages/nostr/test/relayList.test.js @@ -0,0 +1,158 @@ +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); + }); +}); diff --git a/packages/runtime/src/interfaces.js b/packages/runtime/src/interfaces.js index 1f65698..5ea72fa 100644 --- a/packages/runtime/src/interfaces.js +++ b/packages/runtime/src/interfaces.js @@ -20,11 +20,21 @@ * @property {(error: unknown) => void} [onError] */ +/** + * @typedef {Object} ListOptions + * @property {number} [timeout] + * @property {string[]} [relays] - Query only these relays, connecting if needed. + * This is what makes outbox-model fetching possible: a contact's mute list + * lives on their write relays, not on this deployment's. + */ + /** * @typedef {Object} GovernanceTransport - * @property {(filters: object[], options?: object) => Promise} list - * @property {(filters: object[], handlers: SubscriptionHandlers, options?: object) => GovernanceSubscription} subscribe + * @property {(filters: object[], options?: ListOptions) => Promise} list + * @property {(filters: object[], handlers: SubscriptionHandlers, options?: ListOptions) => GovernanceSubscription} subscribe * @property {(event: NostrEvent, options?: object) => Promise} publish + * @property {string[]} [relays] - The transport's configured relays, used as an + * outbox fallback for authors who have published no NIP-65 list */ /** @@ -34,10 +44,21 @@ * @property {Array<{ relay: string, error: string }>} failed */ +/** + * @typedef {Object} Nip44 + * @property {(pubkey: string, ciphertext: string) => Promise} decrypt + * @property {(pubkey: string, plaintext: string) => Promise} [encrypt] + */ + /** * @typedef {Object} GovernanceSigner * @property {() => Promise} getPublicKey * @property {(event: object) => Promise} signEvent + * @property {Nip44} [nip44] - NIP-44 encryption, as NIP-07 providers expose it. + * Optional: without it the viewer's private mute entries stay unread, which + * degrades the feature rather than breaking anything. + * @property {(pubkey: string, ciphertext: string) => Promise} [decrypt] - + * Flat fallback for signers that expose decryption without the nip44 namespace */ /** diff --git a/packages/runtime/src/relayTransport.js b/packages/runtime/src/relayTransport.js index 96470e1..693cc59 100644 --- a/packages/runtime/src/relayTransport.js +++ b/packages/runtime/src/relayTransport.js @@ -213,6 +213,35 @@ export function createRelayTransport(urls, options = {}) { connect(url); } + /** + * Resolve the relays a call should target, connecting to any that are new. + * + * The outbox model routinely needs relays that were never configured — that + * is the entire point of reading someone's relay list — so a target set is + * connected on demand rather than silently narrowed to what we already had. + * + * @param {string[]} [targets] + * @returns {string[]} + */ + function resolveTargets(targets) { + if (!targets?.length) { + return relays; + } + + const resolved = []; + for (const candidate of targets) { + const url = typeof candidate === "string" ? candidate.trim() : ""; + if (!url) { + continue; + } + if (!connections.has(url)) { + connect(url); + } + resolved.push(url); + } + return resolved.length ? resolved : relays; + } + return { relays, @@ -224,17 +253,18 @@ export function createRelayTransport(urls, options = {}) { * and results already received are returned rather than discarded. * * @param {object[]} filters - * @param {{ timeout?: number }} [listOptions] + * @param {{ timeout?: number, relays?: string[] }} [listOptions] * @returns {Promise} */ async list(filters, listOptions = {}) { const id = `bg-list-${(subscriptionCounter += 1)}`; + const targets = resolveTargets(listOptions.relays); /** @type {Map} */ const collected = new Map(); return new Promise((resolve) => { let settled = false; - let pendingEose = relays.length; + let pendingEose = targets.length; const finish = () => { if (settled) { @@ -243,7 +273,7 @@ export function createRelayTransport(urls, options = {}) { settled = true; clearTimeout(timer); subscriptions.delete(id); - for (const url of relays) { + for (const url of targets) { send(url, JSON.stringify(["CLOSE", id])); } resolve([...collected.values()]); @@ -271,7 +301,7 @@ export function createRelayTransport(urls, options = {}) { }, }); - for (const url of relays) { + for (const url of targets) { send(url, JSON.stringify(["REQ", id, ...filters])); } }); @@ -283,8 +313,9 @@ export function createRelayTransport(urls, options = {}) { * @param {SubscriptionHandlers} handlers * @returns {{ close: () => void }} */ - subscribe(filters, handlers) { + subscribe(filters, handlers, subscribeOptions = {}) { const id = `bg-sub-${(subscriptionCounter += 1)}`; + const targets = resolveTargets(subscribeOptions.relays); /** @type {Set} */ const seen = new Set(); @@ -305,7 +336,7 @@ export function createRelayTransport(urls, options = {}) { }); const payload = JSON.stringify(["REQ", id, ...filters]); - for (const url of relays) { + for (const url of targets) { send(url, payload); } @@ -313,7 +344,7 @@ export function createRelayTransport(urls, options = {}) { close() { subscriptions.delete(id); const closePayload = JSON.stringify(["CLOSE", id]); - for (const url of relays) { + for (const url of targets) { send(url, closePayload); } }, diff --git a/packages/runtime/src/runtime.js b/packages/runtime/src/runtime.js index 01d8a6a..9501143 100644 --- a/packages/runtime/src/runtime.js +++ b/packages/runtime/src/runtime.js @@ -17,15 +17,21 @@ import { } from "@bitgate/core"; import { CANONICAL_KIND, + CONTACT_LIST_KIND, LEGACY_KIND, MUTE_LIST_KIND, + RELAY_LIST_KIND, REPORT_KIND, + decodeContactList, decodeContribution, decodeLegacyList, decodeMuteList, decodePolicy, + decodePrivateMuteEntries, + decodeRelayList, decodeReport, decodeRoles, + groupAuthorsByWriteRelay, selectReplaceable, verifyEvents, } from "@bitgate/nostr"; @@ -149,6 +155,11 @@ export class GovernanceRuntime extends Emitter { // rather than pretending state was verified. this.verifySignature = verifySignature; + // The deployment's own relays, used as a fallback for authors who have + // published no NIP-65 list. + /** @type {string[]} */ + this.defaultRelays = Array.isArray(transport.relays) ? [...transport.relays] : []; + this.admin = new GovernanceAdminStore(); this.trust = new TrustGraphStore(); this.reports = new ReportStore(); @@ -182,6 +193,11 @@ export class GovernanceRuntime extends Emitter { /** @type {number} */ this.lastLoadedAt = 0; + // NIP-65 relay lists, keyed by pubkey. Populated by loadRelayLists() and + // consulted when fetching anything authored by a specific person. + /** @type {Map} */ + this.relayLists = new Map(); + /** @type {Array<() => void>} */ this.storeUnsubscribes = [ // Administrative, trust, and policy changes can alter any decision, so @@ -235,6 +251,7 @@ export class GovernanceRuntime extends Emitter { cacheMisses: 0, rejectedSignatures: 0, hydratedFromCache: false, + privateMutesApplied: 0, }; } @@ -283,6 +300,29 @@ export class GovernanceRuntime extends Emitter { const list = decodeMuteList(event); if (list) { this.mutes.replaceList(list); + // The viewer's own private entries are decrypted separately, since it + // needs a signer and cannot be synchronous. + if (list.hasEncryptedEntries && list.owner === this.viewerPubkey) { + void this.#absorbPrivateMutes(event); + } + return true; + } + return false; + } + + if (event.kind === RELAY_LIST_KIND) { + const relayList = decodeRelayList(event); + if (relayList) { + this.relayLists.set(relayList.pubkey, relayList); + return true; + } + return false; + } + + if (event.kind === CONTACT_LIST_KIND) { + const contactList = decodeContactList(event); + if (contactList && contactList.owner === this.viewerPubkey) { + this.trust.setContacts(contactList.contacts); return true; } return false; @@ -488,6 +528,163 @@ export class GovernanceRuntime extends Emitter { return effective.length; } + /** + * Decrypt and apply the viewer's own private mute entries. + * + * Requires a signer exposing NIP-44 decryption. Without one the entries stay + * unread, which is a degradation rather than a failure — public mutes still + * apply. + * + * @param {NostrEvent} event + * @returns {Promise} Number of private entries applied + */ + async #absorbPrivateMutes(event) { + const decrypt = this.signer?.nip44?.decrypt ?? this.signer?.decrypt; + if (typeof decrypt !== "function") { + return 0; + } + + let entries; + try { + entries = await decodePrivateMuteEntries(event, { + viewerPubkey: this.viewerPubkey, + decrypt: (pubkey, ciphertext) => decrypt.call(this.signer, pubkey, ciphertext), + }); + } catch { + return 0; + } + + if (!entries.length) { + return 0; + } + + // A viewer's own mutes are viewer state, not a trusted-mute signal: they + // are a personal preference, not evidence about the target. + const combined = new Set([...this.trust.mutes, ...entries.map((entry) => entry.pubkey)]); + this.trust.setMutes(combined); + this.diagnostics.privateMutesApplied = entries.length; + return entries.length; + } + + /** + * Load NIP-65 relay lists for a set of authors. + * + * Fetched from the configured relays, since a relay list is the one thing + * that cannot be fetched using itself. + * + * @param {string[]} authors + * @returns {Promise} Number of lists learned + */ + async loadRelayLists(authors) { + const pubkeys = Array.from(new Set((authors ?? []).map((key) => normalizePubkey(key)).filter(Boolean))); + if (!pubkeys.length) { + return 0; + } + + let learned = 0; + for (const chunkOfAuthors of chunk(pubkeys, this.chunkSize)) { + let events; + try { + events = await this.transport.list([ + { kinds: [RELAY_LIST_KIND], authors: chunkOfAuthors }, + ]); + } catch (error) { + this.emit("stale", { reason: "relay-lists-unreachable", error: String(error) }); + continue; + } + + for (const event of selectReplaceable(await this.#verified(events)).values()) { + if (this.ingestEvent(event)) { + learned += 1; + } + } + } + + return learned; + } + + /** + * Load the viewer's follow list and adopt it as the trust graph. + * + * @returns {Promise} Number of contacts adopted + */ + async loadContacts() { + if (!this.viewerPubkey) { + return 0; + } + + let events; + try { + events = await this.transport.list([ + { kinds: [CONTACT_LIST_KIND], authors: [this.viewerPubkey] }, + ]); + } catch (error) { + this.emit("stale", { reason: "contacts-unreachable", error: String(error) }); + return 0; + } + + for (const event of selectReplaceable(await this.#verified(events)).values()) { + this.ingestEvent(event); + } + + return this.trust.contacts.size; + } + + /** + * Load trusted mute lists using the outbox model. + * + * Each contact publishes their mute list to their own write relays, so a + * query against a fixed relay set finds only the subset who happen to write + * where this deployment reads. Fetching per-author from their advertised + * relays is the difference between a trust graph that works and one that + * silently reports low counts. + * + * Falls back to the configured relays for anyone with no published relay + * list — dropping them would be worse than querying the wrong place. + * + * @param {Object} [options] + * @param {boolean} [options.discoverRelays] - Fetch relay lists first + * @returns {Promise} Number of mute lists ingested + */ + async loadTrustedMuteLists({ discoverRelays = true } = {}) { + const owners = Array.from(this.trust.trustSet); + if (!owners.length) { + return 0; + } + + if (discoverRelays) { + await this.loadRelayLists(owners); + } + + const grouped = groupAuthorsByWriteRelay(owners, this.relayLists, { + fallback: this.defaultRelays, + }); + + let ingested = 0; + for (const [relay, authors] of grouped) { + for (const authorChunk of chunk(authors, this.chunkSize)) { + let events; + try { + events = await this.transport.list( + [{ kinds: [MUTE_LIST_KIND], authors: authorChunk }], + { relays: [relay] }, + ); + } catch (error) { + this.emit("stale", { reason: "mute-lists-unreachable", relay, error: String(error) }); + continue; + } + + for (const event of selectReplaceable(await this.#verified(events)).values()) { + if (this.ingestEvent(event)) { + ingested += 1; + } + } + } + } + + return ingested; + } + /** * Subscribe to the mute lists published by the viewer's trusted contacts. * @@ -899,6 +1096,9 @@ export class GovernanceRuntime extends Emitter { lastLoadedAt: this.lastLoadedAt, signatureVerification: this.verifySignature ? "enabled" : "disabled", ...this.diagnostics, + // Derived from live state, so it is computed after the counters rather + // than stored alongside them where a stale copy could shadow it. + relayListsKnown: this.relayLists.size, }; } diff --git a/packages/runtime/test/outbox.test.js b/packages/runtime/test/outbox.test.js new file mode 100644 index 0000000..4cc87ac --- /dev/null +++ b/packages/runtime/test/outbox.test.js @@ -0,0 +1,311 @@ +// Outbox-model fetching, contact discovery, and private mutes. +// +// The behaviour under test is the one that decides whether the trust graph is +// fed at all: mute lists live on their authors' own write relays, not on +// whichever relays this deployment happens to use. + +import { describe, expect, it, vi } from "vitest"; + +import { createPolicyDefinition } from "@bitgate/core"; +import { CONTACT_LIST_KIND, MUTE_LIST_KIND, RELAY_LIST_KIND } from "@bitgate/nostr"; + +import { createBitGate } from "../src/runtime.js"; +import { createMemoryTransport } from "../src/interfaces.js"; + +const ROOT = "a1".repeat(32); +const VIEWER = "e5".repeat(32); +const CREATOR = "d4".repeat(32); +const ALICE = "01".repeat(32); +const BOB = "02".repeat(32); +const NOW = 1_750_000_000; + +const POLICY = createPolicyDefinition({ + id: "outbox", + version: "1.0.0", + defaultProfile: "feed", + profiles: { + feed: { name: "feed", reports: {}, mutes: { default: { hide: 1 } } }, + }, +}); + +const event = (parts) => ({ + id: "00".repeat(32), + pubkey: ALICE, + kind: MUTE_LIST_KIND, + created_at: NOW, + tags: [], + content: "", + ...parts, +}); + +/** A transport that records which relays each query targeted. */ +function recordingTransport(eventsByRelay = {}) { + const base = createMemoryTransport(Object.values(eventsByRelay).flat()); + /** @type {Array<{ relays: string[]|undefined, kinds: number[] }>} */ + const calls = []; + + return { + ...base, + relays: ["wss://default.example"], + calls, + async list(filters, options = {}) { + calls.push({ relays: options.relays, kinds: filters[0]?.kinds ?? [] }); + + // Answer only with events the targeted relays actually hold, which is + // what makes an outbox failure visible rather than invisible. + const targets = options.relays ?? ["wss://default.example"]; + const available = targets.flatMap((relay) => eventsByRelay[relay] ?? []); + const source = options.relays ? available : (eventsByRelay["wss://default.example"] ?? []); + + return source.filter((candidate) => + filters.some( + (filter) => + (!filter.kinds || filter.kinds.includes(candidate.kind)) && + (!filter.authors || filter.authors.includes(candidate.pubkey)), + ), + ); + }, + }; +} + +function makeRuntime(transport) { + const runtime = createBitGate({ + applicationId: "outbox-test", + namespace: "test", + root: ROOT, + transport, + policy: POLICY, + now: () => NOW, + }); + runtime.setViewer(VIEWER); + return runtime; +} + +describe("relay list discovery", () => { + it("learns where each contact writes", async () => { + const transport = recordingTransport({ + "wss://default.example": [ + event({ + pubkey: ALICE, + kind: RELAY_LIST_KIND, + tags: [["r", "wss://alice.example", "write"]], + }), + ], + }); + const runtime = makeRuntime(transport); + + expect(await runtime.loadRelayLists([ALICE])).toBe(1); + expect(runtime.relayLists.get(ALICE)?.write).toEqual(["wss://alice.example"]); + expect(runtime.describe().relayListsKnown).toBe(1); + }); + + it("survives an unreachable relay", async () => { + const transport = recordingTransport(); + transport.list = async () => { + throw new Error("down"); + }; + const runtime = makeRuntime(transport); + const stale = vi.fn(); + runtime.on("stale", stale); + + expect(await runtime.loadRelayLists([ALICE])).toBe(0); + expect(stale).toHaveBeenCalledWith(expect.objectContaining({ reason: "relay-lists-unreachable" })); + }); + + it("does nothing for an empty author list", async () => { + expect(await makeRuntime(recordingTransport()).loadRelayLists([])).toBe(0); + }); +}); + +describe("outbox mute fetching", () => { + it("finds a mute list that only exists on its author's own relay", async () => { + // The whole point: this list is not on the deployment's relay, so a naive + // fetch would miss it and report zero trusted mutes. + const transport = recordingTransport({ + "wss://default.example": [ + event({ + pubkey: ALICE, + kind: RELAY_LIST_KIND, + tags: [["r", "wss://alice.example", "write"]], + }), + ], + "wss://alice.example": [event({ pubkey: ALICE, tags: [["p", CREATOR]] })], + }); + + const runtime = makeRuntime(transport); + runtime.trust.setContacts([ALICE]); + + expect(await runtime.loadTrustedMuteLists()).toBe(1); + expect(runtime.mutes.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(1); + }); + + it("targets each author's relays rather than querying everything", async () => { + const transport = recordingTransport({ + "wss://default.example": [ + event({ pubkey: ALICE, kind: RELAY_LIST_KIND, tags: [["r", "wss://alice.example", "write"]] }), + event({ pubkey: BOB, kind: RELAY_LIST_KIND, tags: [["r", "wss://bob.example", "write"]] }), + ], + "wss://alice.example": [event({ pubkey: ALICE, tags: [["p", CREATOR]] })], + "wss://bob.example": [event({ pubkey: BOB, tags: [["p", CREATOR]] })], + }); + + const runtime = makeRuntime(transport); + runtime.trust.setContacts([ALICE, BOB]); + await runtime.loadTrustedMuteLists(); + + const muteQueries = transport.calls.filter((call) => call.kinds.includes(MUTE_LIST_KIND)); + const targeted = muteQueries.flatMap((call) => call.relays ?? []); + expect(targeted).toContain("wss://alice.example"); + expect(targeted).toContain("wss://bob.example"); + + // Two distinct muters, found only because each was queried where they write. + expect(runtime.mutes.toRecordMap().get(`user:${CREATOR}`)).toHaveLength(2); + }); + + it("falls back to the configured relays for authors with no relay list", async () => { + const transport = recordingTransport({ + "wss://default.example": [event({ pubkey: ALICE, tags: [["p", CREATOR]] })], + }); + const runtime = makeRuntime(transport); + runtime.trust.setContacts([ALICE]); + + expect(await runtime.loadTrustedMuteLists()).toBe(1); + }); + + it("feeds evaluation, not just the store", async () => { + const transport = recordingTransport({ + "wss://default.example": [event({ pubkey: ALICE, tags: [["p", CREATOR]] })], + }); + const runtime = makeRuntime(transport); + runtime.trust.setContacts([ALICE]); + await runtime.loadTrustedMuteLists(); + + expect( + runtime.evaluate({ type: "user", pubkey: CREATOR }, { profile: "feed" }).visibility.effect, + ).toBe("hide"); + }); + + it("does nothing when the viewer trusts nobody", async () => { + expect(await makeRuntime(recordingTransport()).loadTrustedMuteLists()).toBe(0); + }); + + it("can skip relay discovery", async () => { + const transport = recordingTransport({ + "wss://default.example": [event({ pubkey: ALICE, tags: [["p", CREATOR]] })], + }); + const runtime = makeRuntime(transport); + runtime.trust.setContacts([ALICE]); + + await runtime.loadTrustedMuteLists({ discoverRelays: false }); + expect(transport.calls.some((call) => call.kinds.includes(RELAY_LIST_KIND))).toBe(false); + }); +}); + +describe("contact list loading", () => { + it("builds the trust graph from the viewer's own follow list", async () => { + const transport = recordingTransport({ + "wss://default.example": [ + event({ + pubkey: VIEWER, + kind: CONTACT_LIST_KIND, + tags: [["p", ALICE], ["p", BOB]], + }), + ], + }); + const runtime = makeRuntime(transport); + + expect(await runtime.loadContacts()).toBe(2); + expect(runtime.trust.contacts.has(ALICE)).toBe(true); + }); + + it("ignores someone else's follow list", async () => { + const transport = recordingTransport({ + "wss://default.example": [ + event({ pubkey: ALICE, kind: CONTACT_LIST_KIND, tags: [["p", BOB]] }), + ], + }); + const runtime = makeRuntime(transport); + + await runtime.loadContacts(); + expect(runtime.trust.contacts.size).toBe(0); + }); + + it("does nothing with no viewer", async () => { + const transport = recordingTransport(); + const runtime = createBitGate({ + applicationId: "t", + namespace: "t", + transport, + policy: POLICY, + now: () => NOW, + }); + expect(await runtime.loadContacts()).toBe(0); + }); +}); + +describe("the viewer's own private mutes", () => { + const PRIVATE = JSON.stringify([["p", CREATOR]]); + + function signerWithNip44() { + return { + async getPublicKey() { + return VIEWER; + }, + async signEvent(template) { + return { ...template, id: "ff".repeat(32), pubkey: VIEWER, sig: "00".repeat(64) }; + }, + nip44: { + async decrypt(pubkey, ciphertext) { + if (pubkey !== VIEWER || ciphertext !== "cipher") { + throw new Error("cannot decrypt"); + } + return PRIVATE; + }, + }, + }; + } + + it("applies them as viewer mutes", async () => { + const transport = recordingTransport(); + const runtime = createBitGate({ + applicationId: "t", + namespace: "t", + root: ROOT, + transport, + signer: signerWithNip44(), + policy: POLICY, + now: () => NOW, + }); + runtime.setViewer(VIEWER); + + runtime.ingestEvent(event({ pubkey: VIEWER, content: "cipher" })); + await vi.waitFor(() => expect(runtime.trust.mutes.has(CREATOR)).toBe(true)); + }); + + it("leaves another account's private entries alone", async () => { + const transport = recordingTransport(); + const runtime = createBitGate({ + applicationId: "t", + namespace: "t", + root: ROOT, + transport, + signer: signerWithNip44(), + policy: POLICY, + now: () => NOW, + }); + runtime.setViewer(VIEWER); + + runtime.ingestEvent(event({ pubkey: ALICE, content: "cipher" })); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(runtime.trust.mutes.size).toBe(0); + }); + + it("degrades quietly without a NIP-44 capable signer", async () => { + const transport = recordingTransport(); + const runtime = makeRuntime(transport); + + expect(() => runtime.ingestEvent(event({ pubkey: VIEWER, content: "cipher" }))).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(runtime.trust.mutes.size).toBe(0); + }); +}); diff --git a/packages/widget/src/provider.js b/packages/widget/src/provider.js index 07329dc..8fc4bf4 100644 --- a/packages/widget/src/provider.js +++ b/packages/widget/src/provider.js @@ -35,7 +35,7 @@ export function requestContext(element) { export class BitGateProvider extends globalThis.HTMLElement { static get observedAttributes() { - return ["relays", "root", "policy", "profile", "application", "namespace", "viewer"]; + return ["relays", "root", "policy", "profile", "application", "namespace", "viewer", "trust"]; } constructor() { @@ -157,6 +157,15 @@ export class BitGateProvider extends globalThis.HTMLElement { ); await this.runtime.loadAdministrativeState(); + + // With a viewer known, build the trust graph without the host having to: + // fetch their follow list, then read each contact's mute list from that + // contact's own write relays. + if (this.runtime.viewerPubkey && this.getAttribute("trust") !== "manual") { + await this.runtime.loadContacts(); + await this.runtime.loadTrustedMuteLists(); + } + this.dispatchEvent(new CustomEvent("bitgate:loaded", { bubbles: true, composed: true })); } catch (error) { this.error = /** @type {Error} */ (error); @@ -205,6 +214,12 @@ export class BitGateProvider extends globalThis.HTMLElement { this.runtime.signer = signer; const pubkey = await signer.getPublicKey(); this.runtime.setViewer(pubkey); + + if (this.getAttribute("trust") !== "manual") { + await this.runtime.loadContacts(); + await this.runtime.loadTrustedMuteLists(); + } + return pubkey; } }