@oxyhq/core 12.9.0 → 12.10.1

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 (106) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +22 -12
  3. package/dist/cjs/crypto/keyManager.js +89 -0
  4. package/dist/cjs/i18n/locales/ar-SA.json +4 -0
  5. package/dist/cjs/i18n/locales/ca-ES.json +4 -0
  6. package/dist/cjs/i18n/locales/de-DE.json +4 -0
  7. package/dist/cjs/i18n/locales/en-US.json +69 -3
  8. package/dist/cjs/i18n/locales/es-ES.json +68 -2
  9. package/dist/cjs/i18n/locales/fr-FR.json +4 -0
  10. package/dist/cjs/i18n/locales/it-IT.json +4 -0
  11. package/dist/cjs/i18n/locales/ja-JP.json +4 -0
  12. package/dist/cjs/i18n/locales/ko-KR.json +4 -0
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +4 -0
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +4 -0
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +4 -0
  16. package/dist/cjs/i18n/locales/locales/en-US.json +69 -3
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +68 -2
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +4 -0
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +4 -0
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +4 -0
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +4 -0
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +4 -0
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +4 -0
  24. package/dist/cjs/i18n/locales/pt-PT.json +4 -0
  25. package/dist/cjs/i18n/locales/zh-CN.json +4 -0
  26. package/dist/cjs/index.js +7 -4
  27. package/dist/cjs/mixins/OxyServices.auth.js +51 -20
  28. package/dist/cjs/mixins/OxyServices.connectedApps.js +6 -1
  29. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -2
  30. package/dist/cjs/mixins/OxyServices.identityBackup.js +11 -0
  31. package/dist/cjs/mixins/OxyServices.user.js +6 -0
  32. package/dist/cjs/session/accountDialogController.js +7 -5
  33. package/dist/cjs/utils/commonsApproval.js +31 -0
  34. package/dist/esm/.tsbuildinfo +1 -1
  35. package/dist/esm/HttpService.js +22 -12
  36. package/dist/esm/crypto/keyManager.js +89 -0
  37. package/dist/esm/i18n/locales/ar-SA.json +4 -0
  38. package/dist/esm/i18n/locales/ca-ES.json +4 -0
  39. package/dist/esm/i18n/locales/de-DE.json +4 -0
  40. package/dist/esm/i18n/locales/en-US.json +69 -3
  41. package/dist/esm/i18n/locales/es-ES.json +68 -2
  42. package/dist/esm/i18n/locales/fr-FR.json +4 -0
  43. package/dist/esm/i18n/locales/it-IT.json +4 -0
  44. package/dist/esm/i18n/locales/ja-JP.json +4 -0
  45. package/dist/esm/i18n/locales/ko-KR.json +4 -0
  46. package/dist/esm/i18n/locales/locales/ar-SA.json +4 -0
  47. package/dist/esm/i18n/locales/locales/ca-ES.json +4 -0
  48. package/dist/esm/i18n/locales/locales/de-DE.json +4 -0
  49. package/dist/esm/i18n/locales/locales/en-US.json +69 -3
  50. package/dist/esm/i18n/locales/locales/es-ES.json +68 -2
  51. package/dist/esm/i18n/locales/locales/fr-FR.json +4 -0
  52. package/dist/esm/i18n/locales/locales/it-IT.json +4 -0
  53. package/dist/esm/i18n/locales/locales/ja-JP.json +4 -0
  54. package/dist/esm/i18n/locales/locales/ko-KR.json +4 -0
  55. package/dist/esm/i18n/locales/locales/pt-PT.json +4 -0
  56. package/dist/esm/i18n/locales/locales/zh-CN.json +4 -0
  57. package/dist/esm/i18n/locales/pt-PT.json +4 -0
  58. package/dist/esm/i18n/locales/zh-CN.json +4 -0
  59. package/dist/esm/index.js +2 -1
  60. package/dist/esm/mixins/OxyServices.auth.js +48 -19
  61. package/dist/esm/mixins/OxyServices.connectedApps.js +6 -1
  62. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -2
  63. package/dist/esm/mixins/OxyServices.identityBackup.js +11 -0
  64. package/dist/esm/mixins/OxyServices.user.js +6 -0
  65. package/dist/esm/session/accountDialogController.js +7 -5
  66. package/dist/esm/utils/commonsApproval.js +27 -0
  67. package/dist/types/.tsbuildinfo +1 -1
  68. package/dist/types/HttpService.d.ts +14 -0
  69. package/dist/types/crypto/keyManager.d.ts +33 -0
  70. package/dist/types/index.d.ts +2 -1
  71. package/dist/types/mixins/OxyServices.auth.d.ts +4 -4
  72. package/dist/types/session/accountDialogController.d.ts +7 -2
  73. package/dist/types/utils/commonsApproval.d.ts +13 -0
  74. package/package.json +3 -3
  75. package/src/HttpService.ts +37 -14
  76. package/src/__tests__/httpServiceAuthSelfAwait.test.ts +108 -0
  77. package/src/__tests__/httpServiceQueueDeadlock.test.ts +128 -0
  78. package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
  79. package/src/crypto/keyManager.ts +99 -0
  80. package/src/i18n/locales/ar-SA.json +4 -0
  81. package/src/i18n/locales/ca-ES.json +4 -0
  82. package/src/i18n/locales/de-DE.json +4 -0
  83. package/src/i18n/locales/en-US.json +69 -3
  84. package/src/i18n/locales/es-ES.json +68 -2
  85. package/src/i18n/locales/fr-FR.json +4 -0
  86. package/src/i18n/locales/it-IT.json +4 -0
  87. package/src/i18n/locales/ja-JP.json +4 -0
  88. package/src/i18n/locales/ko-KR.json +4 -0
  89. package/src/i18n/locales/pt-PT.json +4 -0
  90. package/src/i18n/locales/zh-CN.json +4 -0
  91. package/src/index.ts +7 -1
  92. package/src/mixins/OxyServices.auth.ts +48 -24
  93. package/src/mixins/OxyServices.connectedApps.ts +6 -1
  94. package/src/mixins/OxyServices.deviceBoot.ts +7 -2
  95. package/src/mixins/OxyServices.identityBackup.ts +13 -0
  96. package/src/mixins/OxyServices.user.ts +6 -0
  97. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  98. package/src/mixins/__tests__/commonsSignIn.test.ts +4 -4
  99. package/src/mixins/__tests__/identityBackup.test.ts +57 -0
  100. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +152 -0
  101. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +6 -0
  102. package/src/mixins/__tests__/webauthnAuth.test.ts +6 -6
  103. package/src/session/__tests__/accountDialogController.test.ts +3 -3
  104. package/src/session/accountDialogController.ts +7 -6
  105. package/src/utils/__tests__/commonsApproval.test.ts +60 -0
  106. package/src/utils/commonsApproval.ts +39 -0
