// js/services/authService.js import { setPubkey, setCurrentUserNpub, getPubkey, getCurrentUserNpub, } from "../state/appState.js"; import { userLogger } from "../utils/logger.js"; import { requestDefaultExtensionPermissions } from "../nostr/defaultClient.js"; import { getSavedProfiles, setSavedProfiles, getActiveProfilePubkey, setActiveProfilePubkey, mutateSavedProfiles, persistSavedProfiles, loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage, syncSavedProfileFromCache as syncSavedProfileFromCacheState, loadProfileCacheFromStorage as hydrateProfileCacheFromStorage, persistProfileCacheToStorage as persistProfileCacheState, getProfileCacheEntry as getCachedProfileEntry, setProfileCacheEntry as setCachedProfileEntry, resetModerationSettings, } from "../state/cache.js"; import { profileCache } from "../state/profileCache.js"; import getDefaultAuthProvider, { providers as defaultAuthProviders, } from "./authProviders/index.js"; import { fetchProfileMetadata } from "./profileMetadataService.js"; import { HEX64_REGEX } from "../utils/hex.js"; import { safeEncodeNpub, safeDecodeNpub, normalizeHexPubkey, } from "../utils/nostrHelpers.js"; import { SimpleEventEmitter } from "../utils/simpleEventEmitter.js"; import { FALLBACK_PROFILE, FAST_PROFILE_RELAY_LIMIT, FAST_PROFILE_TIMEOUT_MS, BACKGROUND_PROFILE_TIMEOUT_MS, normalizeProviderId, normalizeAuthType, } from "./authUtils.js"; export default class AuthService { constructor({ nostrClient, userBlocks, relayManager, logger, accessControl, authProviders, getAuthProvider, } = {}) { this.nostrClient = nostrClient || null; this.userBlocks = userBlocks || null; this.relayManager = relayManager || null; this.accessControl = accessControl || null; this.authProviders = authProviders && typeof authProviders === "object" ? authProviders : defaultAuthProviders; if (typeof logger === "function") { this.logger = logger; } else if (logger && typeof logger.log === "function") { this.logger = (...args) => logger.log(...args); } else { this.logger = () => {}; } this.emitter = new SimpleEventEmitter((message, error) => { try { this.logger(message, error); } catch (logError) { userLogger.warn("[AuthService] logger threw", logError); } }); if (typeof getAuthProvider === "function") { this.resolveAuthProvider = (providerId) => { const normalizedId = normalizeProviderId(providerId); const provider = getAuthProvider(normalizedId); if (!provider || typeof provider.login !== "function") { const error = new Error(`Unknown auth provider: ${normalizedId}`); error.code = "unknown-auth-provider"; throw error; } return provider; }; } else { this.resolveAuthProvider = (providerId) => { const normalizedId = normalizeProviderId(providerId); const registry = this.authProviders || defaultAuthProviders; const provider = registry && typeof registry === "object" ? registry[normalizedId] : null; if (provider && typeof provider.login === "function") { return provider; } return getDefaultAuthProvider(normalizedId); }; } } log(...args) { try { this.logger(...args); } catch (error) { userLogger.warn("[AuthService] logger threw", error); } } on(eventName, handler) { return this.emitter.on(eventName, handler); } emit(eventName, detail) { this.emitter.emit(eventName, detail); } hydrateFromStorage() { hydrateProfileCacheFromStorage(); const { profiles, hasExplicitActiveProfile } = hydrateSavedProfilesFromStorage(); if ( !getActiveProfilePubkey() && !hasExplicitActiveProfile && Array.isArray(profiles) && profiles.length ) { const first = profiles[0]?.pubkey || null; if (first) { setActiveProfilePubkey(first, { persist: true }); } } this.emitProfileList("hydrate"); } persistProfileCache() { persistProfileCacheState(); } getProfileCacheEntry(pubkey) { return getCachedProfileEntry(pubkey); } setProfileCacheEntry(pubkey, profile, { persist = true, reason = "manual" } = {}) { const entry = setCachedProfileEntry(pubkey, profile, { persist }); if (entry) { const normalized = this.normalizeHexPubkey(pubkey); if (normalized) { const updated = syncSavedProfileFromCacheState(normalized, { persist, }); if (updated) { this.emitProfileList("cache-sync"); } this.emit("profile:updated", { pubkey: normalized, profile: entry.profile || FALLBACK_PROFILE, reason, savedProfiles: this.cloneSavedProfiles(), activeProfilePubkey: getActiveProfilePubkey(), }); } } return entry; } cloneSavedProfiles() { const saved = getSavedProfiles(); return Array.isArray(saved) ? saved.map((entry) => ({ ...entry })) : []; } emitProfileList(reason = "update") { this.emit("profile:updated", { reason, savedProfiles: this.cloneSavedProfiles(), activeProfilePubkey: getActiveProfilePubkey(), }); } safeEncodeNpub(pubkey) { return safeEncodeNpub(pubkey); } safeDecodeNpub(npub) { return safeDecodeNpub(npub); } normalizeHexPubkey(pubkey) { return normalizeHexPubkey(pubkey); } async requestLogin(rawOptions = {}) { const normalizedOptions = rawOptions && typeof rawOptions === "object" ? { ...rawOptions } : {}; const { providerId: requestedProviderId = "nip07", persistActive, autoApply, ...providerOptions } = normalizedOptions; const providerId = normalizeProviderId(requestedProviderId); const provider = this.resolveAuthProvider(providerId); const providerResult = await provider.login({ nostrClient: this.nostrClient, options: providerOptions, }); const rawPubkey = providerResult && typeof providerResult === "object" ? providerResult.pubkey : providerResult; const pubkey = typeof rawPubkey === "string" ? rawPubkey : providerResult && typeof providerResult === "object" ? providerResult.publicKey || "" : ""; const authTypeCandidate = providerResult && typeof providerResult === "object" ? providerResult.authType : null; const authType = normalizeAuthType( authTypeCandidate, providerId, providerResult, ); const signer = providerResult && typeof providerResult === "object" ? providerResult.signer || null : null; const detailAuthType = typeof authType === "string" && authType.trim() ? authType.trim() : "nip07"; const trimmed = typeof pubkey === "string" ? pubkey.trim() : ""; if (!trimmed) { return { pubkey: null, providerId, authType: detailAuthType, signer }; } if (autoApply === false) { return { pubkey: trimmed, providerId, authType: detailAuthType, signer }; } const detail = await this.login(trimmed, { persistActive: persistActive !== false, providerId, authType: detailAuthType, signer, providerResult, }); return { pubkey: trimmed, detail, providerId, authType: detailAuthType, signer, }; } async handleUploadSubmit(payload, { publish } = {}) { if (!this.getActivePubkey()) { await this.requestLogin({ allowAccountSelection: true }); if (!this.getActivePubkey()) { throw new Error("Login required to publish videos."); } } if (typeof publish !== "function") { throw new Error("A publish callback must be provided to handleUploadSubmit."); } return publish(payload); } get pubkey() { return getPubkey(); } set pubkey(value) { setPubkey(value ?? null); } get currentUserNpub() { return getCurrentUserNpub(); } set currentUserNpub(value) { setCurrentUserNpub(value ?? null); } get activeProfilePubkey() { return getActiveProfilePubkey(); } setActiveProfilePubkey(pubkey, options = {}) { return setActiveProfilePubkey(pubkey, options); } get savedProfiles() { return getSavedProfiles(); } setSavedProfiles(profiles, options = {}) { return setSavedProfiles(profiles, options); } getActivePubkey() { return getPubkey(); } async login(pubkey, options = {}) { const { normalizedOptions, providerId, authType, signer, providerResult, nextPubkey, normalizedPubkey, trimmedPubkey, previousPubkey, persistActive, } = this._validateAndNormalizeLoginInputs(pubkey, options); const { candidateNpub } = this._checkAccessControl( nextPubkey, trimmedPubkey, ); const identityChanged = previousPubkey !== nextPubkey; this._updateGlobalState( normalizedPubkey, nextPubkey, trimmedPubkey, candidateNpub, ); const { savedProfilesMutated, entryAuthType } = this._updateSavedProfiles({ normalizedPubkey, nextPubkey, candidateNpub, authType, providerId, providerResult, persistActive, }); return this._initiatePostLogin({ activeLoginPubkey: getPubkey(), previousPubkey, identityChanged, savedProfiles: this.cloneSavedProfiles(), activeProfilePubkey: getActiveProfilePubkey(), providerId, entryAuthType, signer, providerResult, }); } _validateAndNormalizeLoginInputs(pubkey, options) { const normalizedOptions = options && typeof options === "object" ? { ...options } : {}; const persistActive = normalizedOptions.persistActive === false ? false : true; const providerId = normalizeProviderId(normalizedOptions.providerId); const providerResult = Object.prototype.hasOwnProperty.call( normalizedOptions, "providerResult", ) ? normalizedOptions.providerResult ?? null : null; const authTypeCandidate = typeof normalizedOptions.authType === "string" ? normalizedOptions.authType : null; const authType = normalizeAuthType( authTypeCandidate, providerId, providerResult, ); const signer = Object.prototype.hasOwnProperty.call( normalizedOptions, "signer", ) ? normalizedOptions.signer ?? null : null; const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey(); const normalizedPubkey = this.normalizeHexPubkey(pubkey); const trimmedPubkey = typeof pubkey === "string" ? pubkey.trim() : ""; const nextPubkey = normalizedPubkey || trimmedPubkey || null; if (!nextPubkey) { throw new Error("A valid pubkey is required for login."); } return { normalizedOptions, providerId, authType, signer, providerResult, nextPubkey, normalizedPubkey, trimmedPubkey, previousPubkey, persistActive, }; } _checkAccessControl(nextPubkey, trimmedPubkey) { const control = this.accessControl; const candidateNpub = this.safeEncodeNpub(nextPubkey) || null; let lockdownActive = false; if (control && typeof control.isLockdownActive === "function") { try { lockdownActive = control.isLockdownActive(); } catch (error) { this.log("[AuthService] accessControl.isLockdownActive threw", error); lockdownActive = Boolean(lockdownActive); } } if (lockdownActive) { let isAdminCandidate = false; const adminCheckValue = candidateNpub || (typeof trimmedPubkey === "string" && trimmedPubkey ? trimmedPubkey : null); if (adminCheckValue && typeof control?.isAdminEditor === "function") { try { isAdminCandidate = !!control.isAdminEditor(adminCheckValue); } catch (error) { this.log("[AuthService] accessControl.isAdminEditor threw", error); isAdminCandidate = false; } } if (!isAdminCandidate) { const lockdownError = new Error( "This site is temporarily locked down. Only administrators may sign in right now.", ); lockdownError.code = "site-lockdown"; lockdownError.npub = candidateNpub || adminCheckValue; throw lockdownError; } } if (control && typeof control.canAccess === "function") { let canAccess = true; try { canAccess = control.canAccess(candidateNpub || nextPubkey); } catch (error) { this.log("[AuthService] accessControl.canAccess threw", error); canAccess = Boolean(canAccess); } if (!canAccess) { let isBlocked = false; if (typeof control.isBlacklisted === "function") { try { isBlocked = control.isBlacklisted(candidateNpub || nextPubkey); } catch (error) { this.log("[AuthService] accessControl.isBlacklisted threw", error); isBlocked = false; } } const accessError = new Error( isBlocked ? "Your account has been blocked on this platform." : "Access restricted to admins and moderators users only.", ); throw accessError; } } return { candidateNpub }; } _updateGlobalState( normalizedPubkey, nextPubkey, trimmedPubkey, candidateNpub, ) { if (normalizedPubkey) { setPubkey(normalizedPubkey); if (this.nostrClient && typeof this.nostrClient === "object") { this.nostrClient.pubkey = normalizedPubkey; } profileCache.setActiveProfile(normalizedPubkey); } else { setPubkey(nextPubkey); if (this.nostrClient && typeof this.nostrClient === "object") { this.nostrClient.pubkey = trimmedPubkey ? trimmedPubkey.toLowerCase() : ""; } profileCache.setActiveProfile( trimmedPubkey ? trimmedPubkey.toLowerCase() : null, ); } setCurrentUserNpub(candidateNpub); } _updateSavedProfiles({ normalizedPubkey, nextPubkey, candidateNpub, authType, providerId, providerResult, persistActive, }) { let savedProfilesMutated = false; const entryAuthType = normalizeAuthType( authType, providerId, providerResult, ); const entryProviderId = normalizeProviderId(providerId) || entryAuthType; mutateSavedProfiles( (profiles) => { const draft = Array.isArray(profiles) ? profiles.slice() : []; const existingIndex = draft.findIndex((entry) => { const normalizedEntry = this.normalizeHexPubkey(entry?.pubkey); return normalizedEntry && normalizedEntry === normalizedPubkey; }); const cacheEntry = this.getProfileCacheEntry( normalizedPubkey || nextPubkey, ); const cachedProfile = cacheEntry?.profile || {}; const nextEntry = { pubkey: normalizedPubkey || nextPubkey, npub: candidateNpub || (cachedProfile.npub ?? null), name: cachedProfile.name || draft[existingIndex]?.name || "", picture: cachedProfile.picture || draft[existingIndex]?.picture || "", authType: entryAuthType, providerId: entryProviderId, }; if (existingIndex >= 0) { const currentEntry = draft[existingIndex] || {}; const changed = currentEntry.npub !== nextEntry.npub || currentEntry.name !== nextEntry.name || currentEntry.picture !== nextEntry.picture || currentEntry.authType !== nextEntry.authType || currentEntry.providerId !== nextEntry.providerId; if (changed) { draft[existingIndex] = nextEntry; savedProfilesMutated = true; } } else { draft.push(nextEntry); savedProfilesMutated = true; } return draft; }, { persist: false, persistActive: false }, ); if (persistActive) { setActiveProfilePubkey(normalizedPubkey || nextPubkey, { persist: true }); } if (!persistActive && savedProfilesMutated) { persistSavedProfiles({ persistActive: false }); } else if (persistActive || savedProfilesMutated) { persistSavedProfiles({ persistActive: true }); } if (savedProfilesMutated) { this.emitProfileList("login"); } return { savedProfilesMutated, entryAuthType }; } _initiatePostLogin({ activeLoginPubkey, previousPubkey, identityChanged, savedProfiles, activeProfilePubkey, providerId, entryAuthType, signer, providerResult, }) { const normalizedLoginPubkey = this.normalizeHexPubkey(activeLoginPubkey) || activeLoginPubkey || null; const detail = { pubkey: activeLoginPubkey, npub: getCurrentUserNpub(), previousPubkey: previousPubkey || null, identityChanged, savedProfiles, activeProfilePubkey, providerId, authType: entryAuthType, signer, providerResult, postLogin: { pubkey: normalizedLoginPubkey, blocksLoaded: false, relaysLoaded: false, profile: null, }, postLoginPromise: null, postLoginError: null, }; const { detail: postLoginDetail, completionPromise, relaysReadyPromise, } = this.applyPostLoginState({ deferBlocks: true, }); detail.postLogin = postLoginDetail; detail.relaysReadyPromise = relaysReadyPromise; const postLoginPromise = Promise.resolve(completionPromise) .then((postLogin) => { detail.postLogin = postLogin; detail.postLoginError = null; try { this.emitProfileList("post-login"); } catch (error) { this.log( "[AuthService] Failed to emit profile list after login", error, ); } this.emit("auth:post-login", { ...detail, postLogin, postLoginError: null, savedProfiles: this.cloneSavedProfiles(), activeProfilePubkey: getActiveProfilePubkey(), }); return postLogin; }) .catch((error) => { this.log("[AuthService] applyPostLoginState failed", error); detail.postLoginError = error; this.emit("auth:post-login", { ...detail, postLogin: detail.postLogin, postLoginError: error, savedProfiles: this.cloneSavedProfiles(), activeProfilePubkey: getActiveProfilePubkey(), }); return null; }); detail.postLoginPromise = postLoginPromise; try { Object.defineProperty(detail, "__handled", { configurable: true, enumerable: false, writable: true, value: false, }); } catch (error) { // Ignore descriptor errors (e.g., frozen objects) and fall back to direct assignment. detail.__handled = false; } this.emit("auth:login", detail); return detail; } applyPostLoginState({ deferBlocks = false } = {}) { const activePubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey(); const cachedEntry = activePubkey ? this.getProfileCacheEntry(activePubkey) : null; const cachedProfile = cachedEntry?.profile || FALLBACK_PROFILE; const detail = { pubkey: activePubkey || null, blocksLoaded: deferBlocks ? null : false, relaysLoaded: false, profile: cachedProfile, }; if (!activePubkey) { const resolved = Promise.resolve(detail); return { detail, completionPromise: resolved, relaysReadyPromise: resolved }; } const runOperation = async (operation) => { try { const value = await operation.promise; const resolved = operation.onFulfilled(value); detail[operation.name] = resolved; if (typeof operation.emit === "function") { operation.emit(resolved, null); } return resolved; } catch (error) { const resolved = operation.onRejected(error); detail[operation.name] = resolved; if (typeof operation.emit === "function") { operation.emit(resolved, error); } return resolved; } }; const schedule = (callback) => Promise.resolve().then(() => callback()); // Expose relay readiness separately so list loading can start immediately // after relays are available, without waiting for profile to complete. let resolveRelaysReady; const relaysReadyPromise = new Promise((resolve) => { resolveRelaysReady = resolve; }); const completionPromise = (async () => { if (this.relayManager && typeof this.relayManager.loadRelayList === "function") { const relayOp = { name: "relaysLoaded", promise: schedule(() => this.relayManager.loadRelayList(activePubkey)), onFulfilled: () => true, onRejected: (error) => { this.log("[AuthService] Failed to load relay list", error); return false; }, emit: (value, error) => { this.emit("relaysLoaded", { pubkey: activePubkey, relaysLoaded: value, error: error ?? null, }); }, }; await runOperation(relayOp); } resolveRelaysReady(detail); const concurrentOps = []; if (!deferBlocks) { const blocksOperation = this.createBlocksLoadOperation(activePubkey, schedule); if (blocksOperation) { concurrentOps.push(blocksOperation); } } concurrentOps.push({ name: "profile", promise: schedule(() => this.loadOwnProfile(activePubkey)), onFulfilled: (value) => { const profile = value || FALLBACK_PROFILE; this.setProfileCacheEntry(activePubkey, profile, { persist: true, reason: "post-login", }); return profile; }, onRejected: (error) => { this.log("[AuthService] Failed to load own profile", error); const fallbackProfile = cachedProfile || FALLBACK_PROFILE; this.setProfileCacheEntry(activePubkey, fallbackProfile, { persist: true, reason: "post-login-fallback", }); return fallbackProfile; }, }); await Promise.allSettled(concurrentOps.map(runOperation)); return detail; })(); return { detail, completionPromise, relaysReadyPromise }; } createBlocksLoadOperation( activePubkey, schedule = (callback) => Promise.resolve().then(() => callback()), options = {}, ) { if (!this.userBlocks || typeof this.userBlocks.loadBlocks !== "function") { return null; } return { name: "blocksLoaded", promise: schedule(() => this.userBlocks.loadBlocks(activePubkey, options)), onFulfilled: () => true, onRejected: (error) => { this.log("[AuthService] Failed to load block list", error); return false; }, emit: (value, error) => { this.emit("blocksLoaded", { pubkey: activePubkey, blocksLoaded: value, error: error ?? null, }); }, }; } async loadBlocksForPubkey(pubkey, options = {}) { const normalized = this.normalizeHexPubkey(pubkey) || (typeof pubkey === "string" ? pubkey.trim() : ""); if (!normalized) { return false; } const operation = this.createBlocksLoadOperation(normalized, undefined, options); if (!operation) { return false; } try { const value = await operation.promise; const resolved = operation.onFulfilled(value); operation.emit?.(resolved, null); return resolved; } catch (error) { const resolved = operation.onRejected(error); operation.emit?.(resolved, error); return resolved; } } async logout() { if (this.nostrClient && typeof this.nostrClient.logout === "function") { try { this.nostrClient.logout(); } catch (error) { this.log("[AuthService] nostrClient.logout threw", error); } } const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey(); setPubkey(null); setCurrentUserNpub(null); setActiveProfilePubkey(null, { persist: true }); try { if (profileCache && typeof profileCache.reset === "function") { profileCache.reset(); } } catch (error) { this.log("[AuthService] profileCache.reset threw", error); } try { resetModerationSettings({ persist: true }); } catch (error) { this.log( "[AuthService] Failed to reset moderation settings on logout", error, ); } if (this.userBlocks && typeof this.userBlocks.reset === "function") { try { this.userBlocks.reset(); } catch (error) { this.log("[AuthService] userBlocks.reset threw", error); } } if (this.relayManager && typeof this.relayManager.reset === "function") { try { this.relayManager.reset(); } catch (error) { this.log("[AuthService] relayManager.reset threw", error); } } persistSavedProfiles({ persistActive: true }); const detail = { previousPubkey: previousPubkey || null, savedProfiles: this.cloneSavedProfiles(), activeProfilePubkey: getActiveProfilePubkey(), }; this.emit("auth:logout", detail); return detail; } removeSavedProfile(pubkey) { const normalized = this.normalizeHexPubkey(pubkey) || (typeof pubkey === "string" ? pubkey.trim() : ""); if (!normalized) { return { removed: false }; } const { changed } = mutateSavedProfiles((profiles) => profiles.filter((entry) => entry?.pubkey !== normalized), { persist: true, persistActive: true, }); if (changed && getActiveProfilePubkey() === normalized) { setActiveProfilePubkey(null, { persist: true }); } if (changed) { this.emitProfileList("remove-saved-profile"); } return { removed: changed }; } async switchProfile(pubkey, options = {}) { const normalizedTarget = this.normalizeHexPubkey(pubkey); if (!normalizedTarget) { throw new Error("Unable to switch profiles: invalid account."); } const normalizedOptions = options && typeof options === "object" ? { ...options } : {}; const providerId = normalizeProviderId(normalizedOptions.providerId); const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey()); if (normalizedActive && normalizedActive === normalizedTarget) { return { switched: false, reason: "already-active" }; } const requestOptions = { allowAccountSelection: true, expectPubkey: normalizedTarget, persistActive: true, ...(providerId ? { providerId } : {}), }; const autoApply = requestOptions.autoApply !== false; let requestResult; try { requestResult = await this.requestLogin(requestOptions); } catch (error) { throw error; } const requestedPubkey = this.normalizeHexPubkey(requestResult?.pubkey) || (typeof requestResult?.pubkey === "string" ? requestResult.pubkey.trim() : ""); let detail = requestResult?.detail || null; if (!detail && !autoApply) { const fallbackTarget = this.normalizeHexPubkey(requestedPubkey) || requestedPubkey || normalizedTarget; detail = await this.login(fallbackTarget, { persistActive: true, providerId, }); } mutateSavedProfiles((profiles) => { const draft = profiles.slice(); const index = draft.findIndex( (entry) => this.normalizeHexPubkey(entry?.pubkey) === normalizedTarget ); if (index > 0) { const [moved] = draft.splice(index, 1); draft.unshift(moved); } return draft; }, { persist: true, persistActive: true }); const savedProfiles = this.cloneSavedProfiles(); const activeProfilePubkey = getActiveProfilePubkey(); if (detail && typeof detail === "object") { detail.savedProfiles = savedProfiles; detail.activeProfilePubkey = activeProfilePubkey; } this.emitProfileList("switch-profile"); return { switched: true, detail }; } async fetchProfileFromRelay( relayUrl, filter, normalizedPubkey, timeoutMs, requireEvent, ) { const fetchPromise = this.nostrClient.pool.list([relayUrl], filter); let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { const timeoutError = new Error( `Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`, ); timeoutError.code = "timeout"; timeoutError.relay = relayUrl; timeoutError.timeoutMs = timeoutMs; reject(timeoutError); }, timeoutMs); }); try { const result = await Promise.race([fetchPromise, timeoutPromise]); clearTimeout(timeoutId); const events = Array.isArray(result) ? result.filter((event) => event && event.pubkey === normalizedPubkey) : []; if (requireEvent && !events.length) { const emptyError = new Error( `No profile events returned from ${relayUrl}`, ); emptyError.code = "empty"; emptyError.relay = relayUrl; throw emptyError; } return { relayUrl, events }; } catch (error) { clearTimeout(timeoutId); // Suppress unhandled rejection from fetchPromise if it's still running fetchPromise.catch(() => {}); if (error.relay) { throw error; } const wrapped = error instanceof Error ? error : new Error(String(error)); wrapped.relay = relayUrl; throw wrapped; } } async loadOwnProfile(pubkey) { const normalized = this.normalizeHexPubkey(pubkey) || (typeof pubkey === "string" ? pubkey.trim() : ""); if (!normalized) { return FALLBACK_PROFILE; } if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) { return FALLBACK_PROFILE; } const relays = Array.isArray(this.nostrClient.relays) ? this.nostrClient.relays.filter((url) => typeof url === "string" && url) : []; if (!relays.length) { this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, { persist: true, reason: "load-own-profile:fallback", }); return FALLBACK_PROFILE; } const filter = [{ kinds: [0], authors: [normalized], limit: 1 }]; const selectNewestEvent = (eventList) => { if (!Array.isArray(eventList) || !eventList.length) { return null; } const sorted = eventList .filter((event) => event && event.pubkey === normalized) .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); return sorted.length ? sorted[0] : null; }; const buildProfileFromEvent = (event) => { if (!event?.content) { return FALLBACK_PROFILE; } try { const data = JSON.parse(event.content); return { name: data.display_name || data.name || FALLBACK_PROFILE.name, picture: data.picture || FALLBACK_PROFILE.picture, about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about, website: typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website, banner: typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner, lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16, lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06, }; } catch (error) { this.log("[AuthService] Failed to parse profile metadata", error); return FALLBACK_PROFILE; } }; const fastRelays = relays.slice(0, FAST_PROFILE_RELAY_LIMIT); const backgroundRelays = relays.slice(fastRelays.length); const fastPromises = fastRelays.map((relayUrl) => this.fetchProfileFromRelay( relayUrl, filter, normalized, FAST_PROFILE_TIMEOUT_MS, true, ), ); const backgroundPromises = backgroundRelays.map((relayUrl) => this.fetchProfileFromRelay( relayUrl, filter, normalized, BACKGROUND_PROFILE_TIMEOUT_MS, false, ), ); const background = Promise.allSettled([ ...fastPromises, ...backgroundPromises, ]) .then((outcomes) => { const aggregated = []; outcomes.forEach((outcome) => { if (outcome.status === "fulfilled") { const events = Array.isArray(outcome.value?.events) ? outcome.value.events : []; if (events.length) { aggregated.push(...events); } } else if (outcome.status === "rejected") { const reason = outcome.reason; if (reason?.code === "timeout") { this.log( `[AuthService] Relay ${reason.relay} timed out loading profile (background)` ); } else { this.log( `[AuthService] Relay ${reason?.relay || "unknown"} failed loading profile (background)`, reason ); } } }); const newest = selectNewestEvent(aggregated); if (newest && this.nostrClient && typeof this.nostrClient.handleEvent === "function") { this.nostrClient.handleEvent(newest); } }) .catch((error) => { this.log("[AuthService] Background profile refresh failed", error); }); let fastResult = null; if (fastPromises.length) { try { fastResult = await Promise.any(fastPromises); } catch (error) { if (error instanceof AggregateError) { error.errors?.forEach((err) => { if (err?.code === "timeout") { this.log( `[AuthService] Relay ${err.relay} timed out loading profile` ); } }); } else { this.log("[AuthService] Failed to load profile from fast relays", error); } } } if (fastResult?.events?.length) { const newest = selectNewestEvent(fastResult.events); if (newest) { if (this.nostrClient && typeof this.nostrClient.handleEvent === "function") { this.nostrClient.handleEvent(newest); } const profile = buildProfileFromEvent(newest); // setProfileCacheEntry now delegates to profileCache.setProfile, but handleEvent also does it. // Doing it here returns the profile object which might be needed immediately. background.catch(() => {}); return profile; } } this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, { persist: true, reason: "load-own-profile:fallback", }); background.catch(() => {}); return FALLBACK_PROFILE; } async fetchProfile(pubkey, { forceRefresh = false } = {}) { const normalized = this.normalizeHexPubkey(pubkey) || (typeof pubkey === "string" ? pubkey.trim() : ""); if (!normalized) { return null; } const cacheEntry = this.getProfileCacheEntry(normalized); if (cacheEntry && !forceRefresh) { this.emit("profile:updated", { pubkey: normalized, profile: cacheEntry.profile || FALLBACK_PROFILE, reason: "cache-hit", savedProfiles: this.cloneSavedProfiles(), activeProfilePubkey: getActiveProfilePubkey(), }); return cacheEntry.profile; } if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) { return cacheEntry?.profile || FALLBACK_PROFILE; } try { const result = await fetchProfileMetadata(normalized, { nostr: this.nostrClient, }); if (result?.event && typeof this.nostrClient?.handleEvent === "function") { this.nostrClient.handleEvent(result.event); } if (result?.profile) { return result.profile; } } catch (error) { this.log("[AuthService] Failed to fetch profile", error); } return cacheEntry?.profile || FALLBACK_PROFILE; } async fetchAndRenderProfile(pubkey, forceRefresh = false) { return this.fetchProfile(pubkey, { forceRefresh }); } syncSavedProfileFromCache(pubkey, { persist = false } = {}) { const normalized = this.normalizeHexPubkey(pubkey) || (typeof pubkey === "string" ? pubkey.trim() : ""); if (!normalized) { return false; } const updated = syncSavedProfileFromCacheState(normalized, { persist }); if (updated) { this.emitProfileList("cache-sync"); } return updated; } loadProfileCacheFromStorage() { hydrateProfileCacheFromStorage(); } loadSavedProfilesFromStorage() { const result = hydrateSavedProfilesFromStorage(); this.emitProfileList("hydrate"); return result; } }