mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-11-05 13:01:02 +00:00
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:
1 parent
015f9c3ad4
commit
7a45f1cf3e
15 files changed
+1399
-17
No files matched your search
@@ -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
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user