import { getCurrentPluginTarget } from "../plugins/current-target"; import { ApiRequestError, isHardSessionInvalidMessage } from "./errors"; import type { AccountProfile, AccountProfileUpdate, AuthUser, BuildoutAccountResponse, BuildoutTokenResponse, CloudBrowserHandoffResponse, CloudPricing, CloudVerificationResponse, DeviceAuthStartResponse, DeviceAuthTokenResponse, PersistedAuthUser, } from "./types"; type CloudApiRequest = (path: string, options?: RequestInit, canApplySession?: () => boolean) => Promise; interface CloudAuthApiOptions { getCurrentUser(): AuthUser | null; getSessionToken(): string | null; hasSessionCredential(): boolean; request: CloudApiRequest; requireCapturedSession(message: string): void; setCurrentUser(user: AuthUser | null): void; setSessionToken(token: string | null): void; updateCurrentUser(updater: (user: AuthUser) => AuthUser): void; } export class CloudAuthApi { constructor(private readonly options: CloudAuthApiOptions) {} restoreCachedUser(user: PersistedAuthUser | null): void { if (!this.options.hasSessionCredential() || !user?.id) { this.options.setCurrentUser(null); return; } this.options.setCurrentUser({ id: user.id, name: typeof user.name === "string" && user.name.length > 0 ? user.name : (user.username ?? "User"), email: typeof user.email === "string" ? user.email : "", username: typeof user.username === "string" ? user.username : null, emailVerified: user.emailVerified === true, image: typeof user.image === "string" ? user.image : null, plan: user.plan, trialEndsAt: typeof user.trialEndsAt === "string" ? user.trialEndsAt : null, effectivePlan: user.effectivePlan, syncEnabled: user.syncEnabled === false ? false : true, weeklyRoundupEnabled: user.weeklyRoundupEnabled === false ? false : true, positionAlertsEnabled: user.positionAlertsEnabled === false ? false : true, chatEmailNotificationsEnabled: user.chatEmailNotificationsEnabled === false ? false : true, lastSyncAt: typeof user.lastSyncAt === "string" ? user.lastSyncAt : null, lastRoundupEmailAt: typeof user.lastRoundupEmailAt === "string" ? user.lastRoundupEmailAt : null, createdAt: typeof user.createdAt === "string" ? user.createdAt : "", updatedAt: typeof user.updatedAt === "string" ? user.updatedAt : "", }); } async signUp( email: string, username: string, name: string, password: string, ): Promise { const result = await this.options.request<{ user: AuthUser }>( "/auth/sign-up/email", { method: "POST", body: JSON.stringify({ email, username, name, password }), }, ); this.options.requireCapturedSession( "Account created, but Gloomberb could not save the login session. Please try logging in again.", ); this.options.setCurrentUser(result.user); return result.user; } async signIn(email: string, password: string): Promise { const result = await this.options.request<{ user: AuthUser }>( "/auth/sign-in/email", { method: "POST", body: JSON.stringify({ email, password }), }, ); this.options.requireCapturedSession( "Logged in, but Gloomberb could not save the login session. Please try again.", ); this.options.setCurrentUser(result.user); return result.user; } /** Starts a QR / device sign-in; the mobile app approves the returned user code. */ async startDeviceSignIn(body: { clientName?: string; clientPlatform?: string; }): Promise { return this.options.request("/auth/device/start", { method: "POST", body: JSON.stringify(body), }); } /** * Polls a pending device sign-in. Unlike the email flow, approval hands back * a raw session token in the body instead of a Set-Cookie header; the caller * installs it through the same path boot restoration uses. */ async pollDeviceSignIn(deviceCode: string): Promise { return this.options.request("/auth/device/token", { method: "POST", body: JSON.stringify({ deviceCode }), }); } async signOut(): Promise { try { await this.options.request("/auth/sign-out", { method: "POST" }); } finally { this.options.setSessionToken(null); } } async getSession(isCurrent: () => boolean): Promise<{ user: AuthUser | null; validated: boolean }> { // The answer describes whichever credential was on the wire when the // request left. On boot a check can go out before the persisted token is // installed; by the time "no session" comes back, the token and the cached // user are in place, and applying that answer wiped them. Re-check with the // credential that exists now instead of trusting an answer about one that // no longer does. const credential = this.options.getSessionToken(); const previousUser = this.options.getCurrentUser(); const retainedSession = () => ({ user: this.options.getCurrentUser(), validated: false }); const credentialChanged = () => this.options.getSessionToken() !== credential; try { const result = await this.options.request<{ user: AuthUser }>( "/auth/get-session", { method: "GET", }, () => isCurrent() && this.options.getCurrentUser() === previousUser, ); if (!isCurrent()) return retainedSession(); if (credentialChanged()) return this.getSession(isCurrent); if (this.options.getCurrentUser() !== previousUser) return retainedSession(); const user = result?.user ?? null; this.options.setCurrentUser(user); return { user, validated: true }; } catch (error) { if (!isCurrent()) return retainedSession(); if (credentialChanged()) return this.getSession(isCurrent); if (this.options.getCurrentUser() !== previousUser) return retainedSession(); if ( error instanceof ApiRequestError && isHardSessionInvalidMessage(error.message) ) { this.options.setSessionToken(null); return { user: null, validated: true }; } throw error; } } /** * Emails a password reset link. The reset itself completes on the gloom.sh * site, so the app only ever sends the email. The server answers the same * way whether or not the address exists. */ async requestPasswordReset(email: string): Promise { await this.options.request("/auth/request-password-reset", { method: "POST", body: JSON.stringify({ email }), }); } async sendVerification(): Promise { return this.options.request( "/cloud/auth/send-verification", { method: "POST", body: JSON.stringify({ returnTo: getCurrentPluginTarget() === "web" ? location.href : undefined, }), }, ); } /** * Creates a short-lived browser handoff for the captured native session. * The server deliberately returns an opaque one-time URL, never the session cookie. */ async createBrowserHandoff(): Promise { return this.options.request( "/cloud/auth/browser-handoff", { method: "POST", body: JSON.stringify({}), }, ); } async getAccountProfile(isCurrent: () => boolean = () => true): Promise { const previousUser = this.options.getCurrentUser(); const result = await this.options.request<{ profile: AccountProfile }>( "/account/profile", { method: "GET", }, () => isCurrent() && this.options.getCurrentUser() === previousUser, ); return result.profile; } /** Public Cloud Pro pricing, including the founding discount and trial length. */ async getCloudPricing(): Promise { return this.options.request("/pricing", { method: "GET" }); } async getBuildoutAccount(): Promise { return this.options.request("/account/buildout", { method: "GET", }); } async getBuildoutToken(): Promise { return this.options.request( "/account/buildout/token", { method: "POST", body: JSON.stringify({}), }, ); } async updateAccountProfile( update: AccountProfileUpdate, ): Promise { const result = await this.options.request<{ profile: AccountProfile }>( "/account/profile", { method: "PATCH", body: JSON.stringify(update), }, ); const profile = result.profile; if (this.options.getCurrentUser()?.id === profile.id) { this.options.updateCurrentUser((currentUser) => ({ ...currentUser, name: profile.name, username: profile.username, plan: profile.plan, company: profile.company, title: profile.title, bio: profile.bio, profilePublic: profile.profilePublic, publicEmail: profile.publicEmail, xAccount: profile.xAccount, sharedPortfolioId: profile.sharedPortfolioId, acceptUnknownDms: profile.acceptUnknownDms, chatEmailNotificationsEnabled: profile.chatEmailNotificationsEnabled, portfolioAnalytics: profile.portfolioAnalytics, syncEnabled: profile.syncEnabled, weeklyRoundupEnabled: profile.weeklyRoundupEnabled, positionAlertsEnabled: profile.positionAlertsEnabled, lastSyncAt: profile.lastSyncAt, lastRoundupEmailAt: profile.lastRoundupEmailAt, updatedAt: profile.updatedAt ?? currentUser.updatedAt, })); } return profile; } async changePassword( currentPassword: string, newPassword: string, ): Promise { await this.options.request("/auth/change-password", { method: "POST", body: JSON.stringify({ currentPassword, newPassword, revokeOtherSessions: false, }), }); } async deleteAccount(): Promise { await this.options.request("/account", { method: "DELETE", body: JSON.stringify({}), }); this.options.setSessionToken(null); this.options.setCurrentUser(null); } }