@@ -9,6 +9,10 @@ import { loginResultSchema, safeParseContract } from '@oxyhq/contracts';
9
9
  import type { SessionLoginResponse } from '../models/session';
10
10
  import type { OxyServicesBase } from '../OxyServices.base';
11
11
  import type { PublicApplication } from './OxyServices.connectedApps';
12
+ export {
13
+ getCommonsApprovalBlockingReason,
14
+ parseCommonsApprovalExpiresAt,
15
+ } from '../utils/commonsApproval';
12
16
  import { OxyAuthenticationError } from '../OxyServices.errors';
13
17
  import { KeyManager } from '../crypto/keyManager';
14
18
  import { SignatureService } from '../crypto/signatureService';
@@ -96,7 +100,7 @@ export interface CommonsSignInStatus {
96
100
  */
97
101
  export interface CommonsApprovalInfo {
98
102
  /** Sanitized, display-safe identity of the requesting application. */
99
- application: PublicApplication;
103
+ application: PublicApplication | null;
100
104
  /** OAuth scopes the application is requesting. */
101
105
  scopes: string[];
102
106
  /** The origin the session is bound to (the RP web origin), when applicable. */
@@ -109,8 +113,8 @@ export interface CommonsApprovalInfo {
109
113
  * "not verified") by {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
110
114
  */
111
115
  originVerified: boolean;
112
- /** Server-authoritative expiry (epoch milliseconds). */
113
- expiresAt: number;
116
+ /** Server-authoritative expiry (epoch ms or ISO-8601 string from the API). */
117
+ expiresAt: number | string;
114
118
  /** Session lifecycle status. */
115
119
  status: string;
116
120
  }
@@ -122,11 +126,11 @@ export interface CommonsApprovalInfo {
122
126
  * into {@link CommonsApprovalInfo}.
123
127
  */
124
128
  interface CommonsApprovalInfoResponse {
125
- application: PublicApplication;
129
+ application: PublicApplication | null;
126
130
  scopes: string[];
127
131
  boundOrigin?: string;
128
132
  originVerified?: unknown;
129
- expiresAt: number;
133
+ expiresAt: number | string;
130
134
  status: string;
131
135
  }
132
136
 
@@ -367,7 +371,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
367
371
  'POST',
368
372
  '/auth/service-token',
369
373
  { apiKey: key, apiSecret: secret },
370
- { cache: false, retry: false }
374
+ { cache: false, retry: false, skipAuth: true }
371
375
  );
372
376
 
373
377
  const expiresAt = Date.now() + response.expiresIn * 1000;
@@ -482,7 +486,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
482
486
  publicKey,
483
487
  signature,
484
488
  timestamp,
485
- }, { cache: false });
489
+ }, { cache: false, skipAuth: true });
486
490
 
