@oxyhq/core 9.2.0 → 9.2.2

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 (40) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +13 -0
  3. package/dist/cjs/index.js +6 -4
  4. package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
  5. package/dist/cjs/mixins/OxyServices.utility.js +9 -5
  6. package/dist/cjs/session/SessionClient.js +45 -0
  7. package/dist/cjs/session/accountDialogController.js +31 -0
  8. package/dist/cjs/session/authStateStore.js +196 -16
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/boot/sessionColdBoot.js +13 -0
  11. package/dist/esm/index.js +1 -0
  12. package/dist/esm/mixins/OxyServices.accounts.js +1 -0
  13. package/dist/esm/mixins/OxyServices.utility.js +9 -5
  14. package/dist/esm/session/SessionClient.js +45 -0
  15. package/dist/esm/session/accountDialogController.js +31 -0
  16. package/dist/esm/session/authStateStore.js +195 -15
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/index.d.ts +2 -1
  19. package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
  20. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  21. package/dist/types/models/interfaces.d.ts +3 -1
  22. package/dist/types/models/session.d.ts +6 -0
  23. package/dist/types/session/SessionClient.d.ts +11 -0
  24. package/dist/types/session/accountDialogController.d.ts +17 -0
  25. package/dist/types/session/authStateStore.d.ts +33 -8
  26. package/package.json +2 -2
  27. package/src/boot/__tests__/sessionColdBoot.test.ts +20 -0
  28. package/src/boot/sessionColdBoot.ts +13 -0
  29. package/src/index.ts +3 -0
  30. package/src/mixins/OxyServices.accounts.ts +9 -0
  31. package/src/mixins/OxyServices.auth.ts +2 -0
  32. package/src/mixins/OxyServices.utility.ts +10 -9
  33. package/src/models/interfaces.ts +3 -1
  34. package/src/models/session.ts +6 -0
  35. package/src/session/SessionClient.ts +44 -0
  36. package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
  37. package/src/session/__tests__/accountDialogController.test.ts +93 -1
  38. package/src/session/__tests__/authStateStore.test.ts +170 -0
  39. package/src/session/accountDialogController.ts +54 -1
  40. package/src/session/authStateStore.ts +219 -15
@@ -17,6 +17,8 @@
17
17
  * ESM-safe (no `require()`).
18
18
  */
19
19
 
20
+ import { logger } from '../utils/loggerUtils';
21
+
20
22
  /**
21
23
  * The persisted session credential set for a single origin.
22
24
  *
@@ -81,12 +83,34 @@ export interface NativeKeyValueStorage {
81
83
  }
82
84
 
83
85
  /**
84
- * Versioned storage key. The `.v1` suffix lets a future shape change ship a
85
- * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
86
- * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
86
+ * Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
87
+ * (`sessionId`, `userId`, `deviceId`, `deviceSecret`) never the large JWT
88
+ * `accessToken`. Keeping this blob small (<2KB) matters on Android
89
+ * `expo-secure-store`, whose backing store can silently fail to persist an
90
+ * oversize value; bundling the token here previously took the mint credential
91
+ * down with it on every write, losing the session on cold restart.
92
+ *
93
+ * The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
94
+ * stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
95
+ * in `KeyManager`, so it never collides.
96
+ *
97
+ * BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
98
+ * `expiresAt`) into this single key. `load()` still reads those token fields
99
+ * from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
100
+ * so upgrading users are not signed out; the next `save()` splits them apart.
87
101
  */
88
102
  export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
89
103
 
