mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Under a NIP-46 remote signer, every decrypt is a PUBLISHED relay RPC. The list
services (blocks/subscriptions/hashtags) probed all decryptor families
concurrently via Promise.any — so each list event published nip04 AND nip44 RPCs
at once. On login that flooded the relay ("rate-limited: you are noting too
much"), and the racing/queued RPCs blew past the 30s decrypt budget → the
"Decryption timed out" failures.
A NIP-04 payload carries the "?iv=" marker and can ONLY be decrypted by nip04;
otherwise it's nip44. The other family can never succeed on a given ciphertext,
so racing it is pure waste (and a wasted published RPC under NIP-46). Restrict
the decrypt probe to the family matching the ciphertext format (fall back to all
if the format is somehow unknown). Halves cold-load RPCs and stops the racing
publishes that tripped the rate limit; benefits all signer types.
Spec correction: the hashtag "retries decryptors when hinted scheme fails" test
stubbed an impossible scenario (the same ciphertext decrypting under nip04 after
nip44 failed). Rewritten to assert the real contract — the ciphertext format
routes decryption and overrides a misleading `encrypted` tag, invoking only the
matching decryptor.
test_integrity_note:
change_type: ["spec_correction"]
scenarios:
- id: SCN-decrypt-format-routing
given: "a nip04-format ciphertext (?iv=) tagged misleadingly as nip44"
when: "a list service decrypts it"
then: "only the nip04 decryptor is invoked; nip44 is never attempted"
anti_cheat_rationale:
prevents: ["over-mocking internal logic", "asserting impossible cross-family decryption"]
relaxation:
did_relax_any_assertion: false
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2520 lines
78 KiB
JavaScript
2520 lines
78 KiB
JavaScript
// js/userBlocks.js
|
|
import {
|
|
nostrClient,
|
|
requestDefaultExtensionPermissions,
|
|
} from "./nostrClientFacade.js";
|
|
import { getActiveSigner } from "./nostr/index.js";
|
|
import { isSessionActor } from "./nostr/sessionActor.js";
|
|
import {
|
|
buildBlockListEvent,
|
|
buildMuteListEvent,
|
|
BLOCK_LIST_IDENTIFIER,
|
|
KIND_MUTE_LIST,
|
|
} from "./nostrEventSchemas.js";
|
|
import { devLogger, userLogger } from "./utils/logger.js";
|
|
import {
|
|
noteSignerDecryptTimeout,
|
|
noteSignerDecryptSuccess,
|
|
} from "./utils/signerHealthNotice.js";
|
|
import { STANDARD_TIMEOUT_MS, MAX_BLOCKLIST_ENTRIES } from "./constants.js";
|
|
import {
|
|
publishEventToRelays,
|
|
assertAnyRelayAccepted,
|
|
} from "./nostrPublish.js";
|
|
import { profileCache } from "./state/profileCache.js";
|
|
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
|
|
import { relayManager } from "./relayManager.js";
|
|
import {
|
|
DEFAULT_NIP07_ENCRYPTION_METHODS,
|
|
runNip07WithRetry,
|
|
NIP07_PRIORITY,
|
|
} from "./nostr/nip07Permissions.js";
|
|
import { relaySubscriptionService } from "./services/relaySubscriptionService.js";
|
|
import {
|
|
getLastSuccessfulScheme,
|
|
setLastSuccessfulScheme,
|
|
} from "./nostr/decryptionSchemeCache.js";
|
|
import {
|
|
compareListEventsDesc,
|
|
computeIncrementalSinceWithOverlap,
|
|
selectNewestListEvent,
|
|
} from "./nostr/listEventOrdering.js";
|
|
|
|
class TinyEventEmitter {
|
|
constructor() {
|
|
this.listeners = new Map();
|
|
}
|
|
|
|
on(eventName, handler) {
|
|
if (typeof eventName !== "string" || typeof handler !== "function") {
|
|
return () => {};
|
|
}
|
|
|
|
if (!this.listeners.has(eventName)) {
|
|
this.listeners.set(eventName, new Set());
|
|
}
|
|
|
|
const handlers = this.listeners.get(eventName);
|
|
handlers.add(handler);
|
|
|
|
return () => {
|
|
handlers.delete(handler);
|
|
if (!handlers.size) {
|
|
this.listeners.delete(eventName);
|
|
}
|
|
};
|
|
}
|
|
|
|
emit(eventName, detail) {
|
|
const handlers = this.listeners.get(eventName);
|
|
if (!handlers || !handlers.size) {
|
|
return;
|
|
}
|
|
|
|
for (const handler of Array.from(handlers)) {
|
|
try {
|
|
handler(detail);
|
|
} catch (error) {
|
|
userLogger.warn(
|
|
`[UserBlockList] listener for "${eventName}" threw an error`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const USER_BLOCK_EVENTS = Object.freeze({
|
|
CHANGE: "change",
|
|
STATUS: "status",
|
|
});
|
|
|
|
const FAST_BLOCKLIST_RELAY_LIMIT = 3;
|
|
const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
|
|
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
|
|
const MAX_BACKGROUND_BLOCKLIST_RELAY_LIMIT = 24;
|
|
// Decrypt timeout budget. A nip-07 decrypt is a postMessage round-trip to the
|
|
// extension's (single-threaded, possibly sleeping) service worker — under
|
|
// cold-login load real extensions can take 15-25s to answer even though the
|
|
// crypto itself is instant. The harness (scripts/perf/nip07-channel-sim.mjs)
|
|
// shows lists fail to load the moment per-call latency crosses this budget, so
|
|
// keep it generous (30s) to give a slow-but-responsive signer room to complete
|
|
// rather than timing out and retrying forever (KNOWN_BUGS #0).
|
|
const DECRYPT_TIMEOUT_MS = Math.max(STANDARD_TIMEOUT_MS, 30000);
|
|
const BACKGROUND_DECRYPT_TIMEOUT_MS = 12000;
|
|
// PERF: Reduced from 10s to 3s for faster recovery during login.
|
|
const DECRYPT_RETRY_DELAY_MS = 3000;
|
|
// Cap the exponential backoff well above the decrypt budget so a persistently
|
|
// unresponsive signer settles into an occasional heartbeat instead of pinning
|
|
// the extension/CPU every ~15s indefinitely.
|
|
const MAX_DECRYPT_RETRY_DELAY_MS = 60000;
|
|
|
|
// Transient decrypt failures that must KEEP the stale list and RETRY in the
|
|
// background (the channel/extension recovers shortly), rather than giving up
|
|
// permanently. Besides our own timeout, this covers a severed nip-07 message
|
|
// port ("message channel closed" — KNOWN_BUGS #0) and the circuit breaker's
|
|
// fast-fail while it probes for recovery. Without this, a brief channel drop
|
|
// during the login burst abandons the block list until a page refresh, even
|
|
// though the channel comes back seconds later.
|
|
const TRANSIENT_DECRYPT_ERROR_CODES = new Set([
|
|
"user-blocklist-decrypt-timeout",
|
|
"nip07-channel-unresponsive",
|
|
]);
|
|
const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [
|
|
"message channel closed",
|
|
"could not establish connection",
|
|
"receiving end does not exist",
|
|
"extension context invalidated",
|
|
"connection lost",
|
|
"channel is unresponsive",
|
|
];
|
|
function isTransientDecryptError(error) {
|
|
if (!error) return false;
|
|
if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) {
|
|
return true;
|
|
}
|
|
const message =
|
|
typeof error.message === "string" ? error.message.toLowerCase() : "";
|
|
return TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p));
|
|
}
|
|
function computeRetryDelay(baseDelayMs, attempt) {
|
|
const normalizedAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
|
const multiplier = 2 ** normalizedAttempt;
|
|
const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier));
|
|
return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay);
|
|
}
|
|
|
|
function sanitizeRelayList(candidate) {
|
|
if (!Array.isArray(candidate)) return [];
|
|
const seen = new Set();
|
|
const relays = [];
|
|
for (const value of candidate) {
|
|
const normalized = typeof value === "string" ? value.trim() : "";
|
|
if (!normalized || seen.has(normalized)) continue;
|
|
seen.add(normalized);
|
|
relays.push(normalized);
|
|
}
|
|
return relays;
|
|
}
|
|
function normalizeEncryptionToken(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]/g, "");
|
|
}
|
|
|
|
function extractEncryptionHints(event) {
|
|
if (!event || typeof event !== "object") {
|
|
return [];
|
|
}
|
|
|
|
const tags = Array.isArray(event.tags) ? event.tags : [];
|
|
const hints = [];
|
|
|
|
const pushUnique = (scheme) => {
|
|
if (!scheme || hints.includes(scheme)) {
|
|
return;
|
|
}
|
|
hints.push(scheme);
|
|
};
|
|
|
|
for (const tag of tags) {
|
|
if (!Array.isArray(tag) || tag.length < 2) {
|
|
continue;
|
|
}
|
|
const name = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
|
if (name !== "encrypted" && name !== "encryption") {
|
|
continue;
|
|
}
|
|
const rawValue = typeof tag[1] === "string" ? tag[1] : "";
|
|
if (!rawValue) {
|
|
continue;
|
|
}
|
|
const parts = rawValue
|
|
.split(/[\s,]+/)
|
|
.map((part) => normalizeEncryptionToken(part))
|
|
.filter(Boolean);
|
|
|
|
for (const part of parts) {
|
|
if (part === "nip44v2" || part === "nip44v02") {
|
|
pushUnique("nip44_v2");
|
|
continue;
|
|
}
|
|
if (part === "nip44") {
|
|
pushUnique("nip44");
|
|
continue;
|
|
}
|
|
if (part === "nip04" || part === "nip4") {
|
|
pushUnique("nip04");
|
|
}
|
|
}
|
|
}
|
|
|
|
return hints;
|
|
}
|
|
|
|
function determineDecryptionOrder(event, availableSchemes, cachedScheme = null) {
|
|
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
|
|
const availableSet = new Set(available);
|
|
const prioritized = [];
|
|
|
|
if (cachedScheme && availableSet.has(cachedScheme)) {
|
|
prioritized.push(cachedScheme);
|
|
}
|
|
|
|
const hints = extractEncryptionHints(event);
|
|
const aliasMap = {
|
|
nip04: ["nip04"],
|
|
nip44: ["nip44", "nip44_v2"],
|
|
nip44_v2: ["nip44_v2", "nip44"],
|
|
};
|
|
|
|
for (const hint of hints) {
|
|
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
|
|
for (const candidate of candidates) {
|
|
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
|
|
prioritized.push(candidate);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const fallback of ["nip44_v2", "nip44", "nip04"]) {
|
|
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
|
|
prioritized.push(fallback);
|
|
}
|
|
}
|
|
|
|
return prioritized.length ? prioritized : available;
|
|
}
|
|
|
|
function describeDiscardedEntry(entry) {
|
|
if (typeof entry === "string") {
|
|
return entry.trim();
|
|
}
|
|
|
|
if (entry === null) {
|
|
return "null";
|
|
}
|
|
|
|
if (typeof entry === "undefined") {
|
|
return "undefined";
|
|
}
|
|
|
|
if (Array.isArray(entry)) {
|
|
try {
|
|
return JSON.stringify(entry);
|
|
} catch (_error) {
|
|
return String(entry);
|
|
}
|
|
}
|
|
|
|
if (typeof entry === "object") {
|
|
try {
|
|
return JSON.stringify(entry);
|
|
} catch (_error) {
|
|
return String(entry);
|
|
}
|
|
}
|
|
|
|
return String(entry);
|
|
}
|
|
|
|
function sanitizeMuteTags(values, ownerPubkey, options = {}) {
|
|
const logDiscarded = options?.logDiscarded !== false;
|
|
const owner = normalizeHex(ownerPubkey);
|
|
const sanitized = [];
|
|
const seen = new Set();
|
|
|
|
const iterable = Array.isArray(values)
|
|
? values
|
|
: values instanceof Set
|
|
? Array.from(values)
|
|
: values && typeof values[Symbol.iterator] === "function"
|
|
? Array.from(values)
|
|
: [];
|
|
|
|
const logDiscard = (entry, reason, extra) => {
|
|
if (!logDiscarded) {
|
|
return;
|
|
}
|
|
const detail = describeDiscardedEntry(entry);
|
|
if (extra) {
|
|
userLogger.warn(
|
|
`[UserBlockList] Discarded block entry (${reason}).`,
|
|
detail,
|
|
extra,
|
|
);
|
|
return;
|
|
}
|
|
userLogger.warn(`[UserBlockList] Discarded block entry (${reason}).`, detail);
|
|
};
|
|
|
|
for (const entry of iterable) {
|
|
let targetHex = null;
|
|
|
|
if (Array.isArray(entry)) {
|
|
if (entry.length < 2) {
|
|
logDiscard(entry, "invalid-tuple");
|
|
continue;
|
|
}
|
|
|
|
const label =
|
|
typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : "";
|
|
if (label !== "p") {
|
|
logDiscard(entry, "unsupported-tag");
|
|
continue;
|
|
}
|
|
|
|
targetHex = normalizeHex(entry[1]);
|
|
if (!targetHex) {
|
|
logDiscard(entry, "invalid-target");
|
|
continue;
|
|
}
|
|
} else if (typeof entry === "string") {
|
|
targetHex = normalizeHex(entry);
|
|
if (!targetHex) {
|
|
logDiscard(entry, "invalid-string");
|
|
continue;
|
|
}
|
|
} else if (entry && typeof entry === "object") {
|
|
if (typeof entry.pubkey === "string") {
|
|
targetHex = normalizeHex(entry.pubkey);
|
|
if (!targetHex) {
|
|
logDiscard(entry, "invalid-object-pubkey");
|
|
continue;
|
|
}
|
|
} else {
|
|
logDiscard(entry, "unsupported-entry");
|
|
continue;
|
|
}
|
|
} else {
|
|
logDiscard(entry, "unsupported-entry");
|
|
continue;
|
|
}
|
|
|
|
if (owner && targetHex === owner) {
|
|
logDiscard(entry, "self-target");
|
|
continue;
|
|
}
|
|
|
|
if (seen.has(targetHex)) {
|
|
logDiscard(entry, "duplicate", targetHex);
|
|
continue;
|
|
}
|
|
|
|
seen.add(targetHex);
|
|
sanitized.push(["p", targetHex]);
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
function serializeBlockListTagMatrix(values, ownerPubkey, options = {}) {
|
|
const tags = sanitizeMuteTags(values, ownerPubkey, options);
|
|
return JSON.stringify(tags);
|
|
}
|
|
|
|
function extractPubkeysFromTags(tags, ownerPubkey = null, options = {}) {
|
|
if (!Array.isArray(tags)) {
|
|
return [];
|
|
}
|
|
const collected = [];
|
|
const seen = new Set();
|
|
const owner = ownerPubkey ? normalizeHex(ownerPubkey) : null;
|
|
const limit = Number.isFinite(options?.limit)
|
|
? Math.max(0, Math.floor(options.limit))
|
|
: null;
|
|
|
|
for (const entry of tags) {
|
|
if (!Array.isArray(entry) || entry.length < 2) {
|
|
continue;
|
|
}
|
|
const label = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : "";
|
|
if (label !== "p") {
|
|
continue;
|
|
}
|
|
const normalized = normalizeHex(entry[1]);
|
|
if (!normalized || (owner && normalized === owner) || seen.has(normalized)) {
|
|
continue;
|
|
}
|
|
seen.add(normalized);
|
|
collected.push(normalized);
|
|
if (limit !== null && collected.length >= limit) {
|
|
break;
|
|
}
|
|
}
|
|
return collected;
|
|
}
|
|
|
|
function parseBlockListPlaintext(plaintext, ownerPubkey, options = {}) {
|
|
if (typeof plaintext !== "string" || !plaintext) {
|
|
return [];
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(plaintext);
|
|
} catch (error) {
|
|
userLogger.warn(
|
|
"[UserBlockList] Failed to parse block list ciphertext as JSON; treating as empty.",
|
|
error,
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const owner = normalizeHex(ownerPubkey);
|
|
const limit = Number.isFinite(options?.limit)
|
|
? Math.max(0, Math.floor(options.limit))
|
|
: null;
|
|
|
|
if (Array.isArray(parsed)) {
|
|
if (limit !== null && parsed.length > limit) {
|
|
devLogger.warn(
|
|
"[UserBlockList] Block list payload exceeds entry limit; truncating.",
|
|
{ limit, payloadSize: parsed.length },
|
|
);
|
|
}
|
|
return extractPubkeysFromTags(parsed, owner, { limit });
|
|
}
|
|
|
|
if (parsed && typeof parsed === "object") {
|
|
if (Array.isArray(parsed.tags) && parsed.tags.length) {
|
|
if (limit !== null && parsed.tags.length > limit) {
|
|
devLogger.warn(
|
|
"[UserBlockList] Block list tag payload exceeds entry limit; truncating.",
|
|
{ limit, payloadSize: parsed.tags.length },
|
|
);
|
|
}
|
|
return extractPubkeysFromTags(parsed.tags, owner, { limit });
|
|
}
|
|
|
|
const legacy = Array.isArray(parsed.blockedPubkeys) ? parsed.blockedPubkeys : [];
|
|
if (limit !== null && legacy.length > limit) {
|
|
devLogger.warn(
|
|
"[UserBlockList] Legacy block list payload exceeds entry limit; truncating.",
|
|
{ limit, payloadSize: legacy.length },
|
|
);
|
|
}
|
|
const normalizedLegacy = legacy
|
|
.map((value) => normalizeHex(value))
|
|
.filter((value) => value && value !== owner);
|
|
if (limit === null) {
|
|
return normalizedLegacy;
|
|
}
|
|
return normalizedLegacy.slice(0, limit);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function isUserBlockListEvent(event) {
|
|
if (!event || typeof event.kind !== "number") {
|
|
return false;
|
|
}
|
|
|
|
if (event.kind === KIND_MUTE_LIST) {
|
|
return true;
|
|
}
|
|
|
|
if (event.kind === 30002) {
|
|
const tags = Array.isArray(event.tags) ? event.tags : [];
|
|
return tags.some(
|
|
(tag) =>
|
|
Array.isArray(tag) &&
|
|
typeof tag[0] === "string" &&
|
|
tag[0].trim().toLowerCase() === "d" &&
|
|
typeof tag[1] === "string" &&
|
|
tag[1].trim() === BLOCK_LIST_IDENTIFIER,
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isTaggedBlockListEvent(event) {
|
|
if (!event || !Array.isArray(event.tags)) {
|
|
return false;
|
|
}
|
|
return event.tags.some(
|
|
(tag) =>
|
|
Array.isArray(tag) &&
|
|
tag.length >= 2 &&
|
|
typeof tag[0] === "string" &&
|
|
tag[0].trim().toLowerCase() === "d" &&
|
|
typeof tag[1] === "string" &&
|
|
tag[1].trim() === BLOCK_LIST_IDENTIFIER,
|
|
);
|
|
}
|
|
|
|
const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
const BECH32_CHARSET_MAP = (() => {
|
|
const map = new Map();
|
|
for (let index = 0; index < BECH32_CHARSET.length; index += 1) {
|
|
map.set(BECH32_CHARSET[index], index);
|
|
}
|
|
return map;
|
|
})();
|
|
|
|
function bytesToHex(bytes) {
|
|
if (!bytes || typeof bytes.length !== "number") {
|
|
return "";
|
|
}
|
|
|
|
let hex = "";
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
const value = bytes[index];
|
|
if (typeof value !== "number") {
|
|
return "";
|
|
}
|
|
const normalized = value & 0xff;
|
|
hex += normalized.toString(16).padStart(2, "0");
|
|
}
|
|
return hex;
|
|
}
|
|
|
|
function bech32Polymod(values) {
|
|
let chk = 1;
|
|
const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
|
|
|
for (const value of values) {
|
|
const top = chk >>> 25;
|
|
chk = ((chk & 0x1ffffff) << 5) ^ value;
|
|
for (let bit = 0; bit < generators.length; bit += 1) {
|
|
if ((top >>> bit) & 1) {
|
|
chk ^= generators[bit];
|
|
}
|
|
}
|
|
}
|
|
|
|
return chk;
|
|
}
|
|
|
|
function bech32HrpExpand(hrp) {
|
|
const expanded = [];
|
|
for (let index = 0; index < hrp.length; index += 1) {
|
|
const code = hrp.charCodeAt(index);
|
|
expanded.push(code >>> 5);
|
|
}
|
|
expanded.push(0);
|
|
for (let index = 0; index < hrp.length; index += 1) {
|
|
const code = hrp.charCodeAt(index);
|
|
expanded.push(code & 31);
|
|
}
|
|
return expanded;
|
|
}
|
|
|
|
function bech32VerifyChecksum(hrp, data) {
|
|
return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1;
|
|
}
|
|
|
|
function bech32Decode(value) {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const lower = trimmed.toLowerCase();
|
|
const upper = trimmed.toUpperCase();
|
|
if (trimmed !== lower && trimmed !== upper) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = lower;
|
|
const separatorIndex = normalized.lastIndexOf("1");
|
|
if (separatorIndex < 1 || separatorIndex + 7 > normalized.length) {
|
|
return null;
|
|
}
|
|
|
|
const hrp = normalized.slice(0, separatorIndex);
|
|
const data = [];
|
|
for (let index = separatorIndex + 1; index < normalized.length; index += 1) {
|
|
const char = normalized[index];
|
|
if (!BECH32_CHARSET_MAP.has(char)) {
|
|
return null;
|
|
}
|
|
data.push(BECH32_CHARSET_MAP.get(char));
|
|
}
|
|
|
|
if (!bech32VerifyChecksum(hrp, data)) {
|
|
return null;
|
|
}
|
|
|
|
return { hrp, words: data.slice(0, -6) };
|
|
}
|
|
|
|
function convertBits(data, fromBits, toBits, pad = true) {
|
|
const result = [];
|
|
let acc = 0;
|
|
let bits = 0;
|
|
const maxv = (1 << toBits) - 1;
|
|
const maxAcc = (1 << (fromBits + toBits - 1)) - 1;
|
|
|
|
for (const value of data) {
|
|
if (value < 0 || value >> fromBits) {
|
|
return null;
|
|
}
|
|
acc = ((acc << fromBits) | value) & maxAcc;
|
|
bits += fromBits;
|
|
while (bits >= toBits) {
|
|
bits -= toBits;
|
|
result.push((acc >>> bits) & maxv);
|
|
}
|
|
}
|
|
|
|
if (pad) {
|
|
if (bits) {
|
|
result.push((acc << (toBits - bits)) & maxv);
|
|
}
|
|
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) {
|
|
return null;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function fallbackDecodeNpubToHex(value) {
|
|
const decoded = bech32Decode(value);
|
|
if (!decoded || decoded.hrp !== "npub") {
|
|
return "";
|
|
}
|
|
|
|
const bytes = convertBits(decoded.words, 5, 8, false);
|
|
if (!bytes || !bytes.length) {
|
|
return "";
|
|
}
|
|
|
|
return bytesToHex(bytes);
|
|
}
|
|
|
|
function normalizeDecodedHex(value) {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!/^[0-9a-f]{64}$/i.test(trimmed)) {
|
|
return null;
|
|
}
|
|
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
function normalizeNpubInput(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return "";
|
|
}
|
|
|
|
const lower = trimmed.toLowerCase();
|
|
if (lower.startsWith("nostr:")) {
|
|
return trimmed.slice(6).trim();
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
function decodeNpubToHex(npub) {
|
|
if (typeof npub !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = normalizeNpubInput(npub);
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
const tools = typeof globalThis !== "undefined" ? globalThis?.NostrTools : null;
|
|
const decoder = tools?.nip19?.decode;
|
|
if (typeof decoder === "function") {
|
|
try {
|
|
const decoded = decoder(trimmed);
|
|
if (!decoded || decoded.type !== "npub") {
|
|
return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed));
|
|
}
|
|
if (typeof decoded.data === "string") {
|
|
const normalized = normalizeDecodedHex(decoded.data);
|
|
return normalized || normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed));
|
|
}
|
|
return normalizeDecodedHex(bytesToHex(decoded.data));
|
|
} catch (error) {
|
|
return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed));
|
|
}
|
|
}
|
|
|
|
return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed));
|
|
}
|
|
|
|
function normalizeHex(pubkey) {
|
|
if (typeof pubkey !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = pubkey.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
const decoded = decodeNpubToHex(trimmed);
|
|
if (decoded) {
|
|
return decoded;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
class UserBlockListManager {
|
|
constructor() {
|
|
this.blockedPubkeys = new Set();
|
|
this._privateBlocks = new Set();
|
|
this._publicMutes = new Set();
|
|
this.activePubkey = null;
|
|
this.blockEventId = null;
|
|
this.blockEventCreatedAt = null;
|
|
this.lastPublishedCreatedAt = null;
|
|
this.muteEventId = null;
|
|
this.muteEventCreatedAt = null;
|
|
this.loaded = false;
|
|
this.emitter = new TinyEventEmitter();
|
|
this.seedStateCache = new Map();
|
|
this.decryptRetryTimeoutId = null;
|
|
this.decryptRetryAttempt = 0;
|
|
this.loadPromise = null;
|
|
this.loadingPubkey = null;
|
|
this.blockListSubscriptionKey = null;
|
|
profileCache.subscribe((event, detail) => {
|
|
if (event === "profileChanged") {
|
|
this.reset();
|
|
} else if (event === "runtimeCleared" && detail.pubkey === this.activePubkey) {
|
|
this.reset();
|
|
if (this.activePubkey) {
|
|
this.loadBlocks(this.activePubkey);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
reset() {
|
|
this.stopBlockListSubscription();
|
|
this.blockedPubkeys.clear();
|
|
this._privateBlocks.clear();
|
|
this._publicMutes.clear();
|
|
this.blockEventId = null;
|
|
this.blockEventCreatedAt = null;
|
|
this.lastPublishedCreatedAt = null;
|
|
this.muteEventId = null;
|
|
this.muteEventCreatedAt = null;
|
|
this.loaded = false;
|
|
if (this.decryptRetryTimeoutId) {
|
|
clearTimeout(this.decryptRetryTimeoutId);
|
|
this.decryptRetryTimeoutId = null;
|
|
}
|
|
this.decryptRetryAttempt = 0;
|
|
this.loadPromise = null;
|
|
this.loadingPubkey = null;
|
|
}
|
|
|
|
stopBlockListSubscription() {
|
|
if (this.blockListSubscriptionHandle) {
|
|
try {
|
|
this.blockListSubscriptionHandle.close();
|
|
} catch (error) {
|
|
devLogger.warn("[UserBlockList] Failed to close block-list subscription handle", error);
|
|
}
|
|
this.blockListSubscriptionHandle = null;
|
|
}
|
|
if (this.blockListSubscriptionKey) {
|
|
relaySubscriptionService.stopSubscription(
|
|
this.blockListSubscriptionKey,
|
|
"reset",
|
|
);
|
|
this.blockListSubscriptionKey = null;
|
|
}
|
|
}
|
|
|
|
ensureBlockListSubscription(userPubkey, relays) {
|
|
const normalized = normalizeHex(userPubkey);
|
|
if (!normalized || !nostrClient?.pool) {
|
|
return null;
|
|
}
|
|
|
|
const key = `block-list:${normalized}`;
|
|
this.blockListSubscriptionKey = key;
|
|
|
|
const filters = [
|
|
{ kinds: [KIND_MUTE_LIST], authors: [normalized] },
|
|
{ kinds: [30002], authors: [normalized], "#d": [BLOCK_LIST_IDENTIFIER] },
|
|
];
|
|
|
|
// Route through the L1 SubscriptionManager when available (dedup + reconnect
|
|
// re-issue); fall back to relaySubscriptionService in manager-less envs.
|
|
const manager =
|
|
typeof nostrClient.getSubscriptionManager === "function"
|
|
? nostrClient.getSubscriptionManager()
|
|
: null;
|
|
if (manager) {
|
|
this.blockListSubscriptionHandle = manager.subscribe({
|
|
key,
|
|
relays,
|
|
filters,
|
|
label: "block-list",
|
|
onEvent: (event) => this.handleBlockListEvent(event),
|
|
});
|
|
return this.blockListSubscriptionHandle;
|
|
}
|
|
|
|
return relaySubscriptionService.ensureSubscription({
|
|
key,
|
|
pool: nostrClient.pool,
|
|
relays,
|
|
filters,
|
|
label: "block-list",
|
|
onEvent: (event) => this.handleBlockListEvent(event),
|
|
});
|
|
}
|
|
|
|
handleBlockListEvent(event) {
|
|
const normalized = normalizeHex(event?.pubkey);
|
|
if (!normalized || normalized !== this.activePubkey) {
|
|
return;
|
|
}
|
|
if (!isUserBlockListEvent(event)) {
|
|
return;
|
|
}
|
|
this.loadBlocks(normalized).catch((error) => {
|
|
devLogger.warn("[UserBlockList] Failed to refresh after block list event", error);
|
|
});
|
|
}
|
|
|
|
on(eventName, handler) {
|
|
return this.emitter.on(eventName, handler);
|
|
}
|
|
|
|
getBlockedPubkeys() {
|
|
return Array.from(this.blockedPubkeys);
|
|
}
|
|
|
|
getPublicMutes() {
|
|
return Array.from(this._publicMutes);
|
|
}
|
|
|
|
getPrivateBlocks() {
|
|
return Array.from(this._privateBlocks);
|
|
}
|
|
|
|
scheduleDecryptRetry(userPubkey, detail = {}) {
|
|
if (!userPubkey) {
|
|
return;
|
|
}
|
|
if (this.decryptRetryTimeoutId) {
|
|
clearTimeout(this.decryptRetryTimeoutId);
|
|
}
|
|
const attempt = this.decryptRetryAttempt;
|
|
const retryDelayMs = computeRetryDelay(DECRYPT_RETRY_DELAY_MS, attempt);
|
|
this.decryptRetryAttempt = attempt + 1;
|
|
const retryMeta = detail && typeof detail === "object" ? detail : {};
|
|
const signerStatus = retryMeta.signerStatus || "unknown";
|
|
this.decryptRetryTimeoutId = setTimeout(() => {
|
|
this.decryptRetryTimeoutId = null;
|
|
if (this.activePubkey !== userPubkey) {
|
|
return;
|
|
}
|
|
const sinceValue = Number.isFinite(this.blockEventCreatedAt)
|
|
? Math.max(0, this.blockEventCreatedAt - 1)
|
|
: undefined;
|
|
this.loadBlocks(userPubkey, {
|
|
since: sinceValue,
|
|
}).catch((retryError) => {
|
|
userLogger.warn("[UserBlockList] Decryption retry failed:", retryError);
|
|
});
|
|
}, retryDelayMs);
|
|
devLogger.log(
|
|
`[UserBlockList] Decryption stalled (${signerStatus}); retrying in ${retryDelayMs / 1000}s.`,
|
|
retryMeta.error || retryMeta,
|
|
);
|
|
}
|
|
|
|
isBlocked(pubkey) {
|
|
const normalized = normalizeHex(pubkey);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return this.blockedPubkeys.has(normalized);
|
|
}
|
|
|
|
async ensureLoaded(userPubkey) {
|
|
if (this.loaded) {
|
|
return;
|
|
}
|
|
|
|
const normalized = normalizeHex(userPubkey);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
await this.loadBlocks(normalized);
|
|
}
|
|
|
|
async loadBlocks(userPubkey, options = {}) {
|
|
const normalized = normalizeHex(userPubkey);
|
|
|
|
if (this.loadPromise && this.loadingPubkey === normalized) {
|
|
devLogger.log("[UserBlockList] Reusing in-flight block list load.", {
|
|
pubkey: normalized,
|
|
});
|
|
return this.loadPromise;
|
|
}
|
|
if (this.loadPromise && this.loadingPubkey && this.loadingPubkey !== normalized) {
|
|
devLogger.log("[UserBlockList] Waiting for existing block list load to settle.", {
|
|
previous: this.loadingPubkey,
|
|
next: normalized,
|
|
});
|
|
await this.loadPromise.catch(() => {});
|
|
}
|
|
|
|
const loadPromise = this.loadBlocksInternal(normalized, options);
|
|
this.loadPromise = loadPromise;
|
|
this.loadingPubkey = normalized;
|
|
try {
|
|
return await loadPromise;
|
|
} finally {
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action: "load-settled",
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
loaded: this.loaded === true,
|
|
activePubkey: this.activePubkey || null,
|
|
});
|
|
if (this.loadPromise === loadPromise) {
|
|
this.loadPromise = null;
|
|
this.loadingPubkey = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async loadBlocksInternal(userPubkey, options = {}) {
|
|
const normalized = normalizeHex(userPubkey);
|
|
this.activePubkey = normalized;
|
|
|
|
if (!normalized) {
|
|
this.reset();
|
|
this.loaded = true;
|
|
return;
|
|
}
|
|
|
|
const since = Number.isFinite(options?.since)
|
|
? Math.max(0, Math.floor(options.since))
|
|
: null;
|
|
const statusCallback =
|
|
typeof options?.statusCallback === "function"
|
|
? options.statusCallback
|
|
: null;
|
|
const loadMode =
|
|
options?.mode === "background" ? "background" : "interactive";
|
|
const allowPermissionPrompt =
|
|
options?.allowPermissionPrompt !== false && loadMode !== "background";
|
|
const decryptTimeoutMs = Number.isFinite(options?.decryptTimeoutMs)
|
|
? Math.max(0, Math.floor(options.decryptTimeoutMs))
|
|
: allowPermissionPrompt
|
|
? DECRYPT_TIMEOUT_MS
|
|
: BACKGROUND_DECRYPT_TIMEOUT_MS;
|
|
// Per-call nip-07 decrypt budget. A 6s value (tried earlier) killed
|
|
// in-progress decrypts on slow extensions that legitimately need 15-25s to
|
|
// answer under cold-login load, so the list never decrypted and retried
|
|
// forever. Give the call real room (interactive 25s, background 12s) — it
|
|
// sits below the service-level DECRYPT_TIMEOUT_MS backstop.
|
|
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 25000 : 12000;
|
|
const emitStatus = (detail) => {
|
|
if (!detail || typeof detail !== "object") {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
statusCallback?.(detail);
|
|
} catch (callbackError) {
|
|
userLogger.warn(
|
|
"[UserBlockList] statusCallback threw while emitting status",
|
|
callbackError,
|
|
);
|
|
}
|
|
|
|
try {
|
|
this.emitter.emit(USER_BLOCK_EVENTS.STATUS, detail);
|
|
} catch (emitterError) {
|
|
userLogger.warn(
|
|
"[UserBlockList] Failed to dispatch status event",
|
|
emitterError,
|
|
);
|
|
}
|
|
};
|
|
|
|
const readRelays = relayManager.getReadRelayUrls();
|
|
const writeRelays = relayManager.getWriteRelayUrls();
|
|
const relays = sanitizeRelayList([
|
|
...readRelays,
|
|
...writeRelays,
|
|
]);
|
|
const effectiveRelays =
|
|
relays.length > 0 ? relays : Array.from(DEFAULT_RELAY_URLS);
|
|
emitStatus({ status: "loading", relays: effectiveRelays });
|
|
if (!nostrClient?.pool && typeof nostrClient?.ensurePool === "function") {
|
|
try {
|
|
await nostrClient.ensurePool();
|
|
} catch (error) {
|
|
devLogger.warn("[UserBlockList] Failed to initialize relay pool for subscription", error);
|
|
}
|
|
}
|
|
this.ensureBlockListSubscription(normalized, effectiveRelays);
|
|
|
|
const localData = this._loadLocal(normalized);
|
|
const hasLocalData = !!localData;
|
|
|
|
if (localData) {
|
|
this._privateBlocks = localData.privateBlocks || localData.blockedPubkeys || new Set();
|
|
this._publicMutes = localData.publicMutes || new Set();
|
|
this.blockedPubkeys = new Set([...this._privateBlocks, ...this._publicMutes]);
|
|
|
|
if (Number.isFinite(localData.createdAt)) {
|
|
this.blockEventCreatedAt = localData.createdAt;
|
|
}
|
|
this.loaded = true;
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action: "sync",
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
source: "local",
|
|
});
|
|
}
|
|
|
|
const applySets = (privateSet, publicSet, meta = {}) => {
|
|
this._privateBlocks = privateSet;
|
|
this._publicMutes = publicSet;
|
|
this.blockedPubkeys = new Set([...privateSet, ...publicSet]);
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action: "sync",
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
...meta,
|
|
});
|
|
};
|
|
|
|
const resolveDecryptors = async (event) => {
|
|
let signer = getActiveSigner();
|
|
|
|
const hints = extractEncryptionHints(event);
|
|
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
|
|
const requiresNip04 = !hints.length || hints.includes("nip04");
|
|
|
|
const signerHasRequiredDecryptors = (candidate) => {
|
|
const hasNip04 = typeof candidate?.nip04Decrypt === "function";
|
|
const hasNip44 = typeof candidate?.nip44Decrypt === "function";
|
|
return (!requiresNip44 || hasNip44) && (!requiresNip04 || hasNip04);
|
|
};
|
|
|
|
// FIX: Always attempt to resolve the signer if one isn't available,
|
|
// regardless of allowPermissionPrompt. The previous guard meant that
|
|
// background refreshes (allowPermissionPrompt=false) could never decrypt
|
|
// because they skipped signer resolution entirely. ensureActiveSignerForPubkey
|
|
// does not itself prompt the user — it only waits for an already-injected
|
|
// extension or returns the existing signer.
|
|
if (
|
|
(!signer || !signerHasRequiredDecryptors(signer)) &&
|
|
typeof nostrClient?.ensureActiveSignerForPubkey === "function"
|
|
) {
|
|
signer = await nostrClient.ensureActiveSignerForPubkey(normalized);
|
|
}
|
|
|
|
// FIX: Check method existence only, not capabilities. NIP-07 adapters
|
|
// always expose decrypt methods that check extension state at call time.
|
|
// The capabilities getter dynamically queries window.nostr which may not
|
|
// have injected nip04/nip44 modules yet (lazy injection), causing false
|
|
// negatives that block decryption even when methods will work at call time.
|
|
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
|
|
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
|
|
const signerAvailable = Boolean(signer);
|
|
const signerHasDecryptors = signerHasNip04 || signerHasNip44;
|
|
const signerStatus = signerAvailable
|
|
? signerHasDecryptors
|
|
? "present"
|
|
: "present-missing-decryptor"
|
|
: "missing";
|
|
|
|
let permissionError = null;
|
|
if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) {
|
|
if (!allowPermissionPrompt) {
|
|
const error = new Error(
|
|
"Encryption permissions are required to decrypt the block list.",
|
|
);
|
|
error.code = "user-blocklist-permission-required";
|
|
error.permissionRequestDeferred = true;
|
|
permissionError = error;
|
|
} else {
|
|
try {
|
|
const permissionResult = await requestDefaultExtensionPermissions(
|
|
DEFAULT_NIP07_ENCRYPTION_METHODS,
|
|
);
|
|
if (!permissionResult?.ok) {
|
|
const error =
|
|
permissionResult?.error instanceof Error
|
|
? permissionResult.error
|
|
: new Error(
|
|
"Extension encryption permissions are required to use the browser decryptor.",
|
|
);
|
|
error.code = "user-blocklist-permission-required";
|
|
permissionError = error;
|
|
}
|
|
} catch (error) {
|
|
const wrapped =
|
|
error instanceof Error ? error : new Error(String(error));
|
|
wrapped.code = "user-blocklist-permission-required";
|
|
permissionError = wrapped;
|
|
}
|
|
}
|
|
}
|
|
|
|
const decryptors = new Map();
|
|
const sources = new Map();
|
|
const registerDecryptor = (scheme, handler, source) => {
|
|
if (
|
|
!scheme ||
|
|
typeof handler !== "function" ||
|
|
decryptors.has(scheme)
|
|
) {
|
|
return;
|
|
}
|
|
decryptors.set(scheme, handler);
|
|
sources.set(scheme, source || "unknown");
|
|
};
|
|
|
|
// Pass per-call timeouts to signer decrypt methods so they fail fast
|
|
// during login (3s) instead of using the 60s default. This prevents a
|
|
// single stalled extension call from blocking the entire decrypt pipeline.
|
|
// Use HIGH priority for user blocks as they are critical for feed safety.
|
|
const signerDecryptOptions = {
|
|
priority: NIP07_PRIORITY.HIGH,
|
|
timeoutMs: nip07DecryptTimeoutMs,
|
|
retryMultiplier: 1,
|
|
};
|
|
|
|
if (signerHasNip44) {
|
|
registerDecryptor(
|
|
"nip44",
|
|
(payload) =>
|
|
signer.nip44Decrypt(normalized, payload, signerDecryptOptions),
|
|
"active-signer",
|
|
);
|
|
registerDecryptor(
|
|
"nip44_v2",
|
|
(payload) =>
|
|
signer.nip44Decrypt(normalized, payload, signerDecryptOptions),
|
|
"active-signer",
|
|
);
|
|
}
|
|
|
|
if (signerHasNip04) {
|
|
registerDecryptor(
|
|
"nip04",
|
|
(payload) =>
|
|
signer.nip04Decrypt(normalized, payload, signerDecryptOptions),
|
|
"active-signer",
|
|
);
|
|
}
|
|
|
|
const nostrApi = typeof window !== "undefined" ? window?.nostr : null;
|
|
const decrypterOptions = {
|
|
priority: NIP07_PRIORITY.HIGH,
|
|
timeoutMs: nip07DecryptTimeoutMs,
|
|
retryMultiplier: 1,
|
|
};
|
|
|
|
if (nostrApi) {
|
|
if (typeof nostrApi.nip04?.decrypt === "function") {
|
|
registerDecryptor(
|
|
"nip04",
|
|
(payload) =>
|
|
runNip07WithRetry(
|
|
() => nostrApi.nip04.decrypt(normalized, payload),
|
|
{ label: "nip04.decrypt", ...decrypterOptions },
|
|
),
|
|
"extension",
|
|
);
|
|
}
|
|
|
|
const nip44 =
|
|
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
|
|
? nostrApi.nip44
|
|
: null;
|
|
if (nip44) {
|
|
if (typeof nip44.decrypt === "function") {
|
|
registerDecryptor(
|
|
"nip44",
|
|
(payload) =>
|
|
runNip07WithRetry(() => nip44.decrypt(normalized, payload), {
|
|
label: "nip44.decrypt",
|
|
...decrypterOptions,
|
|
}),
|
|
"extension",
|
|
);
|
|
}
|
|
|
|
const nip44v2 =
|
|
nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
|
|
if (nip44v2 && typeof nip44v2.decrypt === "function") {
|
|
registerDecryptor(
|
|
"nip44_v2",
|
|
(payload) =>
|
|
runNip07WithRetry(() => nip44v2.decrypt(normalized, payload), {
|
|
label: "nip44.v2.decrypt",
|
|
...decrypterOptions,
|
|
}),
|
|
"extension",
|
|
);
|
|
if (!decryptors.has("nip44")) {
|
|
registerDecryptor(
|
|
"nip44",
|
|
(payload) =>
|
|
runNip07WithRetry(
|
|
() => nip44v2.decrypt(normalized, payload),
|
|
{ label: "nip44.v2.decrypt", ...decrypterOptions },
|
|
),
|
|
"extension",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (permissionError) {
|
|
permissionError.signerStatus = signerStatus;
|
|
}
|
|
|
|
// Use the shared cross-service cache so all list services benefit from
|
|
// the first successful decrypt during login.
|
|
const cachedScheme = getLastSuccessfulScheme(normalized);
|
|
|
|
return {
|
|
decryptors,
|
|
sources,
|
|
permissionError,
|
|
order: determineDecryptionOrder(
|
|
event,
|
|
Array.from(decryptors.keys()),
|
|
cachedScheme,
|
|
),
|
|
signerStatus,
|
|
};
|
|
};
|
|
|
|
try {
|
|
if (!effectiveRelays.length) {
|
|
// Only clear if truly no relays, though loadBlocks with no relays is weird.
|
|
if (!this.loaded) {
|
|
this.blockedPubkeys.clear();
|
|
this.blockEventId = null;
|
|
this.blockEventCreatedAt = null;
|
|
this.loaded = true;
|
|
}
|
|
emitStatus({ status: "applied-empty" });
|
|
emitStatus({ status: "settled" });
|
|
return;
|
|
}
|
|
|
|
const applyEvents = async (
|
|
events,
|
|
{ skipIfEmpty = false, source = "fast" } = {},
|
|
) => {
|
|
const previousState = {
|
|
blockedPubkeys: new Set(this.blockedPubkeys),
|
|
privateBlocks: new Set(this._privateBlocks),
|
|
publicMutes: new Set(this._publicMutes),
|
|
blockEventId: this.blockEventId,
|
|
blockEventCreatedAt: this.blockEventCreatedAt,
|
|
loaded: this.loaded,
|
|
};
|
|
|
|
if (!Array.isArray(events) || !events.length) {
|
|
if (skipIfEmpty) {
|
|
return;
|
|
}
|
|
if (
|
|
this.lastPublishedCreatedAt !== null ||
|
|
this.blockEventCreatedAt !== null
|
|
) {
|
|
emitStatus({ status: "stale", reason: "empty-result", source });
|
|
return;
|
|
}
|
|
if (!this.loaded) {
|
|
this.blockEventId = null;
|
|
this.blockEventCreatedAt = null;
|
|
applySets(new Set(), new Set(), { source, reason: "empty-result" });
|
|
}
|
|
emitStatus({ status: "applied-empty", source });
|
|
return;
|
|
}
|
|
|
|
const validEvents = events.filter(
|
|
(event) =>
|
|
event && event.pubkey === normalized && isUserBlockListEvent(event),
|
|
);
|
|
|
|
if (!validEvents.length) {
|
|
if (skipIfEmpty) {
|
|
return;
|
|
}
|
|
if (
|
|
this.lastPublishedCreatedAt !== null ||
|
|
this.blockEventCreatedAt !== null
|
|
) {
|
|
emitStatus({ status: "stale", reason: "empty-result", source });
|
|
return;
|
|
}
|
|
if (!this.loaded) {
|
|
this.blockEventId = null;
|
|
this.blockEventCreatedAt = null;
|
|
applySets(new Set(), new Set(), { source, reason: "empty-result" });
|
|
}
|
|
emitStatus({ status: "applied-empty", source });
|
|
return;
|
|
}
|
|
|
|
// Separate by logic (Block vs Mute)
|
|
// Legacy Block: Kind 10000 with d=user-blocks OR Kind 30002
|
|
const legacyBlockEvents = validEvents
|
|
.filter((e) => isTaggedBlockListEvent(e) || e.kind === 30002);
|
|
|
|
// Standard Mute: Kind 10000 WITHOUT d=user-blocks
|
|
const standardMuteEvents = validEvents
|
|
.filter((e) => e.kind === KIND_MUTE_LIST && !isTaggedBlockListEvent(e));
|
|
|
|
const newestLegacy = selectNewestListEvent(legacyBlockEvents, {
|
|
preferredKinds: [KIND_MUTE_LIST, 30002],
|
|
});
|
|
const newestStandard = selectNewestListEvent(standardMuteEvents, {
|
|
preferredKinds: [KIND_MUTE_LIST],
|
|
});
|
|
|
|
const newestStandardTime = Number.isFinite(newestStandard?.created_at)
|
|
? newestStandard.created_at
|
|
: 0;
|
|
const newestLegacyTime = Number.isFinite(newestLegacy?.created_at)
|
|
? newestLegacy.created_at
|
|
: 0;
|
|
|
|
// The "canonical" newest event for stale checks is simply the latest of either.
|
|
const newestOverall =
|
|
compareListEventsDesc(newestStandard, newestLegacy) <= 0
|
|
? newestStandard
|
|
: newestLegacy;
|
|
const newestCreatedAt = Math.max(newestStandardTime, newestLegacyTime);
|
|
|
|
// We accept this state.
|
|
this.blockEventId = newestOverall?.id || null;
|
|
this.blockEventCreatedAt = newestCreatedAt;
|
|
|
|
const looksLikeJsonPayload = (value) => {
|
|
if (typeof value !== "string") {
|
|
return false;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
const first = trimmed[0];
|
|
return first === "[" || first === "{";
|
|
};
|
|
|
|
// Helper to decrypt a single event
|
|
const decryptEvent = async (ev, meta = {}) => {
|
|
if (!ev) return [];
|
|
const trimmedContent =
|
|
typeof ev.content === "string" ? ev.content.trim() : "";
|
|
const isContentEmpty = !trimmedContent;
|
|
|
|
if (isContentEmpty) {
|
|
return [];
|
|
}
|
|
|
|
const hasEncryptionHints = extractEncryptionHints(ev).length > 0;
|
|
const isLegacyBlockFormat = isTaggedBlockListEvent(ev) || ev.kind === 30002;
|
|
const optionalDecrypt = !hasEncryptionHints && !isLegacyBlockFormat;
|
|
|
|
if (
|
|
!hasEncryptionHints &&
|
|
!isLegacyBlockFormat &&
|
|
looksLikeJsonPayload(trimmedContent)
|
|
) {
|
|
return parseBlockListPlaintext(trimmedContent, normalized, {
|
|
limit: MAX_BLOCKLIST_ENTRIES,
|
|
});
|
|
}
|
|
|
|
const { decryptors, order, sources, permissionError, signerStatus } =
|
|
await resolveDecryptors(ev);
|
|
meta.signerStatus = signerStatus;
|
|
meta.decryptorSources = Array.from(sources.entries()).map(
|
|
([scheme, source]) => ({ scheme, source }),
|
|
);
|
|
|
|
if (!decryptors.size) {
|
|
if (optionalDecrypt) {
|
|
// NIP-51 allows descriptive plaintext in content for standard
|
|
// mute lists. If decryptors are unavailable and encryption was
|
|
// not explicitly indicated, keep public tags and continue.
|
|
return [];
|
|
}
|
|
if (permissionError) {
|
|
throw permissionError;
|
|
}
|
|
const error = new Error("No decryptor available for block list event.");
|
|
error.code = "user-blocklist-no-decryptor";
|
|
error.signerStatus = signerStatus;
|
|
throw error;
|
|
}
|
|
|
|
// PERF: Fast path — if the shared cache already knows the correct
|
|
// scheme (e.g. another list decrypted first), try ONLY that scheme
|
|
// before falling back to the full parallel probe. This avoids
|
|
// queuing redundant NIP-07 extension calls.
|
|
let decryptedText = "";
|
|
const sharedCachedScheme = getLastSuccessfulScheme(normalized);
|
|
if (sharedCachedScheme && decryptors.has(sharedCachedScheme)) {
|
|
try {
|
|
const fastResult = await decryptors.get(sharedCachedScheme)(ev.content);
|
|
if (typeof fastResult === "string" && fastResult) {
|
|
decryptedText = fastResult;
|
|
setLastSuccessfulScheme(normalized, sharedCachedScheme);
|
|
}
|
|
} catch {
|
|
// Cached scheme failed — fall through to parallel probe.
|
|
}
|
|
}
|
|
|
|
// PERF: Try all decryption schemes in parallel via Promise.any().
|
|
// The first scheme to succeed wins, avoiding sequential 3-15s timeouts
|
|
// per scheme that previously made worst-case decryption take 45s+.
|
|
// Deduplicate schemes in the same family (nip44/nip44_v2 both call
|
|
// the same underlying signer.nip44Decrypt) to avoid redundant calls.
|
|
if (!decryptedText) {
|
|
try {
|
|
const getSchemeFamily = (s) =>
|
|
s === "nip44" || s === "nip44_v2" ? "nip44" : s;
|
|
// Route by ciphertext format: a NIP-04 payload carries the "?iv="
|
|
// marker and can ONLY be decrypted by nip04; everything else is
|
|
// nip44. Racing the non-matching family is wasted work — and under
|
|
// a NIP-46 remote signer each attempt is a separately PUBLISHED
|
|
// relay RPC, so racing both families floods the relay
|
|
// ("rate-limited: noting too much") and stalls decryption. Restrict
|
|
// the probe to the matching family (fall back to all if unknown).
|
|
const formatFamily =
|
|
typeof ev.content === "string" && ev.content.includes("?iv=")
|
|
? "nip04"
|
|
: "nip44";
|
|
const matchingOrder = order.filter(
|
|
(scheme) => getSchemeFamily(scheme) === formatFamily,
|
|
);
|
|
const probeOrder = matchingOrder.length ? matchingOrder : order;
|
|
const seenFamilies = new Set();
|
|
const attempts = probeOrder
|
|
.map((scheme) => {
|
|
const family = getSchemeFamily(scheme);
|
|
if (seenFamilies.has(family)) return null;
|
|
seenFamilies.add(family);
|
|
const handler = decryptors.get(scheme);
|
|
if (typeof handler !== "function") return null;
|
|
return handler(ev.content).then((plaintext) => {
|
|
if (typeof plaintext !== "string") {
|
|
throw new Error("non-string-result");
|
|
}
|
|
return { plaintext, scheme };
|
|
});
|
|
})
|
|
.filter(Boolean);
|
|
if (attempts.length) {
|
|
const result = await Promise.any(attempts);
|
|
decryptedText = result.plaintext;
|
|
setLastSuccessfulScheme(normalized, result.scheme);
|
|
}
|
|
} catch (aggregateError) {
|
|
// Promise.any rejects with an AggregateError once EVERY scheme
|
|
// failed. If any failure was a transient channel/extension error
|
|
// (a severed message port, breaker fast-fail, etc.) we must NOT
|
|
// swallow it and return [] — that silently misreads a brief
|
|
// channel drop as "the user has no blocks" and never retries
|
|
// (KNOWN_BUGS #0). Re-throw so applyEvents keeps the stale list
|
|
// and schedules a background retry; once the channel recovers the
|
|
// retry succeeds. Genuine decrypt mismatches (wrong key/content)
|
|
// still fall through to the empty result.
|
|
const subErrors =
|
|
aggregateError && Array.isArray(aggregateError.errors)
|
|
? aggregateError.errors
|
|
: [aggregateError];
|
|
const transient = subErrors.find((e) => isTransientDecryptError(e));
|
|
if (transient) {
|
|
if (!transient.signerStatus) {
|
|
transient.signerStatus = signerStatus;
|
|
}
|
|
throw transient;
|
|
}
|
|
// All schemes failed for non-transient reasons — decryptedText
|
|
// stays empty (treated as "no blocks").
|
|
}
|
|
}
|
|
|
|
if (!decryptedText) {
|
|
return [];
|
|
}
|
|
|
|
return parseBlockListPlaintext(decryptedText, normalized, {
|
|
limit: MAX_BLOCKLIST_ENTRIES,
|
|
});
|
|
};
|
|
|
|
const nextPrivateBlocks = new Set();
|
|
const nextPublicMutes = new Set();
|
|
let decryptionError = null;
|
|
const decryptMeta = {
|
|
standard: { eventId: newestStandard?.id || null },
|
|
legacy: { eventId: newestLegacy?.id || null },
|
|
};
|
|
const resolveSignerStatus = () => {
|
|
const statuses = [decryptMeta.standard.signerStatus, decryptMeta.legacy.signerStatus]
|
|
.filter(Boolean);
|
|
if (!statuses.length) {
|
|
return "unknown";
|
|
}
|
|
if (statuses.every((status) => status === "missing")) {
|
|
return "missing";
|
|
}
|
|
if (statuses.some((status) => status === "present-missing-decryptor")) {
|
|
return "present-missing-decryptor";
|
|
}
|
|
return "present";
|
|
};
|
|
|
|
try {
|
|
const decryptPromise = Promise.all([
|
|
decryptEvent(newestStandard, decryptMeta.standard),
|
|
decryptEvent(newestLegacy, decryptMeta.legacy),
|
|
]);
|
|
const timeoutPromise = new Promise((_, reject) =>
|
|
setTimeout(
|
|
() => {
|
|
const timeoutError = new Error(
|
|
`Decryption timed out after ${decryptTimeoutMs / 1000}s`,
|
|
);
|
|
timeoutError.code = "user-blocklist-decrypt-timeout";
|
|
timeoutError.signerStatus = resolveSignerStatus();
|
|
reject(timeoutError);
|
|
},
|
|
decryptTimeoutMs,
|
|
),
|
|
);
|
|
|
|
const [standardDecrypted, legacyDecrypted] = await Promise.race([
|
|
decryptPromise,
|
|
timeoutPromise,
|
|
]);
|
|
|
|
// Merge the newest standard mute snapshot with legacy/private lists so
|
|
// users retain full cross-client compatibility during migration.
|
|
if (newestStandard) {
|
|
const publicTags = extractPubkeysFromTags(newestStandard.tags, normalized, { limit: MAX_BLOCKLIST_ENTRIES });
|
|
publicTags.forEach((pk) => nextPublicMutes.add(pk));
|
|
standardDecrypted.forEach((pk) => nextPrivateBlocks.add(pk));
|
|
}
|
|
if (newestLegacy) {
|
|
legacyDecrypted.forEach((pk) => nextPrivateBlocks.add(pk));
|
|
}
|
|
|
|
} catch (error) {
|
|
// If permission denied or critical failure
|
|
decryptionError = error;
|
|
}
|
|
|
|
if (decryptionError) {
|
|
if (decryptionError.code === "user-blocklist-permission-required") {
|
|
const signerStatus =
|
|
decryptionError.signerStatus || resolveSignerStatus();
|
|
this.blockedPubkeys = previousState.blockedPubkeys;
|
|
this._privateBlocks = previousState.privateBlocks;
|
|
this._publicMutes = previousState.publicMutes;
|
|
this.blockEventId = previousState.blockEventId;
|
|
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
|
|
this.loaded = true;
|
|
emitStatus({
|
|
status: "permission-required",
|
|
signerStatus,
|
|
source,
|
|
reason: "decryptor-permission",
|
|
allowPermissionPrompt,
|
|
mode: loadMode,
|
|
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
|
error: decryptionError,
|
|
});
|
|
emitStatus({ status: "settled" });
|
|
return;
|
|
}
|
|
if (isTransientDecryptError(decryptionError)) {
|
|
const isTimeout =
|
|
decryptionError.code === "user-blocklist-decrypt-timeout";
|
|
const reason = isTimeout ? "decrypt-timeout" : "channel-unavailable";
|
|
const signerStatus = decryptionError.signerStatus || resolveSignerStatus();
|
|
const recoveredPrivateBlocks = new Set(previousState.privateBlocks);
|
|
const recoveredPublicMutes = new Set([
|
|
...previousState.publicMutes,
|
|
...extractPubkeysFromTags(newestStandard?.tags, normalized, {
|
|
limit: MAX_BLOCKLIST_ENTRIES,
|
|
}),
|
|
]);
|
|
this.blockEventId = newestOverall?.id || previousState.blockEventId;
|
|
this.blockEventCreatedAt =
|
|
Number.isFinite(newestCreatedAt) ? newestCreatedAt : previousState.blockEventCreatedAt;
|
|
applySets(recoveredPrivateBlocks, recoveredPublicMutes, {
|
|
source,
|
|
reason: `${reason}-partial`,
|
|
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
|
});
|
|
this.loaded = true;
|
|
|
|
emitStatus({
|
|
status: "stale",
|
|
reason,
|
|
signerStatus,
|
|
source,
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
});
|
|
emitStatus({ status: "settled" });
|
|
|
|
userLogger.warn(
|
|
isTimeout
|
|
? "[UserBlockList] Decryption timed out; keeping stale list and retrying in background."
|
|
: "[UserBlockList] Signer channel unavailable; keeping stale list and retrying in background.",
|
|
{
|
|
signerStatus,
|
|
reason,
|
|
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
|
},
|
|
);
|
|
// Signer reported present but didn't answer — feed the shared
|
|
// "signer unresponsive" notice so the user gets told to reload/unlock
|
|
// their extension instead of silently retrying forever.
|
|
if (signerStatus === "present") {
|
|
noteSignerDecryptTimeout("blocks");
|
|
}
|
|
this.scheduleDecryptRetry(normalized, {
|
|
error: decryptionError,
|
|
signerStatus,
|
|
});
|
|
return;
|
|
}
|
|
userLogger.warn(
|
|
"[UserBlockList] Decryption failed during merge.",
|
|
decryptionError,
|
|
);
|
|
this.blockedPubkeys = previousState.blockedPubkeys;
|
|
this._privateBlocks = previousState.privateBlocks;
|
|
this._publicMutes = previousState.publicMutes;
|
|
this.blockEventId = previousState.blockEventId;
|
|
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
|
|
this.loaded = true;
|
|
const signerStatus =
|
|
decryptionError.signerStatus || resolveSignerStatus();
|
|
emitStatus({
|
|
status: "error",
|
|
error: decryptionError,
|
|
decryptor: "mixed",
|
|
signerStatus,
|
|
});
|
|
emitStatus({ status: "settled" });
|
|
return;
|
|
}
|
|
|
|
const isStandardEffectiveEmpty = !newestStandard || ((!newestStandard.content || !newestStandard.content.trim()) && (!newestStandard.tags || !extractPubkeysFromTags(newestStandard.tags, normalized).length));
|
|
const isLegacyEffectiveEmpty = !newestLegacy || ((!newestLegacy.content || !newestLegacy.content.trim()) && (!newestLegacy.tags || !extractPubkeysFromTags(newestLegacy.tags, normalized).length));
|
|
|
|
const effectiveReason = (isStandardEffectiveEmpty && isLegacyEffectiveEmpty)
|
|
? "empty-events"
|
|
: "applied-merge";
|
|
|
|
applySets(nextPrivateBlocks, nextPublicMutes, {
|
|
source,
|
|
reason: effectiveReason,
|
|
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
|
});
|
|
this.decryptRetryAttempt = 0;
|
|
noteSignerDecryptSuccess();
|
|
|
|
if (effectiveReason === "empty-events") {
|
|
emitStatus({ status: "applied-empty", event: newestOverall, source });
|
|
} else {
|
|
emitStatus({
|
|
status: "applied",
|
|
event: newestOverall,
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
source,
|
|
});
|
|
}
|
|
};
|
|
|
|
// Anchor the fetch to the service's current state to prevent desync.
|
|
// If we have cached data, we ask for updates since that timestamp.
|
|
// If we have no data, we force a full fetch (since=0).
|
|
const fetchSince =
|
|
hasLocalData && Number.isFinite(this.blockEventCreatedAt)
|
|
? computeIncrementalSinceWithOverlap(this.blockEventCreatedAt, 1)
|
|
: 0;
|
|
|
|
// Concurrent incremental fetch for all variants
|
|
// We prioritize standard NIP-51 lists (Kind 10000) to ensure the UI loads quickly.
|
|
// Legacy variants are fetched in parallel but processed separately to avoid blocking.
|
|
|
|
const fastRelayLimit = Math.max(
|
|
1,
|
|
Math.min(FAST_BLOCKLIST_RELAY_LIMIT, relays.length || FAST_BLOCKLIST_RELAY_LIMIT),
|
|
);
|
|
const backgroundRelayLimit = Math.max(
|
|
fastRelayLimit,
|
|
Math.min(
|
|
MAX_BACKGROUND_BLOCKLIST_RELAY_LIMIT,
|
|
relays.length || MAX_BACKGROUND_BLOCKLIST_RELAY_LIMIT,
|
|
),
|
|
);
|
|
|
|
const standardFetchPromise = nostrClient.fetchListIncrementally({
|
|
kind: KIND_MUTE_LIST,
|
|
pubkey: normalized,
|
|
relayUrls: effectiveRelays,
|
|
since: fetchSince,
|
|
timeoutMs: FAST_BLOCKLIST_TIMEOUT_MS,
|
|
maxRelays: fastRelayLimit,
|
|
});
|
|
|
|
const legacyFetchPromise = Promise.all([
|
|
nostrClient.fetchListIncrementally({
|
|
kind: KIND_MUTE_LIST,
|
|
pubkey: normalized,
|
|
relayUrls: effectiveRelays,
|
|
since: fetchSince,
|
|
timeoutMs: BACKGROUND_BLOCKLIST_TIMEOUT_MS,
|
|
maxRelays: backgroundRelayLimit,
|
|
}),
|
|
nostrClient.fetchListIncrementally({
|
|
kind: KIND_MUTE_LIST,
|
|
pubkey: normalized,
|
|
dTag: BLOCK_LIST_IDENTIFIER,
|
|
relayUrls: effectiveRelays,
|
|
since: fetchSince,
|
|
timeoutMs: BACKGROUND_BLOCKLIST_TIMEOUT_MS,
|
|
maxRelays: backgroundRelayLimit,
|
|
}),
|
|
nostrClient.fetchListIncrementally({
|
|
kind: 30002,
|
|
pubkey: normalized,
|
|
dTag: BLOCK_LIST_IDENTIFIER,
|
|
relayUrls: effectiveRelays,
|
|
since: fetchSince,
|
|
timeoutMs: BACKGROUND_BLOCKLIST_TIMEOUT_MS,
|
|
maxRelays: backgroundRelayLimit,
|
|
}),
|
|
]);
|
|
|
|
// FAST PATH: Process Standard List immediately
|
|
let standardEvents = [];
|
|
try {
|
|
standardEvents = await standardFetchPromise;
|
|
if (Array.isArray(standardEvents) && standardEvents.length > 0) {
|
|
await applyEvents(standardEvents, { source: "fast-standard" });
|
|
this.loaded = true;
|
|
}
|
|
} catch (standardError) {
|
|
devLogger.warn("[UserBlockList] Standard fetch failed:", standardError);
|
|
}
|
|
|
|
// SLOW PATH: Process Legacy Lists (and Standard if it was empty/failed)
|
|
// We do NOT await this for the main return, allowing the UI to unblock immediately
|
|
// if the standard list was found.
|
|
legacyFetchPromise
|
|
.then(async ([backgroundStandardEvents, blockEvents, legacyEvents]) => {
|
|
const combined = [
|
|
...standardEvents,
|
|
...backgroundStandardEvents,
|
|
...blockEvents,
|
|
...legacyEvents,
|
|
];
|
|
|
|
if (!combined.length) {
|
|
// Only apply empty if we haven't already marked loaded (meaning standard failed or was empty)
|
|
// or if we want to ensure we settle state.
|
|
// If standard was found, we don't need to do anything for empty legacy.
|
|
if (!this.loaded) {
|
|
await applyEvents([], { source: "empty-result" });
|
|
}
|
|
} else {
|
|
// Re-apply merged results to catch any legacy blocks
|
|
await applyEvents(combined, { source: "incremental-merge" });
|
|
}
|
|
})
|
|
.catch((legacyError) => {
|
|
devLogger.warn("[UserBlockList] Legacy fetch failed:", legacyError);
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
userLogger.error("[UserBlockList] loadBlocks failed (keeping local state):", error);
|
|
emitStatus({ status: "error", error });
|
|
} finally {
|
|
this.loaded = true;
|
|
emitStatus({ status: "settled" });
|
|
}
|
|
}
|
|
|
|
_loadLocal(actorHex) {
|
|
const normalizedActorHex = normalizeHex(actorHex);
|
|
if (!normalizedActorHex) {
|
|
return null;
|
|
}
|
|
const cached = profileCache.getProfileData(normalizedActorHex, "blocks");
|
|
if (Array.isArray(cached)) {
|
|
return { blockedPubkeys: new Set(cached), createdAt: null };
|
|
}
|
|
if (cached && typeof cached === "object") {
|
|
const privateBlocks = new Set(
|
|
Array.isArray(cached.privateBlocks) ? cached.privateBlocks : [],
|
|
);
|
|
const publicMutes = new Set(
|
|
Array.isArray(cached.publicMutes) ? cached.publicMutes : [],
|
|
);
|
|
// Fallback for legacy cache format
|
|
const blockedPubkeys = new Set(
|
|
Array.isArray(cached.blockedPubkeys) ? cached.blockedPubkeys : [],
|
|
);
|
|
|
|
return {
|
|
privateBlocks,
|
|
publicMutes,
|
|
blockedPubkeys,
|
|
createdAt: Number.isFinite(cached.createdAt) ? cached.createdAt : null,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_saveLocal(actorHex) {
|
|
const normalizedActorHex = normalizeHex(actorHex);
|
|
if (!normalizedActorHex) {
|
|
return;
|
|
}
|
|
profileCache.setProfileData(normalizedActorHex, "blocks", {
|
|
privateBlocks: Array.from(this._privateBlocks),
|
|
publicMutes: Array.from(this._publicMutes),
|
|
// Keep blockedPubkeys for backward compatibility if needed, or just relying on new keys
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
createdAt: this.blockEventCreatedAt,
|
|
version: 2,
|
|
});
|
|
}
|
|
|
|
_getSeedState(actorHex) {
|
|
const normalized = normalizeHex(actorHex);
|
|
if (!normalized) {
|
|
return { seeded: false, removals: new Set() };
|
|
}
|
|
|
|
if (this.seedStateCache.has(normalized)) {
|
|
return this.seedStateCache.get(normalized);
|
|
}
|
|
|
|
const stored =
|
|
profileCache.getProfileData(normalized, "blocks_meta") || {};
|
|
const seeded = !!stored.seeded;
|
|
const removals = new Set(Array.isArray(stored.removals) ? stored.removals : []);
|
|
|
|
const state = { seeded, removals };
|
|
this.seedStateCache.set(normalized, state);
|
|
return state;
|
|
}
|
|
|
|
_persistSeedState(actorHex) {
|
|
const normalized = normalizeHex(actorHex);
|
|
if (!normalized) return;
|
|
const state = this.seedStateCache.get(normalized);
|
|
if (!state) return;
|
|
|
|
profileCache.setProfileData(normalized, "blocks_meta", {
|
|
seeded: state.seeded,
|
|
removals: Array.from(state.removals),
|
|
});
|
|
}
|
|
|
|
_setSeeded(actorHex, seeded) {
|
|
const normalized = normalizeHex(actorHex);
|
|
if (!normalized) return;
|
|
|
|
const state = this._getSeedState(normalized);
|
|
state.seeded = Boolean(seeded);
|
|
this._persistSeedState(normalized);
|
|
}
|
|
|
|
_addSeedRemoval(actorHex, targetHex) {
|
|
const normalizedActor = normalizeHex(actorHex);
|
|
const normalizedTarget = normalizeHex(targetHex);
|
|
if (!normalizedActor || !normalizedTarget) return;
|
|
|
|
const state = this._getSeedState(normalizedActor);
|
|
if (!state.removals.has(normalizedTarget)) {
|
|
state.removals.add(normalizedTarget);
|
|
this._persistSeedState(normalizedActor);
|
|
}
|
|
}
|
|
|
|
_clearSeedRemoval(actorHex, targetHex) {
|
|
const normalizedActor = normalizeHex(actorHex);
|
|
const normalizedTarget = normalizeHex(targetHex);
|
|
if (!normalizedActor || !normalizedTarget) return;
|
|
|
|
const state = this._getSeedState(normalizedActor);
|
|
if (state.removals.delete(normalizedTarget)) {
|
|
this._persistSeedState(normalizedActor);
|
|
}
|
|
}
|
|
|
|
async seedWithNpubs(userPubkey, candidateNpubs = []) {
|
|
const actorHex = normalizeHex(userPubkey);
|
|
if (!actorHex) {
|
|
return { ok: false, seeded: false, reason: "invalid-user" };
|
|
}
|
|
|
|
await this.ensureLoaded(actorHex);
|
|
|
|
const state = this._getSeedState(actorHex);
|
|
if (state.seeded) {
|
|
return { ok: true, seeded: false, reason: "already-seeded" };
|
|
}
|
|
|
|
const removals = state.removals;
|
|
const additions = new Set();
|
|
let invalidCount = 0;
|
|
|
|
const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : [];
|
|
for (const candidate of candidates) {
|
|
const candidateHex = normalizeHex(candidate);
|
|
if (!candidateHex) {
|
|
invalidCount += 1;
|
|
continue;
|
|
}
|
|
if (candidateHex === actorHex) {
|
|
continue;
|
|
}
|
|
if (this.blockedPubkeys.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
if (removals.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
additions.add(candidateHex);
|
|
}
|
|
|
|
if (!additions.size) {
|
|
if (candidates.length) {
|
|
devLogger.info(
|
|
"[UserBlockList] Seed candidates resolved to zero additions.",
|
|
{
|
|
actorPubkey: actorHex,
|
|
candidateCount: candidates.length,
|
|
invalidCount,
|
|
blockedCount: this.blockedPubkeys.size,
|
|
removalCount: removals.size,
|
|
},
|
|
);
|
|
}
|
|
return { ok: true, seeded: false, reason: "no-candidates" };
|
|
}
|
|
|
|
const snapshotPrivate = new Set(this._privateBlocks);
|
|
const snapshotPublic = new Set(this._publicMutes);
|
|
const snapshotUnion = new Set(this.blockedPubkeys);
|
|
|
|
additions.forEach((hex) => {
|
|
this._privateBlocks.add(hex);
|
|
this.blockedPubkeys.add(hex);
|
|
});
|
|
|
|
try {
|
|
await this.publishBlockList(actorHex);
|
|
} catch (error) {
|
|
this._privateBlocks = snapshotPrivate;
|
|
this._publicMutes = snapshotPublic;
|
|
this.blockedPubkeys = snapshotUnion;
|
|
throw error;
|
|
}
|
|
|
|
this._setSeeded(actorHex, true);
|
|
additions.forEach((hex) => this._clearSeedRemoval(actorHex, hex));
|
|
this._saveLocal(actorHex);
|
|
|
|
try {
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action: "seed",
|
|
actorPubkey: actorHex,
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
addedPubkeys: Array.from(additions),
|
|
});
|
|
} catch (error) {
|
|
userLogger.warn("[UserBlockList] Failed to emit seed change event:", error);
|
|
}
|
|
|
|
return { ok: true, seeded: true, addedPubkeys: Array.from(additions) };
|
|
}
|
|
|
|
async seedLocalBaseline(userPubkey, candidateNpubs = []) {
|
|
const actorHex = normalizeHex(userPubkey);
|
|
if (!actorHex) {
|
|
return { ok: false, seeded: false, reason: "invalid-user" };
|
|
}
|
|
|
|
await this.ensureLoaded(actorHex);
|
|
|
|
const state = this._getSeedState(actorHex);
|
|
if (state.seeded) {
|
|
return { ok: true, seeded: false, reason: "already-seeded" };
|
|
}
|
|
|
|
if (this.blockedPubkeys.size > 0) {
|
|
return { ok: true, seeded: false, reason: "non-empty" };
|
|
}
|
|
|
|
const removals = state.removals;
|
|
const additions = new Set();
|
|
let invalidCount = 0;
|
|
|
|
const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : [];
|
|
for (const candidate of candidates) {
|
|
const candidateHex = normalizeHex(candidate);
|
|
if (!candidateHex) {
|
|
invalidCount += 1;
|
|
continue;
|
|
}
|
|
if (candidateHex === actorHex) {
|
|
continue;
|
|
}
|
|
if (removals.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
additions.add(candidateHex);
|
|
}
|
|
|
|
if (!additions.size) {
|
|
if (candidates.length) {
|
|
devLogger.info(
|
|
"[UserBlockList] Local seed candidates resolved to zero additions.",
|
|
{
|
|
actorPubkey: actorHex,
|
|
candidateCount: candidates.length,
|
|
invalidCount,
|
|
removalCount: removals.size,
|
|
},
|
|
);
|
|
}
|
|
this._setSeeded(actorHex, true);
|
|
return { ok: true, seeded: true, addedPubkeys: [] };
|
|
}
|
|
|
|
additions.forEach((hex) => {
|
|
this._privateBlocks.add(hex);
|
|
this.blockedPubkeys.add(hex);
|
|
});
|
|
|
|
this._setSeeded(actorHex, true);
|
|
additions.forEach((hex) => this._clearSeedRemoval(actorHex, hex));
|
|
|
|
try {
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action: "seed",
|
|
actorPubkey: actorHex,
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
addedPubkeys: Array.from(additions),
|
|
localOnly: true,
|
|
});
|
|
} catch (error) {
|
|
userLogger.warn("[UserBlockList] Failed to emit local seed change event:", error);
|
|
}
|
|
|
|
return { ok: true, seeded: true, addedPubkeys: Array.from(additions) };
|
|
}
|
|
|
|
async seedBaselineDelta(userPubkey, candidateNpubs = []) {
|
|
const actorHex = normalizeHex(userPubkey);
|
|
if (!actorHex) {
|
|
return { ok: false, seeded: false, reason: "invalid-user" };
|
|
}
|
|
|
|
await this.ensureLoaded(actorHex);
|
|
|
|
const state = this._getSeedState(actorHex);
|
|
const removals = state.removals;
|
|
const additions = new Set();
|
|
let invalidCount = 0;
|
|
|
|
const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : [];
|
|
for (const candidate of candidates) {
|
|
const candidateHex = normalizeHex(candidate);
|
|
if (!candidateHex) {
|
|
invalidCount += 1;
|
|
continue;
|
|
}
|
|
if (candidateHex === actorHex) {
|
|
continue;
|
|
}
|
|
if (this.blockedPubkeys.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
if (removals.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
additions.add(candidateHex);
|
|
}
|
|
|
|
if (!additions.size) {
|
|
if (candidates.length) {
|
|
devLogger.info(
|
|
"[UserBlockList] Baseline delta candidates resolved to zero additions.",
|
|
{
|
|
actorPubkey: actorHex,
|
|
candidateCount: candidates.length,
|
|
invalidCount,
|
|
blockedCount: this.blockedPubkeys.size,
|
|
removalCount: removals.size,
|
|
},
|
|
);
|
|
}
|
|
if (!state.seeded) {
|
|
this._setSeeded(actorHex, true);
|
|
}
|
|
return { ok: true, seeded: state.seeded, reason: "no-candidates" };
|
|
}
|
|
|
|
additions.forEach((hex) => {
|
|
this._privateBlocks.add(hex);
|
|
this.blockedPubkeys.add(hex);
|
|
});
|
|
|
|
this._setSeeded(actorHex, true);
|
|
this._saveLocal(actorHex);
|
|
|
|
try {
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action: "seed-delta",
|
|
actorPubkey: actorHex,
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
addedPubkeys: Array.from(additions),
|
|
localOnly: true,
|
|
});
|
|
} catch (error) {
|
|
userLogger.warn("[UserBlockList] Failed to emit delta seed change event:", error);
|
|
}
|
|
|
|
return { ok: true, seeded: true, addedPubkeys: Array.from(additions) };
|
|
}
|
|
|
|
async addBlock(targetPubkey, userPubkey) {
|
|
return this.modifyList("block", targetPubkey, userPubkey);
|
|
}
|
|
|
|
async removeBlock(targetPubkey, userPubkey) {
|
|
return this.modifyList("unblock", targetPubkey, userPubkey);
|
|
}
|
|
|
|
async addMute(targetPubkey, userPubkey) {
|
|
return this.modifyList("mute", targetPubkey, userPubkey);
|
|
}
|
|
|
|
async removeMute(targetPubkey, userPubkey) {
|
|
return this.modifyList("unmute", targetPubkey, userPubkey);
|
|
}
|
|
|
|
async modifyList(action, targetPubkey, userPubkey) {
|
|
const actorHex = normalizeHex(userPubkey);
|
|
if (!actorHex) {
|
|
throw new Error("Invalid user pubkey.");
|
|
}
|
|
|
|
const targetHex = normalizeHex(targetPubkey);
|
|
if (!targetHex) {
|
|
const err = new Error("Invalid target pubkey.");
|
|
err.code = "invalid";
|
|
throw err;
|
|
}
|
|
|
|
if (actorHex === targetHex) {
|
|
const err = new Error("Cannot block/mute yourself.");
|
|
err.code = "self";
|
|
throw err;
|
|
}
|
|
|
|
await this.ensureLoaded(actorHex);
|
|
|
|
const snapshotPrivate = new Set(this._privateBlocks);
|
|
const snapshotPublic = new Set(this._publicMutes);
|
|
const snapshotUnion = new Set(this.blockedPubkeys);
|
|
|
|
let changed = false;
|
|
|
|
if (action === "block") {
|
|
if (this._privateBlocks.has(targetHex)) {
|
|
return { ok: true, already: true };
|
|
}
|
|
this._privateBlocks.add(targetHex);
|
|
changed = true;
|
|
} else if (action === "unblock") {
|
|
const inPrivate = this._privateBlocks.delete(targetHex);
|
|
const inPublic = this._publicMutes.delete(targetHex);
|
|
changed = inPrivate || inPublic;
|
|
} else if (action === "mute") {
|
|
if (this._publicMutes.has(targetHex)) {
|
|
return { ok: true, already: true };
|
|
}
|
|
this._publicMutes.add(targetHex);
|
|
changed = true;
|
|
} else if (action === "unmute") {
|
|
const inPublic = this._publicMutes.delete(targetHex);
|
|
changed = inPublic;
|
|
}
|
|
|
|
if (!changed) {
|
|
return { ok: true, already: true };
|
|
}
|
|
|
|
this.blockedPubkeys = new Set([...this._privateBlocks, ...this._publicMutes]);
|
|
|
|
const loggedInPubkey = normalizeHex(nostrClient?.pubkey);
|
|
const sessionPubkey = normalizeHex(nostrClient?.sessionActor?.pubkey);
|
|
const isSessionActor =
|
|
!!actorHex && !loggedInPubkey && actorHex === sessionPubkey;
|
|
|
|
if (isSessionActor) {
|
|
this._saveLocal(actorHex);
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action,
|
|
targetPubkey: targetHex,
|
|
actorPubkey: actorHex,
|
|
});
|
|
if (action === "block" || action === "mute") {
|
|
this._clearSeedRemoval(actorHex, targetHex);
|
|
} else {
|
|
this._addSeedRemoval(actorHex, targetHex);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
try {
|
|
await this.publishBlockList(actorHex);
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action,
|
|
targetPubkey: targetHex,
|
|
actorPubkey: actorHex,
|
|
});
|
|
if (action === "block" || action === "mute") {
|
|
this._clearSeedRemoval(actorHex, targetHex);
|
|
} else {
|
|
this._addSeedRemoval(actorHex, targetHex);
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
this._privateBlocks = snapshotPrivate;
|
|
this._publicMutes = snapshotPublic;
|
|
this.blockedPubkeys = snapshotUnion;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async publishBlockList(userPubkey, options = {}) {
|
|
if (isSessionActor(nostrClient)) {
|
|
const error = new Error(
|
|
"Publishing block lists is not allowed for session actors."
|
|
);
|
|
error.code = "session-actor-publish-blocked";
|
|
throw error;
|
|
}
|
|
|
|
const onStatus =
|
|
options && typeof options.onStatus === "function" ? options.onStatus : null;
|
|
|
|
onStatus?.({ status: "publishing" });
|
|
|
|
let signer = getActiveSigner();
|
|
if (!signer) {
|
|
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
|
|
}
|
|
|
|
if (!signer) {
|
|
const err = new Error(
|
|
"An active signer is required to update the block list."
|
|
);
|
|
err.code = "signer-missing";
|
|
throw err;
|
|
}
|
|
|
|
if (signer.type === "extension" || signer.type === "nip07") {
|
|
const permissionResult = await requestDefaultExtensionPermissions(
|
|
DEFAULT_NIP07_ENCRYPTION_METHODS,
|
|
);
|
|
if (!permissionResult.ok) {
|
|
userLogger.warn(
|
|
"[UserBlockList] Signer permissions denied while updating the block list.",
|
|
permissionResult.error,
|
|
);
|
|
const err = new Error(
|
|
"The active signer must allow encryption before updating the block list.",
|
|
);
|
|
err.code = "extension-encryption-permission-denied";
|
|
err.cause = permissionResult.error;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (typeof signer.signEvent !== "function") {
|
|
const err = new Error("Active signer missing signEvent support.");
|
|
err.code = "sign-event-missing";
|
|
throw err;
|
|
}
|
|
|
|
const normalized = normalizeHex(userPubkey);
|
|
if (!normalized) {
|
|
throw new Error("Invalid user pubkey.");
|
|
}
|
|
|
|
// Encrypt private blocks
|
|
const privateBlockArray = Array.from(this._privateBlocks);
|
|
const sanitizedPrivateTags = sanitizeMuteTags(privateBlockArray, normalized);
|
|
const plaintext = serializeBlockListTagMatrix(sanitizedPrivateTags, normalized, {
|
|
logDiscarded: false,
|
|
});
|
|
|
|
const encryptors = [];
|
|
const registerEncryptor = (scheme, handler) => {
|
|
if (!scheme || typeof handler !== "function") {
|
|
return;
|
|
}
|
|
encryptors.push({ scheme, handler });
|
|
};
|
|
|
|
if (typeof signer.nip44Encrypt === "function") {
|
|
registerEncryptor("nip44_v2", (value) => signer.nip44Encrypt(normalized, value));
|
|
registerEncryptor("nip44", (value) => signer.nip44Encrypt(normalized, value));
|
|
}
|
|
|
|
if (typeof signer.nip04Encrypt === "function") {
|
|
registerEncryptor("nip04", (value) => signer.nip04Encrypt(normalized, value));
|
|
}
|
|
|
|
let cipherText = "";
|
|
let encryptionScheme = "";
|
|
const seenSchemes = new Set();
|
|
const encryptionErrors = [];
|
|
|
|
// Only attempt encryption if we have private blocks
|
|
if (privateBlockArray.length > 0) {
|
|
if (!encryptors.length) {
|
|
const err = new Error(
|
|
"An encryption-capable signer is required to update private blocks."
|
|
);
|
|
err.code = "block-list-missing-encryptor";
|
|
throw err;
|
|
}
|
|
|
|
for (const candidate of encryptors) {
|
|
if (seenSchemes.has(candidate.scheme)) {
|
|
continue;
|
|
}
|
|
seenSchemes.add(candidate.scheme);
|
|
try {
|
|
const encrypted = await candidate.handler(plaintext);
|
|
if (typeof encrypted === "string" && encrypted) {
|
|
cipherText = encrypted;
|
|
encryptionScheme = candidate.scheme;
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
encryptionErrors.push({ scheme: candidate.scheme, error });
|
|
}
|
|
}
|
|
|
|
if (!cipherText) {
|
|
const err = new Error("Failed to encrypt block list.");
|
|
err.code = "block-list-encrypt-failed";
|
|
err.cause = encryptionErrors;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const encryptionTagValue =
|
|
encryptionScheme === "nip44_v2"
|
|
? "nip44_v2"
|
|
: encryptionScheme === "nip44"
|
|
? "nip44"
|
|
: encryptionScheme === "nip04"
|
|
? "nip04"
|
|
: undefined;
|
|
|
|
// Build event with both public mutes (p tags) and private blocks (encrypted content)
|
|
const publicMuteArray = Array.from(this._publicMutes);
|
|
const sanitizedPublicTags = sanitizeMuteTags(publicMuteArray, normalized);
|
|
const pTags = sanitizedPublicTags.map((tag) => tag[1]);
|
|
|
|
const event = buildMuteListEvent({
|
|
pubkey: normalized,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
content: cipherText,
|
|
pTags,
|
|
encrypted: !!encryptionTagValue,
|
|
encryptionTag: encryptionTagValue,
|
|
});
|
|
|
|
const signedEvent = await signer.signEvent(event);
|
|
|
|
const publishResults = await publishEventToRelays(
|
|
nostrClient.pool,
|
|
nostrClient.relays,
|
|
signedEvent
|
|
);
|
|
|
|
let publishSummary;
|
|
try {
|
|
publishSummary = assertAnyRelayAccepted(publishResults, {
|
|
context: "mute list",
|
|
});
|
|
} catch (publishError) {
|
|
if (publishError?.relayFailures?.length) {
|
|
publishError.relayFailures.forEach(
|
|
({ url, error: relayError, reason }) => {
|
|
userLogger.error(
|
|
`[UserBlockList] Mute list rejected by ${url}: ${reason}`,
|
|
relayError || reason
|
|
);
|
|
}
|
|
);
|
|
}
|
|
throw publishError;
|
|
}
|
|
|
|
if (publishSummary.failed.length) {
|
|
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
|
const reason =
|
|
relayError instanceof Error
|
|
? relayError.message
|
|
: relayError
|
|
? String(relayError)
|
|
: "publish failed";
|
|
userLogger.warn(
|
|
`[UserBlockList] Mute list not accepted by ${url}: ${reason}`,
|
|
relayError
|
|
);
|
|
});
|
|
}
|
|
|
|
this.blockEventId = signedEvent.id;
|
|
this.blockEventCreatedAt = Number.isFinite(signedEvent?.created_at)
|
|
? signedEvent.created_at
|
|
: event.created_at;
|
|
this.lastPublishedCreatedAt = this.blockEventCreatedAt;
|
|
onStatus?.({ status: "published", event: signedEvent });
|
|
|
|
try {
|
|
await this.loadBlocks(normalized, {
|
|
since: this.lastPublishedCreatedAt ?? undefined,
|
|
statusCallback: (detail) => {
|
|
if (!onStatus) {
|
|
return;
|
|
}
|
|
onStatus({ status: "relay", detail });
|
|
},
|
|
});
|
|
} catch (refreshError) {
|
|
onStatus?.({ status: "relay-error", error: refreshError });
|
|
}
|
|
|
|
return signedEvent;
|
|
}
|
|
}
|
|
|
|
export const userBlocks = new UserBlockListManager();
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.userBlocks = userBlocks;
|
|
}
|