diff --git a/js/nip07Support.js b/js/nip07Support.js new file mode 100644 index 00000000..5072337d --- /dev/null +++ b/js/nip07Support.js @@ -0,0 +1,132 @@ +// js/nip07Support.js + +import { isDevMode } from "./config.js"; + +export const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds +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 DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 7000; + +export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ + "get_public_key", + "sign_event", + "nip04.encrypt", + "nip04.decrypt", + "read_relays", + "write_relays", +]); + +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; +} + +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, + } = {}, +) { + let hasStarted = false; + let cachedPromise = null; + + const getOrStartOperation = () => { + if (!hasStarted) { + hasStarted = true; + try { + cachedPromise = Promise.resolve(operation()); + } catch (error) { + hasStarted = false; + cachedPromise = null; + throw error; + } + } + + return cachedPromise; + }; + + try { + return await withNip07Timeout(getOrStartOperation, { + timeoutMs, + message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, + }); + } catch (error) { + const isTimeoutError = + error instanceof Error && + error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE; + + if (!isTimeoutError || retryMultiplier <= 1) { + throw error; + } + + const extendedTimeout = Math.max( + timeoutMs, + Math.round(timeoutMs * retryMultiplier), + ); + + if (isDevMode) { + console.warn( + `[nostr] ${label} taking longer than ${timeoutMs}ms. Waiting up to ${extendedTimeout}ms for extension response.`, + ); + } + + return withNip07Timeout(getOrStartOperation, { + timeoutMs: extendedTimeout, + message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, + }); + } +} + +export const __testExports = { runNip07WithRetry }; diff --git a/js/nostr.js b/js/nostr.js index 4f99e4d2..33bd78e4 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -40,6 +40,14 @@ import { assertAnyRelayAccepted, } from "./nostrPublish.js"; import { nostrToolsReady } from "./nostrToolsBootstrap.js"; +import { + DEFAULT_NIP07_PERMISSION_METHODS, + getEnableVariantTimeoutMs, + NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, + NIP07_LOGIN_TIMEOUT_MS, + runNip07WithRetry, +} from "./nip07Support.js"; +import { getAuthProvider } from "./services/authProviders/index.js"; /** * The default relay set BitVid bootstraps with before loading a user's @@ -58,26 +66,6 @@ const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS); const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents"; const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes -const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds -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 DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 7000; - -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; -} const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed"; const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; @@ -89,15 +77,6 @@ const WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS = 5 * 60 * 1000; const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8; const WATCH_HISTORY_REPUBLISH_JITTER = 0.25; -const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ - "get_public_key", - "sign_event", - "nip04.encrypt", - "nip04.decrypt", - "read_relays", - "write_relays", -]); - const viewEventPublishMemory = new Map(); const rebroadcastAttemptMemory = new Map(); @@ -172,7 +151,7 @@ function readToolkitFromScope(scope = globalScope) { return null; } -const __nostrToolsBootstrapResult = await (async () => { +const __nostrToolsBootstrapPromise = (async () => { try { const result = await nostrToolsReadySource; if (result && typeof result === "object" && result.ok === false) { @@ -199,15 +178,20 @@ const __nostrToolsBootstrapResult = await (async () => { } })(); -let cachedNostrTools = __nostrToolsBootstrapResult.toolkit || null; -const nostrToolsBootstrapFailure = __nostrToolsBootstrapResult.failure || null; +let cachedNostrTools = null; +let nostrToolsBootstrapFailure = null; -if (!cachedNostrTools && nostrToolsBootstrapFailure && isDevMode) { - console.warn( - "[nostr] nostr-tools helpers unavailable after bootstrap.", - nostrToolsBootstrapFailure - ); -} +__nostrToolsBootstrapPromise.then((bootstrap) => { + cachedNostrTools = bootstrap.toolkit || null; + nostrToolsBootstrapFailure = bootstrap.failure || null; + + if (!cachedNostrTools && nostrToolsBootstrapFailure && isDevMode) { + console.warn( + "[nostr] nostr-tools helpers unavailable after bootstrap.", + nostrToolsBootstrapFailure + ); + } +}); function rememberNostrTools(candidate) { const normalized = normalizeToolkitCandidate(candidate); @@ -358,103 +342,6 @@ function logErrorOnce(message, eventContent = null) { } } -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); - } - }); -} - -async function runNip07WithRetry( - operation, - { - label = "NIP-07 operation", - timeoutMs = NIP07_LOGIN_TIMEOUT_MS, - retryMultiplier = 2, - } = {}, -) { - let hasStarted = false; - let cachedPromise = null; - - const getOrStartOperation = () => { - if (!hasStarted) { - hasStarted = true; - try { - cachedPromise = Promise.resolve(operation()); - } catch (error) { - hasStarted = false; - cachedPromise = null; - throw error; - } - } - - return cachedPromise; - }; - - try { - return await withNip07Timeout(getOrStartOperation, { - timeoutMs, - message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, - }); - } catch (error) { - const isTimeoutError = - error instanceof Error && - error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE; - - if (!isTimeoutError || retryMultiplier <= 1) { - throw error; - } - - const extendedTimeout = Math.max( - timeoutMs, - Math.round(timeoutMs * retryMultiplier), - ); - - if (isDevMode) { - console.warn( - `[nostr] ${label} taking longer than ${timeoutMs}ms. Waiting up to ${extendedTimeout}ms for extension response.`, - ); - } - - return withNip07Timeout(getOrStartOperation, { - timeoutMs: extendedTimeout, - message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, - }); - } -} - export const __testExports = { runNip07WithRetry }; function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") { @@ -3088,6 +2975,8 @@ export class NostrClient { this.countRequestCounter = 0; this.countUnsupportedRelays = new Set(); this.extensionPermissionCache = new Set(); + this.activeSigner = null; + this.providerSigners = new Map(); } markExtensionPermissions(methods = []) { @@ -3191,6 +3080,119 @@ export class NostrClient { }; } + setActiveSigner(signer) { + if (!this.providerSigners || typeof this.providerSigners.set !== "function") { + this.providerSigners = new Map(); + } + + if (!signer) { + this.activeSigner = null; + this.providerSigners.clear(); + return null; + } + + const normalizedProviderId = + typeof signer.providerId === "string" && signer.providerId.trim() + ? signer.providerId.trim().toLowerCase() + : ""; + const normalizedPubkey = + typeof signer.pubkey === "string" && signer.pubkey.trim() + ? signer.pubkey.trim().toLowerCase() + : ""; + const signEventFn = + signer && typeof signer.signEvent === "function" ? signer.signEvent : null; + const encryptFn = + signer && typeof signer.encrypt === "function" ? signer.encrypt : null; + const decryptFn = + signer && typeof signer.decrypt === "function" ? signer.decrypt : null; + + const hasCapability = !!(signEventFn || encryptFn || decryptFn); + + if (!hasCapability) { + if (normalizedProviderId) { + this.providerSigners.delete(normalizedProviderId); + if (this.activeSigner?.providerId === normalizedProviderId) { + this.activeSigner = null; + } + } else { + this.activeSigner = null; + } + return null; + } + + const metadata = + signer && typeof signer.metadata === "object" && signer.metadata !== null + ? { ...signer.metadata } + : null; + + const normalizedSigner = { + providerId: normalizedProviderId || null, + pubkey: normalizedPubkey, + signEvent: signEventFn, + encrypt: encryptFn, + decrypt: decryptFn, + metadata, + }; + + if (normalizedProviderId) { + this.providerSigners.set(normalizedProviderId, normalizedSigner); + } + + this.activeSigner = normalizedSigner; + return normalizedSigner; + } + + clearActiveSigner(providerId = null) { + if (!this.providerSigners || typeof this.providerSigners.delete !== "function") { + this.providerSigners = new Map(); + } + + if (typeof providerId === "string" && providerId.trim()) { + const normalized = providerId.trim().toLowerCase(); + const removed = this.providerSigners.get(normalized); + this.providerSigners.delete(normalized); + if (removed && this.activeSigner?.providerId === normalized) { + this.activeSigner = null; + } + return; + } + + this.providerSigners.clear(); + this.activeSigner = null; + } + + getActiveSigner() { + return this.activeSigner || null; + } + + getActiveSignerForPubkey(pubkey) { + const active = this.getActiveSigner(); + if (!active) { + return null; + } + + const normalizedTarget = + typeof pubkey === "string" && pubkey.trim() ? pubkey.trim().toLowerCase() : ""; + const normalizedActive = + typeof active.pubkey === "string" && active.pubkey.trim() + ? active.pubkey.trim().toLowerCase() + : ""; + + if (!normalizedTarget) { + return active; + } + + if (!normalizedActive || normalizedActive === normalizedTarget) { + return active; + } + + return null; + } + + async ensureNostrTools() { + return ensureNostrTools(); + } + applyRootCreatedAt(video) { if (!video || typeof video !== "object") { return null; @@ -5666,31 +5668,48 @@ export class NostrClient { let signedEvent = null; - if ( - normalizedActor && - normalizedActor === normalizedLogged && - window?.nostr && - typeof window.nostr.signEvent === "function" - ) { + const activeSigner = this.getActiveSignerForPubkey(normalizedActor); + if (activeSigner && typeof activeSigner.signEvent === "function") { try { - signedEvent = await window.nostr.signEvent(event); + signedEvent = await activeSigner.signEvent(event); } catch (error) { - console.warn("[nostr] Failed to sign view event with extension:", error); - return { ok: false, error: "signing-failed", details: error }; + if (isDevMode) { + console.warn("[nostr] Active signer failed to sign view event:", error); + } + signedEvent = null; } - } else { - try { - if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { - await this.ensureSessionActor(true); + } + + if (!signedEvent) { + const extensionSigner = + normalizedActor && + normalizedActor === normalizedLogged && + window?.nostr && + typeof window.nostr.signEvent === "function" + ? window.nostr.signEvent.bind(window.nostr) + : null; + + if (extensionSigner) { + try { + signedEvent = await extensionSigner(event); + } catch (error) { + console.warn("[nostr] Failed to sign view event with extension:", error); + return { ok: false, error: "signing-failed", details: error }; } - if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { - throw new Error("session-actor-mismatch"); + } else { + try { + if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { + await this.ensureSessionActor(true); + } + if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { + throw new Error("session-actor-mismatch"); + } + const privateKey = this.sessionActor.privateKey; + signedEvent = signEventWithPrivateKey(event, privateKey); + } catch (error) { + console.warn("[nostr] Failed to sign view event with session key:", error); + return { ok: false, error: "signing-failed", details: error }; } - const privateKey = this.sessionActor.privateKey; - signedEvent = signEventWithPrivateKey(event, privateKey); - } catch (error) { - console.warn("[nostr] Failed to sign view event with session key:", error); - return { ok: false, error: "signing-failed", details: error }; } } @@ -5748,7 +5767,7 @@ export class NostrClient { if (isDevMode) { console.warn( "[nostr] Failed to ingest optimistic view event:", - error + error, ); } } @@ -5879,173 +5898,53 @@ export class NostrClient { * Attempt login with a Nostr extension */ async login(options = {}) { + const provider = getAuthProvider("nip07"); + if (!provider || typeof provider.login !== "function") { + throw new Error("NIP-07 login is not available."); + } + + const normalizedOptions = + options && typeof options === "object" ? { ...options } : {}; + try { - const extension = window.nostr; - if (!extension) { - console.log("No Nostr extension found"); - throw new Error( - "Please install a Nostr extension (Alby, nos2x, etc.)." - ); - } - - const { allowAccountSelection = false, expectPubkey } = - typeof options === "object" && options !== null ? options : {}; - const normalizedExpectedPubkey = - typeof expectPubkey === "string" && expectPubkey.trim() - ? expectPubkey.trim().toLowerCase() - : null; - - if (typeof extension.getPublicKey !== "function") { - throw new Error( - "This NIP-07 extension is missing getPublicKey support. Please update the extension." - ); - } - - if (typeof extension.enable === "function") { - if (isDevMode) { - console.log("Requesting permissions from NIP-07 extension..."); - } - const requestedPermissionMethods = Array.from( - DEFAULT_NIP07_PERMISSION_METHODS, - ); - - const permissionVariants = [null]; - const objectPermissions = requestedPermissionMethods - .map((method) => - typeof method === "string" && method.trim() - ? { method: method.trim() } - : null, - ) - .filter(Boolean); - if (objectPermissions.length) { - permissionVariants.push({ permissions: objectPermissions }); - } - const stringPermissions = Array.from( - new Set(objectPermissions.map((entry) => entry.method)), - ); - if (stringPermissions.length) { - permissionVariants.push({ permissions: stringPermissions }); - } - let enableError = null; - for (const options of permissionVariants) { - try { - await runNip07WithRetry( - () => - options - ? extension.enable(options) - : extension.enable(), - { - label: "extension.enable", - ...(options - ? { - timeoutMs: Math.min( - NIP07_LOGIN_TIMEOUT_MS, - getEnableVariantTimeoutMs(), - ), - } - : {}), - retryMultiplier: 1, - }, - ); - enableError = null; - break; - } catch (error) { - enableError = error; - if (options && isDevMode) { - console.warn( - "[nostr] extension.enable request with explicit permissions failed:", - error, - ); - } - } - } - - if (enableError) { - throw new Error( - enableError && enableError.message - ? enableError.message - : "The NIP-07 extension denied the permission request.", - ); - } - - this.markExtensionPermissions(requestedPermissionMethods); - } - - if (allowAccountSelection && typeof extension.selectAccounts === "function") { - try { - const selection = await runNip07WithRetry( - () => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined), - { label: "extension.selectAccounts" } - ); - - const didCancelSelection = - selection === undefined || - selection === null || - selection === false || - (Array.isArray(selection) && selection.length === 0); - - if (didCancelSelection) { - throw new Error("Account selection was cancelled."); - } - } catch (selectionErr) { - const message = - selectionErr && typeof selectionErr.message === "string" - ? selectionErr.message - : "Account selection was cancelled."; - throw new Error(message); - } - } - const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), { - label: "extension.getPublicKey", + const result = await provider.login({ + nostrClient: this, + options: normalizedOptions, + legacy: true, }); - if (!pubkey || typeof pubkey !== "string") { - throw new Error( - "The NIP-07 extension did not return a public key. Please try again." - ); - } - if ( - normalizedExpectedPubkey && - pubkey.toLowerCase() !== normalizedExpectedPubkey - ) { - throw new Error( - "The selected account doesn't match the expected profile. Please try again." - ); - } - const nip19Tools = await ensureNostrTools(); - const npubEncode = nip19Tools?.nip19?.npubEncode; - if (typeof npubEncode !== "function") { - throw new Error("NostrTools nip19 encoder is unavailable."); - } - const npub = npubEncode(pubkey); - - if (isDevMode) { - console.log("Got pubkey:", pubkey); - console.log("Converted to npub:", npub); - console.log("Whitelist:", accessControl.getWhitelist()); - console.log("Blacklist:", accessControl.getBlacklist()); - } - // Access control - if (!accessControl.canAccess(npub)) { - if (accessControl.isBlacklisted(npub)) { - throw new Error("Your account has been blocked on this platform."); - } else { - throw new Error("Access restricted to whitelisted users only."); + let pubkey = ""; + if (typeof result === "string") { + pubkey = result; + } else if (result && typeof result === "object") { + if (typeof result.pubkey === "string") { + pubkey = result.pubkey; + } else if (typeof result.publicKey === "string") { + pubkey = result.publicKey; } } - this.pubkey = pubkey; - if (isDevMode) { - console.log("Logged in with extension. Pubkey:", this.pubkey); + + const trimmed = typeof pubkey === "string" ? pubkey.trim() : ""; + if (trimmed) { + this.pubkey = trimmed; + return trimmed; } - return this.pubkey; - } catch (err) { - console.error("Login error:", err); - throw err; + + return pubkey || null; + } catch (error) { + console.error("Login error:", error); + throw error; } } - logout() { this.pubkey = null; + try { + this.clearActiveSigner(); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to clear active signer during logout:", error); + } + } for (const timer of this.watchHistoryRepublishTimers.values()) { if (timer && typeof timer.timer === "number") { clearTimeout(timer.timer); @@ -6080,19 +5979,14 @@ export class NostrClient { return { ok: false, error: "nostr-uninitialized" }; } - const extension = window?.nostr; - if (!extension) { - return { ok: false, error: "nostr-extension-missing" }; - } - - const nip04 = extension.nip04; - if (!nip04 || typeof nip04.encrypt !== "function") { - return { ok: false, error: "nip04-unavailable" }; - } - - if (typeof extension.signEvent !== "function") { - return { ok: false, error: "sign-event-unavailable" }; - } + const extension = typeof window !== "undefined" ? window.nostr : null; + const nip04 = extension?.nip04; + const extensionEncrypt = + nip04 && typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null; + const extensionSign = + extension && typeof extension.signEvent === "function" + ? extension.signEvent.bind(extension) + : null; let actorHex = typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() @@ -6103,14 +5997,25 @@ export class NostrClient { actorHex = this.pubkey.trim(); } - if (!actorHex && typeof extension.getPublicKey === "function") { + let activeSigner = null; + if (actorHex) { + activeSigner = this.getActiveSignerForPubkey(actorHex); + } else { + const candidate = this.getActiveSigner(); + if (candidate?.pubkey) { + actorHex = candidate.pubkey; + activeSigner = this.getActiveSignerForPubkey(actorHex); + } + } + + if (!actorHex && typeof extension?.getPublicKey === "function") { try { actorHex = await extension.getPublicKey(); } catch (error) { if (isDevMode) { console.warn( "[nostr] Failed to fetch actor pubkey from extension:", - error + error, ); } } @@ -6120,6 +6025,21 @@ export class NostrClient { return { ok: false, error: "missing-actor-pubkey" }; } + const normalizedActor = actorHex.trim(); + activeSigner = this.getActiveSignerForPubkey(normalizedActor) || activeSigner; + + const signerEncrypt = + activeSigner && typeof activeSigner.encrypt === "function" + ? activeSigner.encrypt + : null; + const encryptFn = signerEncrypt || extensionEncrypt; + if (!encryptFn) { + if (!extensionEncrypt && !extensionSign && !signerEncrypt) { + return { ok: false, error: "nostr-extension-missing" }; + } + return { ok: false, error: "nip04-unavailable" }; + } + const targetHex = decodeNpubToHex(trimmedTarget); if (!targetHex) { return { ok: false, error: "invalid-target" }; @@ -6127,7 +6047,7 @@ export class NostrClient { let ciphertext = ""; try { - ciphertext = await nip04.encrypt(targetHex, trimmedMessage); + ciphertext = await encryptFn(targetHex, trimmedMessage); } catch (error) { return { ok: false, error: "encryption-failed", details: error }; } @@ -6137,21 +6057,41 @@ export class NostrClient { created_at: Math.floor(Date.now() / 1000), tags: [["p", targetHex]], content: ciphertext, - pubkey: actorHex, + pubkey: normalizedActor, }; - let signedEvent; - try { - signedEvent = await extension.signEvent(event); - } catch (error) { - return { ok: false, error: "signature-failed", details: error }; + let signedEvent = null; + const signerSign = + activeSigner && typeof activeSigner.signEvent === "function" + ? activeSigner.signEvent + : null; + + if (signerSign) { + try { + signedEvent = await signerSign(event); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Active signer failed to sign DM:", error); + } + } + } + + if (!signedEvent) { + if (!extensionSign) { + return { ok: false, error: "sign-event-unavailable" }; + } + try { + signedEvent = await extensionSign(event); + } catch (error) { + return { ok: false, error: "signature-failed", details: error }; + } } const relays = Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; const publishResults = await Promise.all( - relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) + relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)), ); const success = publishResults.some((result) => result.success); @@ -6200,57 +6140,82 @@ export class NostrClient { let signedEvent = null; let signerPubkey = null; - if ( - extensionSigner && - normalizedEventPubkey && - normalizedEventPubkey === normalizedLogged - ) { - signedEvent = await extensionSigner(event); - } else { + const activeSigner = this.getActiveSignerForPubkey(normalizedEventPubkey); + if (activeSigner && typeof activeSigner.signEvent === "function") { try { - const currentSessionPubkey = - typeof this.sessionActor?.pubkey === "string" - ? this.sessionActor.pubkey.toLowerCase() - : ""; - + signedEvent = await activeSigner.signEvent(event); if ( - usingSessionActor && - normalizedEventPubkey && - normalizedEventPubkey !== currentSessionPubkey + !signerPubkey && + activeSigner && + typeof activeSigner.pubkey === "string" && + activeSigner.pubkey ) { - await this.ensureSessionActor(true); - } else { - await this.ensureSessionActor(); + signerPubkey = activeSigner.pubkey; } - - const sessionActor = this.sessionActor; - if ( - !sessionActor || - typeof sessionActor.pubkey !== "string" || - !sessionActor.pubkey || - typeof sessionActor.privateKey !== "string" || - !sessionActor.privateKey - ) { - throw new Error("session-actor-unavailable"); - } - - const normalizedSessionPubkey = sessionActor.pubkey.toLowerCase(); - if ( - !normalizedEventPubkey || - normalizedEventPubkey !== normalizedSessionPubkey || - event.pubkey !== sessionActor.pubkey - ) { - eventToSign = { ...event, pubkey: sessionActor.pubkey }; - } - - signedEvent = signEventWithPrivateKey( - eventToSign, - sessionActor.privateKey - ); - signerPubkey = sessionActor.pubkey; } catch (error) { - console.warn("[nostr] Failed to sign event with session key:", error); - throw error; + if (isDevMode) { + console.warn( + `[nostr] Active signer failed to sign ${context}:`, + error, + ); + } + signedEvent = null; + } + } + + if (!signedEvent) { + if ( + extensionSigner && + normalizedEventPubkey && + normalizedEventPubkey === normalizedLogged + ) { + signedEvent = await extensionSigner(event); + } else { + try { + const currentSessionPubkey = + typeof this.sessionActor?.pubkey === "string" + ? this.sessionActor.pubkey.toLowerCase() + : ""; + + if ( + usingSessionActor && + normalizedEventPubkey && + normalizedEventPubkey !== currentSessionPubkey + ) { + await this.ensureSessionActor(true); + } else { + await this.ensureSessionActor(); + } + + const sessionActor = this.sessionActor; + if ( + !sessionActor || + typeof sessionActor.pubkey !== "string" || + !sessionActor.pubkey || + typeof sessionActor.privateKey !== "string" || + !sessionActor.privateKey + ) { + throw new Error("session-actor-unavailable"); + } + + const normalizedSessionPubkey = sessionActor.pubkey.toLowerCase(); + if ( + !normalizedEventPubkey || + normalizedEventPubkey !== normalizedSessionPubkey || + event.pubkey !== sessionActor.pubkey + ) { + eventToSign = { ...event, pubkey: sessionActor.pubkey }; + } + + signedEvent = signEventWithPrivateKey( + eventToSign, + sessionActor.privateKey + ); + signerPubkey = sessionActor.pubkey; + } catch (error) { + console.warn("[nostr] Failed to sign event with session key:", error); + throw error; + } } } @@ -6755,44 +6720,20 @@ export class NostrClient { } try { - const signedEvent = await window.nostr.signEvent(event); - if (isDevMode) { - console.log("Signed edited event:", signedEvent); - } - - const publishResults = await publishEventToRelays( - this.pool, - this.relays, - signedEvent - ); - - let publishSummary; - try { - publishSummary = assertAnyRelayAccepted(publishResults, { - context: "edited video note", - }); - } catch (publishError) { - if (publishError?.relayFailures?.length) { - publishError.relayFailures.forEach( - ({ url, error: relayError, reason }) => { - console.error( - `[nostr] Edited video rejected by ${url}: ${reason}`, - relayError || reason - ); - } - ); - } - throw publishError; - } + const { signedEvent, summary } = await this.signAndPublishEvent(event, { + context: "edited video note", + logName: "edited video note", + relaysOverride: this.relays, + }); if (isDevMode) { - publishSummary.accepted.forEach(({ url }) => + summary.accepted.forEach(({ url }) => console.log(`Edited video published to ${url}`) ); } - if (publishSummary.failed.length) { - publishSummary.failed.forEach(({ url, error: relayError }) => { + if (summary.failed.length) { + summary.failed.forEach(({ url, error: relayError }) => { const reason = relayError instanceof Error ? relayError.message @@ -6895,40 +6836,20 @@ export class NostrClient { content: JSON.stringify(contentObject), }; - const signedEvent = await window.nostr.signEvent(event); - const publishResults = await publishEventToRelays( - this.pool, - this.relays, - signedEvent - ); - - let publishSummary; - try { - publishSummary = assertAnyRelayAccepted(publishResults, { - context: "video revert", - }); - } catch (publishError) { - if (publishError?.relayFailures?.length) { - publishError.relayFailures.forEach( - ({ url, error: relayError, reason }) => { - console.error( - `[nostr] Video revert rejected by ${url}: ${reason}`, - relayError || reason - ); - } - ); - } - throw publishError; - } + const { signedEvent, summary } = await this.signAndPublishEvent(event, { + context: "video revert", + logName: "video revert", + relaysOverride: this.relays, + }); if (isDevMode) { - publishSummary.accepted.forEach(({ url }) => + summary.accepted.forEach(({ url }) => console.log(`Revert event published to ${url}`) ); } - if (publishSummary.failed.length) { - publishSummary.failed.forEach(({ url, error: relayError }) => { + if (summary.failed.length) { + summary.failed.forEach(({ url, error: relayError }) => { const reason = relayError instanceof Error ? relayError.message diff --git a/js/relayManager.js b/js/relayManager.js index d40549e2..537b1e66 100644 --- a/js/relayManager.js +++ b/js/relayManager.js @@ -603,10 +603,19 @@ class RelayPreferencesManager { throw error; } - if (!window?.nostr?.signEvent) { - const error = new Error( - "A NIP-07 extension is required to publish relay preferences." - ); + const activeSigner = nostrClient.getActiveSignerForPubkey(normalizedPubkey); + const signerSign = + activeSigner && typeof activeSigner.signEvent === "function" + ? activeSigner.signEvent + : null; + const extensionSign = + typeof window?.nostr?.signEvent === "function" + ? window.nostr.signEvent.bind(window.nostr) + : null; + const signFn = signerSign || extensionSign; + + if (!signFn) { + const error = new Error("A NIP-07 extension is required to publish relay preferences."); error.code = "nostr-extension-missing"; throw error; } @@ -632,7 +641,7 @@ class RelayPreferencesManager { relays: entries, }); - const signedEvent = await window.nostr.signEvent(event); + const signedEvent = await signFn(event); const targets = this.getPublishTargets(options?.relayUrls); if (!targets.length) { diff --git a/js/services/authProviders/nip07.js b/js/services/authProviders/nip07.js index c30cb761..ddc2e209 100644 --- a/js/services/authProviders/nip07.js +++ b/js/services/authProviders/nip07.js @@ -1,3 +1,12 @@ +import { isDevMode } from "../../config.js"; +import { accessControl } from "../../accessControl.js"; +import { + DEFAULT_NIP07_PERMISSION_METHODS, + getEnableVariantTimeoutMs, + NIP07_LOGIN_TIMEOUT_MS, + runNip07WithRetry, +} from "../../nip07Support.js"; + const PROVIDER_ID = "nip07"; const PROVIDER_LABEL = "NIP-07 browser extension"; const PROVIDER_BUTTON_CLASS = @@ -8,32 +17,253 @@ const PROVIDER_SLOW_HINT = "Waiting for the extension prompt…"; const PROVIDER_ERROR_MESSAGE = "Failed to login with NIP-07. Please try again."; +async function resolveNip19Tools(nostrClient) { + if (nostrClient && typeof nostrClient.ensureNostrTools === "function") { + return nostrClient.ensureNostrTools(); + } + return null; +} + async function login({ nostrClient, options } = {}) { - if (!nostrClient || typeof nostrClient.login !== "function") { + if (!nostrClient || typeof nostrClient !== "object") { throw new Error("NIP-07 login is not available."); } - const normalizedOptions = - options && typeof options === "object" ? options : {}; - const result = await nostrClient.login(normalizedOptions); + const extension = typeof window !== "undefined" ? window.nostr : null; + if (!extension) { + throw new Error("Please install a NIP-07 compatible extension."); + } - let pubkey = ""; - let signer = null; - if (typeof result === "string") { - pubkey = result; - } else if (result && typeof result === "object") { - if (typeof result.pubkey === "string") { - pubkey = result.pubkey; - } else if (typeof result.publicKey === "string") { - pubkey = result.publicKey; + if (typeof extension.getPublicKey !== "function") { + throw new Error( + "This NIP-07 extension is missing getPublicKey support. Please update the extension.", + ); + } + + const normalizedOptions = + options && typeof options === "object" ? { ...options } : {}; + const allowAccountSelection = normalizedOptions.allowAccountSelection === true; + const expectPubkey = + typeof normalizedOptions.expectPubkey === "string" + ? normalizedOptions.expectPubkey.trim() + : ""; + const normalizedExpectedPubkey = expectPubkey + ? expectPubkey.toLowerCase() + : null; + + if (typeof extension.enable === "function") { + const requestedPermissionMethods = Array.from( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + + const permissionVariants = [null]; + const objectPermissions = requestedPermissionMethods + .map((method) => + typeof method === "string" && method.trim() + ? { method: method.trim() } + : null, + ) + .filter(Boolean); + if (objectPermissions.length) { + permissionVariants.push({ permissions: objectPermissions }); + } + const stringPermissions = Array.from( + new Set(objectPermissions.map((entry) => entry.method)), + ); + if (stringPermissions.length) { + permissionVariants.push({ permissions: stringPermissions }); } - if (result.signer !== undefined) { - signer = result.signer; + let enableError = null; + for (const variant of permissionVariants) { + try { + await runNip07WithRetry( + () => (variant ? extension.enable(variant) : extension.enable()), + { + label: "extension.enable", + ...(variant + ? { + timeoutMs: Math.min( + NIP07_LOGIN_TIMEOUT_MS, + getEnableVariantTimeoutMs(), + ), + retryMultiplier: 1, + } + : { retryMultiplier: 1 }), + }, + ); + enableError = null; + break; + } catch (error) { + enableError = error; + if (variant && isDevMode) { + console.warn( + "[auth:nip07] extension.enable request with explicit permissions failed:", + error, + ); + } + } + } + + if (enableError) { + throw new Error( + enableError && enableError.message + ? enableError.message + : "The NIP-07 extension denied the permission request.", + ); + } + + if (typeof nostrClient.markExtensionPermissions === "function") { + try { + nostrClient.markExtensionPermissions(requestedPermissionMethods); + } catch (error) { + if (isDevMode) { + console.warn( + "[auth:nip07] Failed to mark extension permissions:", + error, + ); + } + } } } - return { authType: PROVIDER_ID, pubkey, signer }; + if (allowAccountSelection && typeof extension.selectAccounts === "function") { + try { + const selection = await runNip07WithRetry( + () => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined), + { label: "extension.selectAccounts" }, + ); + + const didCancelSelection = + selection === undefined || + selection === null || + selection === false || + (Array.isArray(selection) && selection.length === 0); + + if (didCancelSelection) { + throw new Error("Account selection was cancelled."); + } + } catch (selectionErr) { + const message = + selectionErr && typeof selectionErr.message === "string" + ? selectionErr.message + : "Account selection was cancelled."; + throw new Error(message); + } + } + + const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), { + label: "extension.getPublicKey", + }); + + if (!pubkey || typeof pubkey !== "string") { + throw new Error( + "The NIP-07 extension did not return a public key. Please try again.", + ); + } + + if (normalizedExpectedPubkey && pubkey.toLowerCase() !== normalizedExpectedPubkey) { + throw new Error( + "The selected account doesn't match the expected profile. Please try again.", + ); + } + + const nip19Tools = await resolveNip19Tools(nostrClient); + const npubEncode = nip19Tools?.nip19?.npubEncode; + const npub = typeof npubEncode === "function" ? npubEncode(pubkey) : null; + + if (isDevMode) { + console.log("[auth:nip07] Got pubkey:", pubkey); + if (npub) { + console.log("[auth:nip07] Converted to npub:", npub); + } + console.log("[auth:nip07] Whitelist:", accessControl.getWhitelist()); + console.log("[auth:nip07] Blacklist:", accessControl.getBlacklist()); + } + + if (npub && !accessControl.canAccess(npub)) { + if (accessControl.isBlacklisted(npub)) { + throw new Error("Your account has been blocked on this platform."); + } + throw new Error("Access restricted to whitelisted users only."); + } + + if (typeof nostrClient.markExtensionPermissions === "function") { + try { + nostrClient.markExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); + } catch (error) { + if (isDevMode) { + console.warn( + "[auth:nip07] Failed to memoize default permission set:", + error, + ); + } + } + } + + const signerPayload = (() => { + const signEvent = + typeof extension.signEvent === "function" + ? extension.signEvent.bind(extension) + : null; + const encrypt = + extension.nip04 && typeof extension.nip04.encrypt === "function" + ? extension.nip04.encrypt.bind(extension.nip04) + : null; + const decrypt = + extension.nip04 && typeof extension.nip04.decrypt === "function" + ? extension.nip04.decrypt.bind(extension.nip04) + : null; + + if (!signEvent && !encrypt && !decrypt) { + return null; + } + + return { + providerId: PROVIDER_ID, + pubkey, + signEvent, + encrypt, + decrypt, + metadata: { label: PROVIDER_LABEL }, + }; + })(); + + if (signerPayload && typeof nostrClient.setActiveSigner === "function") { + try { + nostrClient.setActiveSigner(signerPayload); + } catch (error) { + if (isDevMode) { + console.warn("[auth:nip07] Failed to set active signer:", error); + } + } + } else if (typeof nostrClient.clearActiveSigner === "function") { + try { + nostrClient.clearActiveSigner(PROVIDER_ID); + } catch (error) { + if (isDevMode) { + console.warn( + "[auth:nip07] Failed to clear active signer after login:", + error, + ); + } + } + } + + if (typeof nostrClient.pubkey === "string" || nostrClient.pubkey === null) { + nostrClient.pubkey = pubkey; + } + + const signerResult = signerPayload + ? { + providerId: PROVIDER_ID, + signEvent: signerPayload.signEvent, + encrypt: signerPayload.encrypt, + decrypt: signerPayload.decrypt, + } + : null; + + return { authType: PROVIDER_ID, pubkey, signer: signerResult }; } export default Object.freeze({ diff --git a/js/subscriptions.js b/js/subscriptions.js index 110d8fd2..2bb8de9c 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -42,6 +42,27 @@ class SubscriptionsManager { console.warn("[SubscriptionsManager] No pubkey => cannot load subs."); return; } + const activeSigner = nostrClient.getActiveSignerForPubkey(userPubkey); + const signerDecrypt = + activeSigner && typeof activeSigner.decrypt === "function" + ? activeSigner.decrypt + : null; + const extensionDecrypt = + window?.nostr?.nip04?.decrypt + ? window.nostr.nip04.decrypt.bind(window.nostr.nip04) + : null; + const decryptFn = signerDecrypt || extensionDecrypt; + + if (!decryptFn) { + console.warn( + "[SubscriptionsManager] Decryption unavailable; treating subscription list as empty." + ); + this.subscribedPubkeys.clear(); + this.subsEventId = null; + this.loaded = true; + return; + } + try { const filter = { kinds: [30002], @@ -76,10 +97,7 @@ class SubscriptionsManager { let decryptedStr = ""; try { - decryptedStr = await window.nostr.nip04.decrypt( - userPubkey, - newest.content - ); + decryptedStr = await decryptFn(userPubkey, newest.content); } catch (errDecrypt) { console.error("[SubscriptionsManager] Decryption failed:", errDecrypt); this.subscribedPubkeys.clear(); @@ -157,6 +175,35 @@ class SubscriptionsManager { throw new Error("No pubkey => cannot publish subscription list."); } + const activeSigner = nostrClient.getActiveSignerForPubkey(userPubkey); + const signerEncrypt = + activeSigner && typeof activeSigner.encrypt === "function" + ? activeSigner.encrypt + : null; + const extensionEncrypt = + window?.nostr?.nip04?.encrypt + ? window.nostr.nip04.encrypt.bind(window.nostr.nip04) + : null; + const encryptFn = signerEncrypt || extensionEncrypt; + + if (!encryptFn) { + throw new Error("NIP-04 encryption is required to update subscriptions."); + } + + const signerSign = + activeSigner && typeof activeSigner.signEvent === "function" + ? activeSigner.signEvent + : null; + const extensionSign = + typeof window?.nostr?.signEvent === "function" + ? window.nostr.signEvent.bind(window.nostr) + : null; + const signFn = signerSign || extensionSign; + + if (!signFn) { + throw new Error("Nostr signing support is required to update subscriptions."); + } + const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) }; const plainStr = JSON.stringify(plainObj); @@ -170,7 +217,7 @@ class SubscriptionsManager { */ let cipherText = ""; try { - cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr); + cipherText = await encryptFn(userPubkey, plainStr); } catch (err) { console.error("Encryption failed:", err); throw err; @@ -184,7 +231,7 @@ class SubscriptionsManager { let signedEvent; try { - signedEvent = await window.nostr.signEvent(evt); + signedEvent = await signFn(evt); } catch (signErr) { console.error("Failed to sign subscription list:", signErr); throw signErr; diff --git a/js/userBlocks.js b/js/userBlocks.js index 2703efdb..577e86d3 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -73,7 +73,18 @@ class UserBlockListManager { return; } - if (!window?.nostr?.nip04?.decrypt) { + const activeSigner = nostrClient.getActiveSignerForPubkey(normalized); + const signerDecrypt = + activeSigner && typeof activeSigner.decrypt === "function" + ? activeSigner.decrypt + : null; + const extensionDecrypt = + window?.nostr?.nip04?.decrypt + ? window.nostr.nip04.decrypt.bind(window.nostr.nip04) + : null; + const decryptFn = signerDecrypt || extensionDecrypt; + + if (!decryptFn) { console.warn( "[UserBlockList] nip04.decrypt is unavailable; treating block list as empty." ); @@ -200,7 +211,7 @@ class UserBlockListManager { let decrypted = ""; try { - decrypted = await window.nostr.nip04.decrypt(normalized, newest.content); + decrypted = await decryptFn(normalized, newest.content); } catch (err) { console.error("[UserBlockList] Failed to decrypt block list:", err); this.blockedPubkeys.clear(); @@ -373,25 +384,44 @@ class UserBlockListManager { } async publishBlockList(userPubkey) { - if (!window?.nostr?.nip04?.encrypt) { - const err = new Error( - "NIP-04 encryption is required to update the block list." - ); - err.code = "nip04-missing"; - throw err; - } - - if (typeof window.nostr.signEvent !== "function") { - const err = new Error("Nostr extension missing signEvent support."); - err.code = "nip04-missing"; - throw err; - } - const normalized = normalizeHex(userPubkey); if (!normalized) { throw new Error("Invalid user pubkey."); } + const activeSigner = nostrClient.getActiveSignerForPubkey(normalized); + const signerEncrypt = + activeSigner && typeof activeSigner.encrypt === "function" + ? activeSigner.encrypt + : null; + const extensionEncrypt = + window?.nostr?.nip04?.encrypt + ? window.nostr.nip04.encrypt.bind(window.nostr.nip04) + : null; + const encryptFn = signerEncrypt || extensionEncrypt; + + if (!encryptFn) { + const err = new Error("NIP-04 encryption is required to update the block list."); + err.code = "nip04-missing"; + throw err; + } + + const signerSign = + activeSigner && typeof activeSigner.signEvent === "function" + ? activeSigner.signEvent + : null; + const extensionSign = + typeof window?.nostr?.signEvent === "function" + ? window.nostr.signEvent.bind(window.nostr) + : null; + const signFn = signerSign || extensionSign; + + if (!signFn) { + const err = new Error("Nostr extension missing signEvent support."); + err.code = "nip04-missing"; + throw err; + } + const payload = { blockedPubkeys: Array.from(this.blockedPubkeys).filter( (candidate) => candidate && candidate !== normalized @@ -401,7 +431,7 @@ class UserBlockListManager { let cipherText = ""; try { - cipherText = await window.nostr.nip04.encrypt(normalized, plaintext); + cipherText = await encryptFn(normalized, plaintext); } catch (error) { const err = new Error("Failed to encrypt block list."); err.code = "nip04-missing"; @@ -414,7 +444,7 @@ class UserBlockListManager { content: cipherText, }); - const signedEvent = await window.nostr.signEvent(event); + const signedEvent = await signFn(event); const publishResults = await publishEventToRelays( nostrClient.pool,