487
491
  if (!res || (typeof res === 'object' && Object.keys(res).length === 0)) {
488
492
  throw new OxyAuthenticationError('Registration failed', 'REGISTER_FAILED', 400);
@@ -511,7 +515,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
511
515
  try {
512
516
  return await this.makeRequest<ChallengeResponse>('POST', '/auth/challenge', {
513
517
  publicKey,
514
- }, { cache: false, ...requestOptions });
518
+ }, { cache: false, skipAuth: true, ...requestOptions });
515
519
  } catch (error) {
516
520
  throw this.handleError(error);
517
521
  }
@@ -548,7 +552,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
548
552
  timestamp,
549
553
  deviceName,
550
554
  deviceFingerprint,
551
- }, { cache: false, ...requestOptions });
555
+ }, { cache: false, skipAuth: true, ...requestOptions });
552
556
 
553
557
  // Plant the freshly-minted tokens, mirroring `claimSessionByToken`.
554
558
  // `/auth/verify` returns the first access token (and refresh token) in
@@ -577,7 +581,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
577
581
  'GET',
578
582
  `/auth/check-publickey/${encodeURIComponent(publicKey)}`,
579
583
  undefined,
580
- { cache: false }
584
+ { cache: false, skipAuth: true }
581
585
  );
582
586
  } catch (error) {
583
587
  throw this.handleError(error);
@@ -593,7 +597,12 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
593
597
  'GET',
594
598
  `/auth/user/${encodeURIComponent(publicKey)}`,
595
599
  undefined,
596
- { cache: true, cacheTTL: 2 * 60 * 1000 }
600
+ {
601
+ cache: true,
602
+ cacheTTL: 2 * 60 * 1000,
603
+ // Public lookup by public key (pre-session) — skip the bearer preflight.
604
+ skipAuth: true,
605
+ },
597
606
  );
598
607
  return normalizeUserIdentity(user);
599
608
  } catch (error) {
@@ -693,7 +702,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
693
702
  sessionToken,
694
703
  ...(options.deviceFingerprint ? { deviceFingerprint: options.deviceFingerprint } : {}),
695
704
  },
696
- { cache: false, retry: false }
705
+ // Body-authenticated device-flow claim (no bearer) — skip the preflight.
706
+ { cache: false, retry: false, skipAuth: true }
697
707
  );
698
708
 
699
709
  this.setTokens(res.accessToken);
@@ -802,7 +812,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
802
812
  'POST',
803
813
  '/auth/session/create',
804
814
  { sessionToken, expiresAt, clientId: params.clientId },
805
- { cache: false }
815
+ // Public/pre-session (no bearer): skip the preflight so a stale
816
+ // near-expiry token cannot re-enter refreshAccessToken while the
817
+ // refresh handler is already in flight (self-await hang).
818
+ { cache: false, skipAuth: true },
806
819
  );
807
820
 
808
821
  return {
@@ -832,7 +845,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
832
845
  'GET',
833
846
  `/auth/session/status/${encodeURIComponent(sessionToken)}`,
834
847
  undefined,
835
- { cache: false, retry: false }
848
+ // Public/pre-session (no bearer): a preflight here is wrong per se and,
849
+ // if ever reached while a refresh is pending, would re-enter
850
+ // refreshAccessToken and await the very promise it runs inside.
851
+ { cache: false, retry: false, skipAuth: true }
836
852
  );
837
853
  } catch (error) {
838
854
  throw this.handleError(error);
@@ -855,7 +871,9 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
855
871
  'GET',
856
872
  `/auth/session/approve-info/${encodeURIComponent(authorizeCode)}`,
857
873
  undefined,
858
- { cache: false }
874
+ // Public (no auth required) — skip the bearer preflight (avoids the
875
+ // pre-session self-await class).
876
+ { cache: false, skipAuth: true }
859
877
  );
860
878
  return {
861
879
  application: raw.application,
@@ -913,7 +931,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
913
931
  ...(params.deviceName ? { deviceName: params.deviceName } : {}),
914
932
  ...(params.deviceFingerprint ? { deviceFingerprint: params.deviceFingerprint } : {}),
915
933
  },
916
- { cache: false }
934
+ // Key-signed, cookieless (no bearer) — skip the preflight.
935
+ { cache: false, skipAuth: true }
917
936
  );
