mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +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
@@ -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.
|
||||
@@ -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<string, import('./policy.js').CategoryThresholds>}
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<string, string>} 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<string>} */
|
||||
const seen = new Set();
|
||||
/** @type {Map<string, string>} */
|
||||
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<string | { pubkey: string, relay?: string, petname?: string }>} 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 };
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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<string>} [options.decrypt] - NIP-44 decrypt
|
||||
* @returns {Promise<Array<{ pubkey: string, category?: string }>>}
|
||||
*/
|
||||
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<string>} */
|
||||
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
|
||||
|
||||
@@ -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<string>} */
|
||||
const read = new Set();
|
||||
/** @type {Set<string>} */
|
||||
const write = new Set();
|
||||
|
||||
for (const tag of getTags(event, "r")) {
|
||||
const url = normalizeRelayUrl(tag[1]);
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
const marker = typeof tag[2] === "string" ? tag[2].trim().toLowerCase() : "";
|
||||
|
||||
if (marker === "read") {
|
||||
read.add(url);
|
||||
} else if (marker === "write") {
|
||||
write.add(url);
|
||||
} else {
|
||||
read.add(url);
|
||||
write.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
updatedAt: Number.isFinite(event.created_at) ? event.created_at : 0,
|
||||
read: Array.from(read),
|
||||
write: Array.from(write),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group authors by the relays their events should be fetched from.
|
||||
*
|
||||
* Returns a map of relay URL to the authors who write there, which is exactly
|
||||
* the shape a batched outbox query needs: one subscription per relay carrying
|
||||
* every author that relay can answer for.
|
||||
*
|
||||
* Authors with no known relay list fall back to the caller's default relays —
|
||||
* omitting them entirely would quietly drop people who simply have not
|
||||
* published a kind:10002.
|
||||
*
|
||||
* @param {string[]} authors
|
||||
* @param {Map<string, RelayList>} relayLists - By author pubkey
|
||||
* @param {Object} [options]
|
||||
* @param {string[]} [options.fallback] - Relays for authors with no list
|
||||
* @param {number} [options.maxRelaysPerAuthor] - Cap on relays queried per author
|
||||
* @returns {Map<string, string[]>} Relay URL to author pubkeys
|
||||
*/
|
||||
export function groupAuthorsByWriteRelay(authors, relayLists, options = {}) {
|
||||
const fallback = (options.fallback ?? []).map(normalizeRelayUrl).filter(Boolean);
|
||||
// Some accounts advertise a dozen relays. Querying all of them multiplies
|
||||
// connections for diminishing returns, so take the first few and rely on
|
||||
// relay overlap for the rest.
|
||||
const maxPerAuthor = options.maxRelaysPerAuthor ?? 3;
|
||||
|
||||
/** @type {Map<string, Set<string>>} */
|
||||
const byRelay = new Map();
|
||||
|
||||
for (const author of authors ?? []) {
|
||||
const pubkey = normalizePubkeyInput(author);
|
||||
if (!pubkey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const list = relayLists.get(pubkey);
|
||||
const relays = list?.write?.length ? list.write.slice(0, maxPerAuthor) : fallback;
|
||||
|
||||
for (const relay of relays) {
|
||||
const url = normalizeRelayUrl(relay);
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
if (!byRelay.has(url)) {
|
||||
byRelay.set(url, new Set());
|
||||
}
|
||||
/** @type {Set<string>} */ (byRelay.get(url)).add(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Map<string, string[]>} */
|
||||
const grouped = new Map();
|
||||
for (const [relay, pubkeys] of byRelay) {
|
||||
grouped.set(relay, Array.from(pubkeys));
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a relay list as an unsigned event template.
|
||||
* @param {{ read?: string[], write?: string[], both?: string[] }} relays
|
||||
* @returns {{ kind: number, content: string, tags: string[][] }}
|
||||
*/
|
||||
export function encodeRelayList(relays) {
|
||||
/** @type {string[][]} */
|
||||
const tags = [];
|
||||
|
||||
for (const url of relays.both ?? []) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
if (normalized) tags.push(["r", normalized]);
|
||||
}
|
||||
for (const url of relays.read ?? []) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
if (normalized) tags.push(["r", normalized, "read"]);
|
||||
}
|
||||
for (const url of relays.write ?? []) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
if (normalized) tags.push(["r", normalized, "write"]);
|
||||
}
|
||||
|
||||
return { kind: RELAY_LIST_KIND, content: "", tags };
|
||||
}
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user