@oxyhq/core 2.4.0 → 2.4.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.
@@ -305,6 +305,7 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
305
305
  readonly DEFAULT_CONFIG_URL: "https://auth.oxy.so/fedcm.json";
306
306
  readonly FEDCM_TIMEOUT: 15000;
307
307
  readonly FEDCM_SILENT_TIMEOUT: 4000;
308
+ readonly FEDCM_ABORT_SETTLE_GRACE_MS: 500;
308
309
  /**
309
310
  * Check if FedCM is supported in the current browser
310
311
  */
@@ -83,6 +83,32 @@ export interface RunColdBootOptions<S> {
83
83
  * the runner does not guard against an observer that itself throws.
84
84
  */
85
85
  readonly onStepError?: (id: string, error: unknown) => void;
86
+ /**
87
+ * Optional HARD overall deadline (ms) for the entire ordered step loop —
88
+ * defense-in-depth so a single non-settling step can NEVER hang the whole
89
+ * cold boot forever.
90
+ *
91
+ * Each step's `run()` is raced against the SHARED remaining time. If a step
92
+ * fails to settle before the deadline, the runner abandons the await for that
93
+ * step (reporting it via `onStepDeadline`) and CONTINUES to the next step,
94
+ * each now racing against an already-expired deadline. This is deliberate:
95
+ * the runner keeps iterating so the TERMINAL step (e.g. the `/sso` bounce,
96
+ * whose `run()` performs its side effect synchronously before its first
97
+ * `await`) still gets to fire. A step that has nothing to contribute after
98
+ * the deadline simply doesn't settle and is skipped in turn.
99
+ *
100
+ * Per-step timeouts inside `run()` remain the first line of defense and
101
+ * should keep every step well under this budget on a healthy load; this only
102
+ * trips when one of them regresses (the production FedCM-silent hang). When
103
+ * omitted there is NO overall deadline (unchanged legacy behaviour).
104
+ */
105
+ readonly overallDeadlineMs?: number;
106
+ /**
107
+ * Optional observer invoked once per step that was abandoned because the
108
+ * overall deadline expired before it settled. Receives the step `id`. Must
109
+ * not throw.
110
+ */
111
+ readonly onStepDeadline?: (id: string) => void;
86
112
  }
87
113
  /**
88
114
  * Run the ordered cold-boot steps and resolve to the first recovered session,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -214,6 +214,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
214
214
  // 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
215
215
  public static readonly FEDCM_SILENT_TIMEOUT = 4000; // 4 seconds for silent mediation
216
216
 
217
+ // Grace margin between the cooperative abort deadline (`FEDCM_SILENT_TIMEOUT`
218
+ // / `FEDCM_TIMEOUT`) and the HARD settle of `requestIdentityCredential`. The
219
+ // abort fires first; a well-behaved browser surfaces its own `AbortError`
220
+ // within this window (keeping the existing error path intact). If — as seen
221
+ // in production — `navigator.credentials.get()` ignores the abort and the
222
+ // awaited promise never settles, the hard settle resolves the request to
223
+ // `null` this many ms later, guaranteeing the cold-boot step always settles.
224
+ // 500ms is ample for a browser to deliver an abort rejection while keeping the
225
+ // worst-case dead wait tight (silent: 4.5s, interactive: 15.5s).
226
+ public static readonly FEDCM_ABORT_SETTLE_GRACE_MS = 500;
227
+
217
228
  /**
218
229
  * Check if FedCM is supported in the current browser
219
230
  */
@@ -606,6 +617,37 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
606
617
  controller.abort();
607
618
  }, timeoutMs);
608
619
 
