Files
bitvid/js/nostr/nip07Permissions.js
thePR0M3TH3ANandClaude Opus 4.8 f3bac3066d perf(nip07): don't kill slow signers during the permission handshake
On a cold login / permission cache-miss, requestEnablePermissions probes
several permission-payload variants before the no-arg prompt. The specific
(silent) variants were capped at 3s "to fail fast" — but real extensions
REJECT an unsupported payload shape almost immediately (and a fast
rejection already falls through to the next variant), so the 3s cap added
no real safety. What it DID do: kill a slow-but-responsive signer
mid-grant, forcing all 3 specific variants to time out (3s each) before the
prompt variant finally succeeded — ~25s of dead time before signer-ready.

Raise the specific-variant timeout to 20s (SPECIFIC_VARIANT_TIMEOUT_MS).
The bound now only matters for a variant that genuinely HANGS; unsupported
shapes still reject fast, and a slow signer can actually answer the first
supported variant.

Channel-sim at 16s/call: enable extension calls 4 → 1, signer-ready
~42s → ~33s, lists-sync ~58s → ~50s; healthy login unchanged (~1.6s),
all lists still decrypt. Full unit suite green (pre-existing Node
localStorage failures unrelated). KNOWN_BUGS #0.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 13:04:34 -04:00

731 lines
23 KiB
JavaScript

