@oxyhq/core 8.1.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -3
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/coldBootV2.js +22 -332
  4. package/dist/cjs/crypto/keyManager.js +0 -95
  5. package/dist/cjs/index.js +6 -19
  6. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
  8. package/dist/cjs/mixins/index.js +2 -3
  9. package/dist/cjs/session/SessionClient.js +29 -100
  10. package/dist/cjs/session/accountDialogController.js +0 -13
  11. package/dist/cjs/session/authStateStore.js +9 -89
  12. package/dist/cjs/session/createSessionClient.js +2 -9
  13. package/dist/cjs/session/refresh.js +46 -71
  14. package/dist/cjs/utils/registrableApex.js +2 -6
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/boot/coldBootV2.js +23 -330
  17. package/dist/esm/crypto/keyManager.js +0 -95
  18. package/dist/esm/index.js +7 -11
  19. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  20. package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
  21. package/dist/esm/mixins/index.js +2 -3
  22. package/dist/esm/session/SessionClient.js +29 -100
  23. package/dist/esm/session/accountDialogController.js +0 -13
  24. package/dist/esm/session/authStateStore.js +8 -88
  25. package/dist/esm/session/createSessionClient.js +2 -9
  26. package/dist/esm/session/refresh.js +46 -71
  27. package/dist/esm/utils/registrableApex.js +2 -6
  28. package/dist/types/.tsbuildinfo +1 -1
  29. package/dist/types/HttpService.d.ts +3 -3
  30. package/dist/types/boot/coldBootV2.d.ts +28 -53
  31. package/dist/types/crypto/keyManager.d.ts +0 -21
  32. package/dist/types/index.d.ts +3 -5
  33. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  34. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
  35. package/dist/types/models/interfaces.d.ts +15 -0
  36. package/dist/types/session/SessionClient.d.ts +6 -40
  37. package/dist/types/session/accountDialogController.d.ts +1 -3
  38. package/dist/types/session/authStateStore.d.ts +32 -57
  39. package/dist/types/session/createSessionClient.d.ts +2 -9
  40. package/dist/types/session/refresh.d.ts +32 -28
  41. package/dist/types/utils/registrableApex.d.ts +2 -6
  42. package/package.json +3 -3
  43. package/src/HttpService.ts +3 -3
  44. package/src/boot/__tests__/coldBootV2.test.ts +140 -353
  45. package/src/boot/coldBootV2.ts +35 -391
  46. package/src/crypto/keyManager.ts +0 -101
  47. package/src/index.ts +7 -30
  48. package/src/mixins/OxyServices.auth.ts +5 -9
  49. package/src/mixins/OxyServices.deviceBoot.ts +19 -142
  50. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
  51. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  52. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  53. package/src/mixins/index.ts +2 -3
  54. package/src/models/interfaces.ts +15 -0
  55. package/src/session/SessionClient.ts +31 -122
  56. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  57. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  58. package/src/session/__tests__/authStateStore.test.ts +29 -42
  59. package/src/session/__tests__/refresh.test.ts +66 -52
  60. package/src/session/accountDialogController.ts +4 -18
  61. package/src/session/authStateStore.ts +32 -126
  62. package/src/session/createSessionClient.ts +2 -9
  63. package/src/session/refresh.ts +60 -92
  64. package/src/utils/registrableApex.ts +2 -6
  65. package/dist/cjs/boot/deviceBootReturn.js +0 -167
  66. package/dist/esm/boot/deviceBootReturn.js +0 -161
  67. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  68. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
  69. package/src/boot/deviceBootReturn.ts +0 -210
  70. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  71. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
@@ -7,233 +7,52 @@
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';
20
+ import { isNative as detectNative } from '../utils/platform';
31
21
  import { extractErrorStatus } from '../utils/errorUtils';
32
- import { KeyManager } from '../crypto/keyManager';
33
22
  import { logger } from '../utils/loggerUtils';
34
23
  import type { OxyServices } from '../OxyServices';
35
24
  import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