620
+ // Hard settle guarantee for the timeout path.
621
+ //
622
+ // The `setTimeout` above aborts the request's `AbortController`, which is
623
+ // the COOPERATIVE cancel signal. For a regular `fetch` an abort deterministically
624
+ // rejects the awaited promise — but `navigator.credentials.get()` is a
625
+ // browser-internal FedCM primitive whose abort behaviour is NOT guaranteed
626
+ // to settle the awaited promise in every Chrome version / internal state
627
+ // (the credential request can sit "pending" while the browser-side flow is
628
+ // stuck, ignoring the signal). If that happens, `await credentials.get(...)`
629
+ // never resolves OR rejects, this IIFE hangs forever, and — because this is
630
+ // ONE step of the ordered cold-boot sequence — the whole cold boot hangs and
631
+ // the terminal `/sso` bounce never fires. That was the production hang.
632
+ //
633
+ // `settlePromise` races the credential lookup against a timer that ALWAYS
634
+ // resolves to `null` shortly after the abort deadline. The abort still fires
635
+ // first (so the browser is asked to cancel), but even if `credentials.get`
636
+ // never settles, the race resolves and the step falls through cleanly to the
637
+ // next cold-boot step. The small `FEDCM_ABORT_SETTLE_GRACE_MS` margin gives a
638
+ // well-behaved browser the chance to surface its own AbortError (preserving
639
+ // the existing error path) before we force a clean `null`.
640
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
641
+ const settlePromise = new Promise<FedCMIdentityCredential | null>((resolve) => {
642
+ const ctor = this.constructor as typeof OxyServicesBase & {
643
+ FEDCM_ABORT_SETTLE_GRACE_MS: number;
644
+ };
645
+ settleTimer = setTimeout(() => {
646
+ debug.log('Request hard-settled to null', timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS, 'ms (credentials.get never settled after abort)');
647
+ resolve(null);
648
+ }, timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS);
649
+ });
650
+
609
651
  // Normalise the caller's mode to the modern W3C value first. A modern
610
652
  // browser accepts it; an older one (Chrome 125–131) rejects it with a
611
653
  // synchronous TypeError, in which case we retry with the legacy value.
@@ -645,7 +687,13 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
645
687
  debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
646
688
  let credential: FedCMIdentityCredential | null;
647
689
  try {
648
- credential = await credentials.get(buildCredentialOptions(modernMode));
690
+ // Race the browser FedCM lookup against the hard settle guarantee so
691
+ // a `credentials.get` that ignores the abort signal can never hang
692
+ // the cold boot (see `settlePromise`).
693
+ credential = await Promise.race([
694
+ credentials.get(buildCredentialOptions(modernMode)),
695
+ settlePromise,
696
+ ]);
649
697
  } catch (modeError) {
650
698
  // Chrome 125–131 only knows the legacy 'button'/'widget' enum and
651
699
  // throws a synchronous TypeError for the modern 'active'/'passive'
@@ -653,7 +701,10 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
653
701
  if (modernMode && isUnknownModeEnumError(modeError)) {
654
702
  const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
655
703
  debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
656
- credential = await credentials.get(buildCredentialOptions(legacyMode));
704
+ credential = await Promise.race([
705
+ credentials.get(buildCredentialOptions(legacyMode)),
706
+ settlePromise,
707
+ ]);
657
708
  } else {
658
709
  throw modeError;
659
710
  }
@@ -680,6 +731,9 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
680
731
  throw error;
681
732
  } finally {
682
733
  clearTimeout(timeout);
734
+ if (settleTimer !== undefined) {
735
+ clearTimeout(settleTimer);
736
+ }
683
737
  // Only reset the shared lock if it still belongs to THIS request. When an
684
738
  // interactive request aborts a slow silent one, the silent settles (and
685
739
  // runs this `finally`) AFTER the interactive has already taken over the
@@ -192,6 +192,58 @@ describe('OxyServices FedCM nonce binding', () => {
192
192
 
193
193
  await expect(oxy.silentSignInWithFedCM()).resolves.toBeNull();
194
194
  });
195
+
196
+ /**
197
+ * Production-hang regression. `navigator.credentials.get()` is a
198
+ * browser-internal FedCM primitive that, in some Chrome states, IGNORES its
199
+ * abort signal — the awaited promise never settles. The cooperative
200
+ * `setTimeout`→`controller.abort()` alone cannot unblock that await, so the
201
+ * `fedcm-silent` cold-boot step (and the whole cold boot) hangs forever and
202
+ * the terminal `/sso` bounce never fires.
203
+ *
204
+ * The hard settle guarantee (`Promise.race` against a timer that resolves
205
+ * `null` at `FEDCM_SILENT_TIMEOUT + FEDCM_ABORT_SETTLE_GRACE_MS`) must resolve
206
+ * the request to `null` regardless. This test models the hung primitive and
207
+ * asserts `silentSignInWithFedCM()` settles to `null` within that bound.
208
+ */
209
+ it('hard-settles to null when navigator.credentials.get never settles (ignores abort)', async () => {
210
+ jest.useFakeTimers();
211
+ try {
212
+ installBrowserGlobals({
213
+ // Never resolves or rejects, and never observes the abort signal —
214
+ // models the production hung FedCM credential request.
215
+ credentialsGet: () => new Promise<unknown>(() => {}),
216
+ });
217
+
218
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
219
+ localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
220
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
221
+ if (url === '/fedcm/nonce') {
222
+ return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
223
+ }
224
+ throw new Error(`unexpected request to ${url}`);
225
+ });
226
+
227
+ let settled = false;
228
+ const promise = oxy.silentSignInWithFedCM().then((r) => {
229
+ settled = true;
230
+ return r;
231
+ });
232
+
233
+ // Before the hard-settle deadline it must still be pending — proving the
234
+ // primitive really is hung (so the test would fail without the fix).
235
+ await jest.advanceTimersByTimeAsync(4000);
236
+ expect(settled).toBe(false);
237
+
238
+ // FEDCM_SILENT_TIMEOUT (4000) + FEDCM_ABORT_SETTLE_GRACE_MS (500) = 4500.
239
+ await jest.advanceTimersByTimeAsync(600);
240
+ await expect(promise).resolves.toBeNull();
241
+ expect(settled).toBe(true);
242
+ } finally {
243
+ jest.runOnlyPendingTimers();
244
+ jest.useRealTimers();
245
+ }
246
+ });
195
247
  });