918
937
  } catch (error) {
919
938
  throw this.handleError(error);
@@ -932,7 +951,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
932
951
  'POST',
933
952
  `/auth/session/deny/${encodeURIComponent(authorizeCode)}`,
934
953
  undefined,
935
- { cache: false }
954
+ // Public (no auth required) — skip the bearer preflight.
955
+ { cache: false, skipAuth: true }
936
956
  );
937
957
  } catch (error) {
938
958
  throw this.handleError(error);
@@ -1065,7 +1085,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1065
1085
  */
1066
1086
  async checkUsernameAvailability(username: string): Promise<{ available: boolean; message: string }> {
1067
1087
  try {
1068
- return await this.makeRequest('GET', `/auth/check-username/${username}`, undefined, { cache: false });
1088
+ // Public availability lookup (pre-session) skip the bearer preflight.
1089
+ return await this.makeRequest('GET', `/auth/check-username/${username}`, undefined, { cache: false, skipAuth: true });
1069
1090
  } catch (error) {
1070
1091
  throw this.handleError(error);
1071
1092
  }
@@ -1076,7 +1097,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1076
1097
  */
1077
1098
  async checkEmailAvailability(email: string): Promise<{ available: boolean; message: string }> {
1078
1099
  try {
1079
- return await this.makeRequest('GET', `/auth/check-email/${email}`, undefined, { cache: false });
1100
+ // Public availability lookup (pre-session) skip the bearer preflight.
1101
+ return await this.makeRequest('GET', `/auth/check-email/${email}`, undefined, { cache: false, skipAuth: true });
1080
1102
  } catch (error) {
1081
1103
  throw this.handleError(error);
1082
1104
  }
@@ -1099,7 +1121,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1099
1121
  'POST',
1100
1122
  '/auth/webauthn/register/options',
1101
1123
  { ...(username !== undefined ? { username } : {}) },
1102
- { cache: false },
1124
+ { cache: false, ...(username !== undefined ? { skipAuth: true } : {}) },
1103
1125
  );
1104
1126
  } catch (error) {
1105
1127
  throw this.handleError(error);
@@ -1133,7 +1155,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1133
1155
  'POST',
1134
1156
  '/auth/webauthn/register/verify',
1135
1157
  { response, ...envelope },
1136
- { cache: false },
1158
+ { cache: false, ...(envelope.username !== undefined ? { skipAuth: true } : {}) },
1137
1159
  );
1138
1160
  if (res && typeof res === 'object') {
1139
1161
  const record = res as Record<string, unknown>;
@@ -1176,7 +1198,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1176
1198
  'POST',
1177
1199
  '/auth/webauthn/login/options',
1178
1200
  { ...(username !== undefined ? { username } : {}) },
1179
- { cache: false },
1201
+ // Pre-session login ceremony — skip the bearer preflight.
1202
+ { cache: false, skipAuth: true },
1180
1203
  );
1181
1204
  } catch (error) {
1182
1205
  throw this.handleError(error);
@@ -1200,7 +1223,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1200
1223
  'POST',
1201
1224
  '/auth/webauthn/login/verify',
1202
1225
  { response, ...envelope },
1203
- { cache: false },
1226
+ // Pre-session login ceremony — skip the bearer preflight.
1227
+ { cache: false, skipAuth: true },
1204
1228
  );
1205
1229
  const parsed = safeParseContract(loginResultSchema, res);
1206
1230
  if (!parsed) {
@@ -1233,7 +1257,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1233
1257
  clientId: params.clientId,
1234
1258
  redirectUri: params.redirectUri,
1235
1259
  codeVerifier: params.codeVerifier,
1236
- }, { cache: false });
1260
+ }, { cache: false, skipAuth: true });
1237
1261
  const payload =
1238
1262
  (res as { data?: Record<string, unknown> }).data ??
1239
1263
  (res as Record<string, unknown>);
@@ -105,7 +105,12 @@ export function OxyServicesConnectedAppsMixin<T extends typeof OxyServicesBase>(
105
105
  'GET',
106
106
  `/auth/oauth/client/${encodeURIComponent(clientId)}`,
107
107
  undefined,
108
- { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
108
+ {
109
+ cache: true,
110
+ cacheTTL: CACHE_TIMES.MEDIUM,
111
+ // Public client metadata (pre-session consent UI) — skip the bearer preflight.
112
+ skipAuth: true,
113
+ },
109
114
  );
110
115
  return res.application;
111
116
  } catch (error) {
@@ -58,7 +58,10 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
58
58
  'POST',
59
59
  '/session/device/token',
60
60
  { deviceId, deviceSecret },
61
- { cache: false, skipAuth: true, retry: false },
61
+ // `bypassQueue`: this mint is the control-plane call the auth lane
62
+ // depends on — it must run even when every RequestQueue slot is parked
63
+ // awaiting it, or the whole client deadlocks. See RequestOptions.bypassQueue.
64
+ { cache: false, skipAuth: true, retry: false, bypassQueue: true },
62
65
  );
