@oxyhq/core 12.8.0 → 12.9.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 (38) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +16 -3
  3. package/dist/cjs/crypto/identityMarker.js +255 -0
  4. package/dist/cjs/crypto/keyManager.js +844 -106
  5. package/dist/cjs/index.js +8 -4
  6. package/dist/cjs/mixins/OxyServices.auth.js +21 -6
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
  8. package/dist/esm/.tsbuildinfo +1 -1
  9. package/dist/esm/boot/sessionColdBoot.js +16 -3
  10. package/dist/esm/crypto/identityMarker.js +248 -0
  11. package/dist/esm/crypto/keyManager.js +843 -106
  12. package/dist/esm/index.js +2 -1
  13. package/dist/esm/mixins/OxyServices.auth.js +21 -6
  14. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
  15. package/dist/types/.tsbuildinfo +1 -1
  16. package/dist/types/boot/sessionColdBoot.d.ts +25 -0
  17. package/dist/types/crypto/identityMarker.d.ts +94 -0
  18. package/dist/types/crypto/keyManager.d.ts +212 -3
  19. package/dist/types/index.d.ts +4 -2
  20. package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
  21. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
  22. package/package.json +1 -1
  23. package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
  24. package/src/boot/sessionColdBoot.ts +42 -3
  25. package/src/crypto/__tests__/identityMocks.ts +125 -0
  26. package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
  27. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
  28. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
  29. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
  30. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
  31. package/src/crypto/__tests__/keyManager.test.ts +77 -87
  32. package/src/crypto/identityMarker.ts +291 -0
  33. package/src/crypto/keyManager.ts +1026 -105
  34. package/src/index.ts +7 -1
  35. package/src/mixins/OxyServices.auth.ts +31 -7
  36. package/src/mixins/OxyServices.deviceBoot.ts +9 -1
  37. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  38. package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
package/src/index.ts CHANGED
@@ -245,8 +245,14 @@ export {
245
245
  KeyManager,
246
246
  IdentityAlreadyExistsError,
247
247
  IdentityPersistError,
248
+ IdentityUnavailableError,
248
249
  } from './crypto/keyManager';
249
- export type { KeyPair } from './crypto/keyManager';
250
+ export type { KeyPair, IdentityStatus, IdentityRecoveryResult } from './crypto/keyManager';
251
+ export {
252
+ readIdentityMarker,
253
+ updateIdentityMarker,
254
+ } from './crypto/identityMarker';
255
+ export type { IdentityMarker } from './crypto/identityMarker';
250
256
  export { SignatureService } from './crypto/signatureService';
251
257
  export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
252
258
  export { RecoveryPhraseService } from './crypto/recoveryPhrase';
@@ -497,14 +497,21 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
497
497
  /**
498
498
  * Request an authentication challenge
499
499
  * The client must sign this challenge with their private key
500
- *
500
+ *
501
501
  * @param publicKey - The user's public key
502
+ * @param requestOptions - Optional per-call transport overrides (`retry`,
503
+ * `timeout`). Interactive callers omit it (defaults keep retries); the
504
+ * cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
505
+ * network cannot multiply boot latency via the inner retry loop.
502
506
  */
503
- async requestChallenge(publicKey: string): Promise<ChallengeResponse> {
507
+ async requestChallenge(
508
+ publicKey: string,
509
+ requestOptions?: { retry?: boolean; timeout?: number },
510
+ ): Promise<ChallengeResponse> {
504
511
  try {
505
512
  return await this.makeRequest<ChallengeResponse>('POST', '/auth/challenge', {
506
513
  publicKey,
507
- }, { cache: false });
514
+ }, { cache: false, ...requestOptions });
508
515
  } catch (error) {
509
516
  throw this.handleError(error);
510
517
  }
@@ -519,6 +526,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
519
526
  * @param timestamp - Timestamp when the signature was created
520
527
  * @param deviceName - Optional device name
521
528
  * @param deviceFingerprint - Optional device fingerprint
529
+ * @param requestOptions - Optional per-call transport overrides (`retry`,
530
+ * `timeout`). Interactive callers omit it (defaults keep retries); the
531
+ * cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
532
+ * network cannot multiply boot latency via the inner retry loop.
522
533
  */
