mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Extracted complex logic from `AuthService.login` into private helper methods (`_validateAndNormalizeLoginInputs`, `_checkAccessControl`, `_updateGlobalState`, `_updateSavedProfiles`, `_initiatePostLogin`) to improve readability and maintainability. Moved `SimpleEventEmitter` to `js/utils/simpleEventEmitter.js` and auth constants/normalization helpers to `js/services/authUtils.js` to reduce file size and coupling. Co-authored-by: PR0M3TH3AN <[email protected]>
1310 lines
37 KiB
JavaScript
1310 lines
37 KiB
JavaScript
// 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;
|
|
}
|
|
}
|