@oxyhq/core 7.1.1 → 8.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.
- package/README.md +48 -24
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -6
- package/dist/cjs/boot/coldBootV2.js +97 -2
- package/dist/cjs/boot/deviceBootReturn.js +15 -0
- package/dist/cjs/i18n/locales/en-US.json +44 -1
- package/dist/cjs/i18n/locales/es-ES.json +44 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +45 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +45 -2
- package/dist/cjs/index.js +19 -16
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +28 -0
- package/dist/cjs/server/index.js +1 -7
- package/dist/cjs/session/accountDialogController.js +1 -1
- package/dist/cjs/session/accountProjection.js +1 -1
- package/dist/cjs/session/authStateStore.js +6 -0
- package/dist/cjs/session/projectSessionState.js +1 -1
- package/dist/cjs/session/refresh.js +9 -0
- package/dist/cjs/session/sessionClientHost.js +1 -2
- package/dist/cjs/utils/accountUtils.js +1 -1
- package/dist/cjs/utils/oauthPkce.js +142 -0
- package/dist/cjs/utils/platform.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -6
- package/dist/esm/boot/coldBootV2.js +97 -2
- package/dist/esm/boot/deviceBootReturn.js +15 -0
- package/dist/esm/i18n/locales/en-US.json +44 -1
- package/dist/esm/i18n/locales/es-ES.json +44 -1
- package/dist/esm/i18n/locales/locales/en-US.json +45 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +45 -2
- package/dist/esm/index.js +11 -13
- package/dist/esm/mixins/OxyServices.deviceBoot.js +29 -1
- package/dist/esm/server/index.js +0 -5
- package/dist/esm/session/accountDialogController.js +1 -1
- package/dist/esm/session/accountProjection.js +1 -1
- package/dist/esm/session/authStateStore.js +6 -0
- package/dist/esm/session/projectSessionState.js +1 -1
- package/dist/esm/session/refresh.js +9 -0
- package/dist/esm/session/sessionClientHost.js +1 -2
- package/dist/esm/utils/accountUtils.js +1 -1
- package/dist/esm/utils/oauthPkce.js +135 -0
- package/dist/esm/utils/platform.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +1 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.accounts.d.ts +13 -3
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +4 -0
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +17 -1
- package/dist/types/mixins/OxyServices.devices.d.ts +3 -2
- package/dist/types/models/interfaces.d.ts +4 -4
- package/dist/types/server/index.d.ts +0 -1
- package/dist/types/session/accountDialogController.d.ts +1 -1
- package/dist/types/session/accountProjection.d.ts +1 -1
- package/dist/types/session/authStateStore.d.ts +20 -0
- package/dist/types/session/projectSessionState.d.ts +1 -1
- package/dist/types/session/refresh.d.ts +4 -8
- package/dist/types/session/sessionClientHost.d.ts +1 -2
- package/dist/types/utils/accountUtils.d.ts +1 -1
- package/dist/types/utils/oauthPkce.d.ts +74 -0
- package/dist/types/utils/platform.d.ts +1 -1
- package/package.json +3 -3
- package/src/HttpService.ts +6 -6
- package/src/boot/__tests__/coldBootV2.test.ts +215 -1
- package/src/boot/__tests__/deviceBootReturn.test.ts +32 -0
- package/src/boot/coldBootV2.ts +117 -2
- package/src/boot/deviceBootReturn.ts +15 -0
- package/src/i18n/locales/en-US.json +45 -2
- package/src/i18n/locales/es-ES.json +45 -2
- package/src/index.ts +23 -16
- package/src/mixins/OxyServices.accounts.ts +12 -0
- package/src/mixins/OxyServices.connectedApps.ts +4 -0
- package/src/mixins/OxyServices.deviceBoot.ts +38 -0
- package/src/mixins/OxyServices.devices.ts +6 -5
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +44 -1
- package/src/mixins/__tests__/accounts.test.ts +1 -1
- package/src/models/interfaces.ts +7 -5
- package/src/server/index.ts +0 -6
- package/src/session/__tests__/authStateStore.test.ts +27 -0
- package/src/session/__tests__/refresh.test.ts +14 -0
- package/src/session/accountDialogController.ts +1 -1
- package/src/session/accountProjection.ts +1 -1
- package/src/session/authStateStore.ts +26 -0
- package/src/session/projectSessionState.ts +1 -1
- package/src/session/refresh.ts +13 -8
- package/src/session/sessionClientHost.ts +1 -2
- package/src/utils/__tests__/coldBoot.test.ts +55 -65
- package/src/utils/__tests__/oauthPkce.test.ts +154 -0
- package/src/utils/accountUtils.ts +1 -1
- package/src/utils/oauthPkce.ts +189 -0
- package/src/utils/platform.ts +1 -1
- package/dist/cjs/utils/ssoBounce.js +0 -24
- package/dist/esm/utils/ssoBounce.js +0 -21
- package/dist/types/utils/ssoBounce.d.ts +0 -21
- package/src/utils/ssoBounce.ts +0 -22
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { OxyServices } from '../../OxyServices';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AuthTokenBundle,
|
|
4
|
+
DeviceTokenMintResponse,
|
|
5
|
+
TokenRefreshResponse,
|
|
6
|
+
WebSessionResult,
|
|
7
|
+
} from '@oxyhq/contracts';
|
|
3
8
|
import type { SessionLoginResponse } from '../../models/session';
|
|
4
9
|
import {
|
|
5
10
|
runSessionColdBoot,
|
|
@@ -34,6 +39,7 @@ interface OxyOverrides {
|
|
|
34
39
|
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
35
40
|
issueNativeDeviceToken?: OxyServices['issueNativeDeviceToken'];
|
|
36
41
|
requestWebSession?: OxyServices['requestWebSession'];
|
|
42
|
+
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
@@ -48,12 +54,37 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
48
54
|
requestWebSession:
|
|
49
55
|
overrides.requestWebSession
|
|
50
56
|
?? (async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult),
|
|
57
|
+
// Default: no persisted secret in these fixtures, so the mint step skips
|
|
58
|
+
// before ever calling this. Tests that exercise the mint pass an override.
|
|
59
|
+
mintFromDeviceSecret:
|
|
60
|
+
overrides.mintFromDeviceSecret
|
|
61
|
+
?? (async () => {
|
|
62
|
+
throw new Error('mintFromDeviceSecret not stubbed');
|
|
63
|
+
}),
|
|
51
64
|
buildBootstrapUrl: (returnTo: string, state: string) =>
|
|
52
65
|
`https://api.oxy.so/auth/device/bootstrap?return_to=${encodeURIComponent(returnTo)}&state=${state}`,
|
|
53
66
|
} as unknown as OxyServices;
|
|
54
67
|
return { oxy, setTokens };
|
|
55
68
|
}
|
|
56
69
|
|
|
70
|
+
const MINT: DeviceTokenMintResponse = {
|
|
71
|
+
accessToken: 'access-minted',
|
|
72
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
73
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
74
|
+
state: {
|
|
75
|
+
deviceId: 'dev-mint',
|
|
76
|
+
accounts: [{ accountId: 'user-mint', sessionId: 'sess-mint', authuser: 0 }],
|
|
77
|
+
activeAccountId: 'user-mint',
|
|
78
|
+
revision: 2,
|
|
79
|
+
updatedAt: 1_700_000_000_000,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A 401 error shaped like `HttpService`/`handleError` output for the given body. */
|
|
84
|
+
function mint401(body: string): Error & { status: number } {
|
|
85
|
+
return Object.assign(new Error(body), { status: 401 });
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
interface DomHandle {
|
|
58
89
|
dom: ColdBootDom;
|
|
59
90
|
navigate: jest.Mock;
|
|
@@ -183,6 +214,189 @@ describe('runSessionColdBoot — step ordering', () => {
|
|
|
183
214
|
});
|
|
184
215
|
});
|
|
185
216
|
|
|
217
|
+
describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
|
|
218
|
+
function makeCredStore(extra: Partial<import('../../session/authStateStore').PersistedAuthState> = {}) {
|
|
219
|
+
const store = createMemoryAuthStateStore();
|
|
220
|
+
return { store, seed: async () => {
|
|
221
|
+
await store.save({
|
|
222
|
+
sessionId: 'sess-old',
|
|
223
|
+
refreshToken: 'r-abcdefghij',
|
|
224
|
+
userId: 'user-old',
|
|
225
|
+
deviceId: 'dev-mint',
|
|
226
|
+
deviceSecret: 'ds-secret-orig',
|
|
227
|
+
...extra,
|
|
228
|
+
});
|
|
229
|
+
} };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
it('wins FIRST via the mint, persisting nextDeviceSecret BEFORE planting the token', async () => {
|
|
233
|
+
const { store, seed } = makeCredStore();
|
|
234
|
+
await seed();
|
|
235
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
236
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
237
|
+
const saveSpy = jest.spyOn(store, 'save');
|
|
238
|
+
const onSession = jest.fn();
|
|
239
|
+
|
|
240
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom, onSession });
|
|
241
|
+
|
|
242
|
+
expect(outcome).toEqual({ kind: 'session', via: 'device-secret-mint', session: expect.any(Object) });
|
|
243
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
|
|
244
|
+
expect(setTokens).toHaveBeenCalledWith('access-minted');
|
|
245
|
+
// Session identity comes from the mint's authoritative active account.
|
|
246
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
247
|
+
expect.objectContaining({ sessionId: 'sess-mint', userId: 'user-mint', via: 'device-secret-mint' }),
|
|
248
|
+
);
|
|
249
|
+
// Rotation-in-use anti-loss: the store held the NEXT secret before the plant.
|
|
250
|
+
const lastSaveOrder = Math.max(...saveSpy.mock.invocationCallOrder);
|
|
251
|
+
const firstPlantOrder = Math.min(...setTokens.mock.invocationCallOrder);
|
|
252
|
+
expect(lastSaveOrder).toBeLessThan(firstPlantOrder);
|
|
253
|
+
expect(await store.load()).toMatchObject({
|
|
254
|
+
deviceSecret: 'ds-next-secret',
|
|
255
|
+
sessionId: 'sess-mint',
|
|
256
|
+
userId: 'user-mint',
|
|
257
|
+
accessToken: 'access-minted',
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('skips (no mint) when only one of deviceId / deviceSecret is persisted', async () => {
|
|
262
|
+
const store = createMemoryAuthStateStore();
|
|
263
|
+
await store.save({ sessionId: 's', refreshToken: 'r-abcdefghij', userId: 'u', deviceSecret: 'ds-only' });
|
|
264
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
265
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
266
|
+
|
|
267
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
|
|
268
|
+
|
|
269
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
270
|
+
// Falls through to the migratory refresh family.
|
|
271
|
+
expect(outcome).toEqual({ kind: 'session', via: 'stored-tokens', session: expect.any(Object) });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('401 invalid_device_secret → drops the secret (keeps deviceId) and falls through', async () => {
|
|
275
|
+
const { store, seed } = makeCredStore();
|
|
276
|
+
await seed();
|
|
277
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
278
|
+
throw mint401('invalid_device_secret');
|
|
279
|
+
});
|
|
280
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
281
|
+
|
|
282
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
|
|
283
|
+
|
|
284
|
+
expect(mintFromDeviceSecret).toHaveBeenCalled();
|
|
285
|
+
// The migratory refresh lane recovers the session.
|
|
286
|
+
expect(outcome.kind).toBe('session');
|
|
287
|
+
expect(outcome).toMatchObject({ via: 'stored-tokens' });
|
|
288
|
+
const persisted = await store.load();
|
|
289
|
+
expect(persisted?.deviceSecret).toBeUndefined();
|
|
290
|
+
expect(persisted?.deviceId).toBe('dev-mint');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('401 no_active_session → keeps the secret, signed out, NEVER falls to the hop', async () => {
|
|
294
|
+
const { store, seed } = makeCredStore();
|
|
295
|
+
await seed();
|
|
296
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
297
|
+
throw mint401('no_active_session');
|
|
298
|
+
});
|
|
299
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
300
|
+
const refreshWithToken = jest.spyOn(oxy, 'refreshWithToken');
|
|
301
|
+
const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
|
|
302
|
+
const domHandle = makeDom();
|
|
303
|
+
const onSignedOut = jest.fn();
|
|
304
|
+
|
|
305
|
+
const outcome = await runSessionColdBoot({
|
|
306
|
+
oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
310
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
311
|
+
// The known-signed-out device is not bounced and no fallback lane runs.
|
|
312
|
+
expect(refreshWithToken).not.toHaveBeenCalled();
|
|
313
|
+
expect(requestWebSession).not.toHaveBeenCalled();
|
|
314
|
+
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
315
|
+
// The secret is retained — the device may sign in again.
|
|
316
|
+
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('classifies a PLAIN-OBJECT 401 (no Error prototype) — no_active_session still keeps the secret', async () => {
|
|
320
|
+
const { store, seed } = makeCredStore();
|
|
321
|
+
await seed();
|
|
322
|
+
// Cross-realm / ApiError-shaped throw: not `instanceof Error`. Must still be
|
|
323
|
+
// read as no_active_session — misreading it as a stale secret would drop it.
|
|
324
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
325
|
+
throw { status: 401, message: 'no_active_session' };
|
|
326
|
+
});
|
|
327
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
328
|
+
const onSignedOut = jest.fn();
|
|
329
|
+
|
|
330
|
+
const outcome = await runSessionColdBoot({
|
|
331
|
+
oxy, store, platform: WEB, dom: makeDom().dom, onSignedOut,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
335
|
+
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('transient (non-401) mint error → keeps the secret and falls through', async () => {
|
|
339
|
+
const { store, seed } = makeCredStore();
|
|
340
|
+
await seed();
|
|
341
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
342
|
+
throw new Error('network down');
|
|
343
|
+
});
|
|
344
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
345
|
+
|
|
346
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
|
|
347
|
+
|
|
348
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'stored-tokens' });
|
|
349
|
+
// The secret survives a transient failure (rotation preserved through refresh).
|
|
350
|
+
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('transient mint + cookie-lane hop whose bundle omits a secret → prior secret preserved', async () => {
|
|
354
|
+
const { store, seed } = makeCredStore();
|
|
355
|
+
await seed();
|
|
356
|
+
// Mint down, refresh family down → the chain lands on bootstrap-hop, whose
|
|
357
|
+
// web-session BUNDLE carries no deviceSecret. The still-valid prior secret
|
|
358
|
+
// must survive the store overwrite (else the mint lane is orphaned forever).
|
|
359
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
360
|
+
throw new Error('network down');
|
|
361
|
+
});
|
|
362
|
+
const refreshWithToken = jest.fn(async () => {
|
|
363
|
+
throw new Error('refresh down');
|
|
364
|
+
});
|
|
365
|
+
const requestWebSession = jest.fn(
|
|
366
|
+
async () => ({ reason: 'session', session: BUNDLE, deviceToken: 'dt-rotated' }) as WebSessionResult,
|
|
367
|
+
);
|
|
368
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret, refreshWithToken, requestWebSession });
|
|
369
|
+
const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
|
|
370
|
+
|
|
371
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
|
|
372
|
+
|
|
373
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'bootstrap-hop' });
|
|
374
|
+
const persisted = await store.load();
|
|
375
|
+
expect(persisted?.deviceSecret).toBe('ds-secret-orig');
|
|
376
|
+
expect(persisted?.deviceId).toBe('dev-mint');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('is gated OFF while a #oxy_boot return fragment is present (bootstrap-return wins)', async () => {
|
|
380
|
+
const { store, seed } = makeCredStore();
|
|
381
|
+
await seed();
|
|
382
|
+
const frag = { v: 1, state: 'state-match', reason: 'session', code: 'c'.repeat(24), deviceToken: 'd'.repeat(24) };
|
|
383
|
+
const b64 = Buffer.from(JSON.stringify(frag), 'utf-8')
|
|
384
|
+
.toString('base64')
|
|
385
|
+
.replace(/\+/g, '-')
|
|
386
|
+
.replace(/\//g, '_')
|
|
387
|
+
.replace(/=+$/, '');
|
|
388
|
+
const domHandle = makeDom({ hash: `#oxy_boot=${b64}`, sessionState: 'state-match' });
|
|
389
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
390
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
391
|
+
|
|
392
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
|
|
393
|
+
|
|
394
|
+
expect(outcome).toMatchObject({ via: 'bootstrap-return' });
|
|
395
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
396
|
+
expect(domHandle.strip).toHaveBeenCalled();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
186
400
|
describe('runSessionColdBoot — bootstrap-hop (web)', () => {
|
|
187
401
|
it('same-apex: resolves a session via the inline web-session fetch (no navigation)', async () => {
|
|
188
402
|
const store = createMemoryAuthStateStore();
|
|
@@ -119,6 +119,38 @@ describe('consumeDeviceBootReturn', () => {
|
|
|
119
119
|
expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
it('captures the bundle deviceSecret (phase 2c) and preserves a prior deviceId', async () => {
|
|
123
|
+
const { deps, store } = makeDeps(encodeHash(fragmentObject()), {
|
|
124
|
+
exchangeBootCode: async () => ({ ...BUNDLE, deviceSecret: 'ds-from-exchange' }),
|
|
125
|
+
});
|
|
126
|
+
// A prior deviceId-bearing login persisted a deviceId; the bundle carries no
|
|
127
|
+
// deviceId, so the overwrite must preserve it to keep the mint lane usable.
|
|
128
|
+
await store.save({ sessionId: 'prev', refreshToken: 'r-prevabcdefghij', userId: 'user-1', deviceId: 'dev-prev' });
|
|
129
|
+
|
|
130
|
+
await consumeDeviceBootReturn(deps);
|
|
131
|
+
|
|
132
|
+
const persisted = await store.load();
|
|
133
|
+
expect(persisted?.deviceSecret).toBe('ds-from-exchange');
|
|
134
|
+
expect(persisted?.deviceId).toBe('dev-prev');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('preserves a prior deviceSecret when the bundle omits one (never orphans the mint lane)', async () => {
|
|
138
|
+
const { deps, store } = makeDeps(encodeHash(fragmentObject()));
|
|
139
|
+
await store.save({
|
|
140
|
+
sessionId: 'prev',
|
|
141
|
+
refreshToken: 'r-prevabcdefghij',
|
|
142
|
+
userId: 'user-1',
|
|
143
|
+
deviceId: 'dev-prev',
|
|
144
|
+
deviceSecret: 'ds-still-valid',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await consumeDeviceBootReturn(deps);
|
|
148
|
+
|
|
149
|
+
const persisted = await store.load();
|
|
150
|
+
expect(persisted?.deviceSecret).toBe('ds-still-valid');
|
|
151
|
+
expect(persisted?.deviceId).toBe('dev-prev');
|
|
152
|
+
});
|
|
153
|
+
|
|
122
154
|
it('rejects a state mismatch without persisting or exchanging', async () => {
|
|
123
155
|
const { deps, calls, store } = makeDeps(encodeHash(fragmentObject()), { expectedState: 'WRONG' });
|
|
124
156
|
expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
|
package/src/boot/coldBootV2.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { resolveUserId } from '@oxyhq/contracts';
|
|
29
29
|
import { runColdBoot, type ColdBootOutcome, type ColdBootStep } from '../utils/coldBoot';
|
|
30
30
|
import { isWeb as detectWeb, isNative as detectNative } from '../utils/platform';
|
|
31
|
+
import { extractErrorStatus } from '../utils/errorUtils';
|
|
31
32
|
import { KeyManager } from '../crypto/keyManager';
|
|
32
33
|
import { logger } from '../utils/loggerUtils';
|
|
33
34
|
import type { OxyServices } from '../OxyServices';
|
|
@@ -225,6 +226,34 @@ function sessionFromPersisted(state: PersistedAuthState, accessToken: string): D
|
|
|
225
226
|
return { sessionId: state.sessionId, userId: state.userId, accessToken };
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/**
|
|
230
|
+
* How a `mintFromDeviceSecret` (phase 2c) call failed, distinguished so the cold
|
|
231
|
+
* boot can react per the transport contract:
|
|
232
|
+
* - `invalid_secret` — the presented secret no longer matches (another tab/
|
|
233
|
+
* device rotated it, or theft divergence). Drop it and fall back.
|
|
234
|
+
* - `no_active_session` — the device is known but has no live session.
|
|
235
|
+
* Authoritative signed-out; keep the secret, do not fall back to the hop.
|
|
236
|
+
* - `transient` — network / 5xx. Keep the secret and let the fallback lanes try.
|
|
237
|
+
*
|
|
238
|
+
* The mint is bearer-less (`skipAuth`), so `HttpService` surfaces the server's
|
|
239
|
+
* 401 body string (`invalid_device_secret` | `no_active_session`) as the thrown
|
|
240
|
+
* error's `message`; any non-401 is transport/server failure.
|
|
241
|
+
*/
|
|
242
|
+
type MintFailure = 'invalid_secret' | 'no_active_session' | 'transient';
|
|
243
|
+
|
|
244
|
+
function classifyMintFailure(error: unknown): MintFailure {
|
|
245
|
+
if (extractErrorStatus(error) === 401) {
|
|
246
|
+
// Structural read (not `instanceof Error`): the thrown value can be a plain
|
|
247
|
+
// ApiError-shaped object or come from another realm, where instanceof fails
|
|
248
|
+
// and a `no_active_session` would be misread as a stale secret and dropped.
|
|
249
|
+
const message = (error as { message?: unknown })?.message;
|
|
250
|
+
return typeof message === 'string' && message.includes('no_active_session')
|
|
251
|
+
? 'no_active_session'
|
|
252
|
+
: 'invalid_secret';
|
|
253
|
+
}
|
|
254
|
+
return 'transient';
|
|
255
|
+
}
|
|
256
|
+
|
|
228
257
|
/**
|
|
229
258
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
230
259
|
* a side effect, invokes `onSession` (winning session, token already planted)
|
|
@@ -243,9 +272,79 @@ export async function runSessionColdBoot(
|
|
|
243
272
|
// they cannot leak across boots or break under bundler re-evaluation.
|
|
244
273
|
let signedOutReason: SignedOutReason = 'no_session';
|
|
245
274
|
let navigating = false;
|
|
275
|
+
// Set when the zero-cookie mint reports `no_active_session` (phase 2c): the
|
|
276
|
+
// device is authoritatively signed out, so the migratory fallback lanes below
|
|
277
|
+
// (stored-tokens / shared-key / bootstrap-hop) must NOT run — we already know
|
|
278
|
+
// there is no session and must not bounce a known-signed-out device.
|
|
279
|
+
let deviceKnownSignedOut = false;
|
|
246
280
|
|
|
247
281
|
const steps: Array<ColdBootStep<DeviceBootSession>> = [];
|
|
248
282
|
|
|
283
|
+
// 0. device-secret-mint (phase 2c) — the zero-cookie fast path. When the
|
|
284
|
+
// origin persisted a deviceId + deviceSecret, mint a short access token with
|
|
285
|
+
// a single bearer-less POST (no cookie, no navigation). FIRST in the chain
|
|
286
|
+
// so it wins over the migratory cookie lanes below. Gated OFF while a
|
|
287
|
+
// #oxy_boot return fragment is present so `bootstrap-return` still consumes
|
|
288
|
+
// + strips it first (a device holding a secret never triggers that hop, so
|
|
289
|
+
// this only defers a rare stale/forged fragment). The rest of the chain
|
|
290
|
+
// stays as the additive migratory fallback for devices not yet on the secret.
|
|
291
|
+
steps.push({
|
|
292
|
+
id: 'device-secret-mint',
|
|
293
|
+
enabled: () => !(isWeb && hashHasBootFragment(dom.getHash())),
|
|
294
|
+
run: async () => {
|
|
295
|
+
const persisted = await store.load();
|
|
296
|
+
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
297
|
+
return { kind: 'skip' };
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
|
|
301
|
+
// Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
|
|
302
|
+
// fields, + the server's authoritative active account) BEFORE planting
|
|
303
|
+
// the minted access token, so a multi-tab race that rotates again can
|
|
304
|
+
// never strand this tab with a superseded secret.
|
|
305
|
+
const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
|
|
306
|
+
const next: PersistedAuthState = {
|
|
307
|
+
...persisted,
|
|
308
|
+
deviceId: mint.state.deviceId,
|
|
309
|
+
deviceSecret: mint.nextDeviceSecret,
|
|
310
|
+
accessToken: mint.accessToken,
|
|
311
|
+
expiresAt: mint.expiresAt,
|
|
312
|
+
...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
|
|
313
|
+
};
|
|
314
|
+
await store.save(next);
|
|
315
|
+
oxy.setTokens(mint.accessToken);
|
|
316
|
+
return {
|
|
317
|
+
kind: 'session',
|
|
318
|
+
session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
|
|
319
|
+
};
|
|
320
|
+
} catch (error) {
|
|
321
|
+
const failure = classifyMintFailure(error);
|
|
322
|
+
if (failure === 'invalid_secret') {
|
|
323
|
+
// Stale/diverged secret — drop it so the mint lane stops firing, then
|
|
324
|
+
// fall through to the migratory refresh/cookie lanes. Setting it
|
|
325
|
+
// undefined drops the key on the store's JSON serialization, and the
|
|
326
|
+
// mint guard treats undefined as absent.
|
|
327
|
+
await store.save({ ...persisted, deviceSecret: undefined });
|
|
328
|
+
return { kind: 'skip' };
|
|
329
|
+
}
|
|
330
|
+
if (failure === 'no_active_session') {
|
|
331
|
+
// Device known, no live session — authoritative signed-out. KEEP the
|
|
332
|
+
// secret and stop the chain (do not bounce a known-signed-out device).
|
|
333
|
+
deviceKnownSignedOut = true;
|
|
334
|
+
signedOutReason = 'no_session';
|
|
335
|
+
return { kind: 'skip' };
|
|
336
|
+
}
|
|
337
|
+
// Transient (network / 5xx): keep the secret, let the fallback lanes try.
|
|
338
|
+
logger.debug(
|
|
339
|
+
'device-secret mint failed (transient) — keeping secret, falling back',
|
|
340
|
+
{ component: 'coldBootV2', method: 'device-secret-mint' },
|
|
341
|
+
error,
|
|
342
|
+
);
|
|
343
|
+
return { kind: 'skip' };
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
249
348
|
// 1. bootstrap-return (web) — consume a #oxy_boot fragment.
|
|
250
349
|
steps.push({
|
|
251
350
|
id: 'bootstrap-return',
|
|
@@ -279,6 +378,7 @@ export async function runSessionColdBoot(
|
|
|
279
378
|
// 2. stored-tokens — warm-plant or rotate the persisted refresh family.
|
|
280
379
|
steps.push({
|
|
281
380
|
id: 'stored-tokens',
|
|
381
|
+
enabled: () => !deviceKnownSignedOut,
|
|
282
382
|
run: async () => {
|
|
283
383
|
const persisted = await store.load();
|
|
284
384
|
if (!persisted) {
|
|
@@ -308,7 +408,7 @@ export async function runSessionColdBoot(
|
|
|
308
408
|
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
309
409
|
steps.push({
|
|
310
410
|
id: 'shared-key-signin',
|
|
311
|
-
enabled: () => isNative,
|
|
411
|
+
enabled: () => isNative && !deviceKnownSignedOut,
|
|
312
412
|
run: async () => {
|
|
313
413
|
const session = await oxy.signInWithSharedIdentity();
|
|
314
414
|
if (!session?.accessToken) {
|
|
@@ -345,7 +445,7 @@ export async function runSessionColdBoot(
|
|
|
345
445
|
// 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
|
|
346
446
|
steps.push({
|
|
347
447
|
id: 'bootstrap-hop',
|
|
348
|
-
enabled: () => isWeb,
|
|
448
|
+
enabled: () => isWeb && !deviceKnownSignedOut,
|
|
349
449
|
run: async () => {
|
|
350
450
|
const pageHost = dom.getLocationHostname();
|
|
351
451
|
let apiHost: string | null = null;
|
|
@@ -378,6 +478,21 @@ export async function runSessionColdBoot(
|
|
|
378
478
|
accessToken: bundle.accessToken,
|
|
379
479
|
expiresAt: bundle.expiresAt,
|
|
380
480
|
};
|
|
481
|
+
// Phase 2c: the web-session bundle may carry a rotating `deviceSecret`
|
|
482
|
+
// but NOT a deviceId. Persist the secret and carry any prior deviceId
|
|
483
|
+
// (from a deviceId-bearing login lane) forward so the mint lane stays
|
|
484
|
+
// usable — this overwrite must not orphan it.
|
|
485
|
+
const prior = await store.load();
|
|
486
|
+
if (prior?.deviceId) {
|
|
487
|
+
next.deviceId = prior.deviceId;
|
|
488
|
+
}
|
|
489
|
+
// Prefer the bundle's secret (the server just rotated onto it); keep the
|
|
490
|
+
// prior one when the bundle omits it — this lane also runs as the
|
|
491
|
+
// TRANSIENT-mint fallback, and must not orphan a still-valid secret.
|
|
492
|
+
const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
|
|
493
|
+
if (carriedSecret) {
|
|
494
|
+
next.deviceSecret = carriedSecret;
|
|
495
|
+
}
|
|
381
496
|
await store.save(next);
|
|
382
497
|
oxy.setTokens(bundle.accessToken);
|
|
383
498
|
return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
|
|
@@ -178,6 +178,21 @@ export async function consumeDeviceBootReturn(
|
|
|
178
178
|
accessToken: bundle.accessToken,
|
|
179
179
|
expiresAt: bundle.expiresAt,
|
|
180
180
|
};
|
|
181
|
+
// Phase 2c: the cookie-bootstrap bundle may carry a rotating `deviceSecret`
|
|
182
|
+
// but NOT a deviceId. Persist the secret, and carry any prior deviceId
|
|
183
|
+
// forward (from a deviceId-bearing login lane) so the pair stays usable by
|
|
184
|
+
// the zero-cookie mint — an overwrite here must not orphan the mint lane.
|
|
185
|
+
const prior = await deps.store.load();
|
|
186
|
+
if (prior?.deviceId) {
|
|
187
|
+
next.deviceId = prior.deviceId;
|
|
188
|
+
}
|
|
189
|
+
// Prefer the bundle's secret (the server just rotated onto it); keep the
|
|
190
|
+
// prior one when the bundle omits it so a cookie-lane boot can never orphan
|
|
191
|
+
// a still-valid secret captured by an earlier login lane.
|
|
192
|
+
const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
|
|
193
|
+
if (carriedSecret) {
|
|
194
|
+
next.deviceSecret = carriedSecret;
|
|
195
|
+
}
|
|
181
196
|
await deps.store.save(next);
|
|
182
197
|
deps.plantAccessToken(bundle.accessToken);
|
|
183
198
|
return {
|
|
@@ -384,7 +384,11 @@
|
|
|
384
384
|
"subtitle": "Add another account to switch between them quickly"
|
|
385
385
|
},
|
|
386
386
|
"label": "Account switcher",
|
|
387
|
-
"searchPlaceholder": "Search accounts"
|
|
387
|
+
"searchPlaceholder": "Search accounts",
|
|
388
|
+
"scanTitle": "Scan to sign in",
|
|
389
|
+
"scanSubtitle": "Open the Oxy app on your phone and scan this code",
|
|
390
|
+
"scanWithOxy": "Scan with the Oxy app to continue",
|
|
391
|
+
"scanQr": "Scan QR code"
|
|
388
392
|
},
|
|
389
393
|
"reputation": {
|
|
390
394
|
"faq": {
|
|
@@ -1905,5 +1909,44 @@
|
|
|
1905
1909
|
"earlyAdopter": "Early Adopter",
|
|
1906
1910
|
"earlyAdopterDesc": "Been part of the community from the start"
|
|
1907
1911
|
}
|
|
1912
|
+
},
|
|
1913
|
+
"consent": {
|
|
1914
|
+
"title": "Continue to {{app}}",
|
|
1915
|
+
"subtitle": "Use your Oxy account to sign in to {{app}}. Review what this connection means before you continue.",
|
|
1916
|
+
"provenance": {
|
|
1917
|
+
"title": "Who is requesting access",
|
|
1918
|
+
"official": "Official Oxy application",
|
|
1919
|
+
"developer": "Published by {{developer}}",
|
|
1920
|
+
"thirdParty": "Third-party application"
|
|
1921
|
+
},
|
|
1922
|
+
"permissions": {
|
|
1923
|
+
"title": "Permissions requested",
|
|
1924
|
+
"basic": "Sign you in and read your basic profile"
|
|
1925
|
+
},
|
|
1926
|
+
"scopes": {
|
|
1927
|
+
"openid": "Confirm your identity",
|
|
1928
|
+
"profile": "Read your basic profile",
|
|
1929
|
+
"email": "Read your email address",
|
|
1930
|
+
"offlineAccess": "Keep you signed in when you're away",
|
|
1931
|
+
"userRead": "Read your basic profile",
|
|
1932
|
+
"filesRead": "Read your files",
|
|
1933
|
+
"filesWrite": "Upload and modify your files",
|
|
1934
|
+
"filesDelete": "Delete your files",
|
|
1935
|
+
"webhooksReceive": "Receive webhooks",
|
|
1936
|
+
"chatCompletions": "Use AI chat on your behalf",
|
|
1937
|
+
"modelsRead": "List available AI models",
|
|
1938
|
+
"federationWrite": "Act across federated services"
|
|
1939
|
+
},
|
|
1940
|
+
"account": {
|
|
1941
|
+
"title": "Signing in as"
|
|
1942
|
+
},
|
|
1943
|
+
"links": {
|
|
1944
|
+
"website": "Website",
|
|
1945
|
+
"privacy": "Privacy Policy",
|
|
1946
|
+
"terms": "Terms of Service"
|
|
1947
|
+
},
|
|
1948
|
+
"allow": "Continue to {{app}}",
|
|
1949
|
+
"deny": "Cancel",
|
|
1950
|
+
"disclaimer": "By continuing, {{app}} will be able to sign in with your Oxy account. You can manage connected apps anytime in your Oxy account settings."
|
|
1908
1951
|
}
|
|
1909
|
-
}
|
|
1952
|
+
}
|
|
@@ -581,7 +581,11 @@
|
|
|
581
581
|
"subtitle": "Añade otra cuenta para cambiar rápidamente entre ellas"
|
|
582
582
|
},
|
|
583
583
|
"label": "Selector de cuentas",
|
|
584
|
-
"searchPlaceholder": "Buscar cuentas"
|
|
584
|
+
"searchPlaceholder": "Buscar cuentas",
|
|
585
|
+
"scanTitle": "Escanea para iniciar sesión",
|
|
586
|
+
"scanSubtitle": "Abre la app de Oxy en tu móvil y escanea este código",
|
|
587
|
+
"scanWithOxy": "Escanea con la app de Oxy para continuar",
|
|
588
|
+
"scanQr": "Escanear código QR"
|
|
585
589
|
},
|
|
586
590
|
"feedback": {
|
|
587
591
|
"type": {
|
|
@@ -1905,5 +1909,44 @@
|
|
|
1905
1909
|
"earlyAdopter": "Pionero",
|
|
1906
1910
|
"earlyAdopterDesc": "Has formado parte de la comunidad desde el principio"
|
|
1907
1911
|
}
|
|
1912
|
+
},
|
|
1913
|
+
"consent": {
|
|
1914
|
+
"title": "Continuar a {{app}}",
|
|
1915
|
+
"subtitle": "Usa tu cuenta de Oxy para iniciar sesión en {{app}}. Revisa qué implica esta conexión antes de continuar.",
|
|
1916
|
+
"provenance": {
|
|
1917
|
+
"title": "Quién solicita acceso",
|
|
1918
|
+
"official": "Aplicación oficial de Oxy",
|
|
1919
|
+
"developer": "Publicada por {{developer}}",
|
|
1920
|
+
"thirdParty": "Aplicación de terceros"
|
|
1921
|
+
},
|
|
1922
|
+
"permissions": {
|
|
1923
|
+
"title": "Permisos solicitados",
|
|
1924
|
+
"basic": "Iniciar sesión y leer tu perfil básico"
|
|
1925
|
+
},
|
|
1926
|
+
"scopes": {
|
|
1927
|
+
"openid": "Confirmar tu identidad",
|
|
1928
|
+
"profile": "Leer tu perfil básico",
|
|
1929
|
+
"email": "Leer tu dirección de correo",
|
|
1930
|
+
"offlineAccess": "Mantener tu sesión iniciada cuando no estés",
|
|
1931
|
+
"userRead": "Leer tu perfil básico",
|
|
1932
|
+
"filesRead": "Leer tus archivos",
|
|
1933
|
+
"filesWrite": "Subir y modificar tus archivos",
|
|
1934
|
+
"filesDelete": "Eliminar tus archivos",
|
|
1935
|
+
"webhooksReceive": "Recibir webhooks",
|
|
1936
|
+
"chatCompletions": "Usar el chat con IA en tu nombre",
|
|
1937
|
+
"modelsRead": "Ver los modelos de IA disponibles",
|
|
1938
|
+
"federationWrite": "Actuar en servicios federados"
|
|
1939
|
+
},
|
|
1940
|
+
"account": {
|
|
1941
|
+
"title": "Iniciando sesión como"
|
|
1942
|
+
},
|
|
1943
|
+
"links": {
|
|
1944
|
+
"website": "Sitio web",
|
|
1945
|
+
"privacy": "Política de privacidad",
|
|
1946
|
+
"terms": "Términos del servicio"
|
|
1947
|
+
},
|
|
1948
|
+
"allow": "Continuar a {{app}}",
|
|
1949
|
+
"deny": "Cancelar",
|
|
1950
|
+
"disclaimer": "Al continuar, {{app}} podrá iniciar sesión con tu cuenta de Oxy. Puedes gestionar las apps conectadas cuando quieras en los ajustes de tu cuenta de Oxy."
|
|
1908
1951
|
}
|
|
1909
|
-
}
|
|
1952
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -304,9 +304,9 @@ export type {
|
|
|
304
304
|
SecurityActivity,
|
|
305
305
|
SecurityActivityResponse,
|
|
306
306
|
AssetUploadProgress,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
DeviceLinkedSession,
|
|
308
|
+
DeviceLinkedSessionsResponse,
|
|
309
|
+
DeviceLinkedSessionLogoutResponse,
|
|
310
310
|
UpdateDeviceNameResponse,
|
|
311
311
|
} from './models/interfaces';
|
|
312
312
|
export { SECURITY_EVENT_SEVERITY_MAP } from './models/interfaces';
|
|
@@ -494,20 +494,13 @@ export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
|
|
|
494
494
|
// ---------------------------------------------------------------------------
|
|
495
495
|
// Registrable-domain + central-IdP-apex helpers.
|
|
496
496
|
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
// `packages/api/src/utils/sameSite.ts` for same-site origin checks, and
|
|
502
|
-
// `CENTRAL_IDP_APEX` by `server/cors.ts`'s `createOxyCors` (auto-allows
|
|
503
|
-
// `*.oxy.so`). `SSO_CALLBACK_PATH` has no remaining importer outside this
|
|
504
|
-
// module as of wave 2 — kept exported for now rather than removed here (an
|
|
505
|
-
// export deletion is a logic change, out of scope for a comment sweep); flag
|
|
506
|
-
// for a follow-up dead-export cleanup pass.
|
|
497
|
+
// `registrableApex` (eTLD+1) is consumed via the `@oxyhq/core/server`
|
|
498
|
+
// re-export by `packages/api/src/utils/sameSite.ts` for same-site origin
|
|
499
|
+
// checks; `CENTRAL_IDP_APEX` by `server/cors.ts`'s `createOxyCors` (auto-allows
|
|
500
|
+
// `*.oxy.so`).
|
|
507
501
|
// ---------------------------------------------------------------------------
|
|
508
502
|
export { registrableApex } from './utils/registrableApex';
|
|
509
503
|
export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
|
|
510
|
-
export { SSO_CALLBACK_PATH } from './utils/ssoBounce';
|
|
511
504
|
|
|
512
505
|
export { runColdBoot } from './utils/coldBoot';
|
|
513
506
|
export type {
|
|
@@ -519,6 +512,20 @@ export type {
|
|
|
519
512
|
RunColdBootOptions,
|
|
520
513
|
} from './utils/coldBoot';
|
|
521
514
|
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// OAuth 2.0 Authorization Code + PKCE helpers ("Sign in with Oxy" third party).
|
|
517
|
+
// Standard OAuth against auth.oxy.so/authorize — no FedCM/cookies/SSO bounce.
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
export {
|
|
520
|
+
buildOAuthAuthorizeUrl,
|
|
521
|
+
computeCodeChallenge,
|
|
522
|
+
generateOAuthState,
|
|
523
|
+
generatePkcePair,
|
|
524
|
+
DEFAULT_OAUTH_SCOPE,
|
|
525
|
+
OXY_AUTHORIZE_URL,
|
|
526
|
+
} from './utils/oauthPkce';
|
|
527
|
+
export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
|
|
528
|
+
|
|
522
529
|
// ---------------------------------------------------------------------------
|
|
523
530
|
// Session sync (device-scoped multi-account session client)
|
|
524
531
|
// ---------------------------------------------------------------------------
|
|
@@ -531,7 +538,7 @@ export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
|
|
|
531
538
|
|
|
532
539
|
// Shared SessionClient integration layer: the host adapter, the pure
|
|
533
540
|
// DeviceSessionState projection helpers, and the client factory are defined
|
|
534
|
-
// ONCE here so `@oxyhq/services`
|
|
541
|
+
// ONCE here so every `@oxyhq/services` platform variant reuses them instead of
|
|
535
542
|
// duplicating a local copy. Each consumer supplies its own `TokenTransport`
|
|
536
543
|
// (native vs. web mint strategies differ) to `createSessionClient`.
|
|
537
544
|
export { createSessionClientHost } from './session/sessionClientHost';
|
|
@@ -546,7 +553,7 @@ export {
|
|
|
546
553
|
// Unified account-list projection (THE single source of truth for the account
|
|
547
554
|
// chooser: device sign-ins ∪ account graph, deduped by accountId). Pure +
|
|
548
555
|
// I/O-free — the caller hydrates profiles via `getUsersByIds`. Shared by
|
|
549
|
-
// `@oxyhq/services
|
|
556
|
+
// `@oxyhq/services` and auth.oxy.so so the list can't diverge.
|
|
550
557
|
export {
|
|
551
558
|
projectSwitchableAccounts,
|
|
552
559
|
switchableAccountIds,
|