@oxyhq/core 3.5.0 → 3.7.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 (35) 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 +123 -18
  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 +123 -18
  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/index.d.ts +1 -1
  16. package/dist/types/mixins/OxyServices.assets.d.ts +24 -1
  17. package/dist/types/mixins/OxyServices.user.d.ts +54 -28
  18. package/dist/types/utils/cacheKey.d.ts +67 -0
  19. package/dist/types/utils/errorUtils.d.ts +12 -0
  20. package/package.json +1 -1
  21. package/src/HttpService.ts +116 -67
  22. package/src/__tests__/authManager.cookiePath.test.ts +2 -2
  23. package/src/__tests__/authManager.security.test.ts +2 -2
  24. package/src/__tests__/httpServiceCache.test.ts +71 -0
  25. package/src/index.ts +2 -0
  26. package/src/mixins/OxyServices.assets.ts +36 -2
  27. package/src/mixins/OxyServices.user.ts +167 -36
  28. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
  29. package/src/mixins/__tests__/followCacheInvalidation.test.ts +168 -0
  30. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +83 -0
  31. package/src/mixins/__tests__/sso.test.ts +13 -3
  32. package/src/utils/__tests__/cacheKey.test.ts +0 -0
  33. package/src/utils/__tests__/coldBoot.test.ts +125 -0
  34. package/src/utils/cacheKey.ts +98 -0
  35. package/src/utils/errorUtils.ts +25 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Follow-status cache invalidation tests.