import { devLogger, userLogger } from "../utils/logger.js";
import { SHORT_TIMEOUT_MS } from "../constants.js";
export const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds
export const NIP07_EXTENSION_WAIT_TIMEOUT_MS = SHORT_TIMEOUT_MS;
export const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
"Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again.";
const NIP07_PERMISSIONS_STORAGE_KEY = "bitvid:nip07:permissions";
// Give the NIP-07 extension enough time to surface its approval prompt and let
// users unlock/authorize it. Seven seconds proved too aggressive once vendors
// started requiring an unlock step, so we extend the window substantially while
// still allowing manual overrides via __BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__.
const DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 45_000;
// Timeout for the SPECIFIC (silent, payload-carrying) permission variants we
// probe before the no-arg prompt. This is NOT a "fail fast" budget: real
// extensions REJECT a payload shape they don't support almost immediately, and
// a fast rejection moves us to the next variant right away. The timeout only
// bounds a variant that HANGS — so a tiny 3s cap added no real safety against
// unsupported variants but DID kill slow-but-responsive signers mid-grant,
// forcing 3 wasted attempts before the prompt variant succeeded (~25s of dead
// time on a 16s/call signer in the channel-sim). Give it real room.
const SPECIFIC_VARIANT_TIMEOUT_MS = 20_000;
export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([
// Encryption helpers — request both legacy NIP-04 and modern NIP-44 upfront
"nip04.encrypt",
"nip04.decrypt",
"nip44.encrypt",
"nip44.decrypt",
"nip44.v2.encrypt",
"nip44.v2.decrypt",
]);
export const DEFAULT_NIP07_CORE_METHODS = Object.freeze([
// Core auth + relay metadata
"get_public_key",
"sign_event",
]);
export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
...DEFAULT_NIP07_CORE_METHODS,
...DEFAULT_NIP07_ENCRYPTION_METHODS,
]);
export const NIP07_PRIORITY = Object.freeze({
// CRITICAL is reserved for establishing the signer channel itself (the
// permission/enable handshake behind the readiness gate). It must outrank
// list decryption (HIGH): otherwise a flood of HIGH-priority decrypts jumps
// ahead of the gate in the queue and starves it, so the channel never gets
// warmed and every decrypt keeps timing out (the real-env ~48s "signer-ready"
// — KNOWN_BUGS #0).
CRITICAL: 20,
HIGH: 10,
NORMAL: 5,
LOW: 1,
});
class Nip07RequestQueue {
// `reservedForForeground` slots are kept available for non-background
// (priority > LOW) work, so a flood of LOW-priority background decrypts
// (DM backfill, watch-history months) can never occupy every slot and
// starve the feed-driving lists. Background work is therefore capped at
// `maxConcurrent - reservedForForeground` concurrent slots (min 1).
constructor(maxConcurrent = 2, reservedForForeground = 1) {
this.queue = [];
this.maxConcurrent =
Number.isFinite(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 2;
const reserved =
Number.isFinite(reservedForForeground) && reservedForForeground >= 0
? Math.floor(reservedForForeground)
: 1;
// Never reserve so much that background work could never run.
this.backgroundConcurrencyCap = Math.max(1, this.maxConcurrent - reserved);
this.activeCount = 0;
this.backgroundActiveCount = 0;
}
isBackground(priority) {
return Number.isFinite(priority) && priority <= NIP07_PRIORITY.LOW;
}
enqueue(task, priority = NIP07_PRIORITY.NORMAL) {
return new Promise((resolve, reject) => {
this.queue.push({
task,
priority: Number.isFinite(priority) ? priority : NIP07_PRIORITY.NORMAL,
resolve,
reject,
addedAt: Date.now(),
});
// Sort by priority (descending), then by insertion time (ascending) for fairness
this.queue.sort((a, b) => {
if (b.priority !== a.priority) {
return b.priority - a.priority;
}
return a.addedAt - b.addedAt;
});
this.process();
});
}
async process() {
if (this.activeCount >= this.maxConcurrent) return;
while (this.activeCount < this.maxConcurrent && this.queue.length > 0) {
// Peek the highest-priority item. The queue is sorted priority-desc, so
// if the head is background-class and the background cap is already
// saturated, every remaining item is background too — stop, keeping the
// free slot(s) reserved for any foreground work that arrives later.
const head = this.queue[0];
const headIsBackground = this.isBackground(head.priority);
if (
headIsBackground &&
this.backgroundActiveCount >= this.backgroundConcurrencyCap
) {
break;
}
const item = this.queue.shift();
if (!item) break;
const itemIsBackground = this.isBackground(item.priority);
this.activeCount++;
if (itemIsBackground) {
this.backgroundActiveCount++;
}
const { task, resolve, reject } = item;
// We don't await the task here to allow parallelism loop to continue
Promise.resolve()
.then(() => task())
.then((result) => {
resolve(result);
})
.catch((error) => {
reject(error);
})
.finally(() => {
this.activeCount--;
if (itemIsBackground) {
this.backgroundActiveCount--;
}
this.process();
});
}
}
}
// Module-level priority queue to serialize NIP-07 extension requests.
// This prevents "message channel closed" errors caused by race conditions
// when multiple components (blocks, DMs, auth) query the extension simultaneously,
// while allowing critical tasks (e.g. blocklist decryption) to jump the line.
// Stability: keep extension request concurrency conservative. Some NIP-07
// providers/content scripts drop channels under high parallel load, which
// manifests as "message channel closed" and repeated decrypt timeouts.
const requestQueue = new Nip07RequestQueue(2);
export const NIP07_CHANNEL_UNRESPONSIVE_ERROR_MESSAGE =
"The NIP-07 signer channel is unresponsive. It usually recovers shortly; if it persists, refresh the page or re-open your extension.";
// Circuit breaker for a dead/unresponsive nip-07 message channel.
//
// Real-env failure mode (KNOWN_BUGS #0): under the post-login burst the
// extension's single content-script message port drops ("message channel
// closed"). After that EVERY decrypt call hangs to its full timeout (~15s).
// Each list service (blocks, hashtags, subscriptions, watch history)
// independently retries on that timeout, so the app sits in a permanent loop of
// 15s-blocking extension calls — pinning the CPU ("fans at max") and never
// recovering until a manual page refresh.
//
// The breaker converts that into: a few timeouts OPEN the circuit, after which
// calls fail FAST (no 15s wait) so the retry loops become cheap and the CPU
// settles. While open, a single periodic PROBE is allowed through to detect
// recovery; one success CLOSES the circuit and normal decryption resumes — no
// refresh required. Interactive permission prompts bypass the breaker so a user
// taking their time at the extension UI can never trip or be blocked by it.
const CIRCUIT_TIMEOUT_THRESHOLD = 3; // consecutive timeouts before opening
const CIRCUIT_OPEN_MS = 30_000; // how long the circuit stays open
const CIRCUIT_PROBE_INTERVAL_MS = 8_000; // min spacing between recovery probes
const channelBreaker = {
consecutiveTimeouts: 0,
openUntil: 0,
lastProbeAt: 0,
};
export function getNip07ChannelBreakerState() {
const now = Date.now();
return {
open: channelBreaker.openUntil > now,
consecutiveTimeouts: channelBreaker.consecutiveTimeouts,
openUntil: channelBreaker.openUntil,
};
}
export function resetNip07ChannelBreaker() {
channelBreaker.consecutiveTimeouts = 0;
channelBreaker.openUntil = 0;
channelBreaker.lastProbeAt = 0;
}
function isCircuitOpen(now) {
return channelBreaker.openUntil > now;
}
// While open, allow exactly one probe per CIRCUIT_PROBE_INTERVAL_MS so we can
// detect recovery without re-flooding the channel.
function claimProbeSlot(now) {
if (now - channelBreaker.lastProbeAt >= CIRCUIT_PROBE_INTERVAL_MS) {
channelBreaker.lastProbeAt = now;
return true;
}
return false;
}
function recordChannelSuccess() {
const wasOpen = channelBreaker.openUntil > Date.now();
channelBreaker.consecutiveTimeouts = 0;
channelBreaker.openUntil = 0;
if (wasOpen) {
userLogger.info(
"[nostr] NIP-07 signer channel recovered; resuming normal requests.",
);
}
}
function recordChannelTimeout() {
channelBreaker.consecutiveTimeouts += 1;
if (
channelBreaker.consecutiveTimeouts >= CIRCUIT_TIMEOUT_THRESHOLD &&
channelBreaker.openUntil <= Date.now()
) {
channelBreaker.openUntil = Date.now() + CIRCUIT_OPEN_MS;
userLogger.warn(
`[nostr] NIP-07 signer channel unresponsive after ${channelBreaker.consecutiveTimeouts} timeouts; ` +
`failing fast for ${CIRCUIT_OPEN_MS / 1000}s and probing for recovery.`,
);
}
}
// Some non-timeout failures are actually dead-channel signals, NOT "the
// extension answered with an error." When the content-script message port is
// severed the call rejects synchronously with one of these messages — which is
// the very condition the breaker exists for, so they must count TOWARD opening
// the circuit (not reset it). Matching the real-env "message channel closed"
// case (KNOWN_BUGS #0) is the whole point.
const CHANNEL_DEATH_PATTERNS = [
"message channel closed",
"could not establish connection",
"receiving end does not exist",
"extension context invalidated",
"connection lost",
];
function isChannelDeathError(error) {
const message =
error && typeof error.message === "string" ? error.message.toLowerCase() : "";
if (!message) return false;
return CHANNEL_DEATH_PATTERNS.some((pattern) => message.includes(pattern));
}
// A non-timeout failure where the extension genuinely RESPONDED (e.g. user
// rejection, "permission denied", unsupported method) means the channel is
// alive — clear the timeout streak so we don't open on unrelated errors.
function recordChannelResponsiveFailure() {
channelBreaker.consecutiveTimeouts = 0;
}
export function getEnableVariantTimeoutMs() {
const overrideValue =
typeof globalThis !== "undefined" &&
globalThis !== null &&
Number.isFinite(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
? Math.floor(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
: null;
if (overrideValue !== null && overrideValue > 0) {
return Math.max(50, overrideValue);
}
return DEFAULT_ENABLE_VARIANT_TIMEOUT_MS;
}
export function normalizePermissionMethod(method) {
return typeof method === "string" && method.trim() ? method.trim() : "";
}
function getNip07PermissionStorage() {
const scope =
typeof globalThis !== "undefined" && globalThis ? globalThis : undefined;
const browserWindow =
typeof window !== "undefined" && window ? window : undefined;
if (browserWindow?.localStorage) {
return browserWindow.localStorage;
}
if (scope?.localStorage) {
return scope.localStorage;
}
return null;
}
export function readStoredNip07Permissions() {
const storage = getNip07PermissionStorage();
if (!storage) {
return new Set();
}
let rawValue = null;
try {
rawValue = storage.getItem(NIP07_PERMISSIONS_STORAGE_KEY);
} catch (error) {
return new Set();
}
if (!rawValue) {
return new Set();
}
try {
const parsed = JSON.parse(rawValue);
const storedMethods = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.grantedMethods)
? parsed.grantedMethods
: Array.isArray(parsed?.methods)
? parsed.methods
: [];
return new Set(
storedMethods
.map((method) => normalizePermissionMethod(method))
.filter(Boolean),
);
} catch (error) {
clearStoredNip07Permissions();
return new Set();
}
}
export function writeStoredNip07Permissions(methods) {
const storage = getNip07PermissionStorage();
if (!storage) {
return;
}
const normalized = Array.from(
new Set(
Array.from(methods || [])
.map((method) => normalizePermissionMethod(method))
.filter(Boolean),
),
);
try {
if (!normalized.length) {
storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY);
return;
}
storage.setItem(
NIP07_PERMISSIONS_STORAGE_KEY,
JSON.stringify({ grantedMethods: normalized }),
);
} catch (error) {
// ignore persistence failures
}
}
export function clearStoredNip07Permissions() {
const storage = getNip07PermissionStorage();
if (!storage) {
return;
}
try {
storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY);
} catch (error) {
// ignore cleanup issues
}
}
export function withNip07Timeout(
operation,
{
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
message = NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
} = {},
) {
const numericTimeout = Number(timeoutMs);
const effectiveTimeout =
Number.isFinite(numericTimeout) && numericTimeout > 0
? numericTimeout
: NIP07_LOGIN_TIMEOUT_MS;
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(message));
}, effectiveTimeout);
});
let operationResult;
try {
operationResult = operation();
} catch (err) {
if (timeoutId) {
clearTimeout(timeoutId);
}
throw err;
}
const operationPromise = Promise.resolve(operationResult);
return Promise.race([operationPromise, timeoutPromise]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
export async function runNip07WithRetry(
operation,
{
label = "NIP-07 operation",
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
retryMultiplier = 2,
priority = NIP07_PRIORITY.NORMAL,
// Interactive permission prompts bypass the circuit breaker: a user taking
// their time at the extension UI is not a dead channel, and the prompt must
// always be allowed through (and can itself heal the channel).
bypassCircuitBreaker = false,
} = {},
) {
// We wrap the entire retry logic in a queue task to ensure only one
// request hits the extension at a time (per slot).
const executeTask = async () => {
// Circuit breaker: when the channel has been declared unresponsive, fail
// fast (no multi-second hang) for everything except a single periodic probe
// — this stops the per-list retry loops from pinning the CPU on a dead
// channel, while still detecting recovery without a page refresh.
let isProbe = false;
if (!bypassCircuitBreaker) {
const now = Date.now();
if (isCircuitOpen(now)) {
if (claimProbeSlot(now)) {
isProbe = true;
} else {
const fastFail = new Error(
NIP07_CHANNEL_UNRESPONSIVE_ERROR_MESSAGE,
);
fastFail.code = "nip07-channel-unresponsive";
throw fastFail;
}
}
}
// We wrap the operation invocation to ensure it returns a fresh promise each time
// we attempt it. This is critical for retries: if the first attempt stalls (dropped
// by extension), re-awaiting the same promise would just keep waiting on the dead request.
const invokeOperation = () => Promise.resolve(operation());
const onResult = (result) => {
if (!bypassCircuitBreaker) {
recordChannelSuccess();
}
return result;
};
const onTimeout = () => {
if (!bypassCircuitBreaker) {
recordChannelTimeout();
}
};
const onResponsiveFailure = () => {
if (!bypassCircuitBreaker) {
recordChannelResponsiveFailure();
}
};
try {
const result = await withNip07Timeout(invokeOperation, {
timeoutMs,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
return onResult(result);
} catch (error) {
const isTimeoutError =
error instanceof Error &&
error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
if (!isTimeoutError) {
// A severed message port rejects with a channel-death message — that IS
// the dead-channel condition, so count it toward opening the circuit.
// Any other error means the extension actually responded (channel alive).
if (isChannelDeathError(error)) {
onTimeout();
} else {
onResponsiveFailure();
}
throw error;
}
// A probe that times out leaves the circuit open; don't waste a longer
// retry on a channel we already believe is dead.
if (retryMultiplier <= 1 || isProbe) {
onTimeout();
throw error;
}
const extendedTimeout = Math.max(
timeoutMs,
Math.round(timeoutMs * retryMultiplier),
);
devLogger.warn(
`[nostr] ${label} taking longer than ${timeoutMs}ms. Retrying with ${extendedTimeout}ms timeout.`,
);
// On retry, we invoke operation() again to send a fresh request to the extension.
try {
const result = await withNip07Timeout(invokeOperation, {
timeoutMs: extendedTimeout,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
return onResult(result);
} catch (retryError) {
const retryTimedOut =
retryError instanceof Error &&
retryError.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
if (retryTimedOut || isChannelDeathError(retryError)) {
onTimeout();
} else {
onResponsiveFailure();
}
throw retryError;
}
}
};
return requestQueue.enqueue(executeTask, priority);
}
export async function requestEnablePermissions(
extension,
outstandingMethods,
{ isDevMode = false } = {},
) {
if (!extension) {
return { ok: false, error: new Error("extension-unavailable") };
}
const hasRequestPermissions = typeof extension.requestPermissions === "function";
const hasEnable = typeof extension.enable === "function";
const normalized = Array.isArray(outstandingMethods)
? outstandingMethods
.map((method) => normalizePermissionMethod(method))
.filter(Boolean)
: [];
const permissionVariants = (() => {
const variants = [];
if (normalized.length) {
variants.push({
permissions: normalized.map((method) => ({ method })),
});
variants.push({ permissions: normalized });
variants.push(normalized);
}
variants.push(null);
return variants;
})();
if (!hasRequestPermissions && !hasEnable) {
return { ok: true, code: "enable-unavailable" };
}
let lastError = null;
const runPermissionMethod = async (methodName, method) => {
for (const options of permissionVariants) {
// Specific (silent) payload variants get a generous bound so a slow signer
// can actually answer the FIRST supported one (unsupported shapes still
// reject fast and fall through immediately). The no-arg prompt variant
// gets the full window so users have time to confirm in the extension UI.
const variantTimeoutOverrides = options
? {
timeoutMs: Math.min(
SPECIFIC_VARIANT_TIMEOUT_MS,
getEnableVariantTimeoutMs(),
),
retryMultiplier: 1,
}
: {
timeoutMs: Math.min(
NIP07_LOGIN_TIMEOUT_MS,
getEnableVariantTimeoutMs(),
),
retryMultiplier: 1,
};
try {
await runNip07WithRetry(
() =>
options === null
? method()
: methodName === "enable"
? method(options)
: method(options),
{
label: `extension.${methodName}`,
...variantTimeoutOverrides,
// Interactive grant — never gated by (and can heal) the breaker.
bypassCircuitBreaker: true,
// Establishing the channel must outrank list decryption so the
// handshake isn't starved by a flood of HIGH-priority decrypts.
priority: NIP07_PRIORITY.CRITICAL,
},
);
return true;
} catch (error) {
lastError = error;
if (options && isDevMode) {
userLogger.warn(
`[nostr] extension.${methodName} permission request failed:`,
error,
);
}
}
}
return false;
};
// Compatibility-first: prefer standard NIP-07 enable() where available.
if (hasEnable) {
const enableSucceeded = await runPermissionMethod(
"enable",
extension.enable.bind(extension),
);
if (enableSucceeded) {
return { ok: true };
}
}
// Fallback for extensions that implement requestPermissions() but not enable(),
// or where enable() payload handling is broken.
if (hasRequestPermissions) {
const requestPermissionsSucceeded = await runPermissionMethod(
"requestPermissions",
extension.requestPermissions.bind(extension),
);
if (requestPermissionsSucceeded) {
return { ok: true };
}
}
return {
ok: false,
error: lastError || new Error("permission-denied"),
};
}
export const __testExports = {
requestEnablePermissions,
runNip07WithRetry,
withNip07Timeout,
getEnableVariantTimeoutMs,
readStoredNip07Permissions,
writeStoredNip07Permissions,
clearStoredNip07Permissions,
normalizePermissionMethod,
getNip07ChannelBreakerState,
resetNip07ChannelBreaker,
CIRCUIT_TIMEOUT_THRESHOLD,
CIRCUIT_OPEN_MS,
CIRCUIT_PROBE_INTERVAL_MS,
};
export function waitForNip07Extension(timeoutMs = NIP07_EXTENSION_WAIT_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
if (typeof window !== "undefined" && window.nostr) {
resolve(window.nostr);
return;
}
if (typeof window === "undefined") {
reject(new Error("Not running in a browser environment."));
return;
}
const start = Date.now();
let resolved = false;
const finish = (ext) => {
if (resolved) return;
resolved = true;
clearInterval(interval);
resolve(ext);
};
const fail = () => {
if (resolved) return;
resolved = true;
clearInterval(interval);
reject(new Error("Nostr extension not found within timeout."));
};
// Poll every 50ms for window.nostr injection.
const interval = setInterval(() => {
if (window.nostr) {
finish(window.nostr);
} else if (Date.now() - start > timeoutMs) {
fail();
}
}, 50);
// Additionally listen for the DOMContentLoaded event — many extensions
// inject window.nostr during this phase, and detecting it via the event
// can be faster than waiting for the next poll tick.
if (typeof document !== "undefined" && document.readyState === "loading") {
const onReady = () => {
document.removeEventListener("DOMContentLoaded", onReady);
if (window.nostr) {
finish(window.nostr);
}
};
document.addEventListener("DOMContentLoaded", onReady);
}
});
}