import { NO_ACCOUNT, type SignInOutcome, type SignInPrompt, type StoreAccount } from '../../domain/models/StoreAccount' import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway' import type { StoreSelectionService } from './StoreSelectionService' /** A sign-in that is under way: what to show, and how it ended. */ export interface SignInSession { readonly prompt: SignInPrompt readonly finished: Promise } export interface SignInResult { readonly outcome: SignInOutcome readonly account: StoreAccount } /** * Signing in to the store that is open, and out of it again. * * The waiting lives here rather than in the gateway because it is orchestration: a loop * with a cancel and a deadline in it, over a port that only knows how to ask once. That * split is also what keeps the port testable without a clock. * * One sign-in at a time, per application rather than per store: a second one started * while the first is waiting would leave two loops racing to write the same token, and * a person can only be at one browser tab anyway. */ export class AccountService { private cancelled = false private active: SignInSession | null = null public constructor ( private readonly catalogGateway: StoreCatalogGateway, private readonly selection: StoreSelectionService ) {} /** Null where no store is open — the window asks before anything is chosen. */ public async readAccount (): Promise { const store = this.selection.findCurrentStore() if (store === null) return NO_ACCOUNT return await this.catalogGateway.readAccount(store) } /** * Ask the store for a code, then keep polling until somebody answers. * * Returns as soon as there is something to show: the code has to be on screen while * the polling happens, and a person cannot answer a code they have not seen yet. */ public async beginSignIn (clientName: string): Promise { if (this.active !== null) return this.active const store = this.selection.requireCurrentStore() const prompt = await this.catalogGateway.requestSignIn(store, clientName) this.cancelled = false const session: SignInSession = { prompt, finished: this.awaitAnswer(prompt) } this.active = session return session } /** Give up waiting. The code stays valid at the server until it expires by itself. */ public cancelSignIn (): void { this.cancelled = true } public async signOut (): Promise { const store = this.selection.findCurrentStore() if (store === null) return NO_ACCOUNT this.cancelSignIn() return await this.catalogGateway.signOut(store) } private isCancelled (): boolean { return this.cancelled } private async awaitAnswer (prompt: SignInPrompt): Promise { const store = this.selection.requireCurrentStore() const deadline = Date.now() + prompt.expiresInSeconds * 1000 try { while (!this.isCancelled()) { await delay(prompt.intervalSeconds * 1000) // Read through a method, not the field: cancelling happens *during* the delay // above, and a flow analysis that only sees the loop condition concludes this // can never be true. if (this.isCancelled()) break // The server's own expiry is the authority; this one only stops the loop when // the server has stopped answering at all. if (Date.now() > deadline) return { outcome: 'expired', account: await this.readAccount() } const result = await this.catalogGateway.pollSignIn(store, prompt.deviceCode) if (result.state === 'approved') return { outcome: 'signedIn', account: result.account } if (result.state === 'denied') return { outcome: 'denied', account: result.account } if (result.state === 'expired') return { outcome: 'expired', account: result.account } } return { outcome: 'cancelled', account: await this.readAccount() } } finally { this.active = null } } } async function delay (milliseconds: number): Promise { await new Promise((resolve: () => void): void => { setTimeout((): void => { resolve() }, milliseconds) }) }