@oxyhq/core 8.1.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/coldBootV2.js +22 -332
  3. package/dist/cjs/crypto/keyManager.js +0 -95
  4. package/dist/cjs/index.js +6 -19
  5. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  6. package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
  7. package/dist/cjs/mixins/index.js +2 -3
  8. package/dist/cjs/session/SessionClient.js +29 -100
  9. package/dist/cjs/session/accountDialogController.js +0 -13
  10. package/dist/cjs/session/authStateStore.js +9 -89
  11. package/dist/cjs/session/createSessionClient.js +2 -9
  12. package/dist/cjs/session/refresh.js +46 -71
  13. package/dist/cjs/utils/registrableApex.js +2 -6
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/boot/coldBootV2.js +23 -330
  16. package/dist/esm/crypto/keyManager.js +0 -95
  17. package/dist/esm/index.js +7 -11
  18. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  19. package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
  20. package/dist/esm/mixins/index.js +2 -3
  21. package/dist/esm/session/SessionClient.js +29 -100
  22. package/dist/esm/session/accountDialogController.js +0 -13
  23. package/dist/esm/session/authStateStore.js +8 -88
  24. package/dist/esm/session/createSessionClient.js +2 -9
  25. package/dist/esm/session/refresh.js +46 -71
  26. package/dist/esm/utils/registrableApex.js +2 -6
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/HttpService.d.ts +3 -3
  29. package/dist/types/boot/coldBootV2.d.ts +28 -53
  30. package/dist/types/crypto/keyManager.d.ts +0 -21
  31. package/dist/types/index.d.ts +3 -5
  32. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  33. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
  34. package/dist/types/session/SessionClient.d.ts +4 -38
  35. package/dist/types/session/accountDialogController.d.ts +1 -3
  36. package/dist/types/session/authStateStore.d.ts +32 -57
  37. package/dist/types/session/createSessionClient.d.ts +2 -9
  38. package/dist/types/session/refresh.d.ts +32 -28
  39. package/dist/types/utils/registrableApex.d.ts +2 -6
  40. package/package.json +2 -2
  41. package/src/HttpService.ts +3 -3
  42. package/src/boot/__tests__/coldBootV2.test.ts +140 -353
  43. package/src/boot/coldBootV2.ts +35 -391
  44. package/src/crypto/keyManager.ts +0 -101
  45. package/src/index.ts +7 -30
  46. package/src/mixins/OxyServices.auth.ts +5 -9
  47. package/src/mixins/OxyServices.deviceBoot.ts +19 -142
  48. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
  49. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  50. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  51. package/src/mixins/index.ts +2 -3
  52. package/src/session/SessionClient.ts +29 -120
  53. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  54. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  55. package/src/session/__tests__/authStateStore.test.ts +29 -42
  56. package/src/session/__tests__/refresh.test.ts +66 -52
  57. package/src/session/accountDialogController.ts +4 -18
  58. package/src/session/authStateStore.ts +32 -126
  59. package/src/session/createSessionClient.ts +2 -9
  60. package/src/session/refresh.ts +60 -92
  61. package/src/utils/registrableApex.ts +2 -6
  62. package/dist/cjs/boot/deviceBootReturn.js +0 -167
  63. package/dist/esm/boot/deviceBootReturn.js +0 -161
  64. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  65. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
  66. package/src/boot/deviceBootReturn.ts +0 -210
  67. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  68. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
@@ -7,159 +7,19 @@
7
7
  * the app renders with a "Sign in with Oxy" button.
8
8
  *
9
9
  * Ordered steps (first to yield a session wins):