196
248
 
197
249
  /**
@@ -223,4 +223,154 @@ describe('runColdBoot', () => {
223
223
  session: { userId: 'u-ok' },
224
224
  });
225
225
  });
226
+
227
+ describe('overall deadline (defense-in-depth)', () => {
228
+ beforeEach(() => {
229
+ jest.useFakeTimers();
230
+ });
231
+ afterEach(() => {
232
+ jest.runOnlyPendingTimers();
233
+ jest.useRealTimers();
234
+ });
235
+
236
+ /**
237
+ * Reproduces the production hang: a step whose `run()` promise NEVER
238
+ * settles (the FedCM-silent `navigator.credentials.get` that ignored its
239
+ * abort signal). WITHOUT a deadline the whole `runColdBoot` promise hangs
240
+ * forever and the terminal step never runs.
241
+ */
242
+ it('hangs forever when a step never settles and no deadline is set', async () => {
243
+ const terminalRan = jest.fn();
244
+ let settled = false;
245
+
246
+ const outcomePromise = runColdBoot<TestSession>({
247
+ steps: [
248
+ {
249
+ id: 'never-settles',
250
+ // Never resolves or rejects — models the hung FedCM credential get.
251
+ run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
252
+ },
253
+ {
254
+ id: 'terminal',
255
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
256
+ terminalRan();
257
+ return { kind: 'skip' };
258
+ },
259
+ },
260
+ ],
261
+ }).then((o) => {
262
+ settled = true;
263
+ return o;
264
+ });
265
+
266
+ // Advance well past any reasonable budget; nothing can unblock it.
267
+ await jest.advanceTimersByTimeAsync(120000);
268
+
269
+ expect(settled).toBe(false);
270
+ expect(terminalRan).not.toHaveBeenCalled();
271
+
272
+ // Avoid a dangling unhandled promise in the test runner.
273
+ void outcomePromise;
274
+ });
275
+
276
+ /**
277
+ * With `overallDeadlineMs` set, the non-settling step is abandoned at the
278
+ * deadline, the runner CONTINUES to the terminal step (so the cross-domain
279
+ * `/sso` bounce equivalent still fires), and the whole boot settles to
280
+ * `unauthenticated` within the bounded budget.
281
+ */
282
+ it('abandons a non-settling step at the deadline and still runs the terminal step', async () => {
283
+ const terminalRan = jest.fn();
284
+ const onStepDeadline = jest.fn();
285
+
286
+ const outcomePromise = runColdBoot<TestSession>({
287
+ overallDeadlineMs: 5000,
288
+ onStepDeadline,
289
+ steps: [
290
+ {
291
+ id: 'never-settles',
292
+ run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
293
+ },
294
+ {
295
+ id: 'terminal',
296
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
297
+ terminalRan();
298
+ return { kind: 'skip' };
299
+ },
300
+ },
301
+ ],
302
+ });
303
+
304
+ await jest.advanceTimersByTimeAsync(5000);
305
+ const outcome = await outcomePromise;
306
+
307
+ expect(onStepDeadline).toHaveBeenCalledWith('never-settles');
308
+ expect(terminalRan).toHaveBeenCalledTimes(1);
309
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
310
+ });
311
+
312
+ /**
313
+ * The terminal step's synchronous side effect (the real `sso-bounce`
314
+ * navigates BEFORE its first await) must still execute when the deadline
315
+ * trips on an earlier step — the cross-domain fallback is preserved.
316
+ */
317
+ it('lets the terminal step fire its synchronous side effect after the deadline trips', async () => {
318
+ const bounced = jest.fn();
319
+
320
+ const outcomePromise = runColdBoot<TestSession>({
321
+ overallDeadlineMs: 3000,
322
+ steps: [
323
+ {
324
+ id: 'never-settles',
325
+ run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
326
+ },
327
+ {
328
+ id: 'sso-bounce',
329
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
330
+ // Synchronous navigation side effect, exactly like the real bounce.
331
+ bounced();
332
+ return { kind: 'skip' };
333
+ },
334
+ },
335
+ ],
336
+ });
337
+
338
+ await jest.advanceTimersByTimeAsync(3000);
339
+ await outcomePromise;
340
+
341
+ expect(bounced).toHaveBeenCalledTimes(1);
342
+ });
343
+
344
+ /**
345
+ * A healthy step that settles BEFORE the deadline still wins and
346
+ * short-circuits — the deadline never alters the happy path.
347
+ */
348
+ it('a step that settles before the deadline wins and short-circuits', async () => {
349
+ const laterRan = jest.fn();
350
+
351
+ const outcomePromise = runColdBoot<TestSession>({
352
+ overallDeadlineMs: 10000,
353
+ steps: [
354
+ {
355
+ id: 'fast-winner',
356
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
357
+ await Promise.resolve();
358
+ return { kind: 'session', session: { userId: 'u-fast' } };
359
+ },
360
+ },
361
+ sessionStep('later', 'u-later', laterRan),
362
+ ],
363
+ });
364
+
365
+ await jest.advanceTimersByTimeAsync(0);
366
+ const outcome = await outcomePromise;
367
+
368
+ expect(outcome).toEqual({
369
+ kind: 'session',
370
+ via: 'fast-winner',
371
+ session: { userId: 'u-fast' },
372
+ });
373
+ expect(laterRan).not.toHaveBeenCalled();
374
+ });
375
+ });
226
376
  });
