@oxyhq/core 6.0.0 → 7.1.0

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.
Files changed (70) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +1 -1
  3. package/dist/cjs/index.js +32 -11
  4. package/dist/cjs/mixins/OxyServices.accounts.js +7 -13
  5. package/dist/cjs/mixins/index.js +0 -5
  6. package/dist/cjs/server/index.js +2 -2
  7. package/dist/cjs/session/SessionClient.js +181 -10
  8. package/dist/cjs/session/accountDialogController.js +541 -0
  9. package/dist/cjs/session/accountProjection.js +131 -0
  10. package/dist/cjs/session/createSessionClient.js +9 -2
  11. package/dist/cjs/shared/utils/debugUtils.js +3 -3
  12. package/dist/cjs/utils/authWebUrl.js +10 -30
  13. package/dist/cjs/utils/coldBoot.js +10 -8
  14. package/dist/cjs/utils/{fapiAutoDetect.js → registrableApex.js} +8 -10
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/HttpService.js +1 -1
  17. package/dist/esm/index.js +26 -8
  18. package/dist/esm/mixins/OxyServices.accounts.js +7 -13
  19. package/dist/esm/mixins/index.js +0 -5
  20. package/dist/esm/server/index.js +1 -1
  21. package/dist/esm/session/SessionClient.js +181 -10
  22. package/dist/esm/session/accountDialogController.js +536 -0
  23. package/dist/esm/session/accountProjection.js +127 -0
  24. package/dist/esm/session/createSessionClient.js +9 -2
  25. package/dist/esm/shared/utils/debugUtils.js +3 -3
  26. package/dist/esm/utils/authWebUrl.js +9 -29
  27. package/dist/esm/utils/coldBoot.js +10 -8
  28. package/dist/esm/utils/{fapiAutoDetect.js → registrableApex.js} +8 -10
  29. package/dist/types/.tsbuildinfo +1 -1
  30. package/dist/types/HttpService.d.ts +1 -1
  31. package/dist/types/index.d.ts +6 -3
  32. package/dist/types/mixins/OxyServices.accounts.d.ts +24 -27
  33. package/dist/types/mixins/index.d.ts +1 -2
  34. package/dist/types/models/session.d.ts +4 -5
  35. package/dist/types/server/index.d.ts +1 -1
  36. package/dist/types/session/SessionClient.d.ts +52 -1
  37. package/dist/types/session/accountDialogController.d.ts +246 -0
  38. package/dist/types/session/accountProjection.d.ts +142 -0
  39. package/dist/types/session/createSessionClient.d.ts +9 -2
  40. package/dist/types/shared/utils/debugUtils.d.ts +3 -3
  41. package/dist/types/utils/accountUtils.d.ts +2 -3
  42. package/dist/types/utils/authWebUrl.d.ts +9 -29
  43. package/dist/types/utils/coldBoot.d.ts +17 -14
  44. package/dist/types/utils/{fapiAutoDetect.d.ts → registrableApex.d.ts} +8 -10
  45. package/package.json +2 -2
  46. package/src/HttpService.ts +1 -1
  47. package/src/index.ts +43 -11
  48. package/src/mixins/OxyServices.accounts.ts +21 -26
  49. package/src/mixins/index.ts +0 -7
  50. package/src/models/session.ts +4 -5
  51. package/src/server/index.ts +1 -1
  52. package/src/session/SessionClient.ts +202 -12
  53. package/src/session/__tests__/SessionClient.signedOut.test.ts +224 -0
  54. package/src/session/__tests__/accountDialogController.test.ts +469 -0
  55. package/src/session/__tests__/accountProjection.test.ts +181 -0
  56. package/src/session/accountDialogController.ts +682 -0
  57. package/src/session/accountProjection.ts +263 -0
  58. package/src/session/createSessionClient.ts +9 -2
  59. package/src/shared/utils/debugUtils.ts +3 -3
  60. package/src/utils/__tests__/authWebUrl.test.ts +5 -16
  61. package/src/utils/__tests__/{fapiAutoDetect.test.ts → registrableApex.test.ts} +1 -1
  62. package/src/utils/accountUtils.ts +2 -3
  63. package/src/utils/authWebUrl.ts +9 -30
  64. package/src/utils/coldBoot.ts +17 -14
  65. package/src/utils/{fapiAutoDetect.ts → registrableApex.ts} +8 -10
  66. package/dist/cjs/mixins/OxyServices.authorizedApps.js +0 -38
  67. package/dist/esm/mixins/OxyServices.authorizedApps.js +0 -35
  68. package/dist/types/mixins/OxyServices.authorizedApps.d.ts +0 -94
  69. package/src/mixins/OxyServices.authorizedApps.ts +0 -75
  70. package/src/mixins/__tests__/authorizedApps.test.ts +0 -63
