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.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-09 20:52:21 -04:00
1 parent 015f9c3ad4
commit 7a45f1cf3e
15 files changed
+1399 -17

No files matched your search

+23 -2
View File
@@ -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<NostrEvent[]>} list
* @property {(filters: object[], handlers: SubscriptionHandlers, options?: object) => GovernanceSubscription} subscribe
* @property {(filters: object[], options?: ListOptions) => Promise<NostrEvent[]>} list
* @property {(filters: object[], handlers: SubscriptionHandlers, options?: ListOptions) => GovernanceSubscription} subscribe
* @property {(event: NostrEvent, options?: object) => Promise<PublishResult>} 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<string>} decrypt
* @property {(pubkey: string, plaintext: string) => Promise<string>} [encrypt]
*/
/**
* @typedef {Object} GovernanceSigner
* @property {() => Promise<string>} getPublicKey
* @property {(event: object) => Promise<NostrEvent>} 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<string>} [decrypt] -
* Flat fallback for signers that expose decryption without the nip44 namespace
*/
/**
+38 -7
View File
@@ -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<NostrEvent[]>}
*/
async list(filters, listOptions = {}) {
const id = `bg-list-${(subscriptionCounter += 1)}`;
const targets = resolveTargets(listOptions.relays);
/** @type {Map<string, NostrEvent>} */
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<string>} */
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);
}
},
+200
View File
@@ -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<string, import('@bitgate/nostr').RelayList>} */
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>} 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>} 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>} 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>} 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,
};
}