@@ -73,6 +73,16 @@ export type ColdBootOutcome<S> =
73
73
  | { readonly kind: 'session'; readonly via: string; readonly session: S }
74
74
  | { readonly kind: 'unauthenticated' };
75
75
 
76
+ /**
77
+ * The unique sentinel a step's `run()` resolves to (via the internal race)
78
+ * when the overall cold-boot deadline expires before that step settled. It is
79
+ * NOT a {@link ColdBootStepResult} — the runner detects it by identity and
80
+ * treats it as "this step did not settle in time; move on".
81
+ *
82
+ * @internal
83
+ */
84
+ const DEADLINE_EXPIRED: unique symbol = Symbol('coldBoot.deadlineExpired');
85
+
76
86
  /**
77
87
  * Options for {@link runColdBoot}.
78
88
  */
@@ -85,6 +95,32 @@ export interface RunColdBootOptions<S> {
85
95
  * the runner does not guard against an observer that itself throws.
86
96
  */
87
97
  readonly onStepError?: (id: string, error: unknown) => void;
98
+ /**
99
+ * Optional HARD overall deadline (ms) for the entire ordered step loop —
100
+ * defense-in-depth so a single non-settling step can NEVER hang the whole
101
+ * cold boot forever.
102
+ *
103
+ * Each step's `run()` is raced against the SHARED remaining time. If a step
104
+ * fails to settle before the deadline, the runner abandons the await for that
105
+ * step (reporting it via `onStepDeadline`) and CONTINUES to the next step,
106
+ * each now racing against an already-expired deadline. This is deliberate:
107
+ * the runner keeps iterating so the TERMINAL step (e.g. the `/sso` bounce,
108
+ * whose `run()` performs its side effect synchronously before its first
109
+ * `await`) still gets to fire. A step that has nothing to contribute after
110
+ * the deadline simply doesn't settle and is skipped in turn.
111
+ *
112
+ * Per-step timeouts inside `run()` remain the first line of defense and
113
+ * should keep every step well under this budget on a healthy load; this only
114
+ * trips when one of them regresses (the production FedCM-silent hang). When
115
+ * omitted there is NO overall deadline (unchanged legacy behaviour).
116
+ */
117
+ readonly overallDeadlineMs?: number;
118
+ /**
119
+ * Optional observer invoked once per step that was abandoned because the
120
+ * overall deadline expired before it settled. Receives the step `id`. Must
121
+ * not throw.
122
+ */
123
+ readonly onStepDeadline?: (id: string) => void;
88
124
  }
