@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +17 -2
- package/dist/cjs/session/SessionClient.js +181 -10
- package/dist/cjs/session/accountDialogController.js +626 -0
- package/dist/cjs/session/accountProjection.js +131 -0
- package/dist/cjs/session/createSessionClient.js +9 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +11 -0
- package/dist/esm/session/SessionClient.js +181 -10
- package/dist/esm/session/accountDialogController.js +621 -0
- package/dist/esm/session/accountProjection.js +127 -0
- package/dist/esm/session/createSessionClient.js +9 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +4 -0
- package/dist/types/session/SessionClient.d.ts +51 -0
- package/dist/types/session/accountDialogController.d.ts +273 -0
- package/dist/types/session/accountProjection.d.ts +142 -0
- package/dist/types/session/createSessionClient.d.ts +9 -2
- package/package.json +1 -1
- package/src/index.ts +31 -0
- package/src/session/SessionClient.ts +201 -11
- package/src/session/__tests__/SessionClient.signedOut.test.ts +224 -0
- package/src/session/__tests__/accountDialogController.test.ts +592 -0
- package/src/session/__tests__/accountProjection.test.ts +181 -0
- package/src/session/accountDialogController.ts +769 -0
- package/src/session/accountProjection.ts +263 -0
- package/src/session/createSessionClient.ts +9 -2
package/dist/types/index.d.ts
CHANGED
|
@@ -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,273 @@
|
|
|
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 unsubscribeTokens;
|
|
135
|
+
/** Last-observed SDK auth readiness (a planted bearer). Drives the fetch edge. */
|
|
136
|
+
private authed;
|
|
137
|
+
private started;
|
|
138
|
+
private refreshSeq;
|
|
139
|
+
private snapshot;
|
|
140
|
+
constructor(options: AccountDialogControllerOptions);
|
|
141
|
+
/** Returns the current immutable snapshot (stable reference between changes). */
|
|
142
|
+
getSnapshot(): AccountDialogSnapshot;
|
|
143
|
+
/** Subscribe to snapshot changes. Returns an unsubscribe function. */
|
|
144
|
+
subscribe(listener: SnapshotListener): () => void;
|
|
145
|
+
/**
|
|
146
|
+
* Begin driving the dialog: subscribe to `SessionClient` state and load the
|
|
147
|
+
* account list. Idempotent — a second `start()` is a no-op. Pair with
|
|
148
|
+
* {@link destroy}.
|
|
149
|
+
*/
|
|
150
|
+
start(): void;
|
|
151
|
+
/**
|
|
152
|
+
* Stop driving the dialog: unsubscribe from `SessionClient` and tear down the
|
|
153
|
+
* active sign-in flow (timers). Idempotent.
|
|
154
|
+
*/
|
|
155
|
+
destroy(): void;
|
|
156
|
+
/**
|
|
157
|
+
* Whether a PRIVATE endpoint may be called right now. Mirrors the
|
|
158
|
+
* `hasAccessToken` term of `OxyContext.canUsePrivateApi`
|
|
159
|
+
* (`authResolved && isAuthenticated && tokenReady && hasAccessToken`, where
|
|
160
|
+
* `hasAccessToken = Boolean(oxyServices.getAccessToken())`): a planted bearer
|
|
161
|
+
* is the only term that decides whether a request carries auth — the other
|
|
162
|
+
* three are provider render-lifecycle gates with no headless equivalent.
|
|
163
|
+
*
|
|
164
|
+
* `listAccounts()` (`GET /accounts`) and `getUsersByIds()`
|
|
165
|
+
* (`POST /users/by-ids`) are private; calling either before cold-boot restore
|
|
166
|
+
* plants the token 401s → `HttpService` clears the bearer + emits
|
|
167
|
+
* `onTokensChanged(null)` → the app signs out. Every graph/profile fetch gates
|
|
168
|
+
* on this.
|
|
169
|
+
*/
|
|
170
|
+
private isAuthenticated;
|
|
171
|
+
/**
|
|
172
|
+
* Reconcile the account graph against the current auth-readiness edge. On the
|
|
173
|
+
* signed-out → signed-in edge fetch the graph ONCE; on signed-in → signed-out
|
|
174
|
+
* drop it and re-project device-only. A no-op when readiness is unchanged, so
|
|
175
|
+
* a burst of token events / device pushes cannot restart the fetch — and a
|
|
176
|
+
* failed `listAccounts()` never flips the edge, so it cannot re-trigger itself
|
|
177
|
+
* (no retry storm).
|
|
178
|
+
*/
|
|
179
|
+
private reconcileAuth;
|
|
180
|
+
/** Set the dialog view directly. */
|
|
181
|
+
setView(view: AccountDialogView): void;
|
|
182
|
+
/** Return to the account list and cancel any in-flight sign-in flow. */
|
|
183
|
+
close(): void;
|
|
184
|
+
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
185
|
+
add(): void;
|
|
186
|
+
/**
|
|
187
|
+
* Reload the account graph and per-account profiles, then re-project. Safe to
|
|
188
|
+
* call repeatedly; concurrent calls are reconciled by a sequence guard so a
|
|
189
|
+
* slow earlier fetch never overwrites a newer result.
|
|
190
|
+
*/
|
|
191
|
+
refresh(): Promise<void>;
|
|
192
|
+
/**
|
|
193
|
+
* Fetch profiles for any account id (device set ∪ graph) not yet resolved.
|
|
194
|
+
* Cheap no-op when everything is already hydrated — used from the session
|
|
195
|
+
* subscription so a newly-added device account gets a name/avatar.
|
|
196
|
+
*/
|
|
197
|
+
private ensureProfiles;
|
|
198
|
+
private loadProfiles;
|
|
199
|
+
/**
|
|
200
|
+
* Switch the active account to `accountId`.
|
|
201
|
+
*
|
|
202
|
+
* Uniform switch model, mirroring the SDK's existing path — NOT a new switch
|
|
203
|
+
* mechanism:
|
|
204
|
+
* - already on this device → `SessionClient.switchAccount` (device-first
|
|
205
|
+
* switch of `/session/device/switch`);
|
|
206
|
+
* - a graph account not yet on the device (first entry) →
|
|
207
|
+
* `oxyServices.switchToAccount` mints + plants a real session and the
|
|
208
|
+
* server registers it into the device set, then it is committed
|
|
209
|
+
* (`commitSession` when supplied, else `SessionClient.registerAndActivate`).
|
|
210
|
+
*
|
|
211
|
+
* The resulting device-state change flows back through the `SessionClient`
|
|
212
|
+
* subscription, which re-projects the active row. Concurrent switches are
|
|
213
|
+
* ignored while one is in flight.
|
|
214
|
+
*/
|
|
215
|
+
switchTo(accountId: string): Promise<void>;
|
|
216
|
+
/**
|
|
217
|
+
* Start "Sign in with Oxy". Native devices with a shared identity mint a
|
|
218
|
+
* session silently (`signInWithSharedIdentity`); everything else (web, or a
|
|
219
|
+
* native device without a shared identity) falls through to the cross-device
|
|
220
|
+
* QR handoff.
|
|
221
|
+
*/
|
|
222
|
+
signInWithOxy(): Promise<void>;
|
|
223
|
+
/**
|
|
224
|
+
* Begin (or restart) the cross-device QR handoff: create a device-flow
|
|
225
|
+
* session, surface its `authorizeCode` + `qrPayload`, and poll for approval.
|
|
226
|
+
* On approval the secret token is exchanged (`claimSessionByToken`) and the
|
|
227
|
+
* session committed. Requires `clientId`.
|
|
228
|
+
*/
|
|
229
|
+
showQr(): Promise<void>;
|
|
230
|
+
/** Tear down the active sign-in device flow (timers + token) and reset to idle. */
|
|
231
|
+
cancelSignIn(): void;
|
|
232
|
+
/**
|
|
233
|
+
* Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
|
|
234
|
+
* password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
|
|
235
|
+
* IdP; this only hands off. Device-first: after login at the IdP the device
|
|
236
|
+
* session converges and the caller is woken via the device socket /
|
|
237
|
+
* `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
|
|
238
|
+
* the right return.
|
|
239
|
+
*
|
|
240
|
+
* @param params.returnUrl - Where the IdP returns after login. Defaults to the
|
|
241
|
+
* current document URL on web (`globalThis.location.href`); pass explicitly
|
|
242
|
+
* on native (no `location`).
|
|
243
|
+
* @param params.state - Optional opaque state echoed back on return.
|
|
244
|
+
* @returns The absolute auth.oxy.so sign-in URL.
|
|
245
|
+
*/
|
|
246
|
+
openPasswordAtOxyAuth(params?: {
|
|
247
|
+
returnUrl?: string;
|
|
248
|
+
state?: string;
|
|
249
|
+
}): string;
|
|
250
|
+
private scheduleNextPoll;
|
|
251
|
+
private pollOnce;
|
|
252
|
+
private claimAndComplete;
|
|
253
|
+
/**
|
|
254
|
+
* Commit an authorized session, notify, and return to the account list. Shared
|
|
255
|
+
* by the shared-key, QR, and mint-switch paths so they cannot drift.
|
|
256
|
+
*/
|
|
257
|
+
private completeSignIn;
|
|
258
|
+
/**
|
|
259
|
+
* Register a token-planted session into the device set. Prefers the
|
|
260
|
+
* consumer's `commitSession` (durable persist + hydration); falls back to
|
|
261
|
+
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
262
|
+
*/
|
|
263
|
+
private commitAuthorizedSession;
|
|
264
|
+
private failSignIn;
|
|
265
|
+
private clearPollTimer;
|
|
266
|
+
private setSignIn;
|
|
267
|
+
private computeSnapshot;
|
|
268
|
+
/** Recompute the snapshot and notify subscribers. */
|
|
269
|
+
private emit;
|
|
270
|
+
}
|
|
271
|
+
/** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
|
|
272
|
+
export declare function createAccountDialogController(options: AccountDialogControllerOptions): AccountDialogController;
|
|
273
|
+
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
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
|