@oxyhq/core 3.6.0 → 3.7.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 (32) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +102 -66
  3. package/dist/cjs/mixins/OxyServices.assets.js +34 -2
  4. package/dist/cjs/mixins/OxyServices.user.js +78 -14
  5. package/dist/cjs/utils/cacheKey.js +87 -0
  6. package/dist/cjs/utils/errorUtils.js +25 -0
  7. package/dist/esm/.tsbuildinfo +1 -1
  8. package/dist/esm/HttpService.js +101 -65
  9. package/dist/esm/mixins/OxyServices.assets.js +34 -2
  10. package/dist/esm/mixins/OxyServices.user.js +78 -14
  11. package/dist/esm/utils/cacheKey.js +82 -0
  12. package/dist/esm/utils/errorUtils.js +24 -0
  13. package/dist/types/.tsbuildinfo +1 -1
  14. package/dist/types/HttpService.d.ts +24 -16
  15. package/dist/types/mixins/OxyServices.assets.d.ts +24 -1
  16. package/dist/types/mixins/OxyServices.user.d.ts +21 -27
  17. package/dist/types/utils/cacheKey.d.ts +67 -0
  18. package/dist/types/utils/errorUtils.d.ts +12 -0
  19. package/package.json +2 -2
  20. package/src/HttpService.ts +116 -67
  21. package/src/__tests__/authManager.cookiePath.test.ts +2 -2
  22. package/src/__tests__/authManager.security.test.ts +2 -2
  23. package/src/__tests__/httpServiceCache.test.ts +71 -0
  24. package/src/mixins/OxyServices.assets.ts +36 -2
  25. package/src/mixins/OxyServices.user.ts +104 -32
  26. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
  27. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +83 -0
  28. package/src/mixins/__tests__/sso.test.ts +13 -3
  29. package/src/utils/__tests__/cacheKey.test.ts +0 -0
  30. package/src/utils/__tests__/coldBoot.test.ts +125 -0
  31. package/src/utils/cacheKey.ts +98 -0
  32. package/src/utils/errorUtils.ts +25 -0
@@ -373,4 +373,129 @@ describe('runColdBoot', () => {
373
373
  expect(laterRan).not.toHaveBeenCalled();
374
374
  });
375
375
  });