89
125
 
90
126
  /**
@@ -105,32 +141,75 @@ export interface RunColdBootOptions<S> {
105
141
  export async function runColdBoot<S>(
106
142
  options: RunColdBootOptions<S>
107
143
  ): Promise<ColdBootOutcome<S>> {
108
- const { steps, onStepError } = options;
144
+ const { steps, onStepError, overallDeadlineMs, onStepDeadline } = options;
109
145
 
110
- for (const step of steps) {
111
- if (step.enabled) {
112
- let isEnabled: boolean;
146
+ // Arm the optional overall deadline. The budget is SHARED across the whole
147
+ // loop (not reset per step): a single timer resolves a reusable
148
+ // `DEADLINE_EXPIRED` sentinel that every per-step race can observe. Once it
149
+ // fires, later steps race against an already-resolved promise and so never
150
+ // block, yet the loop keeps iterating so the terminal step still fires.
151
+ const deadlineMs =
152
+ typeof overallDeadlineMs === 'number' &&
153
+ Number.isFinite(overallDeadlineMs) &&
154
+ overallDeadlineMs > 0
155
+ ? overallDeadlineMs
156
+ : null;
157
+
158
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
159
+ let deadlinePromise: Promise<typeof DEADLINE_EXPIRED> | undefined;
160
+ if (deadlineMs !== null) {
161
+ deadlinePromise = new Promise<typeof DEADLINE_EXPIRED>((resolve) => {
162
+ deadlineTimer = setTimeout(() => resolve(DEADLINE_EXPIRED), deadlineMs);
163
+ });
164
+ }
165
+
166
+ try {
167
+ for (const step of steps) {
168
+ if (step.enabled) {
169
+ let isEnabled: boolean;
170
+ try {
171
+ isEnabled = step.enabled();
172
+ } catch (error) {
173
+ onStepError?.(step.id, error);
174
+ continue;
175
+ }
176
+ if (!isEnabled) continue;
177
+ }
178
+
179
+ let result: ColdBootStepResult<S> | typeof DEADLINE_EXPIRED;
113
180
  try {
114
- isEnabled = step.enabled();
181
+ // Without a deadline: legacy behaviour — await the step directly.
182
+ // With a deadline: race the step against the shared deadline. The
183
+ // step's `run()` still STARTS synchronously up to its first `await`
184
+ // (so a terminal step's synchronous navigation side effect always
185
+ // executes), but a non-settling step can no longer block the loop —
186
+ // the race resolves with the sentinel and we move on.
187
+ result = deadlinePromise
188
+ ? await Promise.race([step.run(), deadlinePromise])
189
+ : await step.run();
115
190
  } catch (error) {
116
191
  onStepError?.(step.id, error);
117
192
  continue;
118
193
  }
119
- if (!isEnabled) continue;
120
- }
121
194
 
122
- let result: ColdBootStepResult<S>;
123
- try {
124
- result = await step.run();
125
- } catch (error) {
126
- onStepError?.(step.id, error);
127
- continue;
195
+ if (result === DEADLINE_EXPIRED) {
196
+ // The deadline tripped before this step settled. Abandon the await and
197
+ // continue: subsequent steps race against the already-resolved deadline
198
+ // (so they cannot block), which lets a terminal side-effect step still
199
+ // run while guaranteeing the loop terminates promptly.
200
+ onStepDeadline?.(step.id);
201
+ continue;
202
+ }
203
+
204
+ if (result.kind === 'session') {
205
+ return { kind: 'session', via: step.id, session: result.session };
206
+ }
128
207
  }
129
208
 
130
- if (result.kind === 'session') {
131
- return { kind: 'session', via: step.id, session: result.session };
209
+ return { kind: 'unauthenticated' };
210
+ } finally {
211
+ if (deadlineTimer !== undefined) {
212
+ clearTimeout(deadlineTimer);
132
213
  }
133
214
  }
134
-
135
- return { kind: 'unauthenticated' };
136
215
  }