@oxyhq/core 2.3.1 → 2.4.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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { OxyServices } from './OxyServices';
10
10
  import type { SessionLoginResponse, MinimalUserData } from './models/session';
11
- import type { AuthManagerAccount, RestoreFromCookiesResult, SwitchAuthuserResult } from './AuthManagerTypes';
11
+ import type { AuthManagerAccount, RestoreFromCookiesResult, RestoreFromCookiesOptions, SwitchAuthuserResult } from './AuthManagerTypes';
12
12
  /**
13
13
  * Storage adapter interface for platform-agnostic storage.
14
14
  */
@@ -269,7 +269,7 @@ export declare class AuthManager {
269
269
  * Returns the active user on success, or `null` when neither path
270
270
  * restored a session.
271
271
  */
272
- initialize(): Promise<MinimalUserData | null>;
272
+ initialize(options?: RestoreFromCookiesOptions): Promise<MinimalUserData | null>;
273
273
  /**
274
274
  * Read the persisted active `authuser` slot index. Returns `null` when
275
275
  * none is persisted, the value is corrupt, or the storage adapter has no
@@ -343,7 +343,7 @@ export declare class AuthManager {
343
343
  * proceed unauthenticated. State is NOT cleared on failure; existing
344
344
  * accounts (if any) remain intact.
345
345
  */
346
- restoreFromCookies(): Promise<RestoreFromCookiesResult>;
346
+ restoreFromCookies(options?: RestoreFromCookiesOptions): Promise<RestoreFromCookiesResult>;
347
347
  /**
348
348
  * Switch the active account to a different device-local slot.
349
349
  *
@@ -55,6 +55,23 @@ export interface RestoreFromCookiesResult {
55
55
  accounts: AuthManagerAccount[];
56
56
  activeAuthuser: number | null;
57
57
  }
58
+ /**
59
+ * Options for `AuthManager.restoreFromCookies()` / `AuthManager.initialize()`.
60
+ */
61
+ export interface RestoreFromCookiesOptions {
62
+ /**
63
+ * Abort the underlying `POST /auth/refresh-all` after this many milliseconds
64
+ * and treat it as "no signed-in accounts" instead of hanging. Forwarded
65
+ * verbatim to `OxyServices.refreshAllSessions({ timeout })`.
66
+ *
67
+ * Intended for the cold-boot cookie-restore step on a cross-domain RP, where
68
+ * the `Domain=oxy.so` refresh cookie never reaches `api.<apex>` and the
69
+ * request can stall with no useful answer. Omit (the default) to wait
70
+ * indefinitely — the warm cross-tab cascade path passes nothing, preserving
71
+ * its existing behaviour.
72
+ */
73
+ timeout?: number;
74
+ }
58
75
  /**
59
76
  * Outcome of `AuthManager.switchAuthuser()`.
60
77
  *
@@ -21,7 +21,7 @@ export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } fr
21
21
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
22
22
  export { AuthManager, createAuthManager } from './AuthManager';
23
23
  export type { StorageAdapter, AuthStateChangeCallback, AuthMethod, AuthManagerConfig, } from './AuthManager';
24
- export type { AuthManagerAccount, RestoreFromCookiesResult, SwitchAuthuserResult, } from './AuthManagerTypes';
24
+ export type { AuthManagerAccount, RestoreFromCookiesResult, RestoreFromCookiesOptions, SwitchAuthuserResult, } from './AuthManagerTypes';
25
25
  export { CrossDomainAuth, createCrossDomainAuth } from './CrossDomainAuth';
26
26
  export type { CrossDomainAuthOptions } from './CrossDomainAuth';
27
27
  export type { FedCMAuthOptions, FedCMConfig, AuthorizedApp } from './mixins/OxyServices.fedcm';
@@ -10,6 +10,25 @@ export interface ChallengeResponse {
10
10
  challenge: string;
11
11
  expiresAt: string;
12
12
  }
13
+ /**
14
+ * Options for {@link refreshAllSessions}.
15
+ */
16
+ export interface RefreshAllOptions {
17
+ /**
18
+ * Abort the `POST /auth/refresh-all` request after this many milliseconds and
19
+ * resolve as "no signed-in accounts" (`{ accounts: [] }`) rather than hanging.
20
+ *
21
+ * Why: on a cross-domain RP (e.g. `mention.earth`) the `Domain=oxy.so` refresh
22
+ * cookie never reaches `api.<apex>`, so this request can stall behind a slow
23
+ * or unreachable endpoint with no useful answer coming back. As one step of
24
+ * the ordered cold-boot sequence, a stalled refresh is dead latency in front
25
+ * of the steps that actually hold the answer. A bounded abort lets cold boot
26
+ * fall through quickly. Omit (or pass `0`/negative) to wait indefinitely
27
+ * (the legacy behaviour). The abort is treated identically to a 401 — the
28
+ * "not signed in on this device" path — never an error.
29
+ */
30
+ timeout?: number;
31
+ }
13
32
  export interface RegistrationRequest {
14
33
  publicKey: string;
15
34
  username: string;
@@ -293,7 +312,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
293
312
  * tokens do. Each access token still needs to be planted via
294
313
  * `setTokens(...)` (or per-account in-memory storage) at the consumer.
295
314
  */
296
- refreshAllSessions(): Promise<RefreshAllResponse>;
315
+ refreshAllSessions(options?: RefreshAllOptions): Promise<RefreshAllResponse>;
297
316
  /**
298
317
  * Rotate a single refresh-cookie slot and return the fresh access token.
299
318
  *
@@ -304,7 +304,7 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
304
304
  };
305
305
  readonly DEFAULT_CONFIG_URL: "https://auth.oxy.so/fedcm.json";
306
306
  readonly FEDCM_TIMEOUT: 15000;
307
- readonly FEDCM_SILENT_TIMEOUT: 10000;
307
+ readonly FEDCM_SILENT_TIMEOUT: 4000;
308
308
  /**
309
309
  * Check if FedCM is supported in the current browser
310
310
  */
@@ -72,6 +72,14 @@ export interface ConsumeSsoReturnDeps {
72
72
  * fails. NEVER rethrown — `consumeSsoReturn` is total. Default: no-op.
73
73
  */
74
74
  onExchangeError?: (error: unknown) => void;
75
+ /**
76
+ * Notify URL-driven routers (Expo Router / React Navigation web) that the
77
+ * location changed via `history.replaceState`, which does NOT itself emit
78
+ * `popstate`. Default: dispatch a real `PopStateEvent` on `window` when
79
+ * present; no-op off-web. Called ONLY after a successful same-origin
80
+ * dest restore (never when the dest is rejected/absent). NEVER throws.
81
+ */
82
+ dispatchPopState?: () => void;
75
83
  }
76
84
  /**
77
85
  * Consume an SSO return: the commit-free, security-critical kernel of the
@@ -95,10 +103,16 @@ export interface ConsumeSsoReturnDeps {
95
103
  * outcome-independent attempted-flag (the load2 half of the loop proof).
96
104
  * - A throwing exchange is caught, reported via `onExchangeError`, and
97
105
  * treated exactly like "no session" (never loops, never rethrows).
98
- * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
99
- * destination is restored from the DEST key same-origin only (an
100
- * attacker-planted cross-origin or relative-evil dest is rejected). The
101
- * DEST key is removed unconditionally.
106
+ * - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
107
+ * failed-exchange, no-sessionId) not just okif the page landed on
108
+ * {@link SSO_CALLBACK_PATH}, the real pre-bounce destination is restored
109
+ * from the DEST key so the user is never stranded on the internal callback
110
+ * path. Same-origin only (an attacker-planted cross-origin or relative-evil
111
+ * dest is rejected). The DEST key is removed unconditionally.
112
+ * - After a same-origin dest restore (which uses `history.replaceState`, that
113
+ * does NOT itself emit `popstate`), a synthetic `popstate` is dispatched so
114
+ * URL-driven routers (Expo Router / React Navigation web) re-sync to the
115
+ * restored route. It is NOT dispatched when the dest is rejected/absent.
102
116
  *
103
117
  * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
104
118
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
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",
@@ -20,6 +20,7 @@ import type {
20
20
  import type {
21
21
  AuthManagerAccount,
22
22
  RestoreFromCookiesResult,
23
+ RestoreFromCookiesOptions,
23
24
  SwitchAuthuserResult,
24
25
  } from './AuthManagerTypes';
25
26
  import { retryAsync } from './utils/asyncUtils';
@@ -899,9 +900,10 @@ export class AuthManager {
899
900
  * Returns the active user on success, or `null` when neither path
900
901
  * restored a session.
901
902
  */
902
- async initialize(): Promise<MinimalUserData | null> {
903
- // 1. Cookie path (preferred).
904
- const cookieResult = await this.restoreFromCookies();
903
+ async initialize(options: RestoreFromCookiesOptions = {}): Promise<MinimalUserData | null> {
904
+ // 1. Cookie path (preferred). Forward the optional cold-boot fail-fast
905
+ // timeout so a cross-domain stall cannot hang provider init.
906
+ const cookieResult = await this.restoreFromCookies(options);
905
907
  if (cookieResult.accounts.length > 0) {
906
908
  return this.currentUser;
907
909
  }
@@ -1126,7 +1128,7 @@ export class AuthManager {
1126
1128
  * proceed unauthenticated. State is NOT cleared on failure; existing
1127
1129
  * accounts (if any) remain intact.
1128
1130
  */
1129
- async restoreFromCookies(): Promise<RestoreFromCookiesResult> {
1131
+ async restoreFromCookies(options: RestoreFromCookiesOptions = {}): Promise<RestoreFromCookiesResult> {
1130
1132
  // Cross-tab cascade debounce. If we restored within the last
1131
1133
  // _RESTORE_DEBOUNCE_MS for the currently-active slot, skip the network
1132
1134
  // round-trip and return the cached registry verbatim. A burst of N
@@ -1145,7 +1147,9 @@ export class AuthManager {
1145
1147
 
1146
1148
  let snapshot: RefreshAllResponse;
1147
1149
  try {
1148
- snapshot = await this.oxyServices.refreshAllSessions();
1150
+ // Forward the optional cold-boot fail-fast timeout. Undefined (the warm
1151
+ // cross-tab cascade default) preserves the wait-indefinitely behaviour.
1152
+ snapshot = await this.oxyServices.refreshAllSessions({ timeout: options.timeout });
1149
1153
  } catch {
1150
1154
  return { accounts: [], activeAuthuser: null };
1151
1155
  }
@@ -59,6 +59,24 @@ export interface RestoreFromCookiesResult {
59
59
  activeAuthuser: number | null;
60
60
  }
61
61
 
62
+ /**
63
+ * Options for `AuthManager.restoreFromCookies()` / `AuthManager.initialize()`.
64
+ */
65
+ export interface RestoreFromCookiesOptions {
66
+ /**
67
+ * Abort the underlying `POST /auth/refresh-all` after this many milliseconds
68
+ * and treat it as "no signed-in accounts" instead of hanging. Forwarded
69
+ * verbatim to `OxyServices.refreshAllSessions({ timeout })`.
70
+ *
71
+ * Intended for the cold-boot cookie-restore step on a cross-domain RP, where
72
+ * the `Domain=oxy.so` refresh cookie never reaches `api.<apex>` and the
73
+ * request can stall with no useful answer. Omit (the default) to wait
74
+ * indefinitely — the warm cross-tab cascade path passes nothing, preserving
75
+ * its existing behaviour.
76
+ */
77
+ timeout?: number;
78
+ }
79
+
62
80
  /**
63
81
  * Outcome of `AuthManager.switchAuthuser()`.
64
82
  *
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export type {
39
39
  export type {
40
40
  AuthManagerAccount,
41
41
  RestoreFromCookiesResult,
42
+ RestoreFromCookiesOptions,
42
43
  SwitchAuthuserResult,
43
44
  } from './AuthManagerTypes';
44
45
 
@@ -20,6 +20,26 @@ export interface ChallengeResponse {
20
20
  expiresAt: string;
21
21
  }
22
22
 
23
+ /**
24
+ * Options for {@link refreshAllSessions}.
25
+ */
26
+ export interface RefreshAllOptions {
27
+ /**
28
+ * Abort the `POST /auth/refresh-all` request after this many milliseconds and
29
+ * resolve as "no signed-in accounts" (`{ accounts: [] }`) rather than hanging.
30
+ *
31
+ * Why: on a cross-domain RP (e.g. `mention.earth`) the `Domain=oxy.so` refresh
32
+ * cookie never reaches `api.<apex>`, so this request can stall behind a slow
33
+ * or unreachable endpoint with no useful answer coming back. As one step of
34
+ * the ordered cold-boot sequence, a stalled refresh is dead latency in front
35
+ * of the steps that actually hold the answer. A bounded abort lets cold boot
36
+ * fall through quickly. Omit (or pass `0`/negative) to wait indefinitely
37
+ * (the legacy behaviour). The abort is treated identically to a 401 — the
38
+ * "not signed in on this device" path — never an error.
39
+ */
40
+ timeout?: number;
41
+ }
42
+
23
43
  export interface RegistrationRequest {
24
44
  publicKey: string;
25
45
  username: string;
@@ -619,18 +639,41 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
619
639
  * tokens do. Each access token still needs to be planted via
620
640
  * `setTokens(...)` (or per-account in-memory storage) at the consumer.
621
641
  */
622
- async refreshAllSessions(): Promise<RefreshAllResponse> {
642
+ async refreshAllSessions(options: RefreshAllOptions = {}): Promise<RefreshAllResponse> {
623
643
  const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/auth/refresh-all`;
624
644
 
645
+ // Optional bounded abort (see `RefreshAllOptions.timeout`). A positive
646
+ // timeout arms an `AbortController` that aborts the in-flight request; an
647
+ // abort is treated as "no signed-in accounts on this device" — the same
648
+ // outcome as a 401 — so a cross-domain stall falls through cleanly instead
649
+ // of hanging the cold boot.
650
+ const timeout = typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : undefined;
651
+ const controller = timeout !== undefined ? new AbortController() : undefined;
652
+ const timeoutId = timeout !== undefined && controller
653
+ ? setTimeout(() => controller.abort(), timeout)
654
+ : undefined;
655
+
625
656
  let response: Response;
626
657
  try {
627
658
  response = await fetch(url, {
628
659
  method: 'POST',
629
660
  credentials: 'include',
630
661
  headers: { Accept: 'application/json' },
662
+ signal: controller?.signal,
631
663
  });
632
664
  } catch (error) {
665
+ // A bounded-timeout abort is the "not signed in / cross-domain stall"
666
+ // path, NOT an error. The browser raises a DOMException named
667
+ // 'AbortError' (some runtimes use a generic Error); match on the name so
668
+ // we never throw the timeout into the cold-boot error handler.
669
+ if (error instanceof Error && error.name === 'AbortError') {
670
+ return { accounts: [] };
671
+ }
633
672
  throw this.handleError(error);
673
+ } finally {
674
+ if (timeoutId !== undefined) {
675
+ clearTimeout(timeoutId);
676
+ }
634
677
  }
635
678
 
636
679
  if (response.status === 401) {
@@ -202,12 +202,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
202
202
  }
203
203
 
204
204
  public static readonly FEDCM_TIMEOUT = 15000; // 15 seconds for interactive
205
- // Silent mediation runs on page load (e.g. re-signing-in a user whose stored
206
- // session was cleared after a cold-boot token fetch 401'd). The real silent
207
- // round-trip mint nonce navigator.credentials.get /fedcm/exchange was
208
- // measured to take more than 3s for live users, so a 3s budget timed out and
209
- // left them signed out on reload. 10s gives ample margin while staying bounded.
210
- public static readonly FEDCM_SILENT_TIMEOUT = 10000; // 10 seconds for silent mediation
205
+ // Silent mediation runs on page load as ONE step of the ordered cold-boot
206
+ // sequence (mint nonce navigator.credentials.get /fedcm/exchange). The
207
+ // real round-trip was measured at >3s for live users, so the budget must stay
208
+ // comfortably above 3s. It must ALSO be tight: on a logged-out browser this
209
+ // step never resolves a credential, and every millisecond it spends timing
210
+ // out is pure latency in front of the steps that actually hold the answer
211
+ // (stored-session bearer, the per-apex silent iframe, the /sso bounce). 4s is
212
+ // the floor that preserves the >3s success margin while bounding the dead
213
+ // wait — down from the previous 10s, which alone could account for most of a
214
+ // 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
215
+ public static readonly FEDCM_SILENT_TIMEOUT = 4000; // 4 seconds for silent mediation
211
216
 
212
217
  /**
213
218
  * Check if FedCM is supported in the current browser
@@ -419,6 +424,21 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
419
424
 
420
425
  const loginHint = this.getStoredLoginHint();
421
426
 
427
+ // Fast-skip: with no stored login hint this browser has never completed a
428
+ // FedCM sign-in for any Oxy account, so silent mediation cannot return a
429
+ // credential — the IdP has nothing to silently re-issue. Doing the full
430
+ // round-trip anyway (mint a nonce via `POST /fedcm/nonce`, then a
431
+ // `navigator.credentials.get` that aborts after `FEDCM_SILENT_TIMEOUT`) is
432
+ // pure latency in the cold-boot critical path. Return `null` immediately so
433
+ // the next cold-boot step (stored-session / iframe / bounce) runs without
434
+ // the wasted nonce mint + abort wait. A genuinely associated browser always
435
+ // has a hint (it is stored only after a real exchange), so this never skips
436
+ // a recoverable session.
437
+ if (!loginHint) {
438
+ debug.log('Silent SSO: No stored login hint — skipping silent mediation (no association on this browser)');
439
+ return null;
440
+ }
441
+
422
442
  try {
423
443
  // Server-minted, origin-bound nonce — required for `/fedcm/exchange`
424
444
  // to accept the resulting ID token (anti-replay binding).
@@ -522,8 +522,25 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
522
522
  resolve(session || null);
523
523
  };
524
524
 
525
+ // Fail-fast on a load failure. When the per-apex `/auth/silent` host is
526
+ // unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
527
+ // network drops, the iframe never posts a message — without this handler
528
+ // the silent restore would block for the FULL `timeout` (dead latency in
529
+ // the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
530
+ // so resolve `null` immediately and let the next cold-boot step run. The
531
+ // success path posts a message and is handled above; these only catch the
532
+ // no-message failure modes.
533
+ const failFast = () => {
534
+ cleanup();
535
+ resolve(null);
536
+ };
537
+ iframe.onerror = failFast;
538
+ iframe.onabort = failFast;
539
+
525
540
  const cleanup = () => {
526
541
  clearTimeout(timeoutId);
542
+ iframe.onerror = null;
543
+ iframe.onabort = null;
527
544
  window.removeEventListener('message', messageHandler);
528
545
  };
529
546
 
@@ -82,6 +82,11 @@ describe('OxyServices FedCM nonce binding', () => {
82
82
  });
83
83
 
84
84
  const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
85
+ // Silent mediation now fast-skips when there is NO stored login hint (a
86
+ // browser with no prior FedCM association can never get a silent credential,
87
+ // so the nonce mint + credential request would be pure latency). Seed a hint
88
+ // so the silent round-trip actually runs and we can assert the nonce binding.
89
+ localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
85
90
  const makeRequest = jest
86
91
  .spyOn(oxy, 'makeRequest')
87
92
  .mockImplementation(async (_method: string, url: string) => {
@@ -147,6 +152,9 @@ describe('OxyServices FedCM nonce binding', () => {
147
152
  });
148
153
 
149
154
  const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
155
+ // Seed a login hint so the silent path runs past the no-hint fast-skip and
156
+ // exercises the nonce-mint fallback.
157
+ localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
150
158
  jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
151
159
  if (url === '/fedcm/nonce') {
152
160
  throw new Error('network down');
@@ -306,6 +314,9 @@ describe('OxyServices FedCM mode enum (active/passive)', () => {
306
314
  });
307
315
 
308
316
  const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
317
+ // Seed a login hint so the silent path runs past the no-hint fast-skip and
318
+ // reaches `navigator.credentials.get` (where the mode/mediation are set).
319
+ localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
309
320
  jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
310
321
  if (url === '/fedcm/nonce') {
311
322
  return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
@@ -463,6 +474,10 @@ describe('OxyServices FedCM single-flight lock (interactive aborts silent)', ()
463
474
  let interactiveRan = false;
464
475
 
465
476
  const store = new Map<string, string>();
477
+ // Seed a stored login hint so the silent request runs past the no-hint
478
+ // fast-skip and actually reaches `navigator.credentials.get` (where it then
479
+ // hangs, which is what this test needs to observe being aborted).
480
+ store.set('oxy_fedcm_login_hint', 'prior-user-id');
466
481
  const localStorageStub = {
467
482
  getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
468
483
  setItem: (k: string, v: string) => { store.set(k, v); },
@@ -548,7 +563,53 @@ describe('OxyServices FedCM single-flight lock (interactive aborts silent)', ()
548
563
  });
549
564
 
550
565
  describe('OxyServices FedCM silent timeout', () => {
551
- it('uses a 10s silent timeout (enough budget for the real on-load round-trip)', () => {
552
- expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBe(10000);
566
+ it('uses a 4s silent timeout (above the >3s live round-trip, tight enough to bound cold-boot latency)', () => {
567
+ // 4s keeps the >3s measured live success margin while bounding the dead
568
+ // wait on a logged-out browser. Lowered from 10s, which alone could account
569
+ // for most of a 20-30s serial cold-boot stall. Must never drop below 4s.
570
+ expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBe(4000);
571
+ expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBeGreaterThanOrEqual(4000);
572
+ });
573
+ });
574
+
575
+ /**
576
+ * Silent FedCM no-login-hint fast-skip regression test.
577
+ *
578
+ * A browser that has never completed a FedCM sign-in for any Oxy account has no
579
+ * stored login hint, so silent mediation can never return a credential — the
580
+ * IdP has nothing to silently re-issue. Doing the full round-trip anyway (mint a
581
+ * nonce, then a `navigator.credentials.get` that aborts after the silent
582
+ * timeout) is pure latency in the cold-boot critical path. The silent path must
583
+ * return `null` immediately WITHOUT minting a nonce or calling
584
+ * `navigator.credentials.get`.
585
+ */
586
+ describe('OxyServices FedCM silent fast-skip with no login hint', () => {
587
+ afterEach(() => {
588
+ clearBrowserGlobals();
589
+ jest.restoreAllMocks();
590
+ });
591
+
592
+ it('returns null immediately without minting a nonce or calling credentials.get when no hint is stored', async () => {
593
+ let credentialsGetCalled = false;
594
+ installBrowserGlobals({
595
+ credentialsGet: async () => {
596
+ credentialsGetCalled = true;
597
+ return null;
598
+ },
599
+ });
600
+
601
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
602
+ // No stored login hint (fresh empty localStorage) → fast-skip.
603
+ const makeRequest = jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
604
+ throw new Error(`unexpected request to ${url}`);
605
+ });
606
+
607
+ const result = await oxy.silentSignInWithFedCM();
608
+
609
+ expect(result).toBeNull();
610
+ // The nonce mint was NOT attempted and the browser credential UI was never
611
+ // touched — the round-trip was skipped entirely.
612
+ expect(makeRequest).not.toHaveBeenCalled();
613
+ expect(credentialsGetCalled).toBe(false);
553
614
  });
554
615
  });
@@ -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
+ });