@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +102 -66
- package/dist/cjs/mixins/OxyServices.assets.js +34 -2
- package/dist/cjs/mixins/OxyServices.user.js +78 -14
- package/dist/cjs/utils/cacheKey.js +87 -0
- package/dist/cjs/utils/errorUtils.js +25 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +101 -65
- package/dist/esm/mixins/OxyServices.assets.js +34 -2
- package/dist/esm/mixins/OxyServices.user.js +78 -14
- package/dist/esm/utils/cacheKey.js +82 -0
- package/dist/esm/utils/errorUtils.js +24 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +24 -16
- package/dist/types/mixins/OxyServices.assets.d.ts +24 -1
- package/dist/types/mixins/OxyServices.user.d.ts +21 -27
- package/dist/types/utils/cacheKey.d.ts +67 -0
- package/dist/types/utils/errorUtils.d.ts +12 -0
- package/package.json +2 -2
- package/src/HttpService.ts +116 -67
- package/src/__tests__/authManager.cookiePath.test.ts +2 -2
- package/src/__tests__/authManager.security.test.ts +2 -2
- package/src/__tests__/httpServiceCache.test.ts +71 -0
- package/src/mixins/OxyServices.assets.ts +36 -2
- package/src/mixins/OxyServices.user.ts +104 -32
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +83 -0
- package/src/mixins/__tests__/sso.test.ts +13 -3
- package/src/utils/__tests__/cacheKey.test.ts +0 -0
- package/src/utils/__tests__/coldBoot.test.ts +125 -0
- package/src/utils/cacheKey.ts +98 -0
- 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
|
+
}
|
package/src/utils/errorUtils.ts
CHANGED
|
@@ -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
|
*/
|