diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 7d5c9d17..48d3fb65 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -42,8 +42,97 @@ export function createAuthSessionCoordinator(deps) { getActiveProfilePubkey, } = deps; + const now = + typeof performance !== "undefined" && typeof performance.now === "function" + ? () => performance.now() + : () => Date.now(); + + const SIGNER_GATE_STATUS = Object.freeze({ + READY: "signer-ready", + EXTENSION_UNAVAILABLE: "extension-unavailable", + PERMISSION_DENIED: "permission-denied", + }); + + const classifySignerGateError = (error) => { + const code = + typeof error?.code === "string" && error.code.trim() + ? error.code.trim().toLowerCase() + : ""; + const message = + typeof error?.message === "string" && error.message.trim() + ? error.message.trim().toLowerCase() + : ""; + + if ( + code.includes("permission") || + message.includes("permission") || + message.includes("denied") + ) { + return SIGNER_GATE_STATUS.PERMISSION_DENIED; + } + + return SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE; + }; + + const evaluateSignerReadinessGate = async (pubkey) => { + if (!pubkey) { + return { + ready: false, + status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE, + error: null, + }; + } + + try { + const signer = await nostrClient.ensureActiveSignerForPubkey(pubkey); + if (!signer) { + return { + ready: false, + status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE, + error: null, + }; + } + + if ( + signer?.type === "extension" || + signer?.type === "nip07" + ) { + const permissionResult = await nostrClient.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + if (!permissionResult?.ok) { + const error = + permissionResult?.error instanceof Error + ? permissionResult.error + : new Error("permission-denied"); + return { + ready: false, + status: classifySignerGateError(error), + error, + }; + } + } + + return { + ready: true, + status: SIGNER_GATE_STATUS.READY, + error: null, + }; + } catch (error) { + return { + ready: false, + status: classifySignerGateError(error), + error, + }; + } + }; + return { async handleAuthLogin(detail = {}) { + const authLoginStart = now(); + userLogger.info("[auth-login-start]", { + pubkey: detail?.pubkey || this.pubkey || null, + }); const postLoginPromise = detail && typeof detail.postLoginPromise?.then === "function" ? detail.postLoginPromise @@ -193,27 +282,71 @@ export function createAuthSessionCoordinator(deps) { return null; }); - // PRE-AUTH: Request full permissions once so subsequent services don't - // spam individual prompts. This runs immediately (not gated behind - // relaysReadyPromise) so the user can approve while relays connect. - let preAuthSucceeded = false; - const permissionPromise = activePubkey + // Shared signer readiness gate used by both auto-login and manual NIP-07 + // login sync orchestration before decrypt-dependent list fetches begin. + const signerReadinessPromise = activePubkey ? Promise.resolve() .then(async () => { - const signer = await nostrClient.ensureActiveSignerForPubkey( - activePubkey, + const signerGateStart = now(); + let outcome = await evaluateSignerReadinessGate(activePubkey); + if (!outcome.ready) { + outcome = { + ...outcome, + recoveryAttempted: true, + initialStatus: outcome.status, + }; + const recoveryOutcome = await evaluateSignerReadinessGate( + activePubkey, + ); + outcome = { + ...recoveryOutcome, + recoveryAttempted: true, + initialStatus: outcome.initialStatus, + }; + } + + const durationMs = Math.max( + 0, + Math.round(now() - signerGateStart), ); - if (signer?.type === "extension" || signer?.type === "nip07") { - await nostrClient.ensureExtensionPermissions( - DEFAULT_NIP07_PERMISSION_METHODS, + const finalOutcome = { + ...outcome, + recoveryAttempted: Boolean(outcome.recoveryAttempted), + durationMs, + }; + userLogger.info("[signer-ready]", { + ready: finalOutcome.ready, + status: finalOutcome.status, + recoveryAttempted: finalOutcome.recoveryAttempted, + durationMs: finalOutcome.durationMs, + }); + + if (!finalOutcome.ready && finalOutcome.error) { + devLogger.warn( + "[Application] Signer readiness gate not ready after bounded recovery:", + finalOutcome.error, ); } - preAuthSucceeded = true; + + return finalOutcome; }) .catch((error) => { - devLogger.warn("[Application] Pre-authorization failed:", error); + devLogger.warn("[Application] Signer readiness gate failed:", error); + return { + ready: false, + status: classifySignerGateError(error), + error, + recoveryAttempted: true, + durationMs: 0, + }; }) - : Promise.resolve(); + : Promise.resolve({ + ready: false, + status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE, + error: null, + recoveryAttempted: false, + durationMs: 0, + }); // Start list loading as soon as relays are ready — lists don't depend on // profile data, only on relay URLs. This runs in parallel with profile @@ -237,8 +370,17 @@ export function createAuthSessionCoordinator(deps) { // If pre-auth fails (e.g. extension was slow to inject), allow list // services to request permissions themselves so they don't stall waiting // for a retry timer. - const listStatePromise = Promise.all([relaysReadyPromise, permissionPromise]).then(async () => { - const allowPermissionPrompt = !preAuthSucceeded; + const listStatePromise = Promise.all([ + relaysReadyPromise, + signerReadinessPromise, + ]).then(async ([, signerGateOutcome]) => { + const listSyncStart = now(); + const allowPermissionPrompt = !signerGateOutcome?.ready; + userLogger.info("[lists-sync-start]", { + pubkey: activePubkey || null, + allowPermissionPrompt, + signerStatus: signerGateOutcome?.status || null, + }); const hasValidBlocksCache = () => { if (!activePubkey || !userBlocks) { return false; @@ -296,6 +438,7 @@ export function createAuthSessionCoordinator(deps) { async () => { const loaded = await this.authService.loadBlocksForPubkey(activePubkey, { allowPermissionPrompt, + signerReadinessGate: signerGateOutcome, }); const ok = loaded !== false; if (!ok) { @@ -321,6 +464,7 @@ export function createAuthSessionCoordinator(deps) { async () => { await subscriptions.ensureLoaded(activePubkey, { allowPermissionPrompt, + signerReadinessGate: signerGateOutcome, }); const error = subscriptions?.lastLoadError || null; this.capturePermissionPromptFromError(error); @@ -342,6 +486,7 @@ export function createAuthSessionCoordinator(deps) { async () => { await this.hashtagPreferences.load(activePubkey, { allowPermissionPrompt, + signerReadinessGate: signerGateOutcome, }); const error = this.hashtagPreferences?.lastLoadError || null; this.capturePermissionPromptFromError(error); @@ -386,12 +531,15 @@ export function createAuthSessionCoordinator(deps) { Promise.all([ this.authService?.loadBlocksForPubkey?.(activePubkey, { allowPermissionPrompt: true, + signerReadinessGate: signerGateOutcome, }), subscriptions?.ensureLoaded?.(activePubkey, { allowPermissionPrompt: true, + signerReadinessGate: signerGateOutcome, }), this.hashtagPreferences?.load?.(activePubkey, { allowPermissionPrompt: true, + signerReadinessGate: signerGateOutcome, }), ]), ) @@ -450,6 +598,13 @@ export function createAuthSessionCoordinator(deps) { } const listsState = ready ? (degraded ? "degraded" : "ready") : "error"; + userLogger.info("[lists-sync-complete]", { + pubkey: activePubkey || null, + ready, + degraded, + fatal, + durationMs: Math.max(0, Math.round(now() - listSyncStart)), + }); this.updateAuthLoadingState({ lists: listsState, listsDetail: listSyncDetail, @@ -594,6 +749,11 @@ export function createAuthSessionCoordinator(deps) { ); }); } + + userLogger.info("[auth-login-complete]", { + pubkey: activePubkey || null, + durationMs: Math.max(0, Math.round(now() - authLoginStart)), + }); }, handleBlocksLoaded(detail = {}) { diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 6a5d39a5..d4233ba2 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -578,6 +578,14 @@ class HashtagPreferencesService { async loadInternal(pubkey, options = {}) { const normalized = normalizeHexPubkey(pubkey); const allowPermissionPrompt = options?.allowPermissionPrompt !== false; + const signerReadinessGate = + options?.signerReadinessGate && typeof options.signerReadinessGate === "object" + ? options.signerReadinessGate + : null; + const shouldSuppressPermissionEscalation = + signerReadinessGate && + (signerReadinessGate.status === "permission-denied" || + signerReadinessGate.status === "extension-unavailable"); this.lastAttemptAt = Date.now(); this.lastLoadError = null; let wasLoadedForUser = @@ -828,6 +836,7 @@ class HashtagPreferencesService { try { const decryptPromise = this.decryptEvent(latest, normalized, { allowPermissionPrompt, + signerReadinessGate, }); let timeoutId; const timeoutPromise = new Promise((_, reject) => { @@ -859,6 +868,7 @@ class HashtagPreferencesService { if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") { this.scheduleDecryptRetry(normalized, decryptResult.error, { allowPermissionPrompt, + signerReadinessGate, }); return; } @@ -866,8 +876,20 @@ class HashtagPreferencesService { !allowPermissionPrompt && decryptResult.error?.code === "hashtag-preferences-permission-required" ) { + if (shouldSuppressPermissionEscalation) { + userLogger.warn( + `${LOG_PREFIX} Skipping permission escalation due to shared signer gate outcome.`, + signerReadinessGate, + ); + if (wasBackgroundLoading) { + this.backgroundLoading = false; + this.emitChange("background-loaded", { background: false }); + } + return; + } this.scheduleDecryptRetry(normalized, decryptResult.error, { allowPermissionPrompt: true, + signerReadinessGate, }); if (wasBackgroundLoading) { this.backgroundLoading = false; @@ -953,6 +975,14 @@ class HashtagPreferencesService { } const allowPermissionPrompt = options?.allowPermissionPrompt !== false; + const signerReadinessGate = + options?.signerReadinessGate && typeof options.signerReadinessGate === "object" + ? options.signerReadinessGate + : null; + const suppressPermissionPrompt = + signerReadinessGate && + (signerReadinessGate.status === "permission-denied" || + signerReadinessGate.status === "extension-unavailable"); let signer = getActiveSigner(); if ( !signer && @@ -979,6 +1009,13 @@ class HashtagPreferencesService { error.code = "hashtag-preferences-permission-required"; return { ok: false, error }; } + if (suppressPermissionPrompt) { + const error = new Error( + "Shared signer gate indicates extension permissions are unavailable.", + ); + error.code = "hashtag-preferences-permission-required"; + return { ok: false, error }; + } try { const permissionResult = await requestDefaultExtensionPermissions( DEFAULT_NIP07_ENCRYPTION_METHODS, diff --git a/js/subscriptions.js b/js/subscriptions.js index 3616dcbc..646c4631 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -479,6 +479,14 @@ class SubscriptionsManager { try { this.lastAttemptAt = Date.now(); const allowPermissionPrompt = options?.allowPermissionPrompt !== false; + const signerReadinessGate = + options?.signerReadinessGate && typeof options.signerReadinessGate === "object" + ? options.signerReadinessGate + : null; + const shouldSuppressPermissionEscalation = + signerReadinessGate && + (signerReadinessGate.status === "permission-denied" || + signerReadinessGate.status === "extension-unavailable"); const wasBackgroundLoading = this.backgroundLoading; const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; const wasLoadedForUser = @@ -636,6 +644,7 @@ class SubscriptionsManager { try { const decryptPromise = this.decryptSubscriptionEvent(newest, userPubkey, { allowPermissionPrompt, + signerReadinessGate, }); const timeoutPromise = new Promise((_, reject) => setTimeout( @@ -660,6 +669,7 @@ class SubscriptionsManager { if (decryptResult.error?.code === "subscriptions-decrypt-timeout") { this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { allowPermissionPrompt, + signerReadinessGate, }); return; } @@ -667,10 +677,25 @@ class SubscriptionsManager { !allowPermissionPrompt && decryptResult.error?.code === "subscriptions-permission-required" ) { + if (shouldSuppressPermissionEscalation) { + userLogger.warn( + "[SubscriptionsManager] Skipping permission escalation due to shared signer gate outcome.", + signerReadinessGate, + ); + if (wasBackgroundLoading) { + this.backgroundLoading = false; + this.emitter.emit("change", { + action: "background-loaded", + subscribedPubkeys: Array.from(this.subscribedPubkeys), + }); + } + return; + } // Retry with permission prompt enabled so the signer / window.nostr // path is available once the extension has finished initializing. this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { allowPermissionPrompt: true, + signerReadinessGate, }); if (wasBackgroundLoading) { this.backgroundLoading = false; @@ -698,6 +723,7 @@ class SubscriptionsManager { ); this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { allowPermissionPrompt, + signerReadinessGate, }); return; } @@ -710,6 +736,7 @@ class SubscriptionsManager { } this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { allowPermissionPrompt, + signerReadinessGate, }); return; } @@ -959,6 +986,14 @@ class SubscriptionsManager { } const allowPermissionPrompt = options?.allowPermissionPrompt !== false; + const signerReadinessGate = + options?.signerReadinessGate && typeof options.signerReadinessGate === "object" + ? options.signerReadinessGate + : null; + const suppressPermissionPrompt = + signerReadinessGate && + (signerReadinessGate.status === "permission-denied" || + signerReadinessGate.status === "extension-unavailable"); const hints = extractEncryptionHints(event); const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2"); const requiresNip04 = !hints.length || hints.includes("nip04") || !requiresNip44; @@ -1008,6 +1043,13 @@ class SubscriptionsManager { error.code = "subscriptions-permission-required"; return { ok: false, error }; } + if (suppressPermissionPrompt) { + const error = new Error( + "Shared signer gate indicates extension permissions are unavailable.", + ); + error.code = "subscriptions-permission-required"; + return { ok: false, error }; + } try { const permissionResult = await requestDefaultExtensionPermissions( DEFAULT_NIP07_ENCRYPTION_METHODS,