376
+
377
+ /**
378
+ * End-to-end reproduction of the production FedCM-silent hang at the REAL
379
+ * overall deadline the SDK ships.
380
+ *
381
+ * `OxyContext` runs the web cold-boot chain with `COLD_BOOT_OVERALL_DEADLINE`
382
+ * (20000 ms): fedcm-silent → /auth/silent iframe → cookie restore →
383
+ * stored-session → /sso top-level bounce (terminal). The documented gotcha:
384
+ * `navigator.credentials.get({mediation:'silent'})` can sit pending forever,
385
+ * ignoring its AbortController. WITHOUT the overall deadline the whole chain
386
+ * hangs and the terminal `/sso` bounce never fires.
387
+ *
388
+ * These tests model that exact chain shape against the real deadline value and
389
+ * assert the runner (a) abandons the hung silent step at the deadline,
390
+ * (b) still fires the terminal bounce's synchronous side effect, and
391
+ * (c) always settles within the bounded budget — it never hangs.
392
+ */
393
+ describe('production cold-boot deadline semantics (FedCM-silent hang)', () => {
394
+ /**
395
+ * Mirror of `COLD_BOOT_OVERALL_DEADLINE` in
396
+ * `@oxyhq/services` `OxyContext` (the only consumer that arms the deadline).
397
+ * Kept as a local literal because core does not — and must not — import the
398
+ * services package; if the consumer's value changes, update this to match.
399
+ */
400
+ const COLD_BOOT_OVERALL_DEADLINE = 20000;
401
+
402
+ beforeEach(() => {
403
+ jest.useFakeTimers();
404
+ });
405
+ afterEach(() => {
406
+ jest.runOnlyPendingTimers();
407
+ jest.useRealTimers();
408
+ });
409
+
410
+ /** A step whose `run()` never settles — the hung `credentials.get`. */
411
+ const hungFedcmSilentStep = (): ColdBootStep<TestSession> => ({
412
+ id: 'fedcm-silent',
413
+ run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
414
+ });
415
+
416
+ it('abandons the hung fedcm-silent step at the 20s deadline and fires the terminal /sso bounce', async () => {
417
+ const ssoBounced = jest.fn();
418
+ const onStepDeadline = jest.fn();
419
+
420
+ const outcomePromise = runColdBoot<TestSession>({
421
+ overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
422
+ onStepDeadline,
423
+ steps: [
424
+ hungFedcmSilentStep(),
425
+ // Every intermediate step has nothing to contribute on this load.
426
+ skipStep('auth-silent-iframe'),
427
+ skipStep('cookie-restore'),
428
+ skipStep('stored-session'),
429
+ {
430
+ id: 'sso-bounce',
431
+ // Terminal: navigates synchronously before its first await, exactly
432
+ // like the real top-level `/sso` bounce.
433
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
434
+ ssoBounced();
435
+ return { kind: 'skip' };
436
+ },
437
+ },
438
+ ],
439
+ });
440
+
441
+ // Nothing settles before the deadline — the chain is "stuck" on silent.
442
+ await jest.advanceTimersByTimeAsync(COLD_BOOT_OVERALL_DEADLINE - 1);
443
+ expect(ssoBounced).not.toHaveBeenCalled();
444
+
445
+ // At the deadline the silent step is abandoned and the chain proceeds.
446
+ await jest.advanceTimersByTimeAsync(1);
447
+ const outcome = await outcomePromise;
448
+
449
+ expect(onStepDeadline).toHaveBeenCalledWith('fedcm-silent');
450
+ expect(ssoBounced).toHaveBeenCalledTimes(1);
451
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
452
+ });
453
+
454
+ it('settles within the bounded budget instead of hanging forever', async () => {
455
+ let settled = false;
456
+ const outcomePromise = runColdBoot<TestSession>({
457
+ overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
458
+ steps: [hungFedcmSilentStep(), skipStep('terminal')],
459
+ }).then((o) => {
460
+ settled = true;
461
+ return o;
462
+ });
463
+
464
+ // Just before the deadline: still pending (proves the deadline, not an
465
+ // accidental early settle, is what unblocks it).
466
+ await jest.advanceTimersByTimeAsync(COLD_BOOT_OVERALL_DEADLINE - 1);
467
+ expect(settled).toBe(false);
468
+
469
+ await jest.advanceTimersByTimeAsync(1);
470
+ await outcomePromise;
471
+ expect(settled).toBe(true);
472
+ });
473
+
474
+ it('does not penalize a fast silent success — it wins well before the deadline', async () => {
475
+ const laterRan = jest.fn();
476
+ const outcomePromise = runColdBoot<TestSession>({
477
+ overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
478
+ steps: [
479
+ {
480
+ id: 'fedcm-silent',
481
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
482
+ await Promise.resolve();
483
+ return { kind: 'session', session: { userId: 'u-silent' } };
484
+ },
485
+ },
486
+ sessionStep('sso-bounce', 'u-should-not-run', laterRan),
487
+ ],
488
+ });
489
+
490
+ await jest.advanceTimersByTimeAsync(0);
491
+ const outcome = await outcomePromise;
492
+
493
+ expect(outcome).toEqual({
494
+ kind: 'session',
495
+ via: 'fedcm-silent',
496
+ session: { userId: 'u-silent' },
497
+ });
498
+ expect(laterRan).not.toHaveBeenCalled();
499
+ });
500
+ });
376
501
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Cache-key primitives for the identity-scoped HTTP GET response cache.
3
+ *
4
+ * Extracted from {@link HttpService} so the identity-tag derivation is a pure,
5
+ * independently testable function with no dependency on instance/token state.
6
+ * The HTTP service injects the live access token and acting-as id; everything
7
+ * here is referentially transparent given those inputs.
8
+ */
9
+
10
+ import { jwtDecode } from 'jwt-decode';
11
+
12
+ /**
13
+ * Minimal JWT payload shape we read for cache scoping. The identity discriminator
14
+ * comes from `userId` (preferred) or `id`; nothing else is consulted here.
15
+ */
16
+ export interface CacheIdentityJwtPayload {
17
+ userId?: string;
18
+ id?: string;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ /**
23
+ * Discriminator used when there is no access token at all. Anonymous responses
24
+ * must never collide with any authenticated identity.
25
+ */
26
+ export const ANON_IDENTITY = 'anon';
27
+
28
+ /**
29
+ * FNV-1a 32-bit non-cryptographic hash.
30
+ *
31
+ * Used by the cache-key generator for large payloads where full JSON inclusion
32
+ * would balloon the cache map keys, and as the fallback discriminator for an
33
+ * undecodable access token. Content-addressed: every byte of the input
34
+ * contributes to the digest, so two inputs with the same top-level shape but
35
+ * different field values produce different keys (the previous `keys + length`
36
+ * heuristic collided on these).
37
+ *
38
+ * Trade-offs:
39
+ * - 32 bits is ample for an in-process cache (collision risk negligible at our
40
+ * key counts; we also prefix with method + url which further partitions the
41
+ * keyspace).
42
+ * - Not cryptographically secure — never use for security decisions.
43
+ * - Zero dependencies, branch-free hot loop, ~1 GiB/s on V8.
44
+ */
45
+ export function fnv1a32(str: string): string {
46
+ let h = 0x811c9dc5;
47
+ for (let i = 0; i < str.length; i++) {
48
+ h ^= str.charCodeAt(i);
49
+ // h * 16777619 mod 2^32, written as shift-and-add for portability and
50
+ // to avoid 53-bit JS number truncation in the intermediate multiply.
51
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
52
+ }
53
+ return h.toString(16).padStart(8, '0');
54
+ }
55
+
56
+ /**
57
+ * Derive a stable, non-sensitive identity discriminator for cache scoping.
58
+ *
59
+ * The GET-response cache MUST be partitioned by caller identity: endpoints with
60
+ * optional auth (e.g. `GET /profiles/recommendations`) return different content
61
+ * for an anonymous vs an authenticated caller, and per-user content for
62
+ * different authenticated users. Keying solely on `method:url:data` let an
63
+ * anonymous response be served to an authenticated caller — surfacing as
64
+ * "Who to follow" recommending accounts the user already follows after a
65
+ * cold-boot session restore.
66
+ *
67
+ * Resolution order:
68
+ * - no token → {@link ANON_IDENTITY} (`'anon'`).
69
+ * - decodable token → the token's `userId || id`.
70
+ * - undecodable token → a short FNV-1a hash of the token, prefixed `t` so it
71
+ * can never collide with `'anon'` or a real user id.
72
+ *
73
+ * We use the decoded user id rather than the raw JWT so the token never lands
74
+ * in a cache key (no token leakage through any cache-key logging, no key bloat).
75
+ * The acting-as id is folded in because managed-account responses differ per
76
+ * acting identity — and `X-Acting-As` already changes the server response for
77
+ * the same bearer token.
78
+ *
79
+ * @param accessToken The current bearer access token, or `null` when anonymous.
80
+ * @param actingAsUserId The active managed-account id, or `null`.
81
+ */
82
+ export function computeIdentityTag(
83
+ accessToken: string | null,
84
+ actingAsUserId: string | null,
85
+ ): string {
86
+ let principal = ANON_IDENTITY;
87
+ if (accessToken) {
88
+ try {
89
+ const decoded = jwtDecode<CacheIdentityJwtPayload>(accessToken);
90
+ principal = decoded.userId || decoded.id || `t${fnv1a32(accessToken)}`;
91
+ } catch {
92
+ // Undecodable token — still partition it away from anon and from other
93
+ // tokens via a hash. Never silently fall back to ANON_IDENTITY.
94
+ principal = `t${fnv1a32(accessToken)}`;
95
+ }
96
+ }
97
+ return actingAsUserId ? `${principal}~as${actingAsUserId}` : principal;
98
+ }
@@ -180,6 +180,31 @@ export function getErrorCodeFromStatus(status: number): string {
180
180
  }
181
181
  }
182
182
 
183
+ /**
184
+ * Best-effort extraction of an HTTP status code from a thrown value.
185
+ *
186
+ * `HttpService` annotates the errors it throws with both `error.status` and
187
+ * `error.response.status`; an already-normalized {@link ApiError} carries
188
+ * `error.status`. This reads either, returning `undefined` when the value is
189
+ * not an object or carries no numeric status (e.g. a thrown string, a network
190
+ * `TypeError`). Used by discovery/read paths to distinguish a 404 "not found"
191
+ * from a transport/server failure for observability without re-deriving the
192
+ * narrowing at every call site.
193
+ */
194
+ export function extractErrorStatus(error: unknown): number | undefined {
195
+ if (!error || typeof error !== 'object') {
196
+ return undefined;
197
+ }
198
+ const record = error as { status?: unknown; response?: { status?: unknown } };
199
+ if (typeof record.status === 'number') {
200
+ return record.status;
201
+ }
202
+ if (typeof record.response?.status === 'number') {
203
+ return record.response.status;
204
+ }
205
+ return undefined;
206
+ }
207
+
183
208
  /**
184
209
  * Validate required fields and throw error if missing
185
210
  */