63
66
  const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
64
67
  if (!parsed) {
@@ -99,7 +102,9 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
99
102
  'POST',
100
103
  '/session/device/redeem-ticket',
101
104
  { ticket, returnOrigin },
102
- { cache: false, skipAuth: true },
105
+ // Public device-hub sync mint (bearer-less). Same control-plane class as
106
+ // the device-secret mint — bypassQueue so it never waits for a slot.
107
+ { cache: false, skipAuth: true, bypassQueue: true },
103
108
  );
104
109
  const parsed = safeParseContract(deviceHubTicketRedeemResponseSchema, res);
105
110
  if (!parsed) {
@@ -218,6 +218,19 @@ export function OxyServicesIdentityBackupMixin<T extends typeof OxyServicesBase>
218
218
  );
219
219
  const payload = JSON.parse(new TextDecoder().decode(plaintext)) as BackupPayload;
220
220
 
221
+ if (!payload.privateKey || !payload.publicKey) {
222
+ throw new Error('Backup payload is missing key material');
223
+ }
224
+
225
+ const derivedFromPhrase = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
226
+ const derivedFromPrivate = KeyManager.derivePublicKey(payload.privateKey);
227
+ const phrasePk = derivedFromPhrase.toLowerCase();
228
+ const payloadPk = payload.publicKey.toLowerCase();
229
+ const privatePk = derivedFromPrivate.toLowerCase();
230
+ if (phrasePk !== payloadPk || privatePk !== payloadPk) {
231
+ throw new Error('Backup payload does not match the recovery phrase');
232
+ }
233
+
221
234
  // Persist the recovered key. Native-only; refuses to clobber a different
222
235
  // identity unless overwrite — the IdentityAlreadyExistsError propagates.
223
236
  return await KeyManager.importKeyPair(payload.privateKey, {
@@ -186,6 +186,8 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
186
186
  return await this.makeRequest('GET', `/auth/lookup/${encodeURIComponent(username)}`, undefined, {
187
187
  cache: true,
188
188
  cacheTTL: 60 * 1000, // 1 minute cache
189
+ // Public login-flow lookup (pre-session) — skip the bearer preflight.
190
+ skipAuth: true,
189
191
  });
190
192
  }
191
193
 
@@ -606,6 +608,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
606
608
  const result = await this.makeRequest<PrivacySettings>('PATCH', `/privacy/${id}/privacy`, settings, {
607
609
  cache: false,
608
610
  });
611
+ this.clearCacheByPrefix('GET:/session/user/');
612
+ this.clearCacheByPrefix('GET:/users/me');
613
+ this.clearCacheByPrefix('GET:/profiles/username/');
614
+ this.clearCacheEntry(`GET:/users/${id}`);
609
615
  this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
610
616
  return result;
611
617
  } catch (error) {
@@ -31,17 +31,19 @@ describe('OxyServices.deviceBoot', () => {
31
31
  },
32
32
  };
33
33
 
34
- it('POSTs deviceId + deviceSecret with skipAuth + retry:false (no bearer, no cache, single attempt) and returns the validated mint', async () => {
34
+ it('POSTs deviceId + deviceSecret with skipAuth + retry:false + bypassQueue (no bearer, no cache, single attempt, never queued) 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
38
  // `retry: false` — the scheduler/401 lane own backoff; HttpService's inner
39
39
  // retry here would only multiply cold-boot latency on a slow network.
40
+ // `bypassQueue: true` — the mint is the control-plane call the auth lane
41
+ // depends on; it must never wait for a RequestQueue slot (deadlock).
40
42
  expect(makeRequest).toHaveBeenCalledWith(
41
43
  'POST',
42
44
  '/session/device/token',
43
45
  { deviceId: 'dev-1', deviceSecret: 'ds-current-secret' },
44
- { cache: false, skipAuth: true, retry: false },
46
+ { cache: false, skipAuth: true, retry: false, bypassQueue: true },
45
47
  );
46
48
  });
47
49
 
@@ -57,7 +57,7 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
57
57
  'POST',
58
58
  '/auth/session/create',
59
59
  { sessionToken: 'secret-session-token', expiresAt: 1700000300000, clientId: 'oxy_dk_test' },
60
- expect.objectContaining({ cache: false }),
60
+ expect.objectContaining({ cache: false, skipAuth: true }),
61
61
  );
62
62
  // ...and the handle carries the SECRET token + the server's public code/payload.
63
63
  expect(handle).toEqual({
@@ -251,7 +251,7 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
251
251
  'POST',
252
252
  '/auth/challenge',
253
253
  { publicKey: 'pub-x' },
254
- { cache: false },
254
+ { cache: false, skipAuth: true },
255
255
  );
256
256
 
257
257
  await oxy.requestChallenge('pub-x', { retry: false });
@@ -259,7 +259,7 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
259
259
  'POST',
260
260
  '/auth/challenge',
261
261
  { publicKey: 'pub-x' },
262
- { cache: false, retry: false },
262
+ { cache: false, skipAuth: true, retry: false },
263
263
  );
264
264
  });