3
+ *
4
+ * Regression coverage for the "follow resets after navigating away and back"
5
+ * bug. `getFollowStatus(userId)` caches `GET /users/<id>/follow-status` for
6
+ * ~1 minute (identity-scoped). A follow/unfollow write must INVALIDATE that
7
+ * cached entry, otherwise a `FollowButton` that remounts within the TTL window
8
+ * re-reads the STALE pre-write status and reverts the optimistic UI.
9
+ *
10
+ * These tests pin down that:
11
+ * - a cached `follow-status` is NOT served after `followUser` /
12
+ * `unfollowUser` / `followUsers` / `unfollowUsers` — the next read
13
+ * re-fetches and observes the new server truth,
14
+ * - each mutation invalidates the exact logical key(s) for the affected ids.
15
+ */
16
+
17
+ import { OxyServices } from '../../OxyServices';
18
+
19
+ /**
20
+ * Build a non-verified JWT whose payload decodes to the given claims.
21
+ * `jwtDecode` only base64url-decodes the middle segment (no signature check).
22
+ */
23
+ function makeJwt(payload: Record<string, unknown>): string {
24
+ const b64url = (obj: Record<string, unknown>): string =>
25
+ Buffer.from(JSON.stringify(obj)).toString('base64url');
26
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
27
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
28
+ }
29
+
30
+ /** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
31
+ function jsonResponse(data: unknown): Response {
32
+ return new Response(JSON.stringify({ data }), {
33
+ status: 200,
34
+ headers: { 'content-type': 'application/json' },
35
+ });
36
+ }
37
+
38
+ describe('follow-status cache invalidation', () => {
39
+ let originalFetch: typeof globalThis.fetch;
40
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
41
+ let oxy: OxyServices;
42
+
43
+ beforeEach(() => {
44
+ originalFetch = globalThis.fetch;
45
+ fetchMock = jest.fn();
46
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
47
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
48
+ oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
49
+ });
50
+
51
+ afterEach(() => {
52
+ globalThis.fetch = originalFetch;
53
+ jest.clearAllMocks();
54
+ });
55
+
56
+ it('does NOT serve a stale cached follow-status after followUser', async () => {
57
+ // 1) Warm the cache: not following yet.
58
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
59
+ const before = await oxy.getFollowStatus('target-1');
60
+ expect(before.isFollowing).toBe(false);
61
+ expect(fetchMock).toHaveBeenCalledTimes(1);
62
+
63
+ // A second read within the TTL is a cache hit (no extra network call).
64
+ await oxy.getFollowStatus('target-1');
65
+ expect(fetchMock).toHaveBeenCalledTimes(1);
66
+
67
+ // 2) Follow — write succeeds and must bust the cached follow-status.
68
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
69
+ await oxy.followUser('target-1');
70
+ expect(fetchMock).toHaveBeenCalledTimes(2);
71
+
72
+ // 3) Remount-style re-read MUST re-fetch and observe the fresh truth.
73
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
74
+ const after = await oxy.getFollowStatus('target-1');
75
+ expect(fetchMock).toHaveBeenCalledTimes(3);
76
+ expect(after.isFollowing).toBe(true);
77
+ });
78
+
79
+ it('does NOT serve a stale cached follow-status after unfollowUser', async () => {
80
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
81
+ const before = await oxy.getFollowStatus('target-2');
82
+ expect(before.isFollowing).toBe(true);
83
+ expect(fetchMock).toHaveBeenCalledTimes(1);
84
+
85
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
86
+ await oxy.unfollowUser('target-2');
87
+ expect(fetchMock).toHaveBeenCalledTimes(2);
88
+
89
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
90
+ const after = await oxy.getFollowStatus('target-2');
91
+ expect(fetchMock).toHaveBeenCalledTimes(3);
92
+ expect(after.isFollowing).toBe(false);
93
+ });
94
+
95
+ it('busts cached follow-status for every id after followUsers (bulk)', async () => {
96
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
97
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
98
+ await oxy.getFollowStatus('a');
99
+ await oxy.getFollowStatus('b');
100
+ expect(fetchMock).toHaveBeenCalledTimes(2);
101
+
102
+ fetchMock.mockResolvedValueOnce(
103
+ jsonResponse({
104
+ results: [
105
+ { userId: 'a', success: true, alreadyFollowing: false },
106
+ { userId: 'b', success: true, alreadyFollowing: false },
107
+ ],
108
+ followedCount: 2,
109
+ }),
110
+ );
111
+ await oxy.followUsers(['a', 'b']);
112
+ expect(fetchMock).toHaveBeenCalledTimes(3);
113
+
114
+ // Both ids must re-fetch.
115
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
116
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
117
+ expect((await oxy.getFollowStatus('a')).isFollowing).toBe(true);
118
+ expect((await oxy.getFollowStatus('b')).isFollowing).toBe(true);
119
+ expect(fetchMock).toHaveBeenCalledTimes(5);
120
+ });
121
+
122
+ it('busts cached follow-status for every id after unfollowUsers (bulk)', async () => {
123
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
124
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
125
+ await oxy.getFollowStatus('a');
126
+ await oxy.getFollowStatus('b');
127
+ expect(fetchMock).toHaveBeenCalledTimes(2);
128
+
129
+ fetchMock.mockResolvedValueOnce(
130
+ jsonResponse({
131
+ results: [
132
+ { userId: 'a', success: true, wasFollowing: true },
133
+ { userId: 'b', success: true, wasFollowing: true },
134
+ ],
135
+ unfollowedCount: 2,
136
+ }),
137
+ );
138
+ await oxy.unfollowUsers(['a', 'b']);
139
+ expect(fetchMock).toHaveBeenCalledTimes(3);
140
+
141
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
142
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
143
+ expect((await oxy.getFollowStatus('a')).isFollowing).toBe(false);
144
+ expect((await oxy.getFollowStatus('b')).isFollowing).toBe(false);
145
+ expect(fetchMock).toHaveBeenCalledTimes(5);
146
+ });
147
+
148
+ it('invalidates the exact logical follow-status key on a single follow', async () => {
149
+ const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
150
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
151
+
152
+ await oxy.followUser('target-3');
153
+
154
+ expect(clearSpy).toHaveBeenCalledWith('GET:/users/target-3/follow-status');
155
+ clearSpy.mockRestore();
156
+ });
157
+
158
+ it('does not invalidate or call the network when bulk follow gets an empty list', async () => {
159
+ const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
160
+
161
+ const result = await oxy.followUsers([]);
162
+
163
+ expect(result).toEqual({ results: [], followedCount: 0 });
164
+ expect(fetchMock).not.toHaveBeenCalled();
165
+ expect(clearSpy).not.toHaveBeenCalled();
166
+ clearSpy.mockRestore();
167
+ });
168
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `OxyServices.getFileDownloadUrl()` resolution tests.
3
+ *
4
+ * This is the single chokepoint every Oxy app uses to turn a stored asset id
5
+ * into a `<img src>`-ready URL. It resolves to one of two forms:
6
+ *
7
+ * - PUBLIC (no access token planted, no `expiresIn`) → the clean CDN form
8
+ * `${cloudURL}/<id>[?variant=...]` (default `https://cloud.oxy.so/<id>`),
9
+ * which CloudFront resolves against the public media origin.
10
+ * - SIGNED / PRIVATE (an access token is present OR `expiresIn` is passed) →
11
+ * the authenticated API origin form
12
+ * `${baseURL}/assets/<id>/stream?...&token=...` — private assets are not on
13
+ * the public CDN.
14
+ */
15
+
16
+ import { OxyServices } from '../../OxyServices';
17
+
18
+ describe('OxyServices.getFileDownloadUrl', () => {
19
+ describe('public assets (no token, no expiresIn) → CDN', () => {
20
+ it('returns the clean cloud.oxy.so URL for a bare file id', () => {
21
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
22
+
23
+ expect(oxy.getFileDownloadUrl('file123')).toBe('https://cloud.oxy.so/file123');
24
+ });
25
+
26
+ it('appends only a variant query param (no token/fallback) for the thumb variant', () => {
27
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
28
+
29
+ expect(oxy.getFileDownloadUrl('file123', 'thumb')).toBe(
30
+ 'https://cloud.oxy.so/file123?variant=thumb',
31
+ );
32
+ });
33
+
34
+ it('uses the configured cloudURL when overridden', () => {
35
+ const oxy = new OxyServices({
36
+ baseURL: 'https://api.oxy.so',
37
+ cloudURL: 'https://cdn.example.test',
38
+ });
39
+
40
+ expect(oxy.getFileDownloadUrl('file123', 'thumb')).toBe(
41
+ 'https://cdn.example.test/file123?variant=thumb',
42
+ );
43
+ });
44
+
45
+ it('URL-encodes the file id and the variant', () => {
46
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
47
+
48
+ expect(oxy.getFileDownloadUrl('a/b c', 'large size')).toBe(
49
+ 'https://cloud.oxy.so/a%2Fb%20c?variant=large%20size',
50
+ );
51
+ });
52
+ });
53
+
54
+ describe('signed / private assets → authenticated API origin', () => {
55
+ it('returns the stream endpoint with the token when an access token is present', () => {
56
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
57
+ oxy.setTokens('access-token-abc');
58
+
59
+ const url = oxy.getFileDownloadUrl('file123', 'thumb');
60
+
61
+ expect(url.startsWith('https://api.oxy.so/assets/file123/stream?')).toBe(true);
62
+ const params = new URLSearchParams(url.split('?')[1]);
63
+ expect(params.get('variant')).toBe('thumb');
64
+ expect(params.get('token')).toBe('access-token-abc');
65
+ expect(params.get('fallback')).toBe('placeholderVisible');
66
+ expect(url).not.toContain('cloud.oxy.so');
67
+ });
68
+
69
+ it('routes through the stream endpoint when expiresIn is requested even without a token', () => {
70
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
71
+
72
+ const url = oxy.getFileDownloadUrl('file123', 'thumb', 3600);
73
+
74
+ expect(url.startsWith('https://api.oxy.so/assets/file123/stream?')).toBe(true);
75
+ const params = new URLSearchParams(url.split('?')[1]);
76
+ expect(params.get('expiresIn')).toBe('3600');
77
+ expect(params.get('variant')).toBe('thumb');
78
+ expect(params.get('fallback')).toBe('placeholderVisible');
79
+ expect(params.get('token')).toBeNull();
80
+ expect(url).not.toContain('cloud.oxy.so');
81
+ });
82
+ });
83
+ });
@@ -36,7 +36,12 @@ const VALID_BODY = {
36
36
  sessionId: 'sess_sso',
37
37
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
38
38
  authuser: 0,
39
- user: { id: 'user_sso', username: 'ssouser', avatar: 'file_1' },
39
+ user: {
40
+ id: 'user_sso',
41
+ username: 'ssouser',
42
+ name: { displayName: 'SSO User', first: 'SSO', last: 'User', full: 'SSO User' },
43
+ avatar: 'file_1',
44
+ },
40
45
  };
