@oxyhq/core 12.8.0 → 12.10.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 (52) 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 +933 -106
  5. package/dist/cjs/index.js +11 -4
  6. package/dist/cjs/mixins/OxyServices.auth.js +25 -7
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
  8. package/dist/cjs/mixins/OxyServices.identityBackup.js +11 -0
  9. package/dist/cjs/mixins/OxyServices.user.js +4 -0
  10. package/dist/cjs/utils/commonsApproval.js +31 -0
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/boot/sessionColdBoot.js +16 -3
  13. package/dist/esm/crypto/identityMarker.js +248 -0
  14. package/dist/esm/crypto/keyManager.js +932 -106
  15. package/dist/esm/index.js +4 -2
  16. package/dist/esm/mixins/OxyServices.auth.js +22 -6
  17. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
  18. package/dist/esm/mixins/OxyServices.identityBackup.js +11 -0
  19. package/dist/esm/mixins/OxyServices.user.js +4 -0
  20. package/dist/esm/utils/commonsApproval.js +27 -0
  21. package/dist/types/.tsbuildinfo +1 -1
  22. package/dist/types/boot/sessionColdBoot.d.ts +25 -0
  23. package/dist/types/crypto/identityMarker.d.ts +94 -0
  24. package/dist/types/crypto/keyManager.d.ts +245 -3
  25. package/dist/types/index.d.ts +6 -3
  26. package/dist/types/mixins/OxyServices.auth.d.ts +31 -6
  27. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
  28. package/dist/types/utils/commonsApproval.d.ts +13 -0
  29. package/package.json +1 -1
  30. package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
  31. package/src/boot/sessionColdBoot.ts +42 -3
  32. package/src/crypto/__tests__/identityMocks.ts +125 -0
  33. package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
  34. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
  35. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
  36. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
  37. package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
  38. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
  39. package/src/crypto/__tests__/keyManager.test.ts +77 -87
  40. package/src/crypto/identityMarker.ts +291 -0
  41. package/src/crypto/keyManager.ts +1125 -105
  42. package/src/index.ts +14 -2
  43. package/src/mixins/OxyServices.auth.ts +40 -12
  44. package/src/mixins/OxyServices.deviceBoot.ts +9 -1
  45. package/src/mixins/OxyServices.identityBackup.ts +13 -0
  46. package/src/mixins/OxyServices.user.ts +4 -0
  47. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  48. package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
  49. package/src/mixins/__tests__/identityBackup.test.ts +57 -0
  50. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +6 -0
  51. package/src/utils/__tests__/commonsApproval.test.ts +60 -0
  52. package/src/utils/commonsApproval.ts +39 -0
package/src/index.ts CHANGED
@@ -34,7 +34,13 @@ export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
34
34
  // ---------------------------------------------------------------------------
35
35
  // Authentication
36
36
  // ---------------------------------------------------------------------------
37
- export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth';
37
+ export {
38
+ ServiceCredentialMismatchError,
39
+ } from './mixins/OxyServices.auth';
40
+ export {
41
+ getCommonsApprovalBlockingReason,
42
+ parseCommonsApprovalExpiresAt,
43
+ } from './utils/commonsApproval';
38
44
  export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
39
45
  // "Sign in with Oxy" — handoff (Workstream C)