36
- import { refreshPersistedSession } from '../session/refresh';
37
- import {
38
- consumeDeviceBootReturn,
39
- hashHasBootFragment,
40
- BOOT_STATE_SESSION_KEY,
41
- type DeviceBootSession,
42
- } from './deviceBootReturn';
43
-
44
- /**
45
- * localStorage flag marking that the cross-apex bootstrap navigation has fired
46
- * once for this origin. Persistent (not session) so the visible redirect
47
- * happens AT MOST ONCE EVER per browser+origin — a signed-out user is never
48
- * bounced again; they sign in explicitly.
49
- */
50
- export const BOOT_ATTEMPTED_KEY = 'oxy.boot.attempted';
51
25
 
52
- /**
53
- * Do not warm-plant a stored access token with less than this remaining — it
54
- * would need an immediate refresh anyway, so fall through to the rotate path.
55
- * Matches the refresh lead window.
56
- */
57
- 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
+ }
58
32
 
59
33
  /** Why a cold boot ended without a session. */
60
- export type SignedOutReason =
61
- | 'no_session'
62
- | 'new_device'
63
- | 'state-mismatch'
64
- | 'error';
65
-
66
- /**
67
- * The DOM/storage seam. All access is injected so the boot is unit-testable
68
- * under the jest `node` environment; {@link createBrowserColdBootDom} provides
69
- * the guarded real-globals implementation used in production.
70
- */
71
- export interface ColdBootDom {
72
- getHash(): string;
73
- stripFragment(): void;
74
- getSessionItem(key: string): string | null;
75
- setSessionItem(key: string, value: string): void;
76
- removeSessionItem(key: string): void;
77
- getLocalItem(key: string): string | null;
78
- setLocalItem(key: string, value: string): void;
79
- getLocationHostname(): string | null;
80
- /** Current href WITHOUT its hash fragment (the bootstrap `return_to`). */
81
- getReturnToHref(): string | null;
82
- navigate(url: string): void;
83
- /** A fresh high-entropy CSRF state token. */
84
- randomState(): string;
85
- }
34
+ export type SignedOutReason = 'no_session' | 'error';
86
35
 
