@oxyhq/core 8.0.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 +66 -281
  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 +24 -92
  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 +14 -88
  11. package/dist/cjs/session/createSessionClient.js +2 -9
  12. package/dist/cjs/session/refresh.js +46 -62
  13. package/dist/cjs/utils/registrableApex.js +2 -6
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/boot/coldBootV2.js +67 -279
  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 +25 -93
  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 +13 -87
  24. package/dist/esm/session/createSessionClient.js +2 -9
  25. package/dist/esm/session/refresh.js +46 -62
  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 +22 -46
  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 +38 -43
  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 +237 -236
  43. package/src/boot/coldBootV2.ts +92 -333
  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 +30 -115
  48. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
  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 +47 -33
  56. package/src/session/__tests__/refresh.test.ts +72 -44
  57. package/src/session/accountDialogController.ts +4 -18
  58. package/src/session/authStateStore.ts +43 -111
  59. package/src/session/createSessionClient.ts +2 -9
  60. package/src/session/refresh.ts +60 -83
  61. package/src/utils/registrableApex.ts +2 -6
  62. package/dist/cjs/boot/deviceBootReturn.js +0 -152
  63. package/dist/esm/boot/deviceBootReturn.js +0 -146
  64. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  65. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
  66. package/src/boot/deviceBootReturn.ts +0 -195
  67. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  68. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
@@ -7,231 +7,97 @@
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';
31
- import { KeyManager } from '../crypto/keyManager.js';
20
+ import { isNative as detectNative } from '../utils/platform.js';
21
+ import { extractErrorStatus } from '../utils/errorUtils.js';
32
22
  import { logger } from '../utils/loggerUtils.js';