40
46
  export type {
@@ -245,8 +251,14 @@ export {
245
251
  KeyManager,
246
252
  IdentityAlreadyExistsError,
247
253
  IdentityPersistError,
254
+ IdentityUnavailableError,
248
255
  } from './crypto/keyManager';
249
- export type { KeyPair } from './crypto/keyManager';
256
+ export type { KeyPair, IdentityStatus, IdentityRecoveryResult } from './crypto/keyManager';
257
+ export {
258
+ readIdentityMarker,
259
+ updateIdentityMarker,
260
+ } from './crypto/identityMarker';
261
+ export type { IdentityMarker } from './crypto/identityMarker';
250
262
  export { SignatureService } from './crypto/signatureService';
251
263
  export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
252
264
  export { RecoveryPhraseService } from './crypto/recoveryPhrase';
@@ -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
 
@@ -497,14 +501,21 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
497
501
  /**
498
502
  * Request an authentication challenge
499
503
  * The client must sign this challenge with their private key
500
- *
504
+ *
501
505
  * @param publicKey - The user's public key
506
+ * @param requestOptions - Optional per-call transport overrides (`retry`,
507
+ * `timeout`). Interactive callers omit it (defaults keep retries); the
508
+ * cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
509
+ * network cannot multiply boot latency via the inner retry loop.
502
510
  */
503
- async requestChallenge(publicKey: string): Promise<ChallengeResponse> {
511
+ async requestChallenge(
512
+ publicKey: string,
513
+ requestOptions?: { retry?: boolean; timeout?: number },
514
+ ): Promise<ChallengeResponse> {
504
515
  try {
505
516
  return await this.makeRequest<ChallengeResponse>('POST', '/auth/challenge', {
506
517
  publicKey,
507
- }, { cache: false });
518
+ }, { cache: false, ...requestOptions });
508
519
  } catch (error) {
509
520
  throw this.handleError(error);
510
521
  }
@@ -519,6 +530,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
519
530
  * @param timestamp - Timestamp when the signature was created
520
531
  * @param deviceName - Optional device name
521
532
  * @param deviceFingerprint - Optional device fingerprint
533
+ * @param requestOptions - Optional per-call transport overrides (`retry`,
534
+ * `timeout`). Interactive callers omit it (defaults keep retries); the
535
+ * cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
536
+ * network cannot multiply boot latency via the inner retry loop.
522
537
  */
523
538
  async verifyChallenge(
524
539
  publicKey: string,
@@ -526,7 +541,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
526
541
  signature: string,
527
542
  timestamp: number,
528
543
  deviceName?: string,
529
- deviceFingerprint?: string
544
+ deviceFingerprint?: string,
545
+ requestOptions?: { retry?: boolean; timeout?: number },
530
546
  ): Promise<SessionLoginResponse> {
531
547
  try {
532
548
  const res = await this.makeRequest<SessionLoginResponse>('POST', '/auth/verify', {
@@ -536,7 +552,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
536
552
  timestamp,
537
553
  deviceName,
538
554
  deviceFingerprint,
539
- }, { cache: false });
555
+ }, { cache: false, ...requestOptions });
540
556
 
541
557
  // Plant the freshly-minted tokens, mirroring `claimSessionByToken`.
542
558
  // `/auth/verify` returns the first access token (and refresh token) in
@@ -718,9 +734,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
718
734
  *
719
735
  * The cold-boot wiring that CALLS this lives in `OxyContext`
720
736
  * (`@oxyhq/services`); this method just performs the exchange.
737
+ *
738
+ * @param opts.requestOptions - Optional per-call transport overrides
739
+ * (`retry`, `timeout`) forwarded to BOTH the `requestChallenge` and
740
+ * `verifyChallenge` round-trips. Interactive flows omit it (defaults keep
741
+ * retries); the cold-boot `shared-key-signin` step passes `{ retry: false }`
742
+ * so this network step cannot multiply boot latency via the inner retry
743
+ * loop. The token-refresh scheduler / 401 lane still retry later.
721
744
  */
722
745
  async signInWithSharedIdentity(
723
- opts: { deviceName?: string; deviceFingerprint?: string } = {}
746
+ opts: {
747
+ deviceName?: string;
748
+ deviceFingerprint?: string;
749
+ requestOptions?: { retry?: boolean; timeout?: number };
750
+ } = {}
724
751
  ): Promise<SessionLoginResponse | null> {
725
752
  try {
726
753
  // `hasSharedIdentity()` already returns false on web (the shared
@@ -734,7 +761,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
734
761
  return null;
735
762
  }
736
763
 
737
- const { challenge } = await this.requestChallenge(sharedPublicKey);
764
+ const { challenge } = await this.requestChallenge(sharedPublicKey, opts.requestOptions);
738
765
  const signed = await SignatureService.signChallengeWithSharedKey(challenge);
739
766
 
740
767
  // `signed.challenge` carries the SIGNATURE (mirrors `signChallenge`).
@@ -745,6 +772,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
745
772
  signed.timestamp,
746
773
  opts.deviceName,
747
774
  opts.deviceFingerprint,
775
+ opts.requestOptions,
748
776
  );
749
777
  } catch (error) {
750
778
  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) {
@@ -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, {
@@ -606,6 +606,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
606
606
  const result = await this.makeRequest<PrivacySettings>('PATCH', `/privacy/${id}/privacy`, settings, {
607
607
  cache: false,
608
608
  });
609
+ this.clearCacheByPrefix('GET:/session/user/');
610
+ this.clearCacheByPrefix('GET:/users/me');
611
+ this.clearCacheByPrefix('GET:/profiles/username/');
612
+ this.clearCacheEntry(`GET:/users/${id}`);
609
613
  this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
610
614
  return result;
611
615
  } catch (error) {
@@ -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
  });