104
+ /**
105
+ * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
106
+ * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
107
+ * failure (quota / oversize keychain value) is swallowed because the session is
108
+ * fully re-mintable from the durable `deviceSecret`. Kept separate from
109
+ * {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
110
+ * corrupt the durable credential write.
111
+ */
112
+ export const AUTH_STATE_TOKEN_STORAGE_KEY = 'oxy.auth.token.v1';
113
+
90
114
  /**
91
115
  * Parse + shape-validate a stored blob. Returns `null` for anything that is
92
116
  * not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
@@ -142,6 +166,104 @@ function deserialize(raw: string | null): PersistedAuthState | null {
142
166
  return state;
143
167
  }
144
168
 
169
+ /** The parsed warm-token blob: the short-lived optimization fields only. */
170
+ interface WarmToken {
171
+ accessToken?: string;
172
+ expiresAt?: string;
173
+ }
174
+
175
+ /**
176
+ * Parse the best-effort warm-token blob. Returns `null` for anything not a
177
+ * well-formed object so a corrupt warm entry simply forgoes the warm-boot
178
+ * optimization (the durable credential re-mints a fresh token).
179
+ */
180
+ function parseWarmToken(raw: string | null): WarmToken | null {
181
+ if (!raw) {
182
+ return null;
183
+ }
184
+ let parsed: unknown;
185
+ try {
186
+ parsed = JSON.parse(raw);
187
+ } catch {
188
+ return null;
189
+ }
190
+ if (!parsed || typeof parsed !== 'object') {
191
+ return null;
192
+ }
193
+ const candidate = parsed as Record<string, unknown>;
194
+ const warm: WarmToken = {};
195
+ if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
196
+ warm.accessToken = candidate.accessToken;
197
+ }
198
+ if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
199
+ warm.expiresAt = candidate.expiresAt;
200
+ }
201
+ return warm;
202
+ }
203
+
204
+ /** Serialize ONLY the small, re-mint-critical fields for the durable key. */
205
+ function serializeDurable(state: PersistedAuthState): string {
206
+ const durable: Record<string, string> = {
207
+ sessionId: state.sessionId,
208
+ userId: state.userId,
209
+ };
210
+ if (state.deviceId) {
211
+ durable.deviceId = state.deviceId;
212
+ }
213
+ if (state.deviceSecret) {
214
+ durable.deviceSecret = state.deviceSecret;
215
+ }
216
+ return JSON.stringify(durable);
217
+ }
218
+
219
+ /**
220
+ * Serialize the warm-token blob, or `null` when there is no token to persist
221
+ * (so the caller clears the warm key rather than writing an empty object).
222
+ */
223
+ function serializeWarmToken(state: PersistedAuthState): string | null {
224
+ const warm: WarmToken = {};
225
+ if (state.accessToken) {
226
+ warm.accessToken = state.accessToken;
227
+ }
228
+ if (state.expiresAt) {
229
+ warm.expiresAt = state.expiresAt;
230
+ }
231
+ if (!warm.accessToken && !warm.expiresAt) {
232
+ return null;
233
+ }
234
+ return JSON.stringify(warm);
235
+ }
236
+
237
+ /**
238
+ * Compose the unchanged {@link PersistedAuthState} return shape from the two
239
+ * on-disk keys. `accessToken` / `expiresAt` come from the warm key when it is
240
+ * present; when the warm key is ABSENT the token fields fall back to whatever
241
+ * the durable blob carried — the pre-split combined `oxy.auth.v1` blob (BACK-COMPAT).
242
+ */
243
+ function composeState(durableRaw: string | null, warmRaw: string | null): PersistedAuthState | null {
244
+ const state = deserialize(durableRaw);
245
+ if (!state) {
246
+ return null;
247
+ }
248
+ if (warmRaw !== null) {
249
+ // New split layout: the warm key is authoritative for the token fields.
250
+ // Drop anything the durable blob may have carried, then overlay the warm
251
+ // values (a warm key with no token → the session simply has no warm token).
252
+ delete state.accessToken;
253
+ delete state.expiresAt;
254
+ const warm = parseWarmToken(warmRaw);
255
+ if (warm?.accessToken) {
256
+ state.accessToken = warm.accessToken;
257
+ }
258
+ if (warm?.expiresAt) {
259
+ state.expiresAt = warm.expiresAt;
260
+ }
261
+ }
262
+ // else: BACK-COMPAT — the warm key is absent, so `deserialize` already applied
263
+ // any `accessToken` / `expiresAt` from the old combined blob.
264
+ return state;
265
+ }
266
+
145
267
  /**
146
268
  * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
147
269
  * tests/SSR and as the degraded fallback of the web store when `localStorage`
@@ -180,8 +302,9 @@ function safeGetLocalStorage(): Storage | null {
180
302
  }
181
303
 
182
304
  /**
183
- * A `localStorage`-backed {@link AuthStateStore} under the versioned
184
- * {@link AUTH_STATE_STORAGE_KEY}.
305
+ * A `localStorage`-backed {@link AuthStateStore} split across the durable
306
+ * {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
307
+ * {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
185
308
  *
186
309
  * Resilience:
187
310
  * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
@@ -208,18 +331,50 @@ export function createWebAuthStateStore(): AuthStateStore {
208
331
  return sessionMirror;
209
332
  }
210
333
  try {
211
- return deserialize(storage.getItem(AUTH_STATE_STORAGE_KEY));
334
+ return composeState(
335
+ storage.getItem(AUTH_STATE_STORAGE_KEY),
336
+ storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
337
+ );
212
338
  } catch {
213
339
  return null;
214
340
  }
215
341
  },
216
342
  save: async (state) => {
217
343
  sessionMirror = state; // mirror FIRST — authoritative even if persist fails
344
+ // Durable credential FIRST, then VERIFY it landed. The in-memory mirror
345
+ // keeps the session live for this page, but a failed DURABLE write means
346
+ // the mint credential will NOT survive a reload — surface it, never swallow.
347
+ let durablePersisted = false;
218
348
  try {
219
- storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
349
+ const durableJson = serializeDurable(state);
350
+ storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
351
+ durablePersisted = storage.getItem(AUTH_STATE_STORAGE_KEY) === durableJson;
352
+ if (!durablePersisted) {
353
+ logger.error(
354
+ '[authStateStore] durable credential read-back mismatch after save — the device credential did not persist; the session survives this process via the in-memory mirror but will be lost on reload',
355
+ undefined,
356
+ { component: 'authStateStore' },
357
+ );
358
+ }
359
+ } catch (error) {
360
+ logger.error(
361
+ '[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this process via the in-memory mirror but will be lost on reload',
362
+ error,
363
+ { component: 'authStateStore' },
364
+ );
365
+ }
366
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
367
+ // durable credential re-mints a fresh token) and must never abort or
368
+ // corrupt the durable write above.
369
+ try {
370
+ const warmJson = serializeWarmToken(state);
371
+ if (warmJson) {
372
+ storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
373
+ } else {
374
+ storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
375
+ }
220
376
  } catch {
221
- // Quota / private-mode / disabled storage — non-fatal. The session
222
- // stays live via the in-memory mirror; only reload durability is lost.
377
+ // Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
223
378
  }
224
379
  },
225
380
  clear: async () => {
@@ -229,6 +384,11 @@ export function createWebAuthStateStore(): AuthStateStore {
229
384
  } catch {
230
385
  // Non-fatal — see save().
231
386
  }
387
+ try {
388
+ storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
389
+ } catch {
390
+ // Non-fatal — see save().
391
+ }
232
392
  },
233
393
  };
234
394
  }
@@ -237,9 +397,12 @@ export function createWebAuthStateStore(): AuthStateStore {
237
397
  * A native {@link AuthStateStore} over an injected async key/value store.
238
398
  *
239
399
  * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
240
- * the SecureStore-backed adapter and passes it here. Every operation is wrapped
241
- * so a storage exception degrades gracefully (read `null`, write → swallowed)
242
- * exactly like the web store.
400
+ * the SecureStore-backed adapter and passes it here. Persistence is split across
401
+ * the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
402
+ * best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
403
+ * durable write is read-back-verified and its failure surfaced (not swallowed),
404
+ * while the warm-token write and all reads degrade gracefully exactly like the
405
+ * web store.
243
406
  */