523
534
  async verifyChallenge(
524
535
  publicKey: string,
@@ -526,7 +537,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
526
537
  signature: string,
527
538
  timestamp: number,
528
539
  deviceName?: string,
529
- deviceFingerprint?: string
540
+ deviceFingerprint?: string,
541
+ requestOptions?: { retry?: boolean; timeout?: number },
530
542
  ): Promise<SessionLoginResponse> {
531
543
  try {
532
544
  const res = await this.makeRequest<SessionLoginResponse>('POST', '/auth/verify', {
@@ -536,7 +548,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
536
548
  timestamp,
537
549
  deviceName,
538
550
  deviceFingerprint,
539
- }, { cache: false });
551
+ }, { cache: false, ...requestOptions });
540
552
 
541
553
  // Plant the freshly-minted tokens, mirroring `claimSessionByToken`.
542
554
  // `/auth/verify` returns the first access token (and refresh token) in
@@ -718,9 +730,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
718
730
  *
719
731
  * The cold-boot wiring that CALLS this lives in `OxyContext`
720
732
  * (`@oxyhq/services`); this method just performs the exchange.
733
+ *
734
+ * @param opts.requestOptions - Optional per-call transport overrides
735
+ * (`retry`, `timeout`) forwarded to BOTH the `requestChallenge` and
736
+ * `verifyChallenge` round-trips. Interactive flows omit it (defaults keep
737
+ * retries); the cold-boot `shared-key-signin` step passes `{ retry: false }`
738
+ * so this network step cannot multiply boot latency via the inner retry
739
+ * loop. The token-refresh scheduler / 401 lane still retry later.
721
740
  */
722
741
  async signInWithSharedIdentity(
723
- opts: { deviceName?: string; deviceFingerprint?: string } = {}
742
+ opts: {
743
+ deviceName?: string;
744
+ deviceFingerprint?: string;
745
+ requestOptions?: { retry?: boolean; timeout?: number };
746
+ } = {}
724
747
  ): Promise<SessionLoginResponse | null> {
725
748
  try {
726
749
  // `hasSharedIdentity()` already returns false on web (the shared
@@ -734,7 +757,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
734
757
  return null;
735
758
  }
736
759
 
737
- const { challenge } = await this.requestChallenge(sharedPublicKey);
760
+ const { challenge } = await this.requestChallenge(sharedPublicKey, opts.requestOptions);
738
761
  const signed = await SignatureService.signChallengeWithSharedKey(challenge);
739
762
 
740
763
  // `signed.challenge` carries the SIGNATURE (mirrors `signChallenge`).
@@ -745,6 +768,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
745
768
  signed.timestamp,
746
769
  opts.deviceName,
747
770
  opts.deviceFingerprint,
771
+ opts.requestOptions,
748
772
  );
749
773
  } catch (error) {
750
774
  throw this.handleError(error);
@@ -39,6 +39,14 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
39
39
  * `no_active_session`) to decide whether to drop the secret and fall back or
40
40
  * resolve signed-out.
41
41
  *
42
+ * `retry: false`: the mint is a single logical attempt. The proactive
43
+ * token-refresh scheduler and the reactive 401 lane already own backoff and
44
+ * re-arm, so `HttpService`'s inner retry loop here would only multiply the
45
+ * mint's latency on a slow/black-hole network (3 retries × 5s timeout ≈ 20s
46
+ * per lane) with no correctness benefit — it is the dominant term in the cold
47
+ * boot's worst-case time-to-route. A transient failure surfaces once and the
48
+ * scheduler/401 path retries it later.
49
+ *
42
50
  * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
43
51
  */
44
52
  async mintFromDeviceSecret(
@@ -50,7 +58,7 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
50
58
  'POST',
51
59
  '/session/device/token',
52
60
  { deviceId, deviceSecret },
53
- { cache: false, skipAuth: true },
61
+ { cache: false, skipAuth: true, retry: false },
54
62
  );
55
63
  const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
56
64
  if (!parsed) {
@@ -31,15 +31,17 @@ describe('OxyServices.deviceBoot', () => {
31
31
  },
32
32
  };
33
33
 
34
- it('POSTs deviceId + deviceSecret with skipAuth (no bearer, no cache) and returns the validated mint', async () => {
34
+ it('POSTs deviceId + deviceSecret with skipAuth + retry:false (no bearer, no cache, single attempt) and returns the validated mint', async () => {
35
35
  makeRequest.mockResolvedValueOnce(MINT);
36
36
  const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
37
37
  expect(result).toEqual(MINT);
38
+ // `retry: false` — the scheduler/401 lane own backoff; HttpService's inner
39
+ // retry here would only multiply cold-boot latency on a slow network.
38
40
  expect(makeRequest).toHaveBeenCalledWith(
39
41
  'POST',
40
42
  '/session/device/token',
41
43
  { deviceId: 'dev-1', deviceSecret: 'ds-current-secret' },
42
- { cache: false, skipAuth: true },
44
+ { cache: false, skipAuth: true, retry: false },
43
45
  );
44
46
  });
45
47
 
@@ -242,6 +242,51 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
242
242
  });
243
243
  });