@@ -157,7 +157,7 @@ export declare class HttpService {
157
157
  * `clearCacheByPrefix` sweeps and `clearCacheEntry` base-key matching.
158
158
  * The `clearCacheEntry` callsites all pass fixed, dataless logical keys
159
159
  * (`GET:/users/<id>`, `GET:/session/user/<sessionId>`,
160
- * `GET:/fedcm/me/authorized-apps`), so this readable suffix can never be
160
+ * `GET:/auth/grants`), so this readable suffix can never be
161
161
  * ambiguous with a serialized request body.
162
162
  */
163
163
  private static readonly CACHE_IDENTITY_DELIM;
@@ -21,7 +21,6 @@ export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } fr
21
21
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
22
22
  export type { LinkedHttpClient } from './OxyServices.base';
23
23
  export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
24
- export type { AuthorizedApp } from './mixins/OxyServices.authorizedApps';
25
24
  export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth';
26
25
  export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
27
26
  export type { CommonsSignInHandle, CommonsSignInStatus, CommonsApprovalInfo, CommonsSignInActionResult, } from './mixins/OxyServices.auth';
@@ -80,8 +79,8 @@ export type { LogContext } from './utils/loggerUtils';
80
79
  export { updateAvatarVisibility } from './utils/avatarUtils';
81
80
  export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccountFallbackHandle, formatPublicKeyHandle, getAccountColor, } from './utils/accountUtils';
82
81
  export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
83
- export { registrableApex } from './utils/fapiAutoDetect';
84
- export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX } from './utils/authWebUrl';
82
+ export { registrableApex } from './utils/registrableApex';
83
+ export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
85
84
  export { SSO_CALLBACK_PATH } from './utils/ssoBounce';
86
85
  export { runColdBoot } from './utils/coldBoot';
87
86
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
@@ -91,6 +90,10 @@ export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
91
90
  export { createSessionClientHost } from './session/sessionClientHost';
92
91
  export { createSessionClient } from './session/createSessionClient';
93
92
  export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState';
93
+ export { projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection';
94
+ export type { SwitchableAccount, SwitchableAccountUser, ProjectSwitchableAccountsInput, } from './session/accountProjection';
95
+ export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
96
+ export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, SignInFlowPhase, SignInFlowState, } from './session/accountDialogController';
94
97
  export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, DEVICE_TOKEN_STORAGE_KEY, } from './session/authStateStore';
95
98
  export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
96
99
  export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
@@ -28,8 +28,9 @@
28
28
  * SWITCHING INTO AN ACCOUNT: `switchToAccount(accountId)` mints a REAL session
29
29
  * for the target account and plants it as the active session — there is no
30
30
  * per-request "acting-as" header. Identity is carried by the session/token, not
