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