41
46
 
42
47
  describe('OxyServices.exchangeSsoCode', () => {
@@ -83,7 +88,12 @@ describe('OxyServices.exchangeSsoCode', () => {
83
88
  expect(oxy.getAccessToken()).toBe('access_sso');
84
89
  expect(session.sessionId).toBe('sess_sso');
85
90
  expect(session.accessToken).toBe('access_sso');
86
- expect(session.user).toEqual({ id: 'user_sso', username: 'ssouser', avatar: 'file_1' });
91
+ expect(session.user).toEqual({
92
+ id: 'user_sso',
93
+ username: 'ssouser',
94
+ name: { displayName: 'SSO User', first: 'SSO', last: 'User', full: 'SSO User' },
95
+ avatar: 'file_1',
96
+ });
87
97
  expect(session.expiresAt).toBe(VALID_BODY.expiresAt);
88
98
  });
89
99
 
@@ -92,7 +102,7 @@ describe('OxyServices.exchangeSsoCode', () => {
92
102
  mockFetchOnce({
93
103
  accessToken: 'access_sso',
94
104
  sessionId: 'sess_sso',
95
- user: { _id: 'mongo_id', username: 'ssouser' },
105
+ user: { _id: 'mongo_id', username: 'ssouser', name: { displayName: 'SSO User' } },
96
106
  });
97
107
 
98
108
  const session = await oxy.exchangeSsoCode('opaque-code-123');
@@ -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
  */