@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,305 +7,147 @@
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, type ColdBootOutcome, type ColdBootStep } from '../utils/coldBoot';
30
- import { isWeb as detectWeb, isNative as detectNative } from '../utils/platform';
31
- import { KeyManager } from '../crypto/keyManager';
20
+ import { isNative as detectNative } from '../utils/platform';
21
+ import { extractErrorStatus } from '../utils/errorUtils';
32
22
  import { logger } from '../utils/loggerUtils';
33
23
  import type { OxyServices } from '../OxyServices';
34
24
  import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
35
- import { refreshPersistedSession } from '../session/refresh';
36
- import {
37
- consumeDeviceBootReturn,
38
- hashHasBootFragment,
39
- BOOT_STATE_SESSION_KEY,
40
- type DeviceBootSession,
41
- } from './deviceBootReturn';
42
25
 
43
- /**
44
- * localStorage flag marking that the cross-apex bootstrap navigation has fired
45
- * once for this origin. Persistent (not session) so the visible redirect
46
- * happens AT MOST ONCE EVER per browser+origin — a signed-out user is never
47
- * bounced again; they sign in explicitly.
48
- */
49
- export const BOOT_ATTEMPTED_KEY = 'oxy.boot.attempted';
50
-
51
- /**
52
- * Do not warm-plant a stored access token with less than this remaining — it
53
- * would need an immediate refresh anyway, so fall through to the rotate path.
54
- * Matches the refresh lead window.
55
- */
56
- const WARM_MIN_REMAINING_MS = 60_000;
26
+ /** The winning session shape a cold-boot step reports. */
27
+ export interface DeviceBootSession {
28
+ sessionId: string;
29
+ userId: string;
30
+ accessToken: string;
31
+ }
57
32
 
58
33
  /** Why a cold boot ended without a session. */
59
- export type SignedOutReason =
60
- | 'no_session'
61
- | 'new_device'
62
- | 'state-mismatch'
63
- | 'error';
64
-
65
- /**
66
- * The DOM/storage seam. All access is injected so the boot is unit-testable
67
- * under the jest `node` environment; {@link createBrowserColdBootDom} provides
68
- * the guarded real-globals implementation used in production.
69
- */
70
- export interface ColdBootDom {
71
- getHash(): string;
72
- stripFragment(): void;
73
- getSessionItem(key: string): string | null;
74
- setSessionItem(key: string, value: string): void;
75
- removeSessionItem(key: string): void;
76
- getLocalItem(key: string): string | null;
77
- setLocalItem(key: string, value: string): void;
78
- getLocationHostname(): string | null;
79
- /** Current href WITHOUT its hash fragment (the bootstrap `return_to`). */
80
- getReturnToHref(): string | null;
81
- navigate(url: string): void;
82
- /** A fresh high-entropy CSRF state token. */
83
- randomState(): string;
84
- }
34
+ export type SignedOutReason = 'no_session' | 'error';
85
35
 