10
- * 1. `bootstrap-return` (web) — consume a `#oxy_boot` return fragment from a
11
- * just-completed cross-apex hop: strip it, verify state, persist the
12
- * deviceToken, exchange the code.
13
- * 2. `stored-tokens` the persisted per-origin refresh family: warm-plant a
14
- * still-valid access token, else rotate via `/auth/refresh-token`.
15
- * 3. `shared-key-signin` (native) — re-mint from the shared-keychain identity,
16
- * issuing + mirroring a shared deviceToken the first time.
17
- * 4. `bootstrap-hop` (web) — same-apex: an inline credentialed
18
- * `/auth/device/web-session` fetch (no redirect); cross-apex: ONE top-level
19
- * navigation to `/auth/device/bootstrap`, guarded once-ever per origin.
20
- * 5. Signed out.
21
- *
22
- * All mutable guard state lives in STORAGE (localStorage `oxy.boot.attempted` +
23
- * sessionStorage `oxy.boot.state`), never in module scope, so the guard holds
24
- * under Metro/bundler re-evaluation.
10
+ * 1. `device-secret-mint` (web + native) — the zero-cookie transport: when the
11
+ * origin persisted a `deviceId` + `deviceSecret`, mint a short access token
12
+ * with a single bearer-less POST to `/session/device/token` (no cookie, no
13
+ * navigation) and rotate the secret in-use.
14
+ * 2. `shared-key-signin` (native) re-mint from the shared-keychain identity.
15
+ * 3. Signed out.
25
16
  *
26
17
  * ESM-safe (no `require()`); no react/react-native/expo imports.
27
18
  */
28
- import { resolveUserId } from '@oxyhq/contracts';
29
19
  import { runColdBoot } from '../utils/coldBoot.js';
30
- import { isWeb as detectWeb, isNative as detectNative } from '../utils/platform.js';
20
+ import { isNative as detectNative } from '../utils/platform.js';
31
21
  import { extractErrorStatus } from '../utils/errorUtils.js';
32
- import { KeyManager } from '../crypto/keyManager.js';
33
22
  import { logger } from '../utils/loggerUtils.js';