265
265
 
@@ -282,7 +282,7 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
282
282
  deviceName: 'dev',
283
283
  deviceFingerprint: 'fp',
284
284
  },
285
- { cache: false, retry: false, timeout: 9000 },
285
+ { cache: false, skipAuth: true, retry: false, timeout: 9000 },
286
286
  );
287
287
  });
288
288
  });
@@ -15,6 +15,7 @@
15
15
  import { OxyServices } from '../../OxyServices';
16
16
  import { KeyManager, IdentityAlreadyExistsError } from '../../crypto/keyManager';
17
17
  import { RecoveryPhraseService } from '../../crypto/recoveryPhrase';
18
+ import { encryptAead, decryptAead } from '../../crypto/aead';
18
19
  import type { EncryptedBackupEnvelope, BackupUploadRequest } from '@oxyhq/contracts';
19
20
 
20
21
  const FIXED_PHRASE =
@@ -192,6 +193,62 @@ describe('encrypted identity backup mixin', () => {
192
193
  await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow();
193
194
  });
194
195
 
196
+ it('rejects when decrypted payload publicKey does not match the recovery phrase', async () => {
197
+ fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
198
+ await oxy.createEncryptedBackup(FIXED_PHRASE);
199
+ const uploaded = uploadBodyFromCall(0);
200
+
201
+ const { backupKey } = await RecoveryPhraseService.deriveBackupMaterial(FIXED_PHRASE);
202
+ const aad = new TextEncoder().encode(
203
+ JSON.stringify({ version: uploaded.version, publicKeyHint: uploaded.publicKeyHint }),
204
+ );
205
+ const fromHex = (hex: string): Uint8Array => {
206
+ const out = new Uint8Array(hex.length / 2);
207
+ for (let i = 0; i < out.length; i += 1) {
208
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
209
+ }
210
+ return out;
211
+ };
212
+ const toHex = (bytes: Uint8Array): string =>
213
+ Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
214
+
215
+ const plaintext = decryptAead(
216
+ backupKey,
217
+ fromHex(uploaded.nonce),
218
+ fromHex(uploaded.ciphertext),
219
+ aad,
220
+ );
221
+ const payload = JSON.parse(new TextDecoder().decode(plaintext)) as {
222
+ privateKey: string;
223
+ publicKey: string;
224
+ createdAt: string;
225
+ };
226
+ payload.publicKey = `${payload.publicKey.slice(0, -1)}0`;
227
+
228
+ const { nonce, ciphertext } = encryptAead(
229
+ backupKey,
230
+ new TextEncoder().encode(JSON.stringify(payload)),
231
+ aad,
232
+ );
233
+ const tampered: EncryptedBackupEnvelope = {
234
+ version: uploaded.version,
235
+ algorithm: uploaded.algorithm,
236
+ kdfInfo: uploaded.kdfInfo,
237
+ nonce: toHex(nonce),
238
+ ciphertext: toHex(ciphertext),
239
+ publicKeyHint: uploaded.publicKeyHint,
240
+ createdAt: uploaded.createdAt,
241
+ };
242
+
243
+ const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue('x');
244
+ fetchMock.mockResolvedValueOnce(plainResponse(tampered));
245
+
246
+ await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow(
247
+ /does not match the recovery phrase/,
248
+ );
249
+ expect(importSpy).not.toHaveBeenCalled();
250
+ });
251
+
195
252
  it('propagates IdentityAlreadyExistsError UNCHANGED (so the caller can offer overwrite)', async () => {
196
253
  fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
197
254
  await oxy.createEncryptedBackup(FIXED_PHRASE);
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Regression: every purely pre-session public SDK endpoint must pass `skipAuth`
3
+ * so a pending refresh handler cannot self-await (see httpServiceAuthSelfAwait).
4
+ */
5
+ import { OxyServices } from '../../OxyServices';
6
+ import { SignatureService } from '../../crypto/signatureService';
7
+
8
+ describe('pre-session public endpoints use skipAuth', () => {
9
+ let oxy: OxyServices;
10
+ let makeRequest: jest.SpyInstance;
11
+
12
+ beforeEach(() => {
13
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
14
+ makeRequest = jest.spyOn(oxy, 'makeRequest').mockResolvedValue({} as never);
15
+ });
16
+
17
+ afterEach(() => jest.restoreAllMocks());
18
+
19
+ it('lookupUsername skips auth preflight', async () => {
20
+ await oxy.lookupUsername('alice');
21
+ expect(makeRequest).toHaveBeenCalledWith(
22
+ 'GET',
23
+ '/auth/lookup/alice',
24
+ undefined,
25
+ expect.objectContaining({ skipAuth: true }),
26
+ );
27
+ });
28
+
29
+ it('getPublicApplication skips auth preflight', async () => {
30
+ makeRequest.mockResolvedValueOnce({ application: { id: 'app-1', name: 'App' } });
31
+ await oxy.getPublicApplication('oxy_dk_test');
32
+ expect(makeRequest).toHaveBeenCalledWith(
33
+ 'GET',
34
+ '/auth/oauth/client/oxy_dk_test',
35
+ undefined,
36
+ expect.objectContaining({ skipAuth: true }),
37
+ );
38
+ });
39
+
40
+ it('claimSessionByToken skips auth preflight', async () => {
41
+ makeRequest.mockResolvedValueOnce({
42
+ accessToken: 'tok',
43
+ sessionId: 's1',
44
+ deviceId: 'd1',
45
+ expiresAt: '2030-01-01T00:00:00.000Z',
46
+ user: { id: 'u1', username: 'u' },
47
+ });
48
+ await oxy.claimSessionByToken('secret-session-token');
49
+ expect(makeRequest).toHaveBeenCalledWith(
50
+ 'POST',
51
+ '/auth/session/claim',
52
+ { sessionToken: 'secret-session-token' },
53
+ expect.objectContaining({ skipAuth: true, retry: false }),
54
+ );
55
+ });
56
+
57
+ it('startCommonsSignIn skips auth preflight', async () => {
58
+ jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
59
+ const generateChallenge = jest
60
+ .spyOn(SignatureService, 'generateChallenge')
61
+ .mockResolvedValue('secret-session-token');
62
+ makeRequest.mockResolvedValueOnce({
63
+ authorizeCode: 'code-1',
64
+ qrPayload: 'oxycommons://approve?v=1&code=code-1',
65
+ status: 'pending',
66
+ });
67
+ await oxy.startCommonsSignIn({ clientId: 'oxy_dk_test' });
68
+ expect(makeRequest).toHaveBeenCalledWith(
69
+ 'POST',
70
+ '/auth/session/create',
71
+ expect.objectContaining({ sessionToken: 'secret-session-token', clientId: 'oxy_dk_test' }),
72
+ expect.objectContaining({ cache: false, skipAuth: true }),
73
+ );
74
+ generateChallenge.mockRestore();
75
+ });
76
+
77
+ it('getUserByPublicKey skips auth preflight', async () => {
78
+ makeRequest.mockResolvedValueOnce({ id: 'u1', username: 'alice' });
79
+ await oxy.getUserByPublicKey('abc123');
80
+ expect(makeRequest).toHaveBeenCalledWith(
81
+ 'GET',
82
+ '/auth/user/abc123',
83
+ undefined,
84
+ expect.objectContaining({ skipAuth: true }),
85
+ );
86
+ });
87
+
88
+ it('webauthnRegisterOptions skips auth preflight on signup (username provided)', async () => {
89
+ await oxy.webauthnRegisterOptions('alice');
90
+ expect(makeRequest).toHaveBeenCalledWith(
91
+ 'POST',
92
+ '/auth/webauthn/register/options',
93
+ { username: 'alice' },
94
+ expect.objectContaining({ skipAuth: true }),
95
+ );
96
+ });
97
+
98
+ it('webauthnRegisterVerify skips auth preflight on signup (username in envelope)', async () => {
99
+ makeRequest.mockResolvedValueOnce({
100
+ sessionId: 's1',
101
+ deviceId: 'd1',
102
+ accessToken: 'tok',
103
+ expiresAt: '2030-01-01T00:00:00.000Z',
104
+ deviceSecret: 'ds',
105
+ user: { id: 'u1', username: 'alice' },
106
+ });
107
+ await oxy.webauthnRegisterVerify({ id: 'cred' }, { username: 'alice' });
108
+ expect(makeRequest).toHaveBeenCalledWith(
109
+ 'POST',
110
+ '/auth/webauthn/register/verify',
111
+ expect.objectContaining({ username: 'alice' }),
112
+ expect.objectContaining({ skipAuth: true }),
113
+ );
114
+ });
115
+
116
+ it('getServiceToken skips auth preflight', async () => {
117
+ makeRequest.mockResolvedValueOnce({ token: 'svc-tok', expiresIn: 3600 });
118
+ await oxy.getServiceToken('oxy_dk_svc', 'secret-value');
119
+ expect(makeRequest).toHaveBeenCalledWith(
120
+ 'POST',
121
+ '/auth/service-token',
122
+ { apiKey: 'oxy_dk_svc', apiSecret: 'secret-value' },
123
+ expect.objectContaining({ skipAuth: true, cache: false, retry: false }),
124
+ );
125
+ });
126
+
127
+ it('exchangeOAuthCode skips auth preflight', async () => {
128
+ makeRequest.mockResolvedValueOnce({
129
+ sessionId: 's1',
130
+ deviceId: 'd1',
131
+ accessToken: 'tok',
132
+ user: { id: 'u1' },
133
+ });
134
+ await oxy.exchangeOAuthCode({
135
+ code: 'code-1',
136
+ clientId: 'oxy_dk_test',
137
+ redirectUri: 'https://app.example/callback',
138
+ codeVerifier: 'verifier',
139
+ });
140
+ expect(makeRequest).toHaveBeenCalledWith(
141
+ 'POST',
142
+ '/auth/oauth/token',
143
+ {
144
+ code: 'code-1',
145
+ clientId: 'oxy_dk_test',
146
+ redirectUri: 'https://app.example/callback',
147
+ codeVerifier: 'verifier',
148
+ },
149
+ expect.objectContaining({ skipAuth: true }),
150
+ );
151
+ });
152
+ });
@@ -129,6 +129,7 @@ describe('privacy cache invalidation', () => {
129
129
 
130
130
  it('invalidates the exact logical keys on block/restrict/settings writes', async () => {
131
131
  const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
132
+ const clearPrefixSpy = jest.spyOn(oxy, 'clearCacheByPrefix');
132
133
 
133
134
  fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
134
135
  await oxy.blockUser('u1');
@@ -141,7 +142,12 @@ describe('privacy cache invalidation', () => {
141
142
  fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
142
143
  await oxy.updatePrivacySettings({ isPrivateAccount: true }, 'me');
143
144
  expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/me/privacy');
145
+ expect(clearSpy).toHaveBeenCalledWith('GET:/users/me');
146
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/session/user/');
147
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
148
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/profiles/username/');
144
149
 
145
150
  clearSpy.mockRestore();
151
+ clearPrefixSpy.mockRestore();
146
152
  });
147
153
  });
@@ -48,7 +48,7 @@ describe('webauthnRegisterOptions', () => {
48
48
  'POST',
49
49
  '/auth/webauthn/register/options',
50
50
  { username: 'alice' },
51
- { cache: false },
51
+ { cache: false, skipAuth: true },
52
52
  );
53
53
  });
54
54
 
@@ -83,7 +83,7 @@ describe('webauthnLoginOptions', () => {
83
83
  'POST',
84
84
  '/auth/webauthn/login/options',
85
85
  { username: 'alice' },
86
- { cache: false },
86
+ { cache: false, skipAuth: true },
87
87
  );
88
88
  });
89
89
 
@@ -94,7 +94,7 @@ describe('webauthnLoginOptions', () => {
94
94
  'POST',
95
95
  '/auth/webauthn/login/options',
96
96
  {},
97
- { cache: false },
97
+ { cache: false, skipAuth: true },
98
98
  );
99
99
  });
100
100
  });
@@ -129,7 +129,7 @@ describe('webauthnRegisterVerify', () => {
129
129
  'POST',
130
130
  '/auth/webauthn/register/verify',
131
131
  { response: CREATE_RESPONSE, username: 'alice', deviceName: 'Phone', deviceFingerprint: 'fp-1' },
132
- { cache: false },
132
+ { cache: false, skipAuth: true },
133
133
  );
134
134
  });
135
135
 
@@ -183,7 +183,7 @@ describe('webauthnLoginVerify', () => {
183
183
  'POST',
184
184
  '/auth/webauthn/login/verify',
185
185
  { response: GET_RESPONSE, deviceName: 'Phone', deviceFingerprint: 'fp-1', deviceId: 'dev-persisted' },
186
- { cache: false },
186
+ { cache: false, skipAuth: true },
187
187
  );
188
188
  });
189
189
 
@@ -194,7 +194,7 @@ describe('webauthnLoginVerify', () => {
194
194
  'POST',
195
195
  '/auth/webauthn/login/verify',
196
196
  { response: GET_RESPONSE },
197
- { cache: false },
197
+ { cache: false, skipAuth: true },
198
198
  );
199
199
  });
200
200