86
36
  export interface RunSessionColdBootOptions {
87
37
  oxy: OxyServices;
88
38
  store: AuthStateStore;
89
39
  /** Platform hints; default derived from `@oxyhq/core`'s platform detection. */
90
40
  platform?: { isWeb?: boolean; isNative?: boolean };
91
- /**
92
- * The RP return URL for the cross-apex hop. Defaults to the current href
93
- * (sans fragment) via the DOM seam.
94
- */
95
- returnTo?: string;
96
41
  /** Invoked with the winning session (token already planted). */
97
42
  onSession?: (session: DeviceBootSession & { via: string }) => void | Promise<void>;
98
- /** Invoked when the boot ended signed out (not while navigating away). */
43
+ /** Invoked when the boot ended signed out. */
99
44
  onSignedOut?: (reason: SignedOutReason) => void | Promise<void>;
100
45
  onStepError?: (id: string, error: unknown) => void;
101
- /** DOM/storage seam; defaults to the guarded browser implementation. */
102
- dom?: ColdBootDom;
103
- }
104
-
105
- /**
106
- * Generate a 128-bit hex CSRF state token. Prefers Web Crypto
107
- * (`crypto.getRandomValues`, present in browsers and modern Node); falls back
108
- * to a time+`Math.random` mix only when no CSPRNG is reachable (this token
109
- * gates a single-use CSRF echo, not a long-lived secret).
110
- */
111
- function generateStateToken(): string {
112
- const cryptoObj = (globalThis as { crypto?: Crypto }).crypto;
113
- if (cryptoObj?.getRandomValues) {
114
- const bytes = new Uint8Array(16);
115
- cryptoObj.getRandomValues(bytes);
116
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
117
- }
118
- return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`;
119
- }
120
-
121
- /**
122
- * The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
123
- * missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
124
- * or no-op, so the boot degrades to signed-out rather than crashing.
125
- */
126
- export function createBrowserColdBootDom(): ColdBootDom {
127
- const safe = <T>(fn: () => T, fallback: T): T => {
128
- try {
129
- return fn();
130
- } catch {
131
- return fallback;
132
- }
133
- };
134
- return {
135
- getHash: () => safe(() => (typeof window !== 'undefined' ? window.location.hash : ''), ''),
136
- stripFragment: () =>
137
- safe(() => {
138
- if (typeof window !== 'undefined' && window.history?.replaceState) {
139
- const { pathname, search } = window.location;
140
- window.history.replaceState(null, '', `${pathname}${search}`);
141
- }
142
- }, undefined),
143
- getSessionItem: (key) =>
144
- safe(() => (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null), null),
145
- setSessionItem: (key, value) =>
146
- safe(() => {
147
- if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, value);
148
- }, undefined),
149
- removeSessionItem: (key) =>
150
- safe(() => {
151
- if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(key);
152
- }, undefined),
153
- getLocalItem: (key) =>
154
- safe(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null), null),
155
- setLocalItem: (key, value) =>
156
- safe(() => {
157
- if (typeof localStorage !== 'undefined') localStorage.setItem(key, value);
158
- }, undefined),
159
- getLocationHostname: () =>
160
- safe(() => (typeof window !== 'undefined' ? window.location.hostname : null), null),
161
- getReturnToHref: () =>
162
- safe(() => {
163
- if (typeof window === 'undefined') return null;
164
- const { origin, pathname, search } = window.location;
165
- return `${origin}${pathname}${search}`;
166
- }, null),
167
- navigate: (url) =>
168
- safe(() => {
169
- if (typeof window !== 'undefined') window.location.assign(url);
170
- }, undefined),
171
- randomState: generateStateToken,
172
- };
173
- }
174
-
175
- /**
176
- * Registrable domain = the last two labels of a host. A deliberately SMALL
177
- * local helper (the plan forbids coupling the boot to `fapiAutoDetect`/`tldts`).
178
- * Correct for every Oxy apex (all two-label domains: `oxy.so`, `mention.earth`,
179
- * `alia.onl`, `homiio.com`); the only imprecision — a multi-part public suffix
180
- * such as `co.uk` — never arises here because the compared API host is always
181
- * `api.oxy.so`, so a page under a different registrable domain still classifies
182
- * as cross-apex.
183
- */
184
- function registrableDomain(host: string): string {
185
- const labels = host.toLowerCase().split('.').filter(Boolean);
186
- return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
187
46
  }
188
47
 
189
48
  /**
190
- * Is `host` an IP literal (v4/v6) or a single-label host (`localhost`)? Such
191
- * hosts have NO registrable domain — the last-two-labels heuristic would
192
- * mis-group them (`192.168.1.1` and `10.0.1.1` both collapse to `1.1`; IPv6
193
- * `::1` and `localhost` are single "labels"), so a LAN/dev page could be wrongly
194
- * classified same-apex as a different LAN API and skip the cross-apex hop.
195
- */
196
- function isIpOrSingleLabel(host: string): boolean {
197
- if (host.includes(':')) {
198
- return true; // IPv6 literal
199
- }
200
- if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
201
- return true; // IPv4 literal
202
- }
203
- return !host.includes('.'); // single-label (e.g. `localhost`)
204
- }
205
-
206
- /**
207
- * True when both hosts are same-site / same-apex. For a normal multi-label host
208
- * pair, that means sharing a registrable domain. For an IP literal or a
209
- * single-label host (no registrable domain), same-apex requires the two hosts
210
- * to be EXACTLY equal — never grouped by a spurious trailing-label match.
49
+ * How a `mintFromDeviceSecret` call failed, distinguished so the cold boot can
50
+ * react per the transport contract:
51
+ * - `invalid_secret` the presented secret no longer matches (another tab/
52
+ * device rotated it, or theft divergence). Drop it and fall back.
53
+ * - `no_active_session` the device is known but has no live session.
54
+ * Authoritative signed-out.
55
+ * - `transient` network / 5xx. Keep the secret; a later attempt can succeed.
56
+ *
57
+ * The mint is bearer-less (`skipAuth`), so `HttpService` surfaces the server's
58
+ * 401 body string (`invalid_device_secret` | `no_active_session`) as the thrown
59
+ * error's `message`; any non-401 is transport/server failure.
211
60
  */
212
- export function isSameApex(pageHost: string, apiHost: string): boolean {
213
- const a = pageHost.toLowerCase();
214
- const b = apiHost.toLowerCase();
215
- if (isIpOrSingleLabel(a) || isIpOrSingleLabel(b)) {
216
- return a === b;
61
+ type MintFailure = 'invalid_secret' | 'no_active_session' | 'transient';
62
+
63
+ function classifyMintFailure(error: unknown): MintFailure {
64
+ if (extractErrorStatus(error) === 401) {
65
+ // Structural read (not `instanceof Error`): the thrown value can be a plain
66
+ // ApiError-shaped object or come from another realm, where instanceof fails
67
+ // and a `no_active_session` would be misread as a stale secret and dropped.
68
+ const message = (error as { message?: unknown })?.message;
69
+ return typeof message === 'string' && message.includes('no_active_session')
70
+ ? 'no_active_session'
71
+ : 'invalid_secret';
217
72
  }
218
- const ra = registrableDomain(a);
219
- const rb = registrableDomain(b);
220
- return ra !== '' && ra === rb;
221
- }
222
-
223
- /** Build a `DeviceBootSession` from a persisted state (post-refresh/warm-plant). */
224
- function sessionFromPersisted(state: PersistedAuthState, accessToken: string): DeviceBootSession {
225
- return { sessionId: state.sessionId, userId: state.userId, accessToken };
73
+ return 'transient';
226
74
  }
227
75
 
228
76
  /**
229
77
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
230
- * a side effect, invokes `onSession` (winning session, token already planted)
231
- * or `onSignedOut` (no session — unless the boot is navigating away for the
232
- * cross-apex hop, in which case neither fires).
78
+ * a side effect, invokes `onSession` (winning session, token already planted) or
79
+ * `onSignedOut` (no session).
233
80
  */
234
81
  export async function runSessionColdBoot(
235
82
  opts: RunSessionColdBootOptions,
236
83
  ): Promise<ColdBootOutcome<DeviceBootSession>> {
237
84
  const { oxy, store } = opts;
238
- const dom = opts.dom ?? createBrowserColdBootDom();
239
- const isWeb = opts.platform?.isWeb ?? detectWeb();
240
85
  const isNative = opts.platform?.isNative ?? detectNative();
241
86
 
242
- // Signed-out reason + navigating flag are boot-local (not module-level), so
243
- // they cannot leak across boots or break under bundler re-evaluation.
87
+ // Boot-local (not module-level) so it cannot leak across boots or break under
88
+ // bundler re-evaluation.
244
89
  let signedOutReason: SignedOutReason = 'no_session';
245
- let navigating = false;
246
90
 
247
91
  const steps: Array<ColdBootStep<DeviceBootSession>> = [];
248
92
 
249
- // 1. bootstrap-return (web) — consume a #oxy_boot fragment.
93
+ // 1. device-secret-mint (web + native) — the zero-cookie fast path. When the
94
+ // origin persisted a deviceId + deviceSecret, mint a short access token with
95
+ // a single bearer-less POST (no cookie, no navigation).
250
96
  steps.push({
251
- id: 'bootstrap-return',
252
- enabled: () => isWeb && hashHasBootFragment(dom.getHash()),
253
- run: async () => {
254
- const outcome = await consumeDeviceBootReturn({
255
- hash: dom.getHash(),
256
- stripFragment: () => dom.stripFragment(),
257
- readExpectedState: () => dom.getSessionItem(BOOT_STATE_SESSION_KEY),
258
- clearExpectedState: () => dom.removeSessionItem(BOOT_STATE_SESSION_KEY),
259
- store,
260
- exchangeBootCode: (code) => oxy.exchangeBootCode(code),
261
- plantAccessToken: (accessToken) => oxy.setTokens(accessToken),
262
- });
263
- if (outcome.kind === 'session') {
264
- return { kind: 'session', session: outcome.session };
265
- }
266
- if (outcome.kind === 'state-mismatch') {
267
- signedOutReason = 'state-mismatch';
268
- } else if (outcome.kind === 'no-session') {
269
- // `DeviceBootReason` also carries 'session'; a no-session outcome with
270
- // that reason (a `session` reason but no code) is still signed out.
271
- signedOutReason = outcome.reason === 'new_device' ? 'new_device' : 'no_session';
272
- }
273
- // Fall through: a stored refresh family (a prior same-origin session) may
274
- // still recover below. The once-ever flag blocks a second hop.
275
- return { kind: 'skip' };
276
- },
277
- });
278
-
279
- // 2. stored-tokens — warm-plant or rotate the persisted refresh family.
280
- steps.push({
281
- id: 'stored-tokens',
97
+ id: 'device-secret-mint',
282
98
  run: async () => {
283
99
  const persisted = await store.load();
284
- if (!persisted) {
100
+ if (!persisted?.deviceId || !persisted?.deviceSecret) {
285
101
  return { kind: 'skip' };
286
102
  }
287
- // Warm path: a still-valid access token → plant immediately (no network).
288
- if (
289
- persisted.accessToken &&
290
- persisted.expiresAt &&
291
- Date.parse(persisted.expiresAt) - Date.now() > WARM_MIN_REMAINING_MS
292
- ) {
293
- oxy.setTokens(persisted.accessToken);
294
- return { kind: 'session', session: sessionFromPersisted(persisted, persisted.accessToken) };
295
- }
296
- // Rotate path: refreshPersistedSession plants + persists, and clears the
297
- // store on a family-revoked error.
298
- const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: isNative });
299
- if (!token) {
103
+ try {
104
+ const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
105
+ // Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
106
+ // fields, + the server's authoritative active account) BEFORE planting
107
+ // the minted access token, so a multi-tab race that rotates again can
108
+ // never strand this tab with a superseded secret.
109
+ const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
110
+ const next: PersistedAuthState = {
111
+ ...persisted,
112
+ deviceId: mint.state.deviceId,
113
+ deviceSecret: mint.nextDeviceSecret,
114
+ accessToken: mint.accessToken,
115
+ expiresAt: mint.expiresAt,
116
+ ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
117
+ };
118
+ await store.save(next);
119
+ oxy.setTokens(mint.accessToken);
120
+ return {
121
+ kind: 'session',
122
+ session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
123
+ };
124
+ } catch (error) {
125
+ const failure = classifyMintFailure(error);
126
+ if (failure === 'invalid_secret') {
127
+ // Stale/diverged secret — drop it so the mint lane stops firing. On
128
+ // native the shared-key step below can still recover; on web this ends
129
+ // signed out. Setting it undefined drops the key on the store's JSON
130
+ // serialization, and the mint guard treats undefined as absent.
131
+ await store.save({ ...persisted, deviceSecret: undefined });
132
+ return { kind: 'skip' };
133
+ }
134
+ if (failure === 'no_active_session') {
135
+ // Device known, no live session — authoritative signed-out.
136
+ signedOutReason = 'no_session';
137
+ return { kind: 'skip' };
138
+ }
139
+ // Transient (network / 5xx): keep the secret; a later attempt can succeed.
140
+ logger.debug(
141
+ 'device-secret mint failed (transient) — keeping secret',
142
+ { component: 'coldBootV2', method: 'device-secret-mint' },
143
+ error,
144
+ );
300
145
  return { kind: 'skip' };
301
146
  }
302
- const after = await store.load();
303
- const base = after ?? persisted;
304
- return { kind: 'session', session: sessionFromPersisted(base, token) };
305
147
  },
306
148
  });
307
149
 
308
- // 3. shared-key-signin (native) — re-mint from the shared identity.
150
+ // 2. shared-key-signin (native) — re-mint from the shared identity.
309
151
  steps.push({
310
152
  id: 'shared-key-signin',
311
153
  enabled: () => isNative,
@@ -314,23 +156,6 @@ export async function runSessionColdBoot(
314
156
  if (!session?.accessToken) {
315
157
  return { kind: 'skip' };
316
158
  }
317
- // First shared-key sign-in on this device: issue + persist + mirror a
318
- // shared deviceToken so every native Oxy app joins one DeviceSession.
319
- // Best-effort — never fail the sign-in over device-token issuance.
320
- try {
321
- const existing = await KeyManager.getSharedDeviceToken();
322
- if (!existing) {
323
- const deviceToken = await oxy.issueNativeDeviceToken();
324
- await store.saveDeviceToken(deviceToken);
325
- await KeyManager.setSharedDeviceToken(deviceToken);
326
- }
327
- } catch (error) {
328
- logger.debug(
329
- 'Native deviceToken issuance skipped',
330
- { component: 'coldBootV2', method: 'shared-key-signin' },
331
- error,
332
- );
333
- }
334
159
  return {
335
160
  kind: 'session',
336
161
  session: {
@@ -342,68 +167,6 @@ export async function runSessionColdBoot(
342
167
  },
343
168
  });
344
169
 
345
- // 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
346
- steps.push({
347
- id: 'bootstrap-hop',
348
- enabled: () => isWeb,
349
- run: async () => {
350
- const pageHost = dom.getLocationHostname();
351
- let apiHost: string | null = null;
352
- try {
353
- apiHost = new URL(oxy.getBaseURL()).hostname;
354
- } catch {
355
- apiHost = null;
356
- }
357
- if (!pageHost || !apiHost) {
358
- return { kind: 'skip' };
359
- }
360
-
361
- // Same-apex: inline credentialed fetch, no redirect, runs every boot.
362
- if (isSameApex(pageHost, apiHost)) {
363
- const result = await oxy.requestWebSession();
364
- // The rotated deviceToken is on BOTH arms (it is device-level, not
365
- // session-level) — persist it before branching on the session.
366
- await store.saveDeviceToken(result.deviceToken);
367
- if (result.reason === 'session') {
368
- const bundle = result.session;
369
- const userId = resolveUserId(bundle.user);
370
- if (!userId) {
371
- return { kind: 'skip' };
372
- }
373
- const next: PersistedAuthState = {
374
- sessionId: bundle.sessionId,
375
- refreshToken: bundle.refreshToken,
376
- userId,
377
- deviceToken: result.deviceToken,
378
- accessToken: bundle.accessToken,
379
- expiresAt: bundle.expiresAt,
380
- };
381
- await store.save(next);
382
- oxy.setTokens(bundle.accessToken);
383
- return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
384
- }
385
- // Known device, signed out.
386
- signedOutReason = result.reason;
387
- return { kind: 'skip' };
388
- }
389
-
390
- // Cross-apex: ONE visible top-level navigation, once-ever per origin.
391
- if (dom.getLocalItem(BOOT_ATTEMPTED_KEY)) {
392
- return { kind: 'skip' };
393
- }
394
- const returnTo = opts.returnTo ?? dom.getReturnToHref();
395
- if (!returnTo) {
396
- return { kind: 'skip' };
397
- }
398
- const state = dom.randomState();
399
- dom.setSessionItem(BOOT_STATE_SESSION_KEY, state);
400
- dom.setLocalItem(BOOT_ATTEMPTED_KEY, '1');
401
- navigating = true;
402
- dom.navigate(oxy.buildBootstrapUrl(returnTo, state));
403
- return { kind: 'skip' };
404
- },
405
- });
406
-
407
170
  const outcome = await runColdBoot<DeviceBootSession>({
408
171
  steps,
409
172
  onStepError: (id, error) => {
@@ -417,10 +180,6 @@ export async function runSessionColdBoot(
417
180
  return outcome;
418
181
  }
419
182
 
420
- // Navigating away for the cross-apex hop: the page is unloading, so do not
421
- // flash a signed-out state.
422
- if (!navigating) {
423
- await opts.onSignedOut?.(signedOutReason);
424
- }
183
+ await opts.onSignedOut?.(signedOutReason);
425
184
  return outcome;
426
185
  }
@@ -91,11 +91,6 @@ const STORAGE_KEYS = {
91
91
  SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
92
92
  SHARED_SESSION_TOKEN: 'oxy_shared_session_token',
93
93
  SHARED_SESSION_ID: 'oxy_shared_session_id',
94
- // Opaque device-attribution token, shared across all Oxy apps on one device
95
- // so they all resolve to a SINGLE server-side DeviceSession (device-first
96
- // session model). Add-only: it never carries session state, only attributes
97
- // a newly-credentialed session to the shared device set.
98
- SHARED_DEVICE_TOKEN: 'oxy_shared_device_token',
99
94
  } as const;
100
95
 
101
96
  /**
@@ -648,102 +643,6 @@ export class KeyManager {
648
643
  }
649
644
  }
650
645
 
651
- /**
652
- * Store the opaque device-attribution token in the SHARED keychain so every
653
- * Oxy app on this device reads the SAME token and therefore resolves to one
654
- * server-side DeviceSession.
655
- *
656
- * Mirrors the shared-session storage options exactly (iOS keychain access
657
- * group `group.so.oxy.shared`; Android shared secure store). Native-only —
658
- * a no-op on web (the shared keychain does not exist there; web persists its
659
- * deviceToken in the per-origin {@link AuthStateStore} instead).
660
- */
661
- static async setSharedDeviceToken(deviceToken: string): Promise<void> {
662
- if (isWebPlatform()) {
663
- return; // Not supported on web
664
- }
665
-
666
- try {
667
- const store = await initSecureStore();
668
-
669
- if (isIOS()) {
670
- const opts: OxySecureStoreOptions = {
671
- keychainAccessible: store.WHEN_UNLOCKED,
672
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
673
- };
674
- await store.setItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, deviceToken, opts);
675
- } else if (isAndroid()) {
676
- await store.setItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, deviceToken);
677
- }
678
-
679
- if (isDev()) {
680
- logger.debug('Shared device token stored successfully', { component: 'KeyManager' });
681
- }
682
- } catch (error) {
683
- if (isDev()) {
684
- logger.error('Failed to store shared device token', error, { component: 'KeyManager' });
685
- }
686
- throw error;
687
- }
688
- }
689
-
690
- /**
691
- * Read the shared device-attribution token, or `null` when none is set (or
692
- * on web). Mirrors {@link getSharedSession}'s keychain-access-group read.
693
- */
694
- static async getSharedDeviceToken(): Promise<string | null> {
695
- if (isWebPlatform()) {
696
- return null;
697
- }
698
-
699
- try {
700
- const store = await initSecureStore();
701
-
702
- if (isIOS()) {
703
- const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
704
- return await store.getItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, opts);
705
- }
706
- if (isAndroid()) {
707
- return await store.getItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN);
708
- }
709
- return null;
710
- } catch (error) {
711
- if (isDev()) {
712
- logger.warn('Failed to get shared device token', { component: 'KeyManager' }, error);
713
- }
714
- return null;
715
- }
716
- }
717
-
718
- /**
719
- * Delete the shared device-attribution token (device signout-all). Best-effort:
720
- * a keychain failure is logged, not thrown, so it cannot block a sign-out.
721
- */
722
- static async clearSharedDeviceToken(): Promise<void> {
723
- if (isWebPlatform()) {
724
- return;
725
- }
726
-
727
- try {
728
- const store = await initSecureStore();
729
-
730
- if (isIOS()) {
731
- const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
732
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN, opts);
733
- } else if (isAndroid()) {
734
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_DEVICE_TOKEN);
735
- }
736
-
737
- if (isDev()) {
738
- logger.debug('Shared device token cleared successfully', { component: 'KeyManager' });
739
- }
740
- } catch (error) {
741
- if (isDev()) {
742
- logger.error('Failed to clear shared device token', error, { component: 'KeyManager' });
743
- }
744
- }
745
- }
746
-
747
646
  // ==================== END SHARED IDENTITY METHODS ====================
748
647
 
749
648
  /**
package/src/index.ts CHANGED
@@ -582,18 +582,17 @@ export type {
582
582
  } from './session/accountDialogController';
583
583
 
584
584
  // ---------------------------------------------------------------------------
585
- // Device-first session machinery (auth centralization, wave 1) — additive.
586
- // Persisted auth-state store, the unified refresh handler + scheduler, the
587
- // cold-boot v2 runner, and the device-boot return-fragment consumer. Built ON
588
- // the existing `runColdBoot` primitive + `SessionClient`; the legacy
589
- // FedCM/SSO/CrossDomainAuth surface is untouched (cutover happens in F4).
585
+ // Device-first session machinery (zero-cookie transport).
586
+ // Persisted auth-state store, the unified re-mint handler + scheduler, and the
587
+ // cold-boot runner. Built ON the `runColdBoot` primitive + `SessionClient`. The
588
+ // device credential is `deviceId` + `deviceSecret`; the access token is re-minted
589
+ // via `POST /session/device/token`.
590
590
  // ---------------------------------------------------------------------------
591
591
  export {
592
592
  createWebAuthStateStore,
593
593
  createNativeAuthStateStore,
594
594
  createMemoryAuthStateStore,
595
595
  AUTH_STATE_STORAGE_KEY,
596
- DEVICE_TOKEN_STORAGE_KEY,
597
596
  } from './session/authStateStore';
598
597
  export type {
599
598
  PersistedAuthState,
@@ -610,34 +609,12 @@ export {
610
609
  } from './session/refresh';
611
610
  export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
612
611
 
613
- export {
614
- runSessionColdBoot,
615
- createBrowserColdBootDom,
616
- isSameApex,
617
- BOOT_ATTEMPTED_KEY,
618
- } from './boot/coldBootV2';
612
+ export { runSessionColdBoot } from './boot/coldBootV2';
619
613
  export type {
620
614
  RunSessionColdBootOptions,
621
- ColdBootDom,
622
615
  SignedOutReason,
623
- } from './boot/coldBootV2';
624
-
625
- export {
626
- consumeDeviceBootReturn,
627
- parseDeviceBootFragment,
628
- hashHasBootFragment,
629
- BOOT_FRAGMENT_PARAM,
630
- BOOT_STATE_SESSION_KEY,
631
- } from './boot/deviceBootReturn';
632
- export type {
633
616
  DeviceBootSession,
634
- DeviceBootReturnOutcome,
635
- ConsumeDeviceBootReturnDeps,
636
- } from './boot/deviceBootReturn';
637
-
638
- // The web-session result contract (`WebSessionResult`) is owned by
639
- // `@oxyhq/contracts` — import it directly from there; `@oxyhq/core` does not
640
- // re-export contract types.
617
+ } from './boot/coldBootV2';
641
618
 
642
619
  // API response contracts (request/response Zod schemas + inferred types) live in
643
620
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every