@@ -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);
@@ -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
  });
@@ -0,0 +1,60 @@
1
+ import {
2
+ getCommonsApprovalBlockingReason,
3
+ parseCommonsApprovalExpiresAt,
4
+ } from '../commonsApproval';
5
+
6
+ describe('commonsApproval validation', () => {
7
+ const application = {
8
+ id: 'app1',
9
+ name: 'Mention',
10
+ type: 'first_party' as const,
11
+ isOfficial: true,
12
+ isInternal: false,
13
+ scopes: ['profile'],
14
+ };
15
+
16
+ it('blocks when the application is missing', () => {
17
+ expect(
18
+ getCommonsApprovalBlockingReason({
19
+ application: null,
20
+ status: 'pending',
21
+ expiresAt: Date.now() + 60_000,
22
+ }),
23
+ ).toMatch(/could not be resolved/i);
24
+ });
25
+
26
+ it('blocks non-pending sessions', () => {
27
+ expect(
28
+ getCommonsApprovalBlockingReason({
29
+ application,
30
+ status: 'expired',
31
+ expiresAt: Date.now() + 60_000,
32
+ }),
33
+ ).toMatch(/invalid, already used, or expired/i);
34
+ });
35
+
36
+ it('blocks expired pending sessions (ISO expiresAt)', () => {
37
+ expect(
38
+ getCommonsApprovalBlockingReason({
39
+ application,
40
+ status: 'pending',
41
+ expiresAt: new Date(Date.now() - 60_000).toISOString(),
42
+ }),
43
+ ).toMatch(/expired/i);
44
+ });
45
+
46
+ it('allows a pending, unexpired session', () => {
47
+ expect(
48
+ getCommonsApprovalBlockingReason({
49
+ application,
50
+ status: 'pending',
51
+ expiresAt: Date.now() + 60_000,
52
+ }),
53
+ ).toBeNull();
54
+ });
55
+
56
+ it('parses ISO expiresAt strings', () => {
57
+ const iso = '2026-07-19T12:00:00.000Z';
58
+ expect(parseCommonsApprovalExpiresAt(iso)).toBe(Date.parse(iso));
59
+ });
60
+ });
@@ -0,0 +1,39 @@
1
+ import type { PublicApplication } from '../mixins/OxyServices.connectedApps';
2
+
3
+ export interface CommonsApprovalValidationInput {
4
+ application: PublicApplication | null;
5
+ status: string;
6
+ expiresAt: number | string;
7
+ }
8
+
9
+ /**
10
+ * Returns a user-facing blocking reason when an approval payload must not be
11
+ * shown as actionable, or `null` when the request is still pending and valid.
12
+ */
13
+ export function getCommonsApprovalBlockingReason(
14
+ info: CommonsApprovalValidationInput,
15
+ ): string | null {
16
+ if (!info.application?.id) {
17
+ return 'The requesting application could not be resolved.';
18
+ }
19
+ if (info.status !== 'pending') {
20
+ return 'This sign-in request is invalid, already used, or expired.';
21
+ }
22
+ const expiresAtMs = parseCommonsApprovalExpiresAt(info.expiresAt);
23
+ if (expiresAtMs !== null && expiresAtMs < Date.now()) {
24
+ return 'This sign-in request has expired. Ask for a new QR code.';
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** Normalize API `expiresAt` (number or ISO string) to epoch ms. */
30
+ export function parseCommonsApprovalExpiresAt(
31
+ expiresAt: CommonsApprovalValidationInput['expiresAt'],
32
+ ): number | null {
33
+ if (typeof expiresAt === 'number' && Number.isFinite(expiresAt)) return expiresAt;
34
+ if (typeof expiresAt === 'string') {
35
+ const ms = Date.parse(expiresAt);
36
+ return Number.isFinite(ms) ? ms : null;
37
+ }
38
+ return null;
39
+ }