34
- import { refreshPersistedSession } from '../session/refresh.js';
35
- import { consumeDeviceBootReturn, hashHasBootFragment, BOOT_STATE_SESSION_KEY, } from './deviceBootReturn.js';
36
- /**
37
- * localStorage flag marking that the cross-apex bootstrap navigation has fired
38
- * once for this origin. Persistent (not session) so the visible redirect
39
- * happens AT MOST ONCE EVER per browser+origin — a signed-out user is never
40
- * bounced again; they sign in explicitly.
41
- */
42
- export const BOOT_ATTEMPTED_KEY = 'oxy.boot.attempted';
43
- /**
44
- * Do not warm-plant a stored access token with less than this remaining — it
45
- * would need an immediate refresh anyway, so fall through to the rotate path.
46
- * Matches the refresh lead window.
47
- */
48
- const WARM_MIN_REMAINING_MS = 60000;
49
- /**
50
- * Generate a 128-bit hex CSRF state token. Prefers Web Crypto
51
- * (`crypto.getRandomValues`, present in browsers and modern Node); falls back
52
- * to a time+`Math.random` mix only when no CSPRNG is reachable (this token
53
- * gates a single-use CSRF echo, not a long-lived secret).
54
- */
55
- function generateStateToken() {
56
- const cryptoObj = globalThis.crypto;
57
- if (cryptoObj?.getRandomValues) {
58
- const bytes = new Uint8Array(16);
59
- cryptoObj.getRandomValues(bytes);
60
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
61
- }
62
- return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`;
63
- }
64
- /**
65
- * The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
66
- * missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
67
- * or no-op, so the boot degrades to signed-out rather than crashing.
68
- */
69
- export function createBrowserColdBootDom() {
70
- const safe = (fn, fallback) => {
71
- try {
72
- return fn();
73
- }
74
- catch {
75
- return fallback;
76
- }
77
- };
78
- return {
79
- getHash: () => safe(() => (typeof window !== 'undefined' ? window.location.hash : ''), ''),
80
- stripFragment: () => safe(() => {
81
- if (typeof window !== 'undefined' && window.history?.replaceState) {
82
- const { pathname, search } = window.location;
83
- window.history.replaceState(null, '', `${pathname}${search}`);
84
- }
85
- }, undefined),
86
- getSessionItem: (key) => safe(() => (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null), null),
87
- setSessionItem: (key, value) => safe(() => {
88
- if (typeof sessionStorage !== 'undefined')
89
- sessionStorage.setItem(key, value);
90
- }, undefined),
91
- removeSessionItem: (key) => safe(() => {
92
- if (typeof sessionStorage !== 'undefined')
93
- sessionStorage.removeItem(key);
94
- }, undefined),
95
- getLocalItem: (key) => safe(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null), null),
96
- setLocalItem: (key, value) => safe(() => {
97
- if (typeof localStorage !== 'undefined')
98
- localStorage.setItem(key, value);
99
- }, undefined),
100
- getLocationHostname: () => safe(() => (typeof window !== 'undefined' ? window.location.hostname : null), null),
101
- getReturnToHref: () => safe(() => {
102
- if (typeof window === 'undefined')
103
- return null;
104
- const { origin, pathname, search } = window.location;
105
- return `${origin}${pathname}${search}`;
106
- }, null),
107
- navigate: (url) => safe(() => {
108
- if (typeof window !== 'undefined')
109
- window.location.assign(url);
110
- }, undefined),
111
- randomState: generateStateToken,
112
- };
113
- }
114
- /**
115
- * Registrable domain = the last two labels of a host. A deliberately SMALL
116
- * local helper (the plan forbids coupling the boot to `fapiAutoDetect`/`tldts`).
117
- * Correct for every Oxy apex (all two-label domains: `oxy.so`, `mention.earth`,
118
- * `alia.onl`, `homiio.com`); the only imprecision — a multi-part public suffix
119
- * such as `co.uk` — never arises here because the compared API host is always
120
- * `api.oxy.so`, so a page under a different registrable domain still classifies
121
- * as cross-apex.
122
- */
123
- function registrableDomain(host) {
124
- const labels = host.toLowerCase().split('.').filter(Boolean);
125
- return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
126
- }
127
- /**
128
- * Is `host` an IP literal (v4/v6) or a single-label host (`localhost`)? Such
129
- * hosts have NO registrable domain — the last-two-labels heuristic would
130
- * mis-group them (`192.168.1.1` and `10.0.1.1` both collapse to `1.1`; IPv6
131
- * `::1` and `localhost` are single "labels"), so a LAN/dev page could be wrongly
132
- * classified same-apex as a different LAN API and skip the cross-apex hop.
133
- */
134
- function isIpOrSingleLabel(host) {
135
- if (host.includes(':')) {
136
- return true; // IPv6 literal
137
- }
138
- if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
139
- return true; // IPv4 literal
140
- }
141
- return !host.includes('.'); // single-label (e.g. `localhost`)
142
- }
143
- /**
144
- * True when both hosts are same-site / same-apex. For a normal multi-label host
145
- * pair, that means sharing a registrable domain. For an IP literal or a
146
- * single-label host (no registrable domain), same-apex requires the two hosts
147
- * to be EXACTLY equal — never grouped by a spurious trailing-label match.
148
- */
149
- export function isSameApex(pageHost, apiHost) {
150
- const a = pageHost.toLowerCase();
151
- const b = apiHost.toLowerCase();
152
- if (isIpOrSingleLabel(a) || isIpOrSingleLabel(b)) {
153
- return a === b;
154
- }
155
- const ra = registrableDomain(a);
156
- const rb = registrableDomain(b);
157
- return ra !== '' && ra === rb;
158
- }
159
- /** Build a `DeviceBootSession` from a persisted state (post-refresh/warm-plant). */
160
- function sessionFromPersisted(state, accessToken) {
161
- return { sessionId: state.sessionId, userId: state.userId, accessToken };
162
- }
163
23
  function classifyMintFailure(error) {
164
24
  if (extractErrorStatus(error) === 401) {
165
25
  // Structural read (not `instanceof Error`): the thrown value can be a plain
@@ -174,36 +34,21 @@ function classifyMintFailure(error) {
174
34
  }
175
35
  /**
176
36
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
177
- * a side effect, invokes `onSession` (winning session, token already planted)
178
- * or `onSignedOut` (no session — unless the boot is navigating away for the
179
- * cross-apex hop, in which case neither fires).
37
+ * a side effect, invokes `onSession` (winning session, token already planted) or
38
+ * `onSignedOut` (no session).
180
39
  */
181
40
  export async function runSessionColdBoot(opts) {
182
41
  const { oxy, store } = opts;
183
- const dom = opts.dom ?? createBrowserColdBootDom();
184
- const isWeb = opts.platform?.isWeb ?? detectWeb();
185
42
  const isNative = opts.platform?.isNative ?? detectNative();
186
- // Signed-out reason + navigating flag are boot-local (not module-level), so
187
- // they cannot leak across boots or break under bundler re-evaluation.
43
+ // Boot-local (not module-level) so it cannot leak across boots or break under
44
+ // bundler re-evaluation.
188
45
  let signedOutReason = 'no_session';
189
- let navigating = false;
190
- // Set when the zero-cookie mint reports `no_active_session` (phase 2c): the
191
- // device is authoritatively signed out, so the migratory fallback lanes below
192
- // (stored-tokens / shared-key / bootstrap-hop) must NOT run — we already know
193
- // there is no session and must not bounce a known-signed-out device.
194
- let deviceKnownSignedOut = false;
195
46
  const steps = [];
196
- // 0. device-secret-mint (phase 2c) — the zero-cookie fast path. When the
47
+ // 1. device-secret-mint (web + native) — the zero-cookie fast path. When the
197
48
  // origin persisted a deviceId + deviceSecret, mint a short access token with
198
- // a single bearer-less POST (no cookie, no navigation). FIRST in the chain
199
- // so it wins over the migratory cookie lanes below. Gated OFF while a
200
- // #oxy_boot return fragment is present so `bootstrap-return` still consumes
201
- // + strips it first (a device holding a secret never triggers that hop, so
202
- // this only defers a rare stale/forged fragment). The rest of the chain
203
- // stays as the additive migratory fallback for devices not yet on the secret.
49
+ // a single bearer-less POST (no cookie, no navigation).
204
50
  steps.push({
205
51
  id: 'device-secret-mint',
206
- enabled: () => !(isWeb && hashHasBootFragment(dom.getHash())),
207
52
  run: async () => {
208
53
  const persisted = await store.load();
209
54
  if (!persisted?.deviceId || !persisted?.deviceSecret) {
@@ -234,106 +79,33 @@ export async function runSessionColdBoot(opts) {
234
79
  catch (error) {
235
80
  const failure = classifyMintFailure(error);
236
81
  if (failure === 'invalid_secret') {
237
- // Stale/diverged secret — drop it so the mint lane stops firing, then
238
- // fall through to the migratory refresh/cookie lanes. Setting it
239
- // undefined drops the key on the store's JSON serialization, and the
240
- // mint guard treats undefined as absent.
82
+ // Stale/diverged secret — drop it so the mint lane stops firing. On
83
+ // native the shared-key step below can still recover; on web this ends
84
+ // signed out. Setting it undefined drops the key on the store's JSON
85
+ // serialization, and the mint guard treats undefined as absent.
241
86
  await store.save({ ...persisted, deviceSecret: undefined });
242
87
  return { kind: 'skip' };
243
88
  }
244
89
  if (failure === 'no_active_session') {
245
- // Device known, no live session — authoritative signed-out. KEEP the
246
- // secret and stop the chain (do not bounce a known-signed-out device).
247
- deviceKnownSignedOut = true;
90
+ // Device known, no live session — authoritative signed-out.
248
91
  signedOutReason = 'no_session';
249
92
  return { kind: 'skip' };
250
93
  }
251
- // Transient (network / 5xx): keep the secret, let the fallback lanes try.
252
- logger.debug('device-secret mint failed (transient) — keeping secret, falling back', { component: 'coldBootV2', method: 'device-secret-mint' }, error);
94
+ // Transient (network / 5xx): keep the secret; a later attempt can succeed.
95
+ logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'coldBootV2', method: 'device-secret-mint' }, error);
253
96
  return { kind: 'skip' };
254
97
  }
255
98
  },
256
99
  });
257
- // 1. bootstrap-return (web) — consume a #oxy_boot fragment.
258
- steps.push({
259
- id: 'bootstrap-return',
260
- enabled: () => isWeb && hashHasBootFragment(dom.getHash()),
261
- run: async () => {
262
- const outcome = await consumeDeviceBootReturn({
263
- hash: dom.getHash(),
264
- stripFragment: () => dom.stripFragment(),
265
- readExpectedState: () => dom.getSessionItem(BOOT_STATE_SESSION_KEY),
266
- clearExpectedState: () => dom.removeSessionItem(BOOT_STATE_SESSION_KEY),
267
- store,
268
- exchangeBootCode: (code) => oxy.exchangeBootCode(code),
269
- plantAccessToken: (accessToken) => oxy.setTokens(accessToken),
270
- });
271
- if (outcome.kind === 'session') {
272
- return { kind: 'session', session: outcome.session };
273
- }
274
- if (outcome.kind === 'state-mismatch') {
275
- signedOutReason = 'state-mismatch';
276
- }
277
- else if (outcome.kind === 'no-session') {
278
- // `DeviceBootReason` also carries 'session'; a no-session outcome with
279
- // that reason (a `session` reason but no code) is still signed out.
280
- signedOutReason = outcome.reason === 'new_device' ? 'new_device' : 'no_session';
281
- }
282
- // Fall through: a stored refresh family (a prior same-origin session) may
283
- // still recover below. The once-ever flag blocks a second hop.
284
- return { kind: 'skip' };
285
- },
286
- });
287
- // 2. stored-tokens — warm-plant or rotate the persisted refresh family.
288
- steps.push({
289
- id: 'stored-tokens',
290
- enabled: () => !deviceKnownSignedOut,
291
- run: async () => {
292
- const persisted = await store.load();
293
- if (!persisted) {
294
- return { kind: 'skip' };
295
- }
296
- // Warm path: a still-valid access token → plant immediately (no network).
297
- if (persisted.accessToken &&
298
- persisted.expiresAt &&
299
- Date.parse(persisted.expiresAt) - Date.now() > WARM_MIN_REMAINING_MS) {
300
- oxy.setTokens(persisted.accessToken);
301
- return { kind: 'session', session: sessionFromPersisted(persisted, persisted.accessToken) };
302
- }
303
- // Rotate path: refreshPersistedSession plants + persists, and clears the
304
- // store on a family-revoked error.
305
- const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: isNative });
306
- if (!token) {
307
- return { kind: 'skip' };
308
- }
309
- const after = await store.load();
310
- const base = after ?? persisted;
311
- return { kind: 'session', session: sessionFromPersisted(base, token) };
312
- },
313
- });
314
- // 3. shared-key-signin (native) — re-mint from the shared identity.
100
+ // 2. shared-key-signin (native) — re-mint from the shared identity.
315
101
  steps.push({
316
102
  id: 'shared-key-signin',
317
- enabled: () => isNative && !deviceKnownSignedOut,
103
+ enabled: () => isNative,
318
104
  run: async () => {
319
105
  const session = await oxy.signInWithSharedIdentity();
320
106
  if (!session?.accessToken) {
321
107
  return { kind: 'skip' };
322
108
  }
323
- // First shared-key sign-in on this device: issue + persist + mirror a
324
- // shared deviceToken so every native Oxy app joins one DeviceSession.
325
- // Best-effort — never fail the sign-in over device-token issuance.
326
- try {
327
- const existing = await KeyManager.getSharedDeviceToken();
328
- if (!existing) {
329
- const deviceToken = await oxy.issueNativeDeviceToken();
330
- await store.saveDeviceToken(deviceToken);
331
- await KeyManager.setSharedDeviceToken(deviceToken);
332
- }
333
- }
334
- catch (error) {
335
- logger.debug('Native deviceToken issuance skipped', { component: 'coldBootV2', method: 'shared-key-signin' }, error);
336
- }
337
109
  return {
338
110
  kind: 'session',
339
111
  session: {
@@ -344,81 +116,6 @@ export async function runSessionColdBoot(opts) {
344
116
  };
345
117
  },
346
118
  });
347
- // 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
348
- steps.push({
349
- id: 'bootstrap-hop',
350
- enabled: () => isWeb && !deviceKnownSignedOut,
351
- run: async () => {
352
- const pageHost = dom.getLocationHostname();
353
- let apiHost = null;
354
- try {
355
- apiHost = new URL(oxy.getBaseURL()).hostname;
356
- }
357
- catch {
358
- apiHost = null;
359
- }
360
- if (!pageHost || !apiHost) {
361
- return { kind: 'skip' };
362
- }
363
- // Same-apex: inline credentialed fetch, no redirect, runs every boot.
364
- if (isSameApex(pageHost, apiHost)) {
365
- const result = await oxy.requestWebSession();
366
- // The rotated deviceToken is on BOTH arms (it is device-level, not
367
- // session-level) — persist it before branching on the session.
368
- await store.saveDeviceToken(result.deviceToken);
369
- if (result.reason === 'session') {
370
- const bundle = result.session;
371
- const userId = resolveUserId(bundle.user);
372
- if (!userId) {
373
- return { kind: 'skip' };
374
- }
375
- const next = {
376
- sessionId: bundle.sessionId,
377
- refreshToken: bundle.refreshToken,
378
- userId,
379
- deviceToken: result.deviceToken,
380
- accessToken: bundle.accessToken,
381
- expiresAt: bundle.expiresAt,
382
- };
383
- // Phase 2c: the web-session bundle may carry a rotating `deviceSecret`
384
- // but NOT a deviceId. Persist the secret and carry any prior deviceId
385
- // (from a deviceId-bearing login lane) forward so the mint lane stays
386
- // usable — this overwrite must not orphan it.
387
- const prior = await store.load();
388
- if (prior?.deviceId) {
389
- next.deviceId = prior.deviceId;
390
- }
391
- // Prefer the bundle's secret (the server just rotated onto it); keep the
392
- // prior one when the bundle omits it — this lane also runs as the
393
- // TRANSIENT-mint fallback, and must not orphan a still-valid secret.
394
- const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
395
- if (carriedSecret) {
396
- next.deviceSecret = carriedSecret;
397
- }
398
- await store.save(next);
399
- oxy.setTokens(bundle.accessToken);
400
- return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
401
- }
402
- // Known device, signed out.
403
- signedOutReason = result.reason;
404
- return { kind: 'skip' };
405
- }
406
- // Cross-apex: ONE visible top-level navigation, once-ever per origin.
407
- if (dom.getLocalItem(BOOT_ATTEMPTED_KEY)) {
408
- return { kind: 'skip' };
409
- }
410
- const returnTo = opts.returnTo ?? dom.getReturnToHref();
411
- if (!returnTo) {
412
- return { kind: 'skip' };
413
- }
414
- const state = dom.randomState();
415
- dom.setSessionItem(BOOT_STATE_SESSION_KEY, state);
416
- dom.setLocalItem(BOOT_ATTEMPTED_KEY, '1');
417
- navigating = true;
418
- dom.navigate(oxy.buildBootstrapUrl(returnTo, state));
419
- return { kind: 'skip' };
420
- },
421
- });
422
119
  const outcome = await runColdBoot({
423
120
  steps,
424
121
  onStepError: (id, error) => {
@@ -430,10 +127,6 @@ export async function runSessionColdBoot(opts) {
430
127
  await opts.onSession?.({ ...outcome.session, via: outcome.via });
431
128
  return outcome;
432
129
  }
433
- // Navigating away for the cross-apex hop: the page is unloading, so do not
434
- // flash a signed-out state.
435
- if (!navigating) {
436
- await opts.onSignedOut?.(signedOutReason);
437
- }
130
+ await opts.onSignedOut?.(signedOutReason);
438
131
  return outcome;
439
132
  }
@@ -53,11 +53,6 @@ const STORAGE_KEYS = {
53
53
  SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
54
54
  SHARED_SESSION_TOKEN: 'oxy_shared_session_token',
55
55
  SHARED_SESSION_ID: 'oxy_shared_session_id',
56
- // Opaque device-attribution token, shared across all Oxy apps on one device
57
- // so they all resolve to a SINGLE server-side DeviceSession (device-first
58
- // session model). Add-only: it never carries session state, only attributes
59
- // a newly-credentialed session to the shared device set.
60
- SHARED_DEVICE_TOKEN: 'oxy_shared_device_token',
61
56
  };
62
57
  /**
63
58
  * iOS Keychain Access Group for sharing identities across Oxy apps
@@ -545,96 +540,6 @@ export class KeyManager {
545
540
  return false;
546
541
  }
547
542
  }
548
- /**
549
- * Store the opaque device-attribution token in the SHARED keychain so every
550
- * Oxy app on this device reads the SAME token and therefore resolves to one
551
- * server-side DeviceSession.
552
- *
553
- * Mirrors the shared-session storage options exactly (iOS keychain access
554
- * group `group.so.oxy.shared`; Android shared secure store). Native-only —
555
- * a no-op on web (the shared keychain does not exist there; web persists its
556
- * deviceToken in the per-origin {@link AuthStateStore} instead).
557
- */
558
- static async setSharedDeviceToken(deviceToken) {
559
- if (isWebPlatform()) {
560
- return; // Not supported on web
561
- }
562
- try {
563
- const store = await initSecureStore();
564
- if (isIOS()) {
565
- const opts = {
566
- keychainAccessible: store.WHEN_UNLOCKED,
567
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
568
- };
569
- await store.setItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, deviceToken, opts);
570
- }
571
- else if (isAndroid()) {
572
- await store.setItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, deviceToken);
573
- }
574
- if (isDev()) {
575
- logger.debug('Shared device token stored successfully', { component: 'KeyManager' });
576
- }
577
- }
578
- catch (error) {
579
- if (isDev()) {
580
- logger.error('Failed to store shared device token', error, { component: 'KeyManager' });
581
- }
582
- throw error;
583
- }
584
- }
585
- /**
586
- * Read the shared device-attribution token, or `null` when none is set (or
587
- * on web). Mirrors {@link getSharedSession}'s keychain-access-group read.
588
- */
589
- static async getSharedDeviceToken() {
590
- if (isWebPlatform()) {
591
- return null;
592
- }
593
- try {
594
- const store = await initSecureStore();
595
- if (isIOS()) {
596
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
597
- return await store.getItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, opts);
598
- }
599
- if (isAndroid()) {
600
- return await store.getItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN);
601
- }
602
- return null;
603
- }
604
- catch (error) {
605
- if (isDev()) {
606
- logger.warn('Failed to get shared device token', { component: 'KeyManager' }, error);
607
- }
608
- return null;
609
- }
610
- }
611
- /**
612
- * Delete the shared device-attribution token (device signout-all). Best-effort:
613
- * a keychain failure is logged, not thrown, so it cannot block a sign-out.
614
- */
615
- static async clearSharedDeviceToken() {
616
- if (isWebPlatform()) {
617
- return;
618
- }
619
- try {
620
- const store = await initSecureStore();
621
- if (isIOS()) {
622
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
623
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, opts);
624
- }
625
- else if (isAndroid()) {
626
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN);
627
- }
628
- if (isDev()) {
629
- logger.debug('Shared device token cleared successfully', { component: 'KeyManager' });
630
- }
631
- }
632
- catch (error) {
633
- if (isDev()) {
634
- logger.error('Failed to clear shared device token', error, { component: 'KeyManager' });
635
- }
636
- }
637
- }
638
543
  // ==================== END SHARED IDENTITY METHODS ====================
639
544
  /**
640
545
  * Atomically persist a key pair to secure storage with verification + backup.
package/dist/esm/index.js CHANGED
@@ -156,19 +156,15 @@ export { projectSwitchableAccounts, switchableAccountIds, } from './session/acco
156
156
  // for the uniform switch and the existing device-flow methods for sign-in.
157
157
  export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController.js';
158
158
  // ---------------------------------------------------------------------------
159
- // Device-first session machinery (auth centralization, wave 1) — additive.
160
- // Persisted auth-state store, the unified refresh handler + scheduler, the
161
- // cold-boot v2 runner, and the device-boot return-fragment consumer. Built ON
162
- // the existing `runColdBoot` primitive + `SessionClient`; the legacy
163
- // FedCM/SSO/CrossDomainAuth surface is untouched (cutover happens in F4).
159
+ // Device-first session machinery (zero-cookie transport).
160
+ // Persisted auth-state store, the unified re-mint handler + scheduler, and the
161
+ // cold-boot runner. Built ON the `runColdBoot` primitive + `SessionClient`. The
162
+ // device credential is `deviceId` + `deviceSecret`; the access token is re-minted
163
+ // via `POST /session/device/token`.
164
164
  // ---------------------------------------------------------------------------
165
- export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, DEVICE_TOKEN_STORAGE_KEY, } from './session/authStateStore.js';
165
+ export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore.js';
166
166
  export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh.js';
167
- export { runSessionColdBoot, createBrowserColdBootDom, isSameApex, BOOT_ATTEMPTED_KEY, } from './boot/coldBootV2.js';
168
- export { consumeDeviceBootReturn, parseDeviceBootFragment, hashHasBootFragment, BOOT_FRAGMENT_PARAM, BOOT_STATE_SESSION_KEY, } from './boot/deviceBootReturn.js';
169
- // The web-session result contract (`WebSessionResult`) is owned by
170
- // `@oxyhq/contracts` — import it directly from there; `@oxyhq/core` does not
171
- // re-export contract types.
167
+ export { runSessionColdBoot } from './boot/coldBootV2.js';
172
168
  // API response contracts (request/response Zod schemas + inferred types) live in
173
169
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every
174
170
  // client SDK. Import them directly from `@oxyhq/contracts`; `@oxyhq/core` does NOT
@@ -791,11 +791,10 @@ export function OxyServicesAuthMixin(Base) {
791
791
  * (`{ twoFactorRequired, loginToken }`) to complete via
792
792
  * {@link completeTwoFactorSignIn}, or a session arm.
793
793
  *
794
- * `options.deviceToken` attributes the new session to the caller's existing
795
- * device set (so an in-app login from a cross-apex, cookie-less web app
796
- * still joins the same DeviceSession). On the session arm, a returned access
797
- * token is planted immediately (mirroring {@link verifyChallenge}), so the
798
- * caller has an authenticated client without a second round-trip.
794
+ * On the session arm, a returned access token is planted immediately
795
+ * (mirroring {@link verifyChallenge}), so the caller has an authenticated
796
+ * client without a second round-trip. The response's `deviceId` +
797
+ * `deviceSecret` are the zero-cookie restore credential the caller persists.
799
798
  */
800
799
  async passwordSignIn(identifier, password, options = {}) {
801
800
  try {
@@ -804,7 +803,6 @@ export function OxyServicesAuthMixin(Base) {
804
803
  password,
805
804
  deviceName: options.deviceName,
806
805
  deviceFingerprint: options.deviceFingerprint,
807
- deviceToken: options.deviceToken,
808
806
  }, { cache: false });
809
807
  const parsed = safeParseContract(loginResultSchema, res);
810
808
  if (!parsed) {
@@ -832,7 +830,6 @@ export function OxyServicesAuthMixin(Base) {
832
830
  loginToken: params.loginToken,
833
831
  token: params.token,
834
832
  backupCode: params.backupCode,
835
- deviceToken: params.deviceToken,
836
833
  deviceName: params.deviceName,
837
834
  }, { cache: false });
838
835
  const parsed = safeParseContract(loginResultSchema, res);