@oxyhq/core 7.0.0 → 7.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,769 @@
1
+ /**
2
+ * Headless controller for the unified Oxy account dialog.
3
+ *
4
+ * A framework-agnostic state machine + subscribe/getSnapshot store (the same
5
+ * pattern {@link SessionClient} uses — no React, no RN) that both
6
+ * `@oxyhq/services` (RN `OxyProvider`) and `@oxyhq/auth` (web `WebOxyProvider`)
7
+ * bind to via `useSyncExternalStore`, so the account chooser is ONE
8
+ * implementation across the ecosystem instead of the five drifting copies it
9
+ * replaces.
10
+ *
11
+ * The controller owns:
12
+ * - the unified account list (via {@link projectSwitchableAccounts}), fetched
13
+ * from `SessionClient` state ∪ `oxyServices.listAccounts()` and hydrated
14
+ * with `oxyServices.getUsersByIds()`;
15
+ * - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add`);
16
+ * - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
17
+ * account already on the device, `oxyServices.switchToAccount` to mint on
18
+ * first entry into a graph account — reusing the existing SDK primitives, no
19
+ * new switch path);
20
+ * - the "Sign in with Oxy" device flow (same-device shared-keychain via
21
+ * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
22
+ * via `startCommonsSignIn` → poll → `claimSessionByToken`).
23
+ *
24
+ * It deliberately owns NO password/2FA logic — those live at the IdP
25
+ * (auth.oxy.so). {@link AccountDialogController.openPasswordAtOxyAuth} only
26
+ * builds the hand-off URL; device-first convergence syncs the session back.
27
+ */
28
+
29
+ import type { OxyServices } from '../OxyServices';
30
+ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
31
+ import type { User } from '../models/interfaces';
32
+ import { logger } from '../utils/loggerUtils';
33
+ import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
34
+ import { SessionClient } from './SessionClient';
35
+ import {
36
+ projectSwitchableAccounts,
37
+ switchableAccountIds,
38
+ type SwitchableAccount,
39
+ } from './accountProjection';
40
+ import type { AccountNode } from '../mixins/OxyServices.accounts';
41
+
42
+ /** The dialog's top-level view. */
43
+ export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
44
+
45
+ /** Lifecycle phase of the "Sign in with Oxy" device flow. */
46
+ export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
47
+
48
+ /** State of the "Sign in with Oxy" (shared-key / QR) device flow. */
49
+ export interface SignInFlowState {
50
+ phase: SignInFlowPhase;
51
+ /**
52
+ * The PUBLIC, single-use authorize code (safe to display), or `null`. NOT the
53
+ * secret `sessionToken` — the approver resolves the app identity from this.
54
+ */
55
+ authorizeCode: string | null;
56
+ /**
57
+ * The structured deep-link / QR payload (`oxycommons://approve?...`) to render
58
+ * as a QR (cross-device) and open as a deep link (same-device), or `null`.
59
+ */
60
+ qrPayload: string | null;
61
+ /** Server-authoritative expiry (epoch ms), or `null`. */
62
+ expiresAt: number | null;
63
+ /** Human-readable error for the retry UI, or `null`. */
64
+ error: string | null;
65
+ }
66
+
67
+ /** Immutable snapshot consumed by `useSyncExternalStore`. */
68
+ export interface AccountDialogSnapshot {
69
+ /** The current view. */
70
+ view: AccountDialogView;
71
+ /** The unified, deduped account list (device sign-ins ∪ graph accounts). */
72
+ accounts: SwitchableAccount[];
73
+ /** The currently-active account id, or `null` when signed out. */
74
+ activeAccountId: string | null;
75
+ /** `true` while the initial account-list fetch is in flight with no data yet. */
76
+ loading: boolean;
77
+ /** A human-readable account-list error, or `null`. */
78
+ error: string | null;
79
+ /** The `accountId` of an in-flight switch, or `null`. */
80
+ switchingAccountId: string | null;
81
+ /** The "Sign in with Oxy" device-flow state. */
82
+ signIn: SignInFlowState;
83
+ }
84
+
85
+ /** Construction options for {@link AccountDialogController}. */
86
+ export interface AccountDialogControllerOptions {
87
+ /** The API client. Source of graph accounts, profiles, and the sign-in methods. */
88
+ oxyServices: OxyServices;
89
+ /** The device-first session authority. Source of device rows + the switch path. */
90
+ sessionClient: SessionClient;
91
+ /**
92
+ * The RP's registered OAuth client id (ApplicationCredential publicKey).
93
+ * Required for the QR handoff (`startCommonsSignIn`); when absent, `showQr`
94
+ * fails with a clear configuration error instead of creating a session the
95
+ * server would reject.
96
+ */
97
+ clientId?: string | null;
98
+ /** Locale for display-name resolution. */
99
+ locale?: string;
100
+ /**
101
+ * Commit a freshly-authorized session (device flow / shared identity / minted
102
+ * graph switch) into the host's session set — device-first registration +
103
+ * durable persist + profile hydration. The consumer supplies its provider's
104
+ * commit path (`useOxy().handleWebSession` / the auth-sdk equivalent). Called
105
+ * AFTER the SDK has planted the access token. When omitted the controller
106
+ * falls back to `SessionClient.registerAndActivate` (registration + activation
107
+ * only — no provider-side durable persist/hydration).
108
+ */
109
+ commitSession?: (session: SessionLoginResponse & { refreshToken?: string }) => Promise<void>;
110
+ /** Notified after a completed sign-in (bearer planted + session committed). */
111
+ onSignedIn?: (user: MinimalUserData) => void;
112
+ /** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
113
+ idpApex?: string;
114
+ /** QR device-flow poll interval in ms (default 3000). */
115
+ pollIntervalMs?: number;
116
+ /**
117
+ * Optional URL opener. When provided, `openPasswordAtOxyAuth` invokes it with
118
+ * the built URL in addition to returning it (web: `location.assign`; native:
119
+ * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
120
+ */
121
+ openUrl?: (url: string) => void;
122
+ }
123
+
124
+ const DEFAULT_POLL_INTERVAL_MS = 3000;
125
+
126
+ const IDLE_SIGN_IN: SignInFlowState = {
127
+ phase: 'idle',
128
+ authorizeCode: null,
129
+ qrPayload: null,
130
+ expiresAt: null,
131
+ error: null,
132
+ };
133
+
134
+ function errorMessage(error: unknown): string {
135
+ return error instanceof Error ? error.message : String(error);
136
+ }
137
+
138
+ type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
139
+
140
+ export class AccountDialogController {
141
+ private readonly oxyServices: OxyServices;
142
+ private readonly sessionClient: SessionClient;
143
+ private readonly clientId: string | null;
144
+ private readonly locale?: string;
145
+ private readonly commitSession?: (session: SessionLoginResponse & { refreshToken?: string }) => Promise<void>;
146
+ private readonly onSignedIn?: (user: MinimalUserData) => void;
147
+ private readonly idpApex: string;
148
+ private readonly pollIntervalMs: number;
149
+ private readonly openUrl?: (url: string) => void;
150
+
151
+ private readonly listeners = new Set<SnapshotListener>();
152
+
153
+ // --- Internal (unprojected) state ---
154
+ private view: AccountDialogView = 'accounts';
155
+ private graph: AccountNode[] = [];
156
+ private profilesById = new Map<string, User>();
157
+ private loading = false;
158
+ private error: string | null = null;
159
+ private switchingAccountId: string | null = null;
160
+ private signIn: SignInFlowState = IDLE_SIGN_IN;
161
+
162
+ // --- Sign-in device-flow bookkeeping ---
163
+ /** The secret device-flow token of the active QR flow (never surfaced). */
164
+ private signInToken: string | null = null;
165
+ private pollTimer: ReturnType<typeof setTimeout> | null = null;
166
+
167
+ // --- Store plumbing ---
168
+ private unsubscribeSession: (() => void) | null = null;
169
+ private unsubscribeTokens: (() => void) | null = null;
170
+ /** Last-observed SDK auth readiness (a planted bearer). Drives the fetch edge. */
171
+ private authed = false;
172
+ private started = false;
173
+ private refreshSeq = 0;
174
+ private snapshot: AccountDialogSnapshot;
175
+
176
+ constructor(options: AccountDialogControllerOptions) {
177
+ this.oxyServices = options.oxyServices;
178
+ this.sessionClient = options.sessionClient;
179
+ this.clientId = options.clientId ?? null;
180
+ this.locale = options.locale;
181
+ this.commitSession = options.commitSession;
182
+ this.onSignedIn = options.onSignedIn;
183
+ this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
184
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
185
+ this.openUrl = options.openUrl;
186
+ this.snapshot = this.computeSnapshot();
187
+ }
188
+
189
+ // =========================================================================
190
+ // Store surface (useSyncExternalStore)
191
+ // =========================================================================
192
+
193
+ /** Returns the current immutable snapshot (stable reference between changes). */
194
+ getSnapshot(): AccountDialogSnapshot {
195
+ return this.snapshot;
196
+ }
197
+
198
+ /** Subscribe to snapshot changes. Returns an unsubscribe function. */
199
+ subscribe(listener: SnapshotListener): () => void {
200
+ this.listeners.add(listener);
201
+ return () => {
202
+ this.listeners.delete(listener);
203
+ };
204
+ }
205
+
206
+ // =========================================================================
207
+ // Lifecycle
208
+ // =========================================================================
209
+
210
+ /**
211
+ * Begin driving the dialog: subscribe to `SessionClient` state and load the
212
+ * account list. Idempotent — a second `start()` is a no-op. Pair with
213
+ * {@link destroy}.
214
+ */
215
+ start(): void {
216
+ if (this.started) return;
217
+ this.started = true;
218
+ this.authed = this.isAuthenticated();
219
+ this.unsubscribeSession = this.sessionClient.subscribe(() => {
220
+ // A device-state change (switch / sign-out / sibling sign-in) can add or
221
+ // remove accounts — re-project immediately, refetch profiles when new
222
+ // account ids appeared, and reconcile the auth-readiness edge.
223
+ this.emit();
224
+ void this.ensureProfiles();
225
+ this.reconcileAuth();
226
+ });
227
+ // The access token is planted AFTER `SessionClient.applyState` fires its
228
+ // subscription (`applySync` calls `setTokens` only once `applyState`/notify
229
+ // has returned; `ensureActiveToken` plants it async later), so the
230
+ // device-state subscription alone cannot observe the signed-out → signed-in
231
+ // edge. Observe the SDK-canonical readiness signal directly — a change to
232
+ // `oxyServices.getAccessToken()`, the `hasAccessToken` term of
233
+ // `OxyContext.canUsePrivateApi`.
234
+ this.unsubscribeTokens = this.oxyServices.onTokensChanged(() => {
235
+ this.reconcileAuth();
236
+ });
237
+ // Initial projection is device-only. `refresh()` fetches the graph IFF a
238
+ // bearer is already planted (warm start); when signed out (cold boot before
239
+ // restore) it re-projects from device state and makes NO private call.
240
+ void this.refresh();
241
+ }
242
+
243
+ /**
244
+ * Stop driving the dialog: unsubscribe from `SessionClient` and tear down the
245
+ * active sign-in flow (timers). Idempotent.
246
+ */
247
+ destroy(): void {
248
+ this.started = false;
249
+ if (this.unsubscribeSession) {
250
+ this.unsubscribeSession();
251
+ this.unsubscribeSession = null;
252
+ }
253
+ if (this.unsubscribeTokens) {
254
+ this.unsubscribeTokens();
255
+ this.unsubscribeTokens = null;
256
+ }
257
+ this.clearPollTimer();
258
+ this.listeners.clear();
259
+ }
260
+
261
+ // =========================================================================
262
+ // Auth readiness (SDK-canonical — mirrors OxyContext.canUsePrivateApi)
263
+ // =========================================================================
264
+
265
+ /**
266
+ * Whether a PRIVATE endpoint may be called right now. Mirrors the
267
+ * `hasAccessToken` term of `OxyContext.canUsePrivateApi`
268
+ * (`authResolved && isAuthenticated && tokenReady && hasAccessToken`, where
269
+ * `hasAccessToken = Boolean(oxyServices.getAccessToken())`): a planted bearer
270
+ * is the only term that decides whether a request carries auth — the other
271
+ * three are provider render-lifecycle gates with no headless equivalent.
272
+ *
273
+ * `listAccounts()` (`GET /accounts`) and `getUsersByIds()`
274
+ * (`POST /users/by-ids`) are private; calling either before cold-boot restore
275
+ * plants the token 401s → `HttpService` clears the bearer + emits
276
+ * `onTokensChanged(null)` → the app signs out. Every graph/profile fetch gates
277
+ * on this.
278
+ */
279
+ private isAuthenticated(): boolean {
280
+ return Boolean(this.oxyServices.getAccessToken());
281
+ }
282
+
283
+ /**
284
+ * Reconcile the account graph against the current auth-readiness edge. On the
285
+ * signed-out → signed-in edge fetch the graph ONCE; on signed-in → signed-out
286
+ * drop it and re-project device-only. A no-op when readiness is unchanged, so
287
+ * a burst of token events / device pushes cannot restart the fetch — and a
288
+ * failed `listAccounts()` never flips the edge, so it cannot re-trigger itself
289
+ * (no retry storm).
290
+ */
291
+ private reconcileAuth(): void {
292
+ const authed = this.isAuthenticated();
293
+ if (authed === this.authed) return;
294
+ this.authed = authed;
295
+ if (authed) {
296
+ void this.refresh();
297
+ return;
298
+ }
299
+ // Signed out: the graph is no longer fetchable/switchable — drop it and
300
+ // re-project from the device session set alone.
301
+ this.graph = [];
302
+ this.error = null;
303
+ this.loading = false;
304
+ this.emit();
305
+ }
306
+
307
+ // =========================================================================
308
+ // View actions
309
+ // =========================================================================
310
+
311
+ /** Set the dialog view directly. */
312
+ setView(view: AccountDialogView): void {
313
+ if (this.view === view) return;
314
+ this.view = view;
315
+ this.emit();
316
+ }
317
+
318
+ /** Return to the account list and cancel any in-flight sign-in flow. */
319
+ close(): void {
320
+ this.cancelSignIn();
321
+ this.setView('accounts');
322
+ }
323
+
324
+ /** Switch to the "add account" view (the sign-in entry chooser). */
325
+ add(): void {
326
+ this.setView('add');
327
+ }
328
+
329
+ // =========================================================================
330
+ // Account list
331
+ // =========================================================================
332
+
333
+ /**
334
+ * Reload the account graph and per-account profiles, then re-project. Safe to
335
+ * call repeatedly; concurrent calls are reconciled by a sequence guard so a
336
+ * slow earlier fetch never overwrites a newer result.
337
+ */
338
+ async refresh(): Promise<void> {
339
+ const seq = ++this.refreshSeq;
340
+
341
+ // Never hit the private `listAccounts()` while signed out: at cold boot the
342
+ // bearer is not planted yet, so the call 401s → `HttpService` clears the
343
+ // token and signs the user out. Re-project from the device session set alone
344
+ // (`projectSwitchableAccounts` works from `SessionClient` state) and stop.
345
+ if (!this.isAuthenticated()) {
346
+ this.graph = [];
347
+ this.loading = false;
348
+ this.error = null;
349
+ this.emit();
350
+ return;
351
+ }
352
+
353
+ const hadAccounts = this.snapshot.accounts.length > 0;
354
+ this.loading = !hadAccounts;
355
+ this.error = null;
356
+ this.emit();
357
+
358
+ let graph: AccountNode[] = this.graph;
359
+ try {
360
+ graph = await this.oxyServices.listAccounts();
361
+ } catch (error) {
362
+ // A graph-load failure is non-fatal: device rows still render. Surface the
363
+ // message but keep going with whatever graph we already had.
364
+ this.error = errorMessage(error);
365
+ logger.warn('[AccountDialogController] listAccounts failed', { component: 'AccountDialogController' }, error);
366
+ }
367
+ if (seq !== this.refreshSeq) return; // superseded by a newer refresh
368
+
369
+ this.graph = graph;
370
+ await this.loadProfiles(seq);
371
+ if (seq !== this.refreshSeq) return;
372
+
373
+ this.loading = false;
374
+ this.emit();
375
+ }
376
+
377
+ /**
378
+ * Fetch profiles for any account id (device set ∪ graph) not yet resolved.
379
+ * Cheap no-op when everything is already hydrated — used from the session
380
+ * subscription so a newly-added device account gets a name/avatar.
381
+ */
382
+ private async ensureProfiles(): Promise<void> {
383
+ // `getUsersByIds` is private — skip the whole path while signed out.
384
+ if (!this.isAuthenticated()) return;
385
+ const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
386
+ if (ids.every((id) => this.profilesById.has(id))) return;
387
+ await this.loadProfiles(this.refreshSeq);
388
+ this.emit();
389
+ }
390
+
391
+ private async loadProfiles(seq: number): Promise<void> {
392
+ // `getUsersByIds` (`POST /users/by-ids`) is a private call — never issue it
393
+ // while signed out (the 401 → sign-out cascade). Callers already gate; this
394
+ // guards the network chokepoint too (e.g. the token was cleared mid-refresh).
395
+ if (!this.isAuthenticated()) return;
396
+ const ids = switchableAccountIds(this.sessionClient.getState(), this.graph);
397
+ if (ids.length === 0) return;
398
+ let profiles: User[] = [];
399
+ try {
400
+ profiles = await this.oxyServices.getUsersByIds(ids);
401
+ } catch (error) {
402
+ // `getUsersByIds` already swallows per-chunk failures and returns `[]`;
403
+ // this guards the unexpected total failure. Non-fatal — keep prior map.
404
+ logger.warn('[AccountDialogController] getUsersByIds failed', { component: 'AccountDialogController' }, error);
405
+ return;
406
+ }
407
+ if (seq !== this.refreshSeq) return; // superseded
408
+ const next = new Map(this.profilesById);
409
+ for (const profile of profiles) {
410
+ next.set(profile.id, profile);
411
+ }
412
+ this.profilesById = next;
413
+ }
414
+
415
+ // =========================================================================
416
+ // Switching (uniform switch model — reuses the existing SDK primitives)
417
+ // =========================================================================
418
+
419
+ /**
420
+ * Switch the active account to `accountId`.
421
+ *
422
+ * Uniform switch model, mirroring the SDK's existing path — NOT a new switch
423
+ * mechanism:
424
+ * - already on this device → `SessionClient.switchAccount` (device-first
425
+ * switch of `/session/device/switch`);
426
+ * - a graph account not yet on the device (first entry) →
427
+ * `oxyServices.switchToAccount` mints + plants a real session and the
428
+ * server registers it into the device set, then it is committed
429
+ * (`commitSession` when supplied, else `SessionClient.registerAndActivate`).
430
+ *
431
+ * The resulting device-state change flows back through the `SessionClient`
432
+ * subscription, which re-projects the active row. Concurrent switches are
433
+ * ignored while one is in flight.
434
+ */
435
+ async switchTo(accountId: string): Promise<void> {
436
+ if (this.switchingAccountId) return;
437
+ this.switchingAccountId = accountId;
438
+ this.error = null;
439
+ this.emit();
440
+ try {
441
+ const state = this.sessionClient.getState();
442
+ const onDevice = state?.accounts.some((account) => account.accountId === accountId) ?? false;
443
+ if (onDevice) {
444
+ await this.sessionClient.switchAccount(accountId);
445
+ } else {
446
+ const result = await this.oxyServices.switchToAccount(accountId);
447
+ if (!result?.user || !result?.sessionId) {
448
+ throw new Error('Account switch did not return a valid session');
449
+ }
450
+ await this.commitAuthorizedSession(
451
+ {
452
+ sessionId: result.sessionId,
453
+ deviceId: result.deviceId,
454
+ expiresAt: result.expiresAt,
455
+ user: result.user,
456
+ accessToken: result.accessToken,
457
+ ...(readRefreshToken(result) ? { refreshToken: readRefreshToken(result) } : {}),
458
+ },
459
+ result.user,
460
+ );
461
+ }
462
+ // Re-project + refetch immediately; the subscription also fires.
463
+ await this.refresh();
464
+ } catch (error) {
465
+ this.error = errorMessage(error);
466
+ } finally {
467
+ this.switchingAccountId = null;
468
+ this.emit();
469
+ }
470
+ }
471
+
472
+ // =========================================================================
473
+ // Sign in with Oxy (device flow — shared keychain, else cross-device QR)
474
+ // =========================================================================
475
+
476
+ /**
477
+ * Start "Sign in with Oxy". Native devices with a shared identity mint a
478
+ * session silently (`signInWithSharedIdentity`); everything else (web, or a
479
+ * native device without a shared identity) falls through to the cross-device
480
+ * QR handoff.
481
+ */
482
+ async signInWithOxy(): Promise<void> {
483
+ this.setView('qr');
484
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
485
+ try {
486
+ const session = await this.oxyServices.signInWithSharedIdentity();
487
+ if (session) {
488
+ await this.completeSignIn(session, session.user);
489
+ return;
490
+ }
491
+ } catch (error) {
492
+ // Shared-key mint failed — log and fall through to the QR handoff rather
493
+ // than dead-ending the sign-in.
494
+ logger.warn('[AccountDialogController] signInWithSharedIdentity failed', { component: 'AccountDialogController' }, error);
495
+ }
496
+ await this.showQr();
497
+ }
498
+
499
+ /**
500
+ * Begin (or restart) the cross-device QR handoff: create a device-flow
501
+ * session, surface its `authorizeCode` + `qrPayload`, and poll for approval.
502
+ * On approval the secret token is exchanged (`claimSessionByToken`) and the
503
+ * session committed. Requires `clientId`.
504
+ */
505
+ async showQr(): Promise<void> {
506
+ this.cancelSignIn();
507
+ this.setView('qr');
508
+ if (!this.clientId) {
509
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
510
+ return;
511
+ }
512
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
513
+ try {
514
+ const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
515
+ this.signInToken = handle.sessionToken;
516
+ this.setSignIn({
517
+ phase: 'waiting',
518
+ authorizeCode: handle.authorizeCode,
519
+ qrPayload: handle.qrPayload,
520
+ expiresAt: handle.expiresAt,
521
+ error: null,
522
+ });
523
+ this.scheduleNextPoll(handle.sessionToken);
524
+ } catch (error) {
525
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
526
+ }
527
+ }
528
+
529
+ /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
530
+ cancelSignIn(): void {
531
+ this.clearPollTimer();
532
+ this.signInToken = null;
533
+ if (this.signIn !== IDLE_SIGN_IN) {
534
+ this.setSignIn(IDLE_SIGN_IN);
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
540
+ * password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
541
+ * IdP; this only hands off. Device-first: after login at the IdP the device
542
+ * session converges and the caller is woken via the device socket /
543
+ * `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
544
+ * the right return.
545
+ *
546
+ * @param params.returnUrl - Where the IdP returns after login. Defaults to the
547
+ * current document URL on web (`globalThis.location.href`); pass explicitly
548
+ * on native (no `location`).
549
+ * @param params.state - Optional opaque state echoed back on return.
550
+ * @returns The absolute auth.oxy.so sign-in URL.
551
+ */
552
+ openPasswordAtOxyAuth(params: { returnUrl?: string; state?: string } = {}): string {
553
+ const base = `https://auth.${this.idpApex}`;
554
+ const url = new URL('/login', base);
555
+ const returnUrl = params.returnUrl ?? currentLocationHref();
556
+ if (returnUrl) {
557
+ url.searchParams.set('redirect_uri', returnUrl);
558
+ }
559
+ if (this.clientId) {
560
+ url.searchParams.set('client_id', this.clientId);
561
+ }
562
+ if (params.state) {
563
+ url.searchParams.set('state', params.state);
564
+ }
565
+ const href = url.toString();
566
+ this.openUrl?.(href);
567
+ return href;
568
+ }
569
+
570
+ // =========================================================================
571
+ // Internal sign-in helpers
572
+ // =========================================================================
573
+
574
+ private scheduleNextPoll(sessionToken: string): void {
575
+ this.clearPollTimer();
576
+ this.pollTimer = setTimeout(() => {
577
+ void this.pollOnce(sessionToken);
578
+ }, this.pollIntervalMs);
579
+ }
580
+
581
+ private async pollOnce(sessionToken: string): Promise<void> {
582
+ // A superseded / cancelled flow must not act.
583
+ if (this.signInToken !== sessionToken) return;
584
+ const expiresAt = this.signIn.expiresAt;
585
+ if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
586
+ this.failSignIn('Session expired. Please try again.');
587
+ return;
588
+ }
589
+ try {
590
+ const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
591
+ if (this.signInToken !== sessionToken) return; // cancelled mid-request
592
+ if (status.authorized && status.sessionId) {
593
+ this.clearPollTimer();
594
+ await this.claimAndComplete(status.sessionId, sessionToken);
595
+ return;
596
+ }
597
+ if (status.status === 'cancelled') {
598
+ this.failSignIn('Authorization was denied.');
599
+ return;
600
+ }
601
+ if (status.status === 'expired') {
602
+ this.failSignIn('Session expired. Please try again.');
603
+ return;
604
+ }
605
+ } catch (error) {
606
+ // Transient poll error — the next tick retries. Logged, never thrown.
607
+ logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
608
+ }
609
+ if (this.signInToken === sessionToken) {
610
+ this.scheduleNextPoll(sessionToken);
611
+ }
612
+ }
613
+
614
+ private async claimAndComplete(sessionId: string, sessionToken: string): Promise<void> {
615
+ this.setSignIn({ ...this.signIn, phase: 'authorized' });
616
+ let claimed: { accessToken: string; sessionId: string; deviceId: string; expiresAt: string; user: User };
617
+ try {
618
+ claimed = await this.oxyServices.claimSessionByToken(sessionToken);
619
+ } catch (error) {
620
+ this.failSignIn(errorMessage(error));
621
+ return;
622
+ }
623
+ if (!claimed?.accessToken || !claimed.user) {
624
+ this.failSignIn('Authorization succeeded but the session could not be claimed. Please try again.');
625
+ return;
626
+ }
627
+ // `SessionLoginResponse.user` is the minimal session-carried shape; the claim
628
+ // returns the full `User` (avatar is `string | null | undefined`). Normalize
629
+ // rather than widening the minimal shape to accept `null`.
630
+ const minimalUser: MinimalUserData = {
631
+ id: claimed.user.id,
632
+ username: claimed.user.username,
633
+ name: claimed.user.name,
634
+ avatar: claimed.user.avatar ?? undefined,
635
+ };
636
+ const refreshToken = readRefreshToken(claimed);
637
+ try {
638
+ await this.completeSignIn(
639
+ {
640
+ sessionId: claimed.sessionId || sessionId,
641
+ deviceId: claimed.deviceId ?? '',
642
+ expiresAt: claimed.expiresAt ?? '',
643
+ user: minimalUser,
644
+ accessToken: claimed.accessToken,
645
+ ...(refreshToken ? { refreshToken } : {}),
646
+ },
647
+ minimalUser,
648
+ );
649
+ } catch (error) {
650
+ this.failSignIn(errorMessage(error));
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Commit an authorized session, notify, and return to the account list. Shared
656
+ * by the shared-key, QR, and mint-switch paths so they cannot drift.
657
+ */
658
+ private async completeSignIn(
659
+ session: SessionLoginResponse & { refreshToken?: string },
660
+ user: MinimalUserData,
661
+ ): Promise<void> {
662
+ await this.commitAuthorizedSession(session, user);
663
+ this.signInToken = null;
664
+ this.clearPollTimer();
665
+ this.signIn = IDLE_SIGN_IN;
666
+ this.view = 'accounts';
667
+ this.emit();
668
+ this.onSignedIn?.(user);
669
+ await this.refresh();
670
+ }
671
+
672
+ /**
673
+ * Register a token-planted session into the device set. Prefers the
674
+ * consumer's `commitSession` (durable persist + hydration); falls back to
675
+ * `SessionClient.registerAndActivate` (registration + activation only).
676
+ */
677
+ private async commitAuthorizedSession(
678
+ session: SessionLoginResponse & { refreshToken?: string },
679
+ user: MinimalUserData,
680
+ ): Promise<void> {
681
+ if (this.commitSession) {
682
+ await this.commitSession(session);
683
+ } else {
684
+ await this.sessionClient.registerAndActivate(user.id);
685
+ }
686
+ }
687
+
688
+ private failSignIn(message: string): void {
689
+ this.clearPollTimer();
690
+ this.signInToken = null;
691
+ this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
692
+ }
693
+
694
+ private clearPollTimer(): void {
695
+ if (this.pollTimer !== null) {
696
+ clearTimeout(this.pollTimer);
697
+ this.pollTimer = null;
698
+ }
699
+ }
700
+
701
+ // =========================================================================
702
+ // Snapshot plumbing
703
+ // =========================================================================
704
+
705
+ private setSignIn(next: SignInFlowState): void {
706
+ this.signIn = next;
707
+ this.emit();
708
+ }
709
+
710
+ private computeSnapshot(): AccountDialogSnapshot {
711
+ const state = this.sessionClient.getState();
712
+ return {
713
+ view: this.view,
714
+ accounts: projectSwitchableAccounts({
715
+ state,
716
+ graph: this.graph,
717
+ profilesById: this.profilesById,
718
+ locale: this.locale,
719
+ resolveAvatarUrl: (avatar) =>
720
+ (avatar ? this.oxyServices.getFileDownloadUrl(avatar, 'thumb') : undefined),
721
+ }),
722
+ activeAccountId: state?.activeAccountId ?? null,
723
+ loading: this.loading,
724
+ error: this.error,
725
+ switchingAccountId: this.switchingAccountId,
726
+ signIn: this.signIn,
727
+ };
728
+ }
729
+
730
+ /** Recompute the snapshot and notify subscribers. */
731
+ private emit(): void {
732
+ this.snapshot = this.computeSnapshot();
733
+ for (const listener of this.listeners) {
734
+ try {
735
+ listener(this.snapshot);
736
+ } catch (error) {
737
+ logger.error('[AccountDialogController] subscriber threw', error);
738
+ }
739
+ }
740
+ }
741
+ }
742
+
743
+ /** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
744
+ export function createAccountDialogController(
745
+ options: AccountDialogControllerOptions,
746
+ ): AccountDialogController {
747
+ return new AccountDialogController(options);
748
+ }
749
+
750
+ // ---------------------------------------------------------------------------
751
+ // Local helpers
752
+ // ---------------------------------------------------------------------------
753
+
754
+ /**
755
+ * The rotating refresh-token family head is threaded on the runtime object by
756
+ * the trusted device-flow / switch lanes even though it is NOT on the typed
757
+ * return of `claimSessionByToken` / `switchToAccount`. Read it defensively so
758
+ * the commit funnel can persist a durable session.
759
+ */
760
+ function readRefreshToken(value: unknown): string | undefined {
761
+ const token = (value as { refreshToken?: unknown }).refreshToken;
762
+ return typeof token === 'string' ? token : undefined;
763
+ }
764
+
765
+ /** Current document URL on web; empty string where `location` is absent (native/SSR). */
766
+ function currentLocationHref(): string {
767
+ const location = (globalThis as { location?: { href?: string } }).location;
768
+ return typeof location?.href === 'string' ? location.href : '';
769
+ }