87
36
  export interface RunSessionColdBootOptions {
88
37
  oxy: OxyServices;
89
38
  store: AuthStateStore;
90
39
  /** Platform hints; default derived from `@oxyhq/core`'s platform detection. */
91
40
  platform?: { isWeb?: boolean; isNative?: boolean };
92
- /**
93
- * The RP return URL for the cross-apex hop. Defaults to the current href
94
- * (sans fragment) via the DOM seam.
95
- */
96
- returnTo?: string;
97
41
  /** Invoked with the winning session (token already planted). */
98
42
  onSession?: (session: DeviceBootSession & { via: string }) => void | Promise<void>;
99
- /** Invoked when the boot ended signed out (not while navigating away). */
43
+ /** Invoked when the boot ended signed out. */
100
44
  onSignedOut?: (reason: SignedOutReason) => void | Promise<void>;
101
45
  onStepError?: (id: string, error: unknown) => void;
102
- /** DOM/storage seam; defaults to the guarded browser implementation. */
103
- dom?: ColdBootDom;
104
- }
105
-
106
- /**
107
- * Generate a 128-bit hex CSRF state token. Prefers Web Crypto
108
- * (`crypto.getRandomValues`, present in browsers and modern Node); falls back
109
- * to a time+`Math.random` mix only when no CSPRNG is reachable (this token
110
- * gates a single-use CSRF echo, not a long-lived secret).
111
- */
112
- function generateStateToken(): string {
113
- const cryptoObj = (globalThis as { crypto?: Crypto }).crypto;
114
- if (cryptoObj?.getRandomValues) {
115
- const bytes = new Uint8Array(16);
116
- cryptoObj.getRandomValues(bytes);
117
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
118
- }
119
- return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`;
120
- }
121
-
122
- /**
123
- * The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
124
- * missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
125
- * or no-op, so the boot degrades to signed-out rather than crashing.
126
- */
127
- export function createBrowserColdBootDom(): ColdBootDom {
128
- const safe = <T>(fn: () => T, fallback: T): T => {
129
- try {
130
- return fn();
131
- } catch {
132
- return fallback;
133
- }
134
- };
135
- return {
136
- getHash: () => safe(() => (typeof window !== 'undefined' ? window.location.hash : ''), ''),
137
- stripFragment: () =>
138
- safe(() => {
139
- if (typeof window !== 'undefined' && window.history?.replaceState) {
140
- const { pathname, search } = window.location;
141
- window.history.replaceState(null, '', `${pathname}${search}`);
142
- }
143
- }, undefined),
144
- getSessionItem: (key) =>
145
- safe(() => (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null), null),
146
- setSessionItem: (key, value) =>
147
- safe(() => {
148
- if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, value);
149
- }, undefined),
150
- removeSessionItem: (key) =>
151
- safe(() => {
152
- if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(key);
153
- }, undefined),
154
- getLocalItem: (key) =>
155
- safe(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null), null),
156
- setLocalItem: (key, value) =>
157
- safe(() => {
158
- if (typeof localStorage !== 'undefined') localStorage.setItem(key, value);
159
- }, undefined),
160
- getLocationHostname: () =>
161
- safe(() => (typeof window !== 'undefined' ? window.location.hostname : null), null),
162
- getReturnToHref: () =>
163
- safe(() => {
164
- if (typeof window === 'undefined') return null;
165
- const { origin, pathname, search } = window.location;
166
- return `${origin}${pathname}${search}`;
167
- }, null),
168
- navigate: (url) =>
169
- safe(() => {
170
- if (typeof window !== 'undefined') window.location.assign(url);
171
- }, undefined),
172
- randomState: generateStateToken,
173
- };
174
46
  }
175
47
 
176
48
  /**
177
- * Registrable domain = the last two labels of a host. A deliberately SMALL
178
- * local helper (the plan forbids coupling the boot to `fapiAutoDetect`/`tldts`).
179
- * Correct for every Oxy apex (all two-label domains: `oxy.so`, `mention.earth`,
180
- * `alia.onl`, `homiio.com`); the only imprecision — a multi-part public suffix
181
- * such as `co.uk` — never arises here because the compared API host is always
182
- * `api.oxy.so`, so a page under a different registrable domain still classifies
183
- * as cross-apex.
184
- */
185
- function registrableDomain(host: string): string {
186
- const labels = host.toLowerCase().split('.').filter(Boolean);
187
- return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
188
- }
189
-
190
- /**
191
- * Is `host` an IP literal (v4/v6) or a single-label host (`localhost`)? Such
192
- * hosts have NO registrable domain — the last-two-labels heuristic would
193
- * mis-group them (`192.168.1.1` and `10.0.1.1` both collapse to `1.1`; IPv6
194
- * `::1` and `localhost` are single "labels"), so a LAN/dev page could be wrongly
195
- * classified same-apex as a different LAN API and skip the cross-apex hop.
196
- */
197
- function isIpOrSingleLabel(host: string): boolean {
198
- if (host.includes(':')) {
199
- return true; // IPv6 literal
200
- }
201
- if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
202
- return true; // IPv4 literal
203
- }
204
- return !host.includes('.'); // single-label (e.g. `localhost`)
205
- }
206
-
207
- /**
208
- * True when both hosts are same-site / same-apex. For a normal multi-label host
209
- * pair, that means sharing a registrable domain. For an IP literal or a
210
- * single-label host (no registrable domain), same-apex requires the two hosts
211
- * to be EXACTLY equal — never grouped by a spurious trailing-label match.
212
- */
213
- export function isSameApex(pageHost: string, apiHost: string): boolean {
214
- const a = pageHost.toLowerCase();
215
- const b = apiHost.toLowerCase();
216
- if (isIpOrSingleLabel(a) || isIpOrSingleLabel(b)) {
217
- return a === b;
218
- }
219
- const ra = registrableDomain(a);
220
- const rb = registrableDomain(b);
221
- return ra !== '' && ra === rb;
222
- }
223
-
224
- /** Build a `DeviceBootSession` from a persisted state (post-refresh/warm-plant). */
225
- function sessionFromPersisted(state: PersistedAuthState, accessToken: string): DeviceBootSession {
226
- return { sessionId: state.sessionId, userId: state.userId, accessToken };
227
- }
228
-
229
- /**
230
- * How a `mintFromDeviceSecret` (phase 2c) call failed, distinguished so the cold
231
- * boot can react per the transport contract:
49
+ * How a `mintFromDeviceSecret` call failed, distinguished so the cold boot can
50
+ * react per the transport contract:
232
51
  * - `invalid_secret` — the presented secret no longer matches (another tab/
233
52
  * device rotated it, or theft divergence). Drop it and fall back.
234
53
  * - `no_active_session` — the device is known but has no live session.
235
- * Authoritative signed-out; keep the secret, do not fall back to the hop.
236
- * - `transient` — network / 5xx. Keep the secret and let the fallback lanes try.
54
+ * Authoritative signed-out.
55
+ * - `transient` — network / 5xx. Keep the secret; a later attempt can succeed.
237
56
  *
238
57
  * The mint is bearer-less (`skipAuth`), so `HttpService` surfaces the server's
239
58
  * 401 body string (`invalid_device_secret` | `no_active_session`) as the thrown
@@ -256,41 +75,26 @@ function classifyMintFailure(error: unknown): MintFailure {
256
75
 
257
76
  /**
258
77
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
259
- * a side effect, invokes `onSession` (winning session, token already planted)
260
- * or `onSignedOut` (no session — unless the boot is navigating away for the
261
- * cross-apex hop, in which case neither fires).
78
+ * a side effect, invokes `onSession` (winning session, token already planted) or
79
+ * `onSignedOut` (no session).
262
80
  */
263
81
  export async function runSessionColdBoot(
264
82
  opts: RunSessionColdBootOptions,
265
83
  ): Promise<ColdBootOutcome<DeviceBootSession>> {
266
84
  const { oxy, store } = opts;
267
- const dom = opts.dom ?? createBrowserColdBootDom();
268
- const isWeb = opts.platform?.isWeb ?? detectWeb();
269
85
  const isNative = opts.platform?.isNative ?? detectNative();
270
86
 
271
- // Signed-out reason + navigating flag are boot-local (not module-level), so
272
- // 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.
273
89
  let signedOutReason: SignedOutReason = 'no_session';
274
- let navigating = false;
275
- // Set when the zero-cookie mint reports `no_active_session` (phase 2c): the
276
- // device is authoritatively signed out, so the migratory fallback lanes below
277
- // (stored-tokens / shared-key / bootstrap-hop) must NOT run — we already know
278
- // there is no session and must not bounce a known-signed-out device.
279
- let deviceKnownSignedOut = false;
280
90
 
281
91
  const steps: Array<ColdBootStep<DeviceBootSession>> = [];
282
92
 
283
- // 0. device-secret-mint (phase 2c) — the zero-cookie fast path. When the
93
+ // 1. device-secret-mint (web + native) — the zero-cookie fast path. When the
284
94
  // origin persisted a deviceId + deviceSecret, mint a short access token with
285
- // a single bearer-less POST (no cookie, no navigation). FIRST in the chain
286
- // so it wins over the migratory cookie lanes below. Gated OFF while a
287
- // #oxy_boot return fragment is present so `bootstrap-return` still consumes
288
- // + strips it first (a device holding a secret never triggers that hop, so
289
- // this only defers a rare stale/forged fragment). The rest of the chain
290
- // stays as the additive migratory fallback for devices not yet on the secret.
95
+ // a single bearer-less POST (no cookie, no navigation).
291
96
  steps.push({
292
97
  id: 'device-secret-mint',
293
- enabled: () => !(isWeb && hashHasBootFragment(dom.getHash())),
294
98
  run: async () => {
295
99
  const persisted = await store.load();
296
100
  if (!persisted?.deviceId || !persisted?.deviceSecret) {
@@ -320,23 +124,21 @@ export async function runSessionColdBoot(
320
124
  } catch (error) {
321
125
  const failure = classifyMintFailure(error);
322
126
  if (failure === 'invalid_secret') {
323
- // Stale/diverged secret — drop it so the mint lane stops firing, then
324
- // fall through to the migratory refresh/cookie lanes. Setting it
325
- // undefined drops the key on the store's JSON serialization, and the
326
- // mint guard treats undefined as absent.
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.
327
131
  await store.save({ ...persisted, deviceSecret: undefined });
328
132
  return { kind: 'skip' };
329
133
  }
330
134
  if (failure === 'no_active_session') {
331
- // Device known, no live session — authoritative signed-out. KEEP the
332
- // secret and stop the chain (do not bounce a known-signed-out device).
333
- deviceKnownSignedOut = true;
135
+ // Device known, no live session — authoritative signed-out.
334
136
  signedOutReason = 'no_session';
335
137
  return { kind: 'skip' };
336
138
  }
337
- // Transient (network / 5xx): keep the secret, let the fallback lanes try.
139
+ // Transient (network / 5xx): keep the secret; a later attempt can succeed.
338
140
  logger.debug(
339
- 'device-secret mint failed (transient) — keeping secret, falling back',
141
+ 'device-secret mint failed (transient) — keeping secret',
340
142
  { component: 'coldBootV2', method: 'device-secret-mint' },
341
143
  error,
342
144
  );
@@ -345,92 +147,15 @@ export async function runSessionColdBoot(
345
147
  },
346
148
  });
347
149
 
348
- // 1. bootstrap-return (web) — consume a #oxy_boot fragment.
349
- steps.push({
350
- id: 'bootstrap-return',
351
- enabled: () => isWeb && hashHasBootFragment(dom.getHash()),
352
- run: async () => {
353
- const outcome = await consumeDeviceBootReturn({
354
- hash: dom.getHash(),
355
- stripFragment: () => dom.stripFragment(),
356
- readExpectedState: () => dom.getSessionItem(BOOT_STATE_SESSION_KEY),
357
- clearExpectedState: () => dom.removeSessionItem(BOOT_STATE_SESSION_KEY),
358
- store,
359
- exchangeBootCode: (code) => oxy.exchangeBootCode(code),
360
- plantAccessToken: (accessToken) => oxy.setTokens(accessToken),
361
- });
362
- if (outcome.kind === 'session') {
363
- return { kind: 'session', session: outcome.session };
364
- }
365
- if (outcome.kind === 'state-mismatch') {
366
- signedOutReason = 'state-mismatch';
367
- } else if (outcome.kind === 'no-session') {
368
- // `DeviceBootReason` also carries 'session'; a no-session outcome with
369
- // that reason (a `session` reason but no code) is still signed out.
370
- signedOutReason = outcome.reason === 'new_device' ? 'new_device' : 'no_session';
371
- }
372
- // Fall through: a stored refresh family (a prior same-origin session) may
373
- // still recover below. The once-ever flag blocks a second hop.
374
- return { kind: 'skip' };
375
- },
376
- });
377
-
378
- // 2. stored-tokens — warm-plant or rotate the persisted refresh family.
379
- steps.push({
380
- id: 'stored-tokens',
381
- enabled: () => !deviceKnownSignedOut,
382
- run: async () => {
383
- const persisted = await store.load();
384
- if (!persisted) {
385
- return { kind: 'skip' };
386
- }
387
- // Warm path: a still-valid access token → plant immediately (no network).
388
- if (
389
- persisted.accessToken &&
390
- persisted.expiresAt &&
391
- Date.parse(persisted.expiresAt) - Date.now() > WARM_MIN_REMAINING_MS
392
- ) {
393
- oxy.setTokens(persisted.accessToken);
394
- return { kind: 'session', session: sessionFromPersisted(persisted, persisted.accessToken) };
395
- }
396
- // Rotate path: refreshPersistedSession plants + persists, and clears the
397
- // store on a family-revoked error.
398
- const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: isNative });
399
- if (!token) {
400
- return { kind: 'skip' };
401
- }
402
- const after = await store.load();
403
- const base = after ?? persisted;
404
- return { kind: 'session', session: sessionFromPersisted(base, token) };
405
- },
406
- });
407
-
408
- // 3. shared-key-signin (native) — re-mint from the shared identity.
150
+ // 2. shared-key-signin (native) — re-mint from the shared identity.
409
151
  steps.push({
410
152
  id: 'shared-key-signin',
411
- enabled: () => isNative && !deviceKnownSignedOut,
153
+ enabled: () => isNative,
412
154
  run: async () => {
413
155
  const session = await oxy.signInWithSharedIdentity();
414
156
  if (!session?.accessToken) {
415
157
  return { kind: 'skip' };
416
158
  }
417
- // First shared-key sign-in on this device: issue + persist + mirror a
418
- // shared deviceToken so every native Oxy app joins one DeviceSession.
419
- // Best-effort — never fail the sign-in over device-token issuance.
420
- try {
421
- const existing = await KeyManager.getSharedDeviceToken();
422
- if (!existing) {
423
- const deviceToken = await oxy.issueNativeDeviceToken();
424
- await store.saveDeviceToken(deviceToken);
425
- await KeyManager.setSharedDeviceToken(deviceToken);
426
- }
427
- } catch (error) {
428
- logger.debug(
429
- 'Native deviceToken issuance skipped',
430
- { component: 'coldBootV2', method: 'shared-key-signin' },
431
- error,
432
- );
433
- }
434
159
  return {
435
160
  kind: 'session',
436
161
  session: {
@@ -442,83 +167,6 @@ export async function runSessionColdBoot(
442
167
  },
443
168
  });
444
169
 
445
- // 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
446
- steps.push({
447
- id: 'bootstrap-hop',
448
- enabled: () => isWeb && !deviceKnownSignedOut,
449
- run: async () => {
450
- const pageHost = dom.getLocationHostname();
451
- let apiHost: string | null = null;
452
- try {
453
- apiHost = new URL(oxy.getBaseURL()).hostname;
454
- } catch {
455
- apiHost = null;
456
- }
457
- if (!pageHost || !apiHost) {
458
- return { kind: 'skip' };
459
- }
460
-
461
- // Same-apex: inline credentialed fetch, no redirect, runs every boot.
462
- if (isSameApex(pageHost, apiHost)) {
463
- const result = await oxy.requestWebSession();
464
- // The rotated deviceToken is on BOTH arms (it is device-level, not
465
- // session-level) — persist it before branching on the session.
466
- await store.saveDeviceToken(result.deviceToken);
467
- if (result.reason === 'session') {
468
- const bundle = result.session;
469
- const userId = resolveUserId(bundle.user);
470
- if (!userId) {
471
- return { kind: 'skip' };
472
- }
473
- const next: PersistedAuthState = {
474
- sessionId: bundle.sessionId,
475
- refreshToken: bundle.refreshToken,
476
- userId,
477
- deviceToken: result.deviceToken,
478
- accessToken: bundle.accessToken,
479
- expiresAt: bundle.expiresAt,
480
- };
481
- // Phase 2c: the web-session bundle may carry a rotating `deviceSecret`
482
- // but NOT a deviceId. Persist the secret and carry any prior deviceId
483
- // (from a deviceId-bearing login lane) forward so the mint lane stays
484
- // usable — this overwrite must not orphan it.
485
- const prior = await store.load();
486
- if (prior?.deviceId) {
487
- next.deviceId = prior.deviceId;
488
- }
489
- // Prefer the bundle's secret (the server just rotated onto it); keep the
490
- // prior one when the bundle omits it — this lane also runs as the
491
- // TRANSIENT-mint fallback, and must not orphan a still-valid secret.
492
- const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
493
- if (carriedSecret) {
494
- next.deviceSecret = carriedSecret;
495
- }
496
- await store.save(next);
497
- oxy.setTokens(bundle.accessToken);
498
- return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
499
- }
500
- // Known device, signed out.
501
- signedOutReason = result.reason;
502
- return { kind: 'skip' };
503
- }
504
-
505
- // Cross-apex: ONE visible top-level navigation, once-ever per origin.
506
- if (dom.getLocalItem(BOOT_ATTEMPTED_KEY)) {
507
- return { kind: 'skip' };
508
- }
509
- const returnTo = opts.returnTo ?? dom.getReturnToHref();
510
- if (!returnTo) {
511
- return { kind: 'skip' };
512
- }
513
- const state = dom.randomState();
514
- dom.setSessionItem(BOOT_STATE_SESSION_KEY, state);
515
- dom.setLocalItem(BOOT_ATTEMPTED_KEY, '1');
516
- navigating = true;
517
- dom.navigate(oxy.buildBootstrapUrl(returnTo, state));
518
- return { kind: 'skip' };
519
- },
520
- });
521
-
522
170
  const outcome = await runColdBoot<DeviceBootSession>({
523
171
  steps,
524
172
  onStepError: (id, error) => {
@@ -532,10 +180,6 @@ export async function runSessionColdBoot(
532
180
  return outcome;
533
181
  }
534
182
 
535
- // Navigating away for the cross-apex hop: the page is unloading, so do not
536
- // flash a signed-out state.
537
- if (!navigating) {
538
- await opts.onSignedOut?.(signedOutReason);
539
- }
183
+ await opts.onSignedOut?.(signedOutReason);
540
184
  return outcome;
541
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
  /**