244
244
 
245
+ describe('requestChallenge / verifyChallenge — requestOptions spread into makeRequest', () => {
246
+ it('requestChallenge omits transport overrides by default (retries ON) and spreads them when given', async () => {
247
+ makeRequestSpy.mockResolvedValue(challengeFixture);
248
+
249
+ await oxy.requestChallenge('pub-x');
250
+ expect(makeRequestSpy).toHaveBeenLastCalledWith(
251
+ 'POST',
252
+ '/auth/challenge',
253
+ { publicKey: 'pub-x' },
254
+ { cache: false },
255
+ );
256
+
257
+ await oxy.requestChallenge('pub-x', { retry: false });
258
+ expect(makeRequestSpy).toHaveBeenLastCalledWith(
259
+ 'POST',
260
+ '/auth/challenge',
261
+ { publicKey: 'pub-x' },
262
+ { cache: false, retry: false },
263
+ );
264
+ });
265
+
266
+ it('verifyChallenge spreads requestOptions (retry + timeout) into makeRequest', async () => {
267
+ makeRequestSpy.mockResolvedValue(sessionFixture);
268
+
269
+ await oxy.verifyChallenge('pub-x', 'chal', 'sig', 123, 'dev', 'fp', {
270
+ retry: false,
271
+ timeout: 9000,
272
+ });
273
+
274
+ expect(makeRequestSpy).toHaveBeenLastCalledWith(
275
+ 'POST',
276
+ '/auth/verify',
277
+ {
278
+ publicKey: 'pub-x',
279
+ challenge: 'chal',
280
+ signature: 'sig',
281
+ timestamp: 123,
282
+ deviceName: 'dev',
283
+ deviceFingerprint: 'fp',
284
+ },
285
+ { cache: false, retry: false, timeout: 9000 },
286
+ );
287
+ });
288
+ });
289
+
245
290
  describe('signInWithSharedIdentity (Mechanism A — same-device SSO)', () => {
246
291
  it('mints a session from the shared key when one exists (native)', async () => {
247
292
  jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
@@ -263,7 +308,9 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
263
308
  deviceFingerprint: 'fp-1',
264
309
  });
265
310
 
266
- expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub');
311
+ // No requestOptions passed → both round-trips get `undefined` (defaults:
312
+ // retries ON), preserving interactive behaviour.
313
+ expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub', undefined);
267
314
  expect(verifyChallengeSpy).toHaveBeenCalledWith(
268
315
  'shared-pub',
269
316
  'chal-shared',
@@ -271,6 +318,42 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
271
318
  1700000000456,
272
319
  'iPad',
273
320
  'fp-1',
321
+ undefined,
322
+ );
323
+ expect(result).toEqual(sessionFixture);
324
+ });
325
+
326
+ it('threads requestOptions into BOTH the challenge and verify round-trips (cold-boot retry:false)', async () => {
327
+ jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
328
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue('shared-pub');
329
+ const requestChallengeSpy = jest
330
+ .spyOn(oxy, 'requestChallenge')
331
+ .mockResolvedValue({ challenge: 'chal-shared', expiresAt: '2026-06-26T00:05:00.000Z' });
332
+ jest.spyOn(SignatureService, 'signChallengeWithSharedKey').mockResolvedValue({
333
+ challenge: 'sig-shared',
334
+ publicKey: 'shared-pub',
335
+ timestamp: 1700000000456,
336
+ });
337
+ const verifyChallengeSpy = jest
338
+ .spyOn(oxy, 'verifyChallenge')
339
+ .mockResolvedValue(sessionFixture);
340
+
341
+ const result = await oxy.signInWithSharedIdentity({
342
+ requestOptions: { retry: false },
343
+ });
344
+
345
+ // The SAME requestOptions object is forwarded to both calls — this is how
346
+ // the cold-boot `shared-key-signin` step keeps its two round-trips as
347
+ // single attempts without changing interactive defaults.
348
+ expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub', { retry: false });
349
+ expect(verifyChallengeSpy).toHaveBeenCalledWith(
350
+ 'shared-pub',
351
+ 'chal-shared',
352
+ 'sig-shared',
353
+ 1700000000456,
354
+ undefined,
355
+ undefined,
356
+ { retry: false },
274
357
  );
275
358
  expect(result).toEqual(sessionFixture);
276
359
  });