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