@oxyhq/core 7.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.
@@ -90,6 +90,10 @@ export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
90
90
  export { createSessionClientHost } from './session/sessionClientHost';
91
91
  export { createSessionClient } from './session/createSessionClient';
92
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';
93
97
  export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, DEVICE_TOKEN_STORAGE_KEY, } from './session/authStateStore';
94
98
  export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
95
99
  export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
@@ -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 {};
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Unified account-list projection — THE single source of truth.
3
+ *
4
+ * Produces the flat `SwitchableAccount[]` every account chooser renders, by
5
+ * merging the device's server-authoritative session set (`DeviceSessionState`
6
+ * from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
7
+ * from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
8
+ * `@oxyhq/core` so `@oxyhq/services` (RN) and `@oxyhq/auth` (web) — and
9
+ * `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
10
+ * diverge.
11
+ *
12
+ * Pure and I/O-free: the caller resolves per-account profiles via
13
+ * `oxyServices.getUsersByIds(...)` and passes them in as `profilesById`, and
14
+ * binds `resolveAvatarUrl` to `oxyServices.getFileDownloadUrl`. This is the same
15
+ * split the former `@oxyhq/services` `buildSwitchableAccounts` used — hoisted
16
+ * into core, keyed directly on `DeviceSessionState` (whose `activeAccountId` is
17
+ * atomic, so no cross-call current-row reconciliation is needed).
18
+ */
19
+ import type { DeviceSessionState } from '@oxyhq/contracts';
20
+ import type { User } from '../models/interfaces';
21
+ import type { AccountNode, AccountRelationship, AccountKind, AccountMember } from '../mixins/OxyServices.accounts';
22
+ /**
23
+ * The per-account user shape carried by a {@link SwitchableAccount}. The SDK's
24
+ * canonical {@link User} document — either a profile resolved via
25
+ * `oxyServices.getUsersByIds()` (device rows), the caller-supplied
26
+ * `activeUser` override (the freshest copy of the active row), or the `account`
27
+ * document embedded in an account-graph node (graph-only rows).
28
+ */
29
+ export type SwitchableAccountUser = User;
30
+ /**
31
+ * One account the signed-in user can switch INTO, in the uniform switch model.
32
+ *
33
+ * A switchable account is either a device sign-in, an account-graph node (owned
34
+ * org / shared-with-you), or BOTH (an account that has been switched into
35
+ * becomes a device session while still being a graph node — the two are deduped
36
+ * into a single row). Every row carries a canonical `accountId` (the uniform
37
+ * switch key); `sessionId` is present IFF the account is currently signed in on
38
+ * THIS device.
39
+ */
40
+ export interface SwitchableAccount {
41
+ /**
42
+ * Canonical account id (the underlying `User._id`). The single key EVERY
43
+ * switch uses — `controller.switchTo(accountId)`. Always present.
44
+ */
45
+ accountId: string;
46
+ /**
47
+ * Device session id, present IFF this account is signed in on THIS device.
48
+ * Absent for a graph account not yet switched into. Used only for
49
+ * device-scoped actions (per-account sign-out); switching ALWAYS goes through
50
+ * `switchTo(accountId)`.
51
+ */
52
+ sessionId?: string;
53
+ /**
54
+ * Device-local account slot index (0..N-1) carried on the underlying
55
+ * `SessionAccount`. Absent for graph-only rows.
56
+ */
57
+ authuser?: number;
58
+ /** Whether this account is the currently-active one (`accountId === activeAccountId`). */
59
+ isCurrent: boolean;
60
+ /** Whether this account is signed in on THIS device (has a `sessionId`). */
61
+ onDevice: boolean;
62
+ /**
63
+ * The caller's relationship to this account when it appears in the account
64
+ * graph: `self` (the caller's own personal account), `owner` (an org/project/
65
+ * bot the caller owns), or `member` (shared with the caller). Absent for an
66
+ * independent device sign-in that is NOT in the active account's graph.
67
+ */
68
+ relationship?: AccountRelationship;
69
+ /** Account classification (personal/organization/…). Cosmetic badge only. */
70
+ kind?: AccountKind;
71
+ /** Parent account id for 2-level tree grouping, or `null` for a root. */
72
+ parentAccountId?: string | null;
73
+ /**
74
+ * The caller's effective membership (role + permissions) in this account when
75
+ * it appears in the graph, or `null`/absent otherwise. Use `permissions` to
76
+ * gate per-account settings UI.
77
+ */
78
+ callerMembership?: AccountMember | null;
79
+ /** Friendly display name (never blank — falls back to a handle/sentinel). */
80
+ displayName: string;
81
+ /**
82
+ * Real account email, or `null` when the account genuinely has none. NEVER a
83
+ * synthesized `username@oxy.so` — a missing email falls back to the `@handle`
84
+ * secondary line.
85
+ */
86
+ email: string | null;
87
+ /** Resolved avatar thumbnail URL, or `undefined` when the account has no avatar. */
88
+ avatarUrl?: string;
89
+ /** Account's preferred Bloom color preset, or `null` when unset. */
90
+ color: string | null;
91
+ /** The underlying per-account user payload. */
92
+ user: SwitchableAccountUser;
93
+ }
94
+ /** Input to {@link projectSwitchableAccounts}. */
95
+ export interface ProjectSwitchableAccountsInput {
96
+ /**
97
+ * The device-scoped session state from `SessionClient.getState()`. `null`
98
+ * (or an empty account set) contributes no device rows.
99
+ */
100
+ state: DeviceSessionState | null;
101
+ /** The caller's account graph (`oxyServices.listAccounts()`). `[]` when none. */
102
+ graph: AccountNode[];
103
+ /**
104
+ * Per-account profiles resolved via `oxyServices.getUsersByIds()`, keyed by
105
+ * account id (`User.id`). Device accounts whose profile is absent here are
106
+ * omitted until a subsequent fetch resolves them (unless they are the active
107
+ * account and `activeUser` is supplied).
108
+ */
109
+ profilesById: Map<string, User>;
110
+ /**
111
+ * The freshest copy of the ACTIVE account's user (e.g. `useOxy().user`),
112
+ * preferred over `profilesById` for the active row so a just-committed profile
113
+ * edit is reflected immediately. Optional — the controller relies on
114
+ * `profilesById` alone when omitted.
115
+ */
116
+ activeUser?: User | null;
117
+ /** Locale for display-name resolution (passed to `getAccountDisplayName`). */
118
+ locale?: string;
119
+ /**
120
+ * Resolves an avatar file id to a thumbnail URL — bind to
121
+ * `(id) => id ? oxyServices.getFileDownloadUrl(id, 'thumb') : undefined`.
122
+ */
123
+ resolveAvatarUrl: (avatar: string | null | undefined) => string | undefined;
124
+ }
125
+ /**
126
+ * Pure union of device sign-ins and account-graph nodes into the flat
127
+ * {@link SwitchableAccount}[] every switcher renders.
128
+ *
129
+ * Order: device rows first (in `state.accounts` order, active flagged), then
130
+ * graph-only rows (in graph order). An account present as BOTH a device session
131
+ * and a graph node is deduped into ONE device row enriched with the graph
132
+ * metadata (relationship / kind / parent / membership).
133
+ */
134
+ export declare function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[];
135
+ /**
136
+ * Every distinct account id referenced by a device session set AND an account
137
+ * graph, sorted for a stable profile-fetch key. Feed to
138
+ * `oxyServices.getUsersByIds(...)`; graph nodes already embed their `account`
139
+ * document, but including their ids lets the caller pass one id set and lets the
140
+ * projection prefer freshly-fetched profiles uniformly.
141
+ */
142
+ export declare function switchableAccountIds(state: DeviceSessionState | null, graph: AccountNode[]): string[];
@@ -1,5 +1,5 @@
1
1
  import type { OxyServices } from '../OxyServices';
2
- import { SessionClient, type TokenTransport } from './SessionClient';
2
+ import { SessionClient, type SessionClientOptions, type TokenTransport } from './SessionClient';
3
3
  import type { SocketIOFactory } from './socketLoader';
4
4
  import { createSessionClientHost } from './sessionClientHost';
5
5
  /**
@@ -24,7 +24,14 @@ import { createSessionClientHost } from './sessionClientHost';
24
24
  * specifier (bundler-fragile in Metro/Expo-web and Vite against the published
25
25
  * dist). When omitted, the client falls back to the lazy loader.
26
26
  */
27
- export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport, socketFactory?: SocketIOFactory): {
27
+ export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport, socketFactory?: SocketIOFactory,
28
+ /**
29
+ * Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
30
+ * while signed out so an idle tab receives its device pushes) and
31
+ * `onSessionAppeared` (self-acquire when a sibling signs in). See
32
+ * {@link SessionClientOptions}.
33
+ */
34
+ extra?: Pick<SessionClientOptions, 'signedOutSocketAuth' | 'onSessionAppeared'>): {
28
35
  client: SessionClient;
29
36
  host: ReturnType<typeof createSessionClientHost>;
30
37
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
package/src/index.ts CHANGED
@@ -543,6 +543,37 @@ export {
543
543
  accountIdsOf,
544
544
  } from './session/projectSessionState';
545
545
 
546
+ // Unified account-list projection (THE single source of truth for the account
547
+ // chooser: device sign-ins ∪ account graph, deduped by accountId). Pure +
548
+ // I/O-free — the caller hydrates profiles via `getUsersByIds`. Shared by
549
+ // `@oxyhq/services`, `@oxyhq/auth`, and auth.oxy.so so the list can't diverge.
550
+ export {
551
+ projectSwitchableAccounts,
552
+ switchableAccountIds,
553
+ } from './session/accountProjection';
554
+ export type {
555
+ SwitchableAccount,
556
+ SwitchableAccountUser,
557
+ ProjectSwitchableAccountsInput,
558
+ } from './session/accountProjection';
559
+
560
+ // Headless controller for the unified account dialog. Framework-agnostic
561
+ // state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
562
+ // — no password/2FA logic (that lives at the IdP; `openPasswordAtOxyAuth` only
563
+ // hands off). Reuses `SessionClient.switchAccount` / `oxyServices.switchToAccount`
564
+ // for the uniform switch and the existing device-flow methods for sign-in.
565
+ export {
566
+ AccountDialogController,
567
+ createAccountDialogController,
568
+ } from './session/accountDialogController';
569
+ export type {
570
+ AccountDialogControllerOptions,
571
+ AccountDialogSnapshot,
572
+ AccountDialogView,
573
+ SignInFlowPhase,
574
+ SignInFlowState,
575
+ } from './session/accountDialogController';
576
+
546
577
  // ---------------------------------------------------------------------------
547
578
  // Device-first session machinery (auth centralization, wave 1) — additive.
548
579
  // Persisted auth-state store, the unified refresh handler + scheduler, the