31
- * a delegation header, so a switch propagates through reload and `refresh-all`
32
- * exactly like a login.
31
+ * a delegation header, so a switch propagates through reload and cross-domain
32
+ * exactly like a login, via the device-first session model (the server
33
+ * registers the switched session into the operator's device-set directly).
33
34
  */
34
35
  import type { User } from '../models/interfaces';
35
36
  import type { SessionLoginResponse } from '../models/session';
@@ -387,22 +388,22 @@ export interface AccountSuccessResult {
387
388
  /**
388
389
  * Result of {@link OxyServicesAccountsMixin.switchToAccount} — the freshly
389
390
  * minted session for the target account, in the SAME shape the canonical login
390
- * / `claimSessionByToken` responses use (`SessionLoginResponse`), plus the
391
- * device-local refresh-cookie slot index.
391
+ * / `claimSessionByToken` responses use (`SessionLoginResponse`).
392
392
  *
393
393
  * `accessToken` is the first access token for the new session (already planted
394
- * as the active token by `switchToAccount`). The refresh token is NOT in the
395
- * body the server sets it as the httpOnly `oxy_rt_<authuser>` cookie, which
396
- * joins the device multi-account set so the switched session survives reload and
397
- * propagates cross-domain via `/auth/refresh-all`. `user` is the target account.
394
+ * as the active token by `switchToAccount`). The switched session's survival
395
+ * across reload and cross-domain sync is device-first: the server registers
396
+ * it into the operator's `DeviceSession` set directly
397
+ * (`deviceSessionService.addAccount`, broadcast to the device room) there is
398
+ * no client-side refresh-cookie slot to establish. `user` is the target
399
+ * account.
398
400
  */
399
401
  export interface SwitchAccountResult extends SessionLoginResponse {
400
402
  /**
401
- * The device-local refresh-cookie slot index (`oxy_rt_<authuser>`) the server
402
- * assigned to the minted session. Surfaced so the consumer can register the
403
- * new session in its device multi-account set exactly like a login response.
404
- * Absent only when the server could not set the cookie (best-effort — the
405
- * switch itself still succeeds).
403
+ * Legacy device-local refresh-cookie slot index. The current server switch
404
+ * response never sets this field (device-set registration replaced the
405
+ * cookie-slot model) kept optional for backward type-compatibility with
406
+ * any caller still reading it, but always `undefined` in practice.
406
407
  */
407
408
  authuser?: number;
408
409
  }
@@ -432,24 +433,18 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
432
433
  * target, directly or inherited — else 403; 404 if missing/archived; 403 if
433
434
  * the target is a personal account), then mints a REAL session for the
434
435
  * target account and returns it in the canonical login / `claimSessionByToken`
435
- * shape (`{ sessionId, deviceId, expiresAt, accessToken, user, authuser }`).
436
+ * shape (`{ sessionId, deviceId, expiresAt, accessToken, user }`).
436
437
  *
437
438
  * Unlike the removed `X-Acting-As` delegation header, the returned session
438
439
  * IS the new identity: this plants `accessToken` as the active token —
439
440
  * exactly like `claimSessionByToken` / `verifyChallenge` — so every
440
441
  * subsequent request authenticates as the target account.
441
442
  *
442
- * Joining the device multi-account set (so the switch survives a reload and
443
- * propagates cross-domain via `/auth/refresh-all`) requires a SECOND call, to
444
- * `POST /auth/session`, made here after the token is planted. The switch route
445
- * lives at `/accounts/*`, OUTSIDE the `oxy_rt_<authuser>` cookie's `Path=/auth`
446
- * scope, so the server never sees the device's existing slots from it and
447
- * would clobber slot 0 (destroying the operator's own session). `/auth/session`
448
- * runs where those cookies ARE visible, so the server allocates a NEW slot that
449
- * coexists with the operator's and returns its `authuser`. This step is
450
- * web-only (native multi-account uses stored sessions, not cookies) and
451
- * best-effort — a failure leaves the in-session switch intact; the switched
452
- * account simply won't survive a reload until the cookie is next established.
443
+ * A single call is all that's needed: the server registers the switched
444
+ * session into the operator's `DeviceSession` set directly, inheriting the
445
+ * operator's central `deviceId` so the switch survives a reload and syncs
446
+ * cross-domain via the same device-first session model as a normal login —
447
+ * there is no separate client-side cookie/slot step to make it stick.
453
448
  *
454
449
  * After planting, the SDK's identity-scoped GET cache is fully cleared so
455
450
  * every cached read re-fetches as the new account. (The consuming
@@ -459,7 +454,7 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
459
454
  * same-user silent refreshes, so the sweep here is explicit.)
460
455
  *
461
456
  * @param accountId - The target account's Mongo `_id`.
462
- * @returns The minted session (planted) plus the device `authuser` slot.
457
+ * @returns The minted session, already planted as the active session.
463
458
  */
464
459
  switchToAccount(accountId: string): Promise<SwitchAccountResult>;
465
460
  /**
@@ -694,7 +689,9 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
694
689
  handleError(error: unknown): Error;
695
690
  healthCheck(): Promise<{
696
691
  status: string;
697
- users?: number;
692
+ users
693
+ /** Aggregate totals for an application over the requested period. */
694
+ ? /** Aggregate totals for an application over the requested period. */: number;
698
695
  timestamp?: string;
699
696
  [key: string]: any;
700
697
  }>;
@@ -6,7 +6,6 @@
6
6
  */
7
7
  import { OxyServicesBase } from '../OxyServices.base';
8
8
  import { OxyServicesAuthMixin } from './OxyServices.auth';
9
- import { OxyServicesAuthorizedAppsMixin } from './OxyServices.authorizedApps';
10
9
  import { OxyServicesUserMixin } from './OxyServices.user';
11
10
  import { OxyServicesIdentityMixin } from './OxyServices.identity';
12
11
  import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
@@ -38,7 +37,7 @@ import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
38
37
  * If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
39
38
  * are visible without a cast.
40
39
  */
41
- type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAuthorizedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
40
+ type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
42
41
  /**
43
42
  * Constructor type for the fully composed mixin pipeline. Each mixin returns
44
43
  * a new constructor that augments its input; reducing across the pipeline
@@ -7,11 +7,10 @@ export interface ClientSession {
7
7
  userId?: string;
8
8
  isCurrent?: boolean;
9
9
  /**
10
- * Web-only: the device-local refresh-cookie slot index (0..N) that backs
11
- * this session. Populated from `POST /auth/refresh-all` and from login /
12
- * signup / fedcm-exchange responses. Required for per-session web token
13
- * refresh via `refreshTokenViaCookie({ authuser })` without a bearer token.
14
- * Absent on native (RN uses the bearer-protected session id directly).
10
+ * The account's ordinal slot (0..N) within the device's account set
11
+ * (`SessionAccount.authuser` in `@oxyhq/contracts`), projected from the
12
+ * device-first `DeviceSessionState` used purely for stable Google-style
13
+ * account-chooser ordering, not for any token-refresh mechanism.
15
14
  */
16
15
  authuser?: number;
17
16
  }