33
- import { refreshPersistedSession } from '../session/refresh.js';
34
- import { consumeDeviceBootReturn, hashHasBootFragment, BOOT_STATE_SESSION_KEY, } from './deviceBootReturn.js';
35
- /**
36
- * localStorage flag marking that the cross-apex bootstrap navigation has fired
37
- * once for this origin. Persistent (not session) so the visible redirect
38
- * happens AT MOST ONCE EVER per browser+origin — a signed-out user is never
39
- * bounced again; they sign in explicitly.
40
- */
41
- export const BOOT_ATTEMPTED_KEY = 'oxy.boot.attempted';
42
- /**
43
- * Do not warm-plant a stored access token with less than this remaining — it
44
- * would need an immediate refresh anyway, so fall through to the rotate path.
45
- * Matches the refresh lead window.
46
- */
47
- const WARM_MIN_REMAINING_MS = 60000;
48
- /**
49
- * Generate a 128-bit hex CSRF state token. Prefers Web Crypto
50
- * (`crypto.getRandomValues`, present in browsers and modern Node); falls back
51
- * to a time+`Math.random` mix only when no CSPRNG is reachable (this token
52
- * gates a single-use CSRF echo, not a long-lived secret).
53
- */
54
- function generateStateToken() {
55
- const cryptoObj = globalThis.crypto;
56
- if (cryptoObj?.getRandomValues) {
57
- const bytes = new Uint8Array(16);
58
- cryptoObj.getRandomValues(bytes);
59
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
23
+ function classifyMintFailure(error) {
24
+ if (extractErrorStatus(error) === 401) {
25
+ // Structural read (not `instanceof Error`): the thrown value can be a plain
26
+ // ApiError-shaped object or come from another realm, where instanceof fails
27
+ // and a `no_active_session` would be misread as a stale secret and dropped.
28
+ const message = error?.message;
29
+ return typeof message === 'string' && message.includes('no_active_session')
30
+ ? 'no_active_session'
31
+ : 'invalid_secret';
60
32
  }
61
- return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`;
62
- }
63
- /**
64
- * The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
65
- * missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
66
- * or no-op, so the boot degrades to signed-out rather than crashing.
67
- */
68
- export function createBrowserColdBootDom() {
69
- const safe = (fn, fallback) => {
70
- try {
71
- return fn();
72
- }
73
- catch {
74
- return fallback;
75
- }
76
- };
77
- return {
78
- getHash: () => safe(() => (typeof window !== 'undefined' ? window.location.hash : ''), ''),
79
- stripFragment: () => safe(() => {
80
- if (typeof window !== 'undefined' && window.history?.replaceState) {
81
- const { pathname, search } = window.location;
82
- window.history.replaceState(null, '', `${pathname}${search}`);
83
- }
84
- }, undefined),
85
- getSessionItem: (key) => safe(() => (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null), null),
86
- setSessionItem: (key, value) => safe(() => {
87
- if (typeof sessionStorage !== 'undefined')
88
- sessionStorage.setItem(key, value);
89
- }, undefined),
90
- removeSessionItem: (key) => safe(() => {
91
- if (typeof sessionStorage !== 'undefined')
92
- sessionStorage.removeItem(key);
93
- }, undefined),
94
- getLocalItem: (key) => safe(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null), null),
95
- setLocalItem: (key, value) => safe(() => {
96
- if (typeof localStorage !== 'undefined')
97
- localStorage.setItem(key, value);
98
- }, undefined),
99
- getLocationHostname: () => safe(() => (typeof window !== 'undefined' ? window.location.hostname : null), null),
100
- getReturnToHref: () => safe(() => {
101
- if (typeof window === 'undefined')
102
- return null;
103
- const { origin, pathname, search } = window.location;
104
- return `${origin}${pathname}${search}`;
105
- }, null),
106
- navigate: (url) => safe(() => {
107
- if (typeof window !== 'undefined')
108
- window.location.assign(url);
109
- }, undefined),
110
- randomState: generateStateToken,
111
- };
112
- }
113
- /**
114
- * Registrable domain = the last two labels of a host. A deliberately SMALL
115
- * local helper (the plan forbids coupling the boot to `fapiAutoDetect`/`tldts`).
116
- * Correct for every Oxy apex (all two-label domains: `oxy.so`, `mention.earth`,
117
- * `alia.onl`, `homiio.com`); the only imprecision — a multi-part public suffix
118
- * such as `co.uk` — never arises here because the compared API host is always
119
- * `api.oxy.so`, so a page under a different registrable domain still classifies
120
- * as cross-apex.
121
- */
122
- function registrableDomain(host) {
123
- const labels = host.toLowerCase().split('.').filter(Boolean);
124
- return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
125
- }
126
- /**
127
- * Is `host` an IP literal (v4/v6) or a single-label host (`localhost`)? Such
128
- * hosts have NO registrable domain — the last-two-labels heuristic would
129
- * mis-group them (`192.168.1.1` and `10.0.1.1` both collapse to `1.1`; IPv6
130
- * `::1` and `localhost` are single "labels"), so a LAN/dev page could be wrongly
131
- * classified same-apex as a different LAN API and skip the cross-apex hop.
132
- */
133
- function isIpOrSingleLabel(host) {
134
- if (host.includes(':')) {
135
- return true; // IPv6 literal
136
- }
137
- if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
138
- return true; // IPv4 literal
139
- }
140
- return !host.includes('.'); // single-label (e.g. `localhost`)
141
- }
142
- /**
143
- * True when both hosts are same-site / same-apex. For a normal multi-label host
144
- * pair, that means sharing a registrable domain. For an IP literal or a
145
- * single-label host (no registrable domain), same-apex requires the two hosts
146
- * to be EXACTLY equal — never grouped by a spurious trailing-label match.
147
- */
148
- export function isSameApex(pageHost, apiHost) {
149
- const a = pageHost.toLowerCase();
150
- const b = apiHost.toLowerCase();
151
- if (isIpOrSingleLabel(a) || isIpOrSingleLabel(b)) {
152
- return a === b;
153
- }
154
- const ra = registrableDomain(a);
155
- const rb = registrableDomain(b);
156
- return ra !== '' && ra === rb;
157
- }
158
- /** Build a `DeviceBootSession` from a persisted state (post-refresh/warm-plant). */
159
- function sessionFromPersisted(state, accessToken) {
160
- return { sessionId: state.sessionId, userId: state.userId, accessToken };
33
+ return 'transient';
161
34
  }
162
35
  /**
163
36
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
164
- * a side effect, invokes `onSession` (winning session, token already planted)
165
- * or `onSignedOut` (no session — unless the boot is navigating away for the
166
- * cross-apex hop, in which case neither fires).
37
+ * a side effect, invokes `onSession` (winning session, token already planted) or
38
+ * `onSignedOut` (no session).
167
39
  */
168
40
  export async function runSessionColdBoot(opts) {
169
41
  const { oxy, store } = opts;
170
- const dom = opts.dom ?? createBrowserColdBootDom();
171
- const isWeb = opts.platform?.isWeb ?? detectWeb();
172
42
  const isNative = opts.platform?.isNative ?? detectNative();
173
- // Signed-out reason + navigating flag are boot-local (not module-level), so
174
- // 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.
175
45
  let signedOutReason = 'no_session';
176
- let navigating = false;
177
46
  const steps = [];
178
- // 1. bootstrap-return (web) — consume a #oxy_boot fragment.
47
+ // 1. device-secret-mint (web + native) — the zero-cookie fast path. When the
48
+ // origin persisted a deviceId + deviceSecret, mint a short access token with
49
+ // a single bearer-less POST (no cookie, no navigation).
179
50
  steps.push({
180
- id: 'bootstrap-return',
181
- enabled: () => isWeb && hashHasBootFragment(dom.getHash()),
182
- run: async () => {
183
- const outcome = await consumeDeviceBootReturn({
184
- hash: dom.getHash(),
185
- stripFragment: () => dom.stripFragment(),
186
- readExpectedState: () => dom.getSessionItem(BOOT_STATE_SESSION_KEY),
187
- clearExpectedState: () => dom.removeSessionItem(BOOT_STATE_SESSION_KEY),
188
- store,
189
- exchangeBootCode: (code) => oxy.exchangeBootCode(code),
190
- plantAccessToken: (accessToken) => oxy.setTokens(accessToken),
191
- });
192
- if (outcome.kind === 'session') {
193
- return { kind: 'session', session: outcome.session };
194
- }
195
- if (outcome.kind === 'state-mismatch') {
196
- signedOutReason = 'state-mismatch';
197
- }
198
- else if (outcome.kind === 'no-session') {
199
- // `DeviceBootReason` also carries 'session'; a no-session outcome with
200
- // that reason (a `session` reason but no code) is still signed out.
201
- signedOutReason = outcome.reason === 'new_device' ? 'new_device' : 'no_session';
202
- }
203
- // Fall through: a stored refresh family (a prior same-origin session) may
204
- // still recover below. The once-ever flag blocks a second hop.
205
- return { kind: 'skip' };
206
- },
207
- });
208
- // 2. stored-tokens — warm-plant or rotate the persisted refresh family.
209
- steps.push({
210
- id: 'stored-tokens',
51
+ id: 'device-secret-mint',
211
52
  run: async () => {
212
53
  const persisted = await store.load();
213
- if (!persisted) {
54
+ if (!persisted?.deviceId || !persisted?.deviceSecret) {
214
55
  return { kind: 'skip' };
215
56
  }
216
- // Warm path: a still-valid access token → plant immediately (no network).
217
- if (persisted.accessToken &&
218
- persisted.expiresAt &&
219
- Date.parse(persisted.expiresAt) - Date.now() > WARM_MIN_REMAINING_MS) {
220
- oxy.setTokens(persisted.accessToken);
221
- return { kind: 'session', session: sessionFromPersisted(persisted, persisted.accessToken) };
57
+ try {
58
+ const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
59
+ // Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
60
+ // fields, + the server's authoritative active account) BEFORE planting
61
+ // the minted access token, so a multi-tab race that rotates again can
62
+ // never strand this tab with a superseded secret.
63
+ const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
64
+ const next = {
65
+ ...persisted,
66
+ deviceId: mint.state.deviceId,
67
+ deviceSecret: mint.nextDeviceSecret,
68
+ accessToken: mint.accessToken,
69
+ expiresAt: mint.expiresAt,
70
+ ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
71
+ };
72
+ await store.save(next);
73
+ oxy.setTokens(mint.accessToken);
74
+ return {
75
+ kind: 'session',
76
+ session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
77
+ };
222
78
  }
223
- // Rotate path: refreshPersistedSession plants + persists, and clears the
224
- // store on a family-revoked error.
225
- const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: isNative });
226
- if (!token) {
79
+ catch (error) {
80
+ const failure = classifyMintFailure(error);
81
+ if (failure === 'invalid_secret') {
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.
86
+ await store.save({ ...persisted, deviceSecret: undefined });
87
+ return { kind: 'skip' };
88
+ }
89
+ if (failure === 'no_active_session') {
90
+ // Device known, no live session — authoritative signed-out.
91
+ signedOutReason = 'no_session';
92
+ return { kind: 'skip' };
93
+ }
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);
227
96
  return { kind: 'skip' };
228
97
  }
229
- const after = await store.load();
230
- const base = after ?? persisted;
231
- return { kind: 'session', session: sessionFromPersisted(base, token) };
232
98
  },
233
99
  });
234
- // 3. shared-key-signin (native) — re-mint from the shared identity.
100
+ // 2. shared-key-signin (native) — re-mint from the shared identity.
235
101
  steps.push({
236
102
  id: 'shared-key-signin',
237
103
  enabled: () => isNative,
@@ -240,20 +106,6 @@ export async function runSessionColdBoot(opts) {
240
106
  if (!session?.accessToken) {
241
107
  return { kind: 'skip' };
242
108
  }
243
- // First shared-key sign-in on this device: issue + persist + mirror a
244
- // shared deviceToken so every native Oxy app joins one DeviceSession.
245
- // Best-effort — never fail the sign-in over device-token issuance.
246
- try {
247
- const existing = await KeyManager.getSharedDeviceToken();
248
- if (!existing) {
249
- const deviceToken = await oxy.issueNativeDeviceToken();
250
- await store.saveDeviceToken(deviceToken);
251
- await KeyManager.setSharedDeviceToken(deviceToken);
252
- }
253
- }
254
- catch (error) {
255
- logger.debug('Native deviceToken issuance skipped', { component: 'coldBootV2', method: 'shared-key-signin' }, error);
256
- }
257
109
  return {
258
110
  kind: 'session',
259
111
  session: {
@@ -264,66 +116,6 @@ export async function runSessionColdBoot(opts) {
264
116
  };
265
117
  },
266
118
  });
267
- // 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
268
- steps.push({
269
- id: 'bootstrap-hop',
270
- enabled: () => isWeb,
271
- run: async () => {
272
- const pageHost = dom.getLocationHostname();
273
- let apiHost = null;
274
- try {
275
- apiHost = new URL(oxy.getBaseURL()).hostname;
276
- }
277
- catch {
278
- apiHost = null;
279
- }
280
- if (!pageHost || !apiHost) {
281
- return { kind: 'skip' };
282
- }
283
- // Same-apex: inline credentialed fetch, no redirect, runs every boot.
284
- if (isSameApex(pageHost, apiHost)) {
285
- const result = await oxy.requestWebSession();
286
- // The rotated deviceToken is on BOTH arms (it is device-level, not
287
- // session-level) — persist it before branching on the session.
288
- await store.saveDeviceToken(result.deviceToken);
289
- if (result.reason === 'session') {
290
- const bundle = result.session;
291
- const userId = resolveUserId(bundle.user);
292
- if (!userId) {
293
- return { kind: 'skip' };
294
- }
295
- const next = {
296
- sessionId: bundle.sessionId,
297
- refreshToken: bundle.refreshToken,
298
- userId,
299
- deviceToken: result.deviceToken,
300
- accessToken: bundle.accessToken,
301
- expiresAt: bundle.expiresAt,
302
- };
303
- await store.save(next);
304
- oxy.setTokens(bundle.accessToken);
305
- return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
306
- }
307
- // Known device, signed out.
308
- signedOutReason = result.reason;
309
- return { kind: 'skip' };
310
- }
311
- // Cross-apex: ONE visible top-level navigation, once-ever per origin.
312
- if (dom.getLocalItem(BOOT_ATTEMPTED_KEY)) {
313
- return { kind: 'skip' };
314
- }
315
- const returnTo = opts.returnTo ?? dom.getReturnToHref();
316
- if (!returnTo) {
317
- return { kind: 'skip' };
318
- }
319
- const state = dom.randomState();
320
- dom.setSessionItem(BOOT_STATE_SESSION_KEY, state);
321
- dom.setLocalItem(BOOT_ATTEMPTED_KEY, '1');
322
- navigating = true;
323
- dom.navigate(oxy.buildBootstrapUrl(returnTo, state));
324
- return { kind: 'skip' };
325
- },
326
- });
327
119
  const outcome = await runColdBoot({
328
120
  steps,
329
121
  onStepError: (id, error) => {
@@ -335,10 +127,6 @@ export async function runSessionColdBoot(opts) {
335
127
  await opts.onSession?.({ ...outcome.session, via: outcome.via });
336
128
  return outcome;
337
129
  }
338
- // Navigating away for the cross-apex hop: the page is unloading, so do not
339
- // flash a signed-out state.
340
- if (!navigating) {
341
- await opts.onSignedOut?.(signedOutReason);
342
- }
130
+ await opts.onSignedOut?.(signedOutReason);
343
131
  return outcome;
344
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);