244
407
  export function createNativeAuthStateStore(storage: NativeKeyValueStorage): AuthStateStore {
245
408
  // Same in-memory mirror as the web store — a locked/failed SecureStore write
@@ -251,17 +414,53 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
251
414
  return sessionMirror;
252
415
  }
253
416
  try {
254
- return deserialize(await storage.getItem(AUTH_STATE_STORAGE_KEY));
417
+ const [durableRaw, warmRaw] = await Promise.all([
418
+ storage.getItem(AUTH_STATE_STORAGE_KEY),
419
+ storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
420
+ ]);
421
+ return composeState(durableRaw, warmRaw);
255
422
  } catch {
256
423
  return null;
257
424
  }
258
425
  },
259
426
  save: async (state) => {
260
427
  sessionMirror = state;
428
+ // Durable credential FIRST, then VERIFY. On Android SecureStore an
429
+ // oversize/failed write can resolve WITHOUT throwing, so a read-back is the
430
+ // only reliable proof. The mirror keeps the session live for this app run,
431
+ // but a failed DURABLE write means the mint credential will NOT survive a
432
+ // cold restart — surface it, never swallow.
433
+ let durablePersisted = false;
434
+ try {
435
+ const durableJson = serializeDurable(state);
436
+ await storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
437
+ durablePersisted = (await storage.getItem(AUTH_STATE_STORAGE_KEY)) === durableJson;
438
+ if (!durablePersisted) {
439
+ logger.error(
440
+ '[authStateStore] durable credential read-back mismatch after save — the device credential did not persist (likely oversize SecureStore value); the session survives this app run via the in-memory mirror but will be lost on cold restart',
441
+ undefined,
442
+ { component: 'authStateStore' },
443
+ );
444
+ }
445
+ } catch (error) {
446
+ logger.error(
447
+ '[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart',
448
+ error,
449
+ { component: 'authStateStore' },
450
+ );
451
+ }
452
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
453
+ // durable credential re-mints a fresh token) and must never abort or
454
+ // corrupt the durable write above.
261
455
  try {
262
- await storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
456
+ const warmJson = serializeWarmToken(state);
457
+ if (warmJson) {
458
+ await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
459
+ } else {
460
+ await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
461
+ }
263
462
  } catch {
264
- // Non-fatal session stays live via the in-memory mirror.
463
+ // Locked / oversize keychain non-fatal warm-boot loss only.
265
464
  }
266
465
  },
267
466
  clear: async () => {
@@ -271,6 +470,11 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
271
470
  } catch {
272
471
  // Non-fatal.
273
472
  }
473
+ try {
474
+ await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
475
+ } catch {
476
+ // Non-fatal.
477
+ }
274
478
  },
275
479
  };
276
480
  }