@@ -23,5 +23,5 @@ export type { SafeFetchOptions, SafeFetchResult, SsrfCheckFail, SsrfCheckOk, Ssr
23
23
  export { createOxyCors } from './cors';
24
24
  export type { OxyCorsOptions } from './cors';
25
25
  export { verifySecret } from './verifySecret';
26
- export { registrableApex } from '../utils/fapiAutoDetect';
26
+ export { registrableApex } from '../utils/registrableApex';
27
27
  export { SSO_CALLBACK_PATH } from '../utils/ssoBounce';
@@ -1,7 +1,7 @@
1
1
  import { type DeviceSessionState } from '@oxyhq/contracts';
2
2
  import type { MinimalSocket, SocketIOFactory } from './socketLoader';
3
3
  export interface TokenTransport {
4
- /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
4
+ /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via the persisted refresh family / shared keychain). Best-effort. */
5
5
  ensureActiveToken(state: DeviceSessionState): Promise<void>;
6
6
  }
7
7
  export interface SessionClientHost {
@@ -38,6 +38,30 @@ export interface SessionClientOptions {
38
38
  * absent it falls back to `getSocketIO()`.
39
39
  */
40
40
  socketFactory?: SocketIOFactory;
41
+ /**
42
+ * Gate + credential for opening the realtime socket while SIGNED OUT (no
43
+ * access token), so an idle tab still joins its `device:<id>` room and can
44
+ * self-acquire the moment a sibling app/tab signs in on the same device.
45
+ * Returns:
46
+ * - `true` → connect and rely on the first-party `oxy_device` cookie
47
+ * riding the same-site handshake (web `*.oxy.so`; the cookie is HttpOnly
48
+ * so JS cannot read it, but the browser sends it automatically).
49
+ * - a string → connect and present it as `deviceToken` in the handshake
50
+ * auth (native shared-keychain device token; RN has no cookie jar).
51
+ * - `false`/`null` → do NOT open a signed-out socket (the default — e.g. a
52
+ * native app with no known device yet).
53
+ * Called at connect time (and each reconnect); may be async (keychain read).
54
+ */
55
+ signedOutSocketAuth?: () => boolean | string | null | Promise<boolean | string | null>;
56
+ /**
57
+ * Invoked when a `session_state` push (or a same-origin BroadcastChannel wake)
58
+ * arrives while this tab is SIGNED OUT and the pushed device state has at
59
+ * least one account — i.e. a sibling just signed in on this device. The
60
+ * consumer runs its session acquisition (cold boot / `requestWebSession`),
61
+ * which plants a token and flips the tab to signed-in. Guarded + idempotent:
62
+ * only one acquisition runs at a time, and a returned promise gates the next.
63
+ */
64
+ onSessionAppeared?: () => void | Promise<void>;
41
65
  }
42
66
  type StateListener = (state: DeviceSessionState | null) => void;
43
67
  export declare class SessionClient {
@@ -48,6 +72,12 @@ export declare class SessionClient {
48
72
  protected socket: MinimalSocket | null;
49
73
  private tokenUnsub;
50
74
  private started;
75
+ /** In-flight guard so a burst of pushes triggers at most ONE acquisition. */
76
+ private acquiring;
77
+ /** True while the live socket is an anonymous (signed-out) device connection. */
78
+ private socketAnonymous;
79
+ /** Same-origin cross-tab wake channel; null on platforms without BroadcastChannel. */
80
+ private channel;
51
81
  constructor(host: SessionClientHost, options?: SessionClientOptions);
52
82
  getState(): DeviceSessionState | null;
53
83
  subscribe(listener: StateListener): () => void;
@@ -88,5 +118,26 @@ export declare class SessionClient {
88
118
  start(): Promise<void>;
89
119
  stop(): void;
90
120
  private connectSocket;
121
+ /**
122
+ * Run the consumer's session acquisition at most once at a time. A returned
123
+ * promise gates the next attempt (reset on settle), so a failed acquisition
124
+ * can retry on the NEXT push while a burst of identical pushes cannot pile up.
125
+ */
126
+ private requestAcquisition;
127
+ /**
128
+ * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
129
+ * commits a session posts a wake ping; on receipt a signed-in tab re-syncs its
130
+ * device state and a signed-out tab self-acquires — instant + network-free for
131
+ * the common "two tabs of the same origin" case, with no state (and no tokens)
132
+ * ever crossing the channel. No-op on native (no BroadcastChannel).
133
+ */
134
+ private openBroadcastChannel;
135
+ /**
136
+ * Wake same-origin sibling tabs after a locally-initiated session mutation.
137
+ * Opens the channel lazily: a sign-in registers the account (`addCurrentAccount`
138
+ * / `switchAccount`) BEFORE `start()` runs, so the ping must not depend on
139
+ * `start()` having opened the channel first.
140
+ */
141
+ private postCommitPing;
91
142
  }
92
143
  export {};
@@ -0,0 +1,246 @@
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
+ import type { OxyServices } from '../OxyServices';
29
+ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
30
+ import { SessionClient } from './SessionClient';
31
+ import { type SwitchableAccount } from './accountProjection';
32
+ /** The dialog's top-level view. */
33
+ export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
34
+ /** Lifecycle phase of the "Sign in with Oxy" device flow. */
35
+ export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
36
+ /** State of the "Sign in with Oxy" (shared-key / QR) device flow. */
37
+ export interface SignInFlowState {
38
+ phase: SignInFlowPhase;
39
+ /**
40
+ * The PUBLIC, single-use authorize code (safe to display), or `null`. NOT the
41
+ * secret `sessionToken` — the approver resolves the app identity from this.
42
+ */
43
+ authorizeCode: string | null;
44
+ /**
45
+ * The structured deep-link / QR payload (`oxycommons://approve?...`) to render
46
+ * as a QR (cross-device) and open as a deep link (same-device), or `null`.
47
+ */
48
+ qrPayload: string | null;
49
+ /** Server-authoritative expiry (epoch ms), or `null`. */
50
+ expiresAt: number | null;
51
+ /** Human-readable error for the retry UI, or `null`. */
52
+ error: string | null;
53
+ }
54
+ /** Immutable snapshot consumed by `useSyncExternalStore`. */
55
+ export interface AccountDialogSnapshot {
56
+ /** The current view. */
57
+ view: AccountDialogView;
58
+ /** The unified, deduped account list (device sign-ins ∪ graph accounts). */
59
+ accounts: SwitchableAccount[];
60
+ /** The currently-active account id, or `null` when signed out. */
61
+ activeAccountId: string | null;
62
+ /** `true` while the initial account-list fetch is in flight with no data yet. */
63
+ loading: boolean;
64
+ /** A human-readable account-list error, or `null`. */
65
+ error: string | null;
66
+ /** The `accountId` of an in-flight switch, or `null`. */
67
+ switchingAccountId: string | null;
68
+ /** The "Sign in with Oxy" device-flow state. */
69
+ signIn: SignInFlowState;
70
+ }
71
+ /** Construction options for {@link AccountDialogController}. */
72
+ export interface AccountDialogControllerOptions {
73
+ /** The API client. Source of graph accounts, profiles, and the sign-in methods. */
74
+ oxyServices: OxyServices;
75
+ /** The device-first session authority. Source of device rows + the switch path. */
76
+ sessionClient: SessionClient;
77
+ /**
78
+ * The RP's registered OAuth client id (ApplicationCredential publicKey).
79
+ * Required for the QR handoff (`startCommonsSignIn`); when absent, `showQr`
80
+ * fails with a clear configuration error instead of creating a session the
81
+ * server would reject.
82
+ */
83
+ clientId?: string | null;
84
+ /** Locale for display-name resolution. */
85
+ locale?: string;
86
+ /**
87
+ * Commit a freshly-authorized session (device flow / shared identity / minted
88
+ * graph switch) into the host's session set — device-first registration +
89
+ * durable persist + profile hydration. The consumer supplies its provider's
90
+ * commit path (`useOxy().handleWebSession` / the auth-sdk equivalent). Called
91
+ * AFTER the SDK has planted the access token. When omitted the controller
92
+ * falls back to `SessionClient.registerAndActivate` (registration + activation
93
+ * only — no provider-side durable persist/hydration).
94
+ */
95
+ commitSession?: (session: SessionLoginResponse & {
96
+ refreshToken?: string;
97
+ }) => Promise<void>;
98
+ /** Notified after a completed sign-in (bearer planted + session committed). */
99
+ onSignedIn?: (user: MinimalUserData) => void;
100
+ /** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
101
+ idpApex?: string;
102
+ /** QR device-flow poll interval in ms (default 3000). */
103
+ pollIntervalMs?: number;
104
+ /**
105
+ * Optional URL opener. When provided, `openPasswordAtOxyAuth` invokes it with
106
+ * the built URL in addition to returning it (web: `location.assign`; native:
107
+ * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
108
+ */
109
+ openUrl?: (url: string) => void;
110
+ }
111
+ type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
112
+ export declare class AccountDialogController {
113
+ private readonly oxyServices;
114
+ private readonly sessionClient;
115
+ private readonly clientId;
116
+ private readonly locale?;
117
+ private readonly commitSession?;
118
+ private readonly onSignedIn?;
119
+ private readonly idpApex;
120
+ private readonly pollIntervalMs;
121
+ private readonly openUrl?;
122
+ private readonly listeners;
123
+ private view;
124
+ private graph;
125
+ private profilesById;
126
+ private loading;
127
+ private error;
128
+ private switchingAccountId;
129
+ private signIn;
130
+ /** The secret device-flow token of the active QR flow (never surfaced). */
131
+ private signInToken;
132
+ private pollTimer;
133
+ private unsubscribeSession;
134
+ private started;
135
+ private refreshSeq;
136
+ private snapshot;
137
+ constructor(options: AccountDialogControllerOptions);
138
+ /** Returns the current immutable snapshot (stable reference between changes). */
139
+ getSnapshot(): AccountDialogSnapshot;
140
+ /** Subscribe to snapshot changes. Returns an unsubscribe function. */
141
+ subscribe(listener: SnapshotListener): () => void;
142
+ /**
143
+ * Begin driving the dialog: subscribe to `SessionClient` state and load the
144
+ * account list. Idempotent — a second `start()` is a no-op. Pair with
145
+ * {@link destroy}.
146
+ */
147
+ start(): void;
148
+ /**
149
+ * Stop driving the dialog: unsubscribe from `SessionClient` and tear down the
150
+ * active sign-in flow (timers). Idempotent.
151
+ */
152
+ destroy(): void;
153
+ /** Set the dialog view directly. */
154
+ setView(view: AccountDialogView): void;
155
+ /** Return to the account list and cancel any in-flight sign-in flow. */
156
+ close(): void;
157
+ /** Switch to the "add account" view (the sign-in entry chooser). */
158
+ add(): void;
159
+ /**
160
+ * Reload the account graph and per-account profiles, then re-project. Safe to
161
+ * call repeatedly; concurrent calls are reconciled by a sequence guard so a
162
+ * slow earlier fetch never overwrites a newer result.
163
+ */
164
+ refresh(): Promise<void>;
165
+ /**
166
+ * Fetch profiles for any account id (device set ∪ graph) not yet resolved.
167
+ * Cheap no-op when everything is already hydrated — used from the session
168
+ * subscription so a newly-added device account gets a name/avatar.
169
+ */
170
+ private ensureProfiles;
171
+ private loadProfiles;
172
+ /**
173
+ * Switch the active account to `accountId`.
174
+ *
175
+ * Uniform switch model, mirroring the SDK's existing path — NOT a new switch
176
+ * mechanism:
177
+ * - already on this device → `SessionClient.switchAccount` (device-first
178
+ * switch of `/session/device/switch`);
179
+ * - a graph account not yet on the device (first entry) →
180
+ * `oxyServices.switchToAccount` mints + plants a real session and the
181
+ * server registers it into the device set, then it is committed
182
+ * (`commitSession` when supplied, else `SessionClient.registerAndActivate`).
183
+ *
184
+ * The resulting device-state change flows back through the `SessionClient`
185
+ * subscription, which re-projects the active row. Concurrent switches are
186
+ * ignored while one is in flight.
187
+ */
188
+ switchTo(accountId: string): Promise<void>;
189
+ /**
190
+ * Start "Sign in with Oxy". Native devices with a shared identity mint a
191
+ * session silently (`signInWithSharedIdentity`); everything else (web, or a
192
+ * native device without a shared identity) falls through to the cross-device
193
+ * QR handoff.
194
+ */
195
+ signInWithOxy(): Promise<void>;
196
+ /**
197
+ * Begin (or restart) the cross-device QR handoff: create a device-flow
198
+ * session, surface its `authorizeCode` + `qrPayload`, and poll for approval.
199
+ * On approval the secret token is exchanged (`claimSessionByToken`) and the
200
+ * session committed. Requires `clientId`.
201
+ */
202
+ showQr(): Promise<void>;
203
+ /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
204
+ cancelSignIn(): void;
205
+ /**
206
+ * Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
207
+ * password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
208
+ * IdP; this only hands off. Device-first: after login at the IdP the device
209
+ * session converges and the caller is woken via the device socket /
210
+ * `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
211
+ * the right return.
212
+ *
213
+ * @param params.returnUrl - Where the IdP returns after login. Defaults to the
214
+ * current document URL on web (`globalThis.location.href`); pass explicitly
215
+ * on native (no `location`).
216
+ * @param params.state - Optional opaque state echoed back on return.
217
+ * @returns The absolute auth.oxy.so sign-in URL.
218
+ */
219
+ openPasswordAtOxyAuth(params?: {
220
+ returnUrl?: string;
221
+ state?: string;
222
+ }): string;
223
+ private scheduleNextPoll;
224
+ private pollOnce;
225
+ private claimAndComplete;
226
+ /**
227
+ * Commit an authorized session, notify, and return to the account list. Shared
228
+ * by the shared-key, QR, and mint-switch paths so they cannot drift.
229
+ */
230
+ private completeSignIn;
231
+ /**
232
+ * Register a token-planted session into the device set. Prefers the
233
+ * consumer's `commitSession` (durable persist + hydration); falls back to
234
+ * `SessionClient.registerAndActivate` (registration + activation only).
235
+ */
236
+ private commitAuthorizedSession;
237
+ private failSignIn;
238
+ private clearPollTimer;
239
+ private setSignIn;
240
+ private computeSnapshot;
241
+ /** Recompute the snapshot and notify subscribers. */
242
+ private emit;
243
+ }
244
+ /** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
245
+ export declare function createAccountDialogController(options: AccountDialogControllerOptions): AccountDialogController;
246
+ export {};