@oxyhq/core 2.3.2 → 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,3 +305,70 @@ describe('OxyServices popup mixin — openBlankPopup helper', () => {
305
305
  expect(oxy.openBlankPopup()).toBeNull();
306
306
  });
307
307
  });
308
+
309
+ /**
310
+ * `waitForIframeAuth` fail-fast regression tests.
311
+ *
312
+ * The cross-domain durable-restore iframe (`/auth/silent` at the per-apex host)
313
+ * posts a message on success. On a FAILED load — host unreachable, blocked by
314
+ * CSP `frame-ancestors`/`X-Frame-Options`, or a dropped network — it never
315
+ * posts, so without an `onerror`/`onabort` handler the silent restore would
316
+ * block for the FULL timeout (dead latency in the cold-boot critical path). The
317
+ * handler must resolve `null` immediately on a load failure, well before the
318
+ * timeout fires.
319
+ */
320
+ interface FakeIframe {
321
+ onerror: ((this: unknown, ...args: unknown[]) => unknown) | null;
322
+ onabort: ((this: unknown, ...args: unknown[]) => unknown) | null;
323
+ }
324
+
325
+ describe('OxyServices waitForIframeAuth fail-fast on iframe load error', () => {
326
+ afterEach(() => {
327
+ clearBrowserGlobals();
328
+ jest.restoreAllMocks();
329
+ });
330
+
331
+ it('resolves null immediately when the iframe fires onerror (does not wait for the timeout)', async () => {
332
+ installBrowserGlobals();
333
+
334
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
335
+ const iframe: FakeIframe = { onerror: null, onabort: null };
336
+
337
+ // A long timeout proves the resolution comes from `onerror`, not the timer.
338
+ const LONG_TIMEOUT = 100000;
339
+ const settled = oxy.waitForIframeAuth(
340
+ iframe as unknown as HTMLIFrameElement,
341
+ LONG_TIMEOUT,
342
+ 'https://auth.mention.earth',
343
+ );
344
+
345
+ // The handler is installed synchronously; fire it on the next tick.
346
+ await Promise.resolve();
347
+ expect(typeof iframe.onerror).toBe('function');
348
+ iframe.onerror?.call(iframe);
349
+
350
+ await expect(settled).resolves.toBeNull();
351
+ // Cleanup detaches the handlers so a late event cannot double-resolve.
352
+ expect(iframe.onerror).toBeNull();
353
+ expect(iframe.onabort).toBeNull();
354
+ });
355
+
356
+ it('resolves null immediately when the iframe fires onabort', async () => {
357
+ installBrowserGlobals();
358
+
359
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
360
+ const iframe: FakeIframe = { onerror: null, onabort: null };
361
+
362
+ const settled = oxy.waitForIframeAuth(
363
+ iframe as unknown as HTMLIFrameElement,
364
+ 100000,
365
+ 'https://auth.mention.earth',
366
+ );
367
+
368
+ await Promise.resolve();
369
+ expect(typeof iframe.onabort).toBe('function');
370
+ iframe.onabort?.call(iframe);
371
+
372
+ await expect(settled).resolves.toBeNull();
373
+ });
374
+ });
@@ -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
  }