@oxy.so/core 1.9.1 → 1.11.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 (71) hide show
  1. package/README.md +4 -4
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +56 -163
  4. package/dist/cjs/OxyServices.base.js +4 -2
  5. package/dist/cjs/boot/sessionColdBoot.js +3 -0
  6. package/dist/cjs/crypto/deviceBackup.js +75 -0
  7. package/dist/cjs/crypto/keyManager.js +285 -12
  8. package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
  9. package/dist/cjs/mixins/OxyServices.user.js +7 -6
  10. package/dist/cjs/mixins/index.js +4 -0
  11. package/dist/cjs/server/cors.js +0 -1
  12. package/dist/cjs/server/securityHeaders.js +3 -0
  13. package/dist/cjs/session/refresh.js +32 -2
  14. package/dist/cjs/utils/errorUtils.js +1 -1
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/HttpService.js +56 -163
  17. package/dist/esm/OxyServices.base.js +4 -2
  18. package/dist/esm/boot/sessionColdBoot.js +3 -0
  19. package/dist/esm/crypto/deviceBackup.js +70 -0
  20. package/dist/esm/crypto/keyManager.js +252 -12
  21. package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
  22. package/dist/esm/mixins/OxyServices.user.js +7 -6
  23. package/dist/esm/mixins/index.js +4 -0
  24. package/dist/esm/server/cors.js +0 -1
  25. package/dist/esm/server/securityHeaders.js +3 -0
  26. package/dist/esm/session/refresh.js +32 -2
  27. package/dist/esm/utils/errorUtils.js +1 -1
  28. package/dist/types/.tsbuildinfo +1 -1
  29. package/dist/types/HttpService.d.ts +31 -7
  30. package/dist/types/OxyServices.base.d.ts +3 -1
  31. package/dist/types/crypto/deviceBackup.d.ts +60 -0
  32. package/dist/types/crypto/keyManager.d.ts +79 -12
  33. package/dist/types/index.d.ts +1 -0
  34. package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
  35. package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
  36. package/dist/types/mixins/OxyServices.user.d.ts +8 -6
  37. package/dist/types/mixins/index.d.ts +2 -1
  38. package/dist/types/models/interfaces.d.ts +30 -0
  39. package/dist/types/server/securityHeaders.d.ts +5 -0
  40. package/dist/types/session/refresh.d.ts +7 -0
  41. package/dist/types/utils/errorUtils.d.ts +1 -1
  42. package/package.json +2 -2
  43. package/src/HttpService.ts +59 -187
  44. package/src/OxyServices.base.ts +4 -2
  45. package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
  46. package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
  47. package/src/__tests__/inSessionRefresh.test.ts +53 -0
  48. package/src/__tests__/linkedClient.test.ts +0 -7
  49. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
  50. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
  51. package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
  52. package/src/boot/sessionColdBoot.ts +3 -0
  53. package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
  54. package/src/crypto/deviceBackup.ts +108 -0
  55. package/src/crypto/keyManager.ts +278 -13
  56. package/src/index.ts +1 -0
  57. package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
  58. package/src/mixins/OxyServices.user.ts +9 -7
  59. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
  60. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
  61. package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
  62. package/src/mixins/__tests__/topics.test.ts +2 -3
  63. package/src/mixins/index.ts +6 -0
  64. package/src/models/interfaces.ts +30 -1
  65. package/src/server/__tests__/securityHeaders.test.ts +7 -0
  66. package/src/server/cors.ts +0 -1
  67. package/src/server/securityHeaders.ts +8 -0
  68. package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
  69. package/src/session/__tests__/refresh.test.ts +115 -2
  70. package/src/session/refresh.ts +38 -2
  71. package/src/utils/errorUtils.ts +1 -1
@@ -18,7 +18,6 @@ import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/request
18
18
  import { retryAsync } from './utils/asyncUtils';
19
19
  import { createCancelledError, ErrorCodes, handleHttpError, isCancelledError, parseHttpErrorBody } from './utils/errorUtils';
20
20
  import { jwtDecode } from 'jwt-decode';
21
- import { isNative, getPlatformOS } from './utils/platform';
22
21
  import { isReactNative } from '@oxy.so/protocol';
23
22
  import { computeIdentityTag, fnv1a32 } from './utils/cacheKey';
24
23
  import { redactUrlQuery } from './utils/redactUrl';
@@ -28,12 +27,6 @@ import { OxyAuthenticationError } from './OxyServices.errors';
28
27
  import { getBrowserEdgeRegionHeader } from './utils/edgeRegion';
29
28
  import { getBrowserActivityIdHeader } from './utils/activityId';
30
29
 
31
- /**
32
- * Check if we're running in a native app environment (React Native, not web)
33
- * This is used to determine CSRF handling mode
34
- */
35
- const isNativeApp = isNative();
36
-
37
30
  interface JwtPayload {
38
31
  exp?: number;
39
32
  userId?: string;
@@ -144,8 +137,6 @@ interface RequestConfig extends RequestOptions {
144
137
  params?: Record<string, unknown>;
145
138
  /** @internal Used to prevent infinite auth retry loops */
146
139
  _isAuthRetry?: boolean;
147
- /** @internal Used to prevent infinite CSRF retry loops */
148
- _isCsrfRetry?: boolean;
149
140
  }
150
141
 
151
142
  /**
@@ -156,27 +147,6 @@ interface RequestConfig extends RequestOptions {
156
147
  */
157
148
  const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
158
149
 
159
- /**
160
- * Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
161
- * regular request timeout: this is a small, fast, unauthenticated call and
162
- * should never inherit a longer per-request budget.
163
- */
164
- const CSRF_FETCH_TIMEOUT_MS = 5000;
165
-
166
- /**
167
- * Number of attempts for fetching a CSRF token before giving up. The first
168
- * failure is usually a cold edge/cookie race; a single retry recovers it
169
- * without masking a genuinely broken `/csrf-token` route.
170
- */
171
- const CSRF_FETCH_MAX_ATTEMPTS = 2;
172
-
173
- /**
174
- * Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
175
- * fetch sits in the critical path of a state-changing request, so the retry
176
- * must add minimal latency.
177
- */
178
- const CSRF_FETCH_RETRY_DELAY_MS = 500;
179
-
180
150
  /**
181
151
  * Cooldown (ms) applied after a failed access-token refresh before another
182
152
  * refresh is attempted while the CURRENT token is still valid (a proactive,
@@ -284,8 +254,6 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
284
254
  */
285
255
  class TokenStore {
286
256
  private accessToken: string | null = null;
287
- private csrfToken: string | null = null;
288
- private csrfTokenFetchPromise: Promise<string | null> | null = null;
289
257
 
290
258
  setTokens(accessToken: string): void {
291
259
  this.accessToken = accessToken;
@@ -302,27 +270,6 @@ class TokenStore {
302
270
  hasAccessToken(): boolean {
303
271
  return !!this.accessToken;
304
272
  }
305
-
306
- setCsrfToken(token: string | null): void {
307
- this.csrfToken = token;
308
- }
309
-
310
- getCsrfToken(): string | null {
311
- return this.csrfToken;
312
- }
313
-
314
- setCsrfTokenFetchPromise(promise: Promise<string | null> | null): void {
315
- this.csrfTokenFetchPromise = promise;
316
- }
317
-
318
- getCsrfTokenFetchPromise(): Promise<string | null> | null {
319
- return this.csrfTokenFetchPromise;
320
- }
321
-
322
- clearCsrfToken(): void {
323
- this.csrfToken = null;
324
- this.csrfTokenFetchPromise = null;
325
- }
326
273
  }
327
274
 
328
275
  /**
@@ -432,6 +379,21 @@ export class HttpService {
432
379
  private authRefreshHandler: AuthRefreshHandler | null = null;
433
380
  private accessTokenProvider: AccessTokenProvider | null = null;
434
381
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
382
+ /**
383
+ * Bumped by every {@link endSession}. A re-mint captures it before its first
384
+ * await and plants nothing if it moved: a refresh that was already in flight
385
+ * when the user signed out would otherwise complete a moment later and put a
386
+ * bearer — and through the token listeners, the account — back.
387
+ */
388
+ private sessionEpoch = 0;
389
+ /**
390
+ * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
391
+ * native shared-keychain re-mint arm: after a sign-out a 401 is the expected
392
+ * answer, not a cue to sign back in with the identity key. The device-secret
393
+ * arm is NOT gated on it — minting from a credential the store still holds is
394
+ * how a signed-out tab joins a sign-in made in another tab.
395
+ */
396
+ private sessionEnded = false;
435
397
 
436
398
  /**
437
399
  * Epoch (ms) before which a cache-size telemetry warning must not be
@@ -629,14 +591,9 @@ export class HttpService {
629
591
  // it before enqueue means auth-blocked requests hold NO slot while the shared
630
592
  // mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
631
593
  // and skip the near-expiry preflight — see RequestOptions.skipAuth. The
632
- // 401/CSRF retry re-enters request() with a fresh config, so it re-resolves
633
- // these here with the refreshed token / cleared CSRF.
634
- const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
594
+ // 401 retry re-enters request() with a fresh config, so it re-resolves
595
+ // this here with the refreshed token.
635
596
  const authHeader = config.skipAuth ? null : await this.getAuthHeader();
636
- // CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
637
- // clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
638
- // should not need to implement a duplicate `/csrf-token` route.
639
- const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
640
597
  const edgeRegionHeader = await getBrowserEdgeRegionHeader();
641
598
  const activityIdHeader = getBrowserActivityIdHeader();
642
599
 
@@ -717,32 +674,6 @@ export class HttpService {
717
674
  headers['Authorization'] = authHeader;
718
675
  }
719
676
 
720
- // Add CSRF token header for state-changing requests
721
- if (csrfToken) {
722
- headers['X-CSRF-Token'] = csrfToken;
723
- }
724
-
725
- // Add native app header for React Native (required for CSRF validation)
726
- // Native apps can't persist cookies like browsers, so the server uses
727
- // header-only CSRF validation when this header is present
728
- if (isNativeApp && isStateChangingMethod) {
729
- headers['X-Native-App'] = 'true';
730
- }
731
-
732
- // Debug logging for CSRF issues, routed through SimpleLogger so it only
733
- // fires when consumers opt in via `enableLogging`.
734
- if (isStateChangingMethod) {
735
- this.logger.debug('CSRF Debug:', {
736
- url,
737
- method,
738
- isNativeApp,
739
- platformOS: getPlatformOS(),
740
- hasCsrfToken: !!csrfToken,
741
- csrfTokenLength: csrfToken?.length,
742
- hasNativeAppHeader: headers['X-Native-App'] === 'true',
743
- });
744
- }
745
-
746
677
  // Merge custom headers if provided
747
678
  if (config.headers) {
748
679
  Object.entries(config.headers).forEach(([key, value]) => {
@@ -807,35 +738,20 @@ export class HttpService {
807
738
  if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
808
739
  const refreshed = await this.refreshAccessToken('response-401');
809
740
  if (refreshed) {
810
- // `deduplicate: false` is REQUIRED on the retry (mirrors the 403
811
- // CSRF retry below). This re-issue runs while the ORIGINAL request
812
- // is still in-flight under its dedupe key; the refreshed token is
741
+ // `deduplicate: false` is REQUIRED on the retry. This re-issue runs
742
+ // while the ORIGINAL request is still in-flight under its dedupe
743
+ // key; the refreshed token is
813
744
  // for the SAME user, so the identity-scoped key is UNCHANGED — a
814
745
  // deduplicated retry would resolve to the still-pending original
815
746
  // and await itself (deadlock). Opting the retry out of dedupe makes
816
747
  // it a fresh request.
817
748
  return this.request<T>({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
818
749
  }
819
- // Refresh failed or no token — clear tokens and stale CSRF
750
+ // Refresh failed or no token — clear tokens
820
751
  this.tokenStore.clearTokens();
821
- this.tokenStore.clearCsrfToken();
822
752
  this.notifyTokenChange();
823
753
  }
824
754
 
825
- // On 403 with CSRF error, clear cached token and retry once
826
- if (response.status === 403 && !config._isCsrfRetry) {
827
- try {
828
- const clonedResponse = response.clone();
829
- const errBody = await clonedResponse.json() as { code?: string } | null;
830
- if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
831
- this.tokenStore.clearCsrfToken();
832
- return this.request<T>({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
833
- }
834
- } catch {
835
- // Failed to parse error body — not a CSRF error
836
- }
837
- }
838
-
839
755
  // Read the error body (may be absent, non-JSON, empty or malformed).
840
756
  // Anything unreadable leaves `errorBody` undefined and degrades to the
841
757
  // status-based message — an error path that throws its own error is
@@ -1269,85 +1185,6 @@ export class HttpService {
1269
1185
  }
1270
1186
  }
1271
1187
 
1272
- /**
1273
- * Fetch CSRF token from server (with deduplication)
1274
- * Required for state-changing requests (POST, PUT, PATCH, DELETE)
1275
- */
1276
- private async fetchCsrfToken(): Promise<string | null> {
1277
- // Return cached token if available
1278
- const cachedToken = this.tokenStore.getCsrfToken();
1279
- if (cachedToken) {
1280
- this.logger.debug('Using cached CSRF token');
1281
- return cachedToken;
1282
- }
1283
-
1284
- // Deduplicate concurrent CSRF token fetches
1285
- const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
1286
- if (existingPromise) {
1287
- this.logger.debug('Waiting for existing CSRF fetch');
1288
- return existingPromise;
1289
- }
1290
-
1291
- const fetchPromise = (async () => {
1292
- for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
1293
- try {
1294
- this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
1295
-
1296
- // Use AbortController for timeout (more compatible than AbortSignal.timeout)
1297
- const controller = new AbortController();
1298
- const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
1299
-
1300
- const response = await fetch(`${this.baseURL}/csrf-token`, {
1301
- method: 'GET',
1302
- headers: { 'Accept': 'application/json' },
1303
- credentials: 'include', // Required to receive and send cookies
1304
- signal: controller.signal,
1305
- });
1306
-
1307
- clearTimeout(timeoutId);
1308
-
1309
- this.logger.debug('CSRF fetch response:', response.status, response.ok);
1310
-
1311
- if (response.ok) {
1312
- const data = await response.json() as { csrfToken?: string };
1313
- const token = data.csrfToken || null;
1314
- this.logger.debug('CSRF response data:', {
1315
- hasCsrfToken: typeof token === 'string' && token.length > 0,
1316
- csrfTokenLength: token?.length,
1317
- });
1318
- this.tokenStore.setCsrfToken(token);
1319
- this.logger.debug('CSRF token fetched');
1320
- return token;
1321
- }
1322
-
1323
- // Also check response header for CSRF token
1324
- const headerToken = response.headers.get('X-CSRF-Token');
1325
- if (headerToken) {
1326
- this.tokenStore.setCsrfToken(headerToken);
1327
- this.logger.debug('CSRF token from header');
1328
- return headerToken;
1329
- }
1330
-
1331
- this.logger.debug('CSRF fetch failed with status:', response.status);
1332
- this.logger.warn('Failed to fetch CSRF token:', response.status);
1333
- } catch (error) {
1334
- this.logger.debug('CSRF fetch error:', error);
1335
- this.logger.warn('CSRF token fetch error:', error);
1336
- }
1337
- // Brief backoff before the next attempt.
1338
- if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
1339
- await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
1340
- }
1341
- }
1342
- return null;
1343
- })().finally(() => {
1344
- this.tokenStore.setCsrfTokenFetchPromise(null);
1345
- });
1346
-
1347
- this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
1348
- return fetchPromise;
1349
- }
1350
-
1351
1188
  /**
1352
1189
  * Get auth header with automatic token refresh
1353
1190
  */
@@ -1413,12 +1250,21 @@ export class HttpService {
1413
1250
  // by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
1414
1251
  // on the way out would discard the flag it just set.
1415
1252
  this.lastRefreshWasRateLimited = false;
1253
+ const epoch = this.sessionEpoch;
1416
1254
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1417
1255
  .then((newToken) => {
1256
+ if (epoch !== this.sessionEpoch) {
1257
+ // The session ended while this re-mint was in flight. Not a failure
1258
+ // (no cooldown), and nothing to plant.
1259
+ this.logger.debug('Discarded a token refresh that outlived its session');
1260
+ return null;
1261
+ }
1418
1262
  if (!newToken) {
1419
1263
  this.lastRefreshFailureAt = Date.now();
1420
1264
  return null;
1421
1265
  }
1266
+ // A token is planted again, so there is a session again.
1267
+ this.sessionEnded = false;
1422
1268
  if (this.tokenStore.getAccessToken() !== newToken) {
1423
1269
  this.tokenStore.setTokens(newToken);
1424
1270
  this.notifyTokenChange();
@@ -1639,7 +1485,6 @@ export class HttpService {
1639
1485
  }
1640
1486
 
1641
1487
  this.tokenStore.clearTokens();
1642
- this.tokenStore.clearCsrfToken();
1643
1488
  this.notifyTokenChange();
1644
1489
  }
1645
1490
 
@@ -1680,10 +1525,38 @@ export class HttpService {
1680
1525
 
1681
1526
  // Token management
1682
1527
  setTokens(accessToken: string): void {
1528
+ this.sessionEnded = false;
1683
1529
  this.tokenStore.setTokens(accessToken);
1684
1530
  this.notifyTokenChange();
1685
1531
  }
1686
1532
 
1533
+ /**
1534
+ * End the local session: clear the bearer and abandon every re-mint already
1535
+ * in flight, so none of them can plant a token after the sign-out.
1536
+ *
1537
+ * {@link clearTokens} alone only drops the bearer, which is right for a
1538
+ * mirror (a linked client following its parent) but not for a sign-out: a
1539
+ * refresh started a moment earlier would finish and plant a new one.
1540
+ */
1541
+ endSession(): void {
1542
+ this.sessionEpoch += 1;
1543
+ this.sessionEnded = true;
1544
+ this.clearTokens();
1545
+ }
1546
+
1547
+ /**
1548
+ * The current session epoch — see {@link endSession}. A re-mint lane that
1549
+ * plants tokens itself (the device-secret arm) compares it across its awaits.
1550
+ */
1551
+ getSessionEpoch(): number {
1552
+ return this.sessionEpoch;
1553
+ }
1554
+
1555
+ /** Whether {@link endSession} ran and no token has been planted since. */
1556
+ hasSessionEnded(): boolean {
1557
+ return this.sessionEnded;
1558
+ }
1559
+
1687
1560
  setAuthRefreshHandler(handler: AuthRefreshHandler | null): void {
1688
1561
  this.authRefreshHandler = handler;
1689
1562
  }
@@ -1694,7 +1567,6 @@ export class HttpService {
1694
1567
 
1695
1568
  clearTokens(): void {
1696
1569
  this.tokenStore.clearTokens();
1697
- this.tokenStore.clearCsrfToken();
1698
1570
  // Drop the response cache on logout. The cache is identity-scoped, so a
1699
1571
  // different user could never read these entries, but a logged-out client
1700
1572
  // must not keep the previous session's personalized data resident in
@@ -1819,7 +1691,7 @@ export class HttpService {
1819
1691
 
1820
1692
  // Test-only utility — clears tokens on this instance
1821
1693
  __resetTokensForTests(): void {
1694
+ this.sessionEnded = false;
1822
1695
  this.tokenStore.clearTokens();
1823
- this.tokenStore.clearCsrfToken();
1824
1696
  }
1825
1697
  }
@@ -212,10 +212,12 @@ export class OxyServicesBase {
212
212
  }
213
213
 
214
214
  /**
215
- * Clear stored authentication tokens
215
+ * Clear stored authentication tokens and end the local session: a re-mint
216
+ * already in flight plants nothing, and no new one runs until a token is set
217
+ * again (see `HttpService.endSession`).
216
218
  */
217
219
  public clearTokens(): void {
218
- this.httpService.clearTokens();
220
+ this.httpService.endSession();
219
221
  this._cachedUserId = undefined;
220
222
  this._cachedAccessToken = null;
221
223
  }
@@ -46,8 +46,8 @@ describe('HttpService form-urlencoded bodies', () => {
46
46
  });
47
47
 
48
48
  /**
49
- * POST a form body and return the token-endpoint call. Selected by URL
50
- * because an unauthenticated write is preceded by a `GET /csrf-token`.
49
+ * POST a form body and return the token-endpoint call. Selected by URL so
50
+ * any preflight a future change adds cannot shift the call under test.
51
51
  */
52
52
  async function postForm(body: URLSearchParams): Promise<FetchCall> {
53
53
  const calls: FetchCall[] = [];
@@ -111,9 +111,6 @@ describe('HttpService form-urlencoded bodies', () => {
111
111
  it('prefers OAuth error_description over the bare error code', async () => {
112
112
  globalThis.fetch = async (input) => {
113
113
  const url = String(input);
114
- if (url.endsWith('/csrf-token')) {
115
- return jsonResponse({ csrfToken: 'csrf-test' });
116
- }
117
114
  return new Response(
118
115
  JSON.stringify({
119
116
  error: 'invalid_grant',
@@ -31,7 +31,12 @@ function readHeaders(init: RequestInit | undefined): Record<string, string> {
31
31
  return headers as Record<string, string>;
32
32
  }
33
33
 
34
- describe('HttpService CSRF behavior', () => {
34
+ /**
35
+ * Writes carry exactly one credential: the bearer, when there is one. Oxy's API
36
+ * has no CSRF layer (issue #1044) and the SDK neither fetches `/csrf-token` nor
37
+ * sends `X-CSRF-Token` / `X-Native-App`, for its own API or a linked one.
38
+ */
39
+ describe('HttpService writes', () => {
35
40
  const originalFetch = globalThis.fetch;
36
41
 
37
42
  afterEach(() => {
@@ -39,13 +44,18 @@ describe('HttpService CSRF behavior', () => {
39
44
  jest.restoreAllMocks();
40
45
  });
41
46
 
42
- it('does not fetch csrf-token before bearer-authenticated writes', async () => {
47
+ function recordFetches(respond: (url: string) => Response = () => jsonResponse({ ok: true })): FetchCall[] {
43
48
  const calls: FetchCall[] = [];
44
49
  globalThis.fetch = async (input, init) => {
45
50
  const url = String(input);
46
51
  calls.push({ url, init });
47
- return jsonResponse({ ok: true });
52
+ return respond(url);
48
53
  };
54
+ return calls;
55
+ }
56
+
57
+ it('sends a bearer-authenticated write as a single request', async () => {
58
+ const calls = recordFetches();
49
59
 
50
60
  const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
51
61
  const accessToken = createJwt({
@@ -56,20 +66,31 @@ describe('HttpService CSRF behavior', () => {
56
66
 
57
67
  await http.post('/posts', { text: 'hello' });
58
68
 
59
- expect(calls).toHaveLength(1);
60
- expect(calls[0].url).toBe('https://api.mention.earth/posts');
69
+ expect(calls.map((call) => call.url)).toEqual(['https://api.mention.earth/posts']);
61
70
  const headers = readHeaders(calls[0].init);
62
71
  expect(headers.Authorization).toBe(`Bearer ${accessToken}`);
63
72
  expect(headers['X-CSRF-Token']).toBeUndefined();
73
+ expect(headers['X-Native-App']).toBeUndefined();
74
+ });
75
+
76
+ it('sends a write without a bearer as a single request with no CSRF preflight', async () => {
77
+ const calls = recordFetches();
78
+
79
+ const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
80
+
81
+ await http.post('/users/by-ids', { ids: ['user_1'] });
82
+
83
+ // POSITIVE CONTROL: the write itself went out, so the absence below is not
84
+ // the absence of any request at all.
85
+ expect(calls.map((call) => call.url)).toEqual(['https://api.oxy.so/users/by-ids']);
86
+ const headers = readHeaders(calls[0].init);
87
+ expect(headers.Authorization).toBeUndefined();
88
+ expect(headers['X-CSRF-Token']).toBeUndefined();
89
+ expect(headers['X-Native-App']).toBeUndefined();
64
90
  });
65
91
 
66
92
  it('keeps a valid near-expiry bearer token when preflight refresh cannot refresh', async () => {
67
- const calls: FetchCall[] = [];
68
- globalThis.fetch = async (input, init) => {
69
- const url = String(input);
70
- calls.push({ url, init });
71
- return jsonResponse({ ok: true });
72
- };
93
+ const calls = recordFetches();
73
94
 
74
95
  const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
75
96
  const accessToken = createJwt({
@@ -86,26 +107,12 @@ describe('HttpService CSRF behavior', () => {
86
107
  await http.post('/posts', { text: 'hello' });
87
108
 
88
109
  expect(refreshAttempts).toBe(1);
89
- expect(calls).toHaveLength(1);
90
- expect(calls[0].url).toBe('https://api.mention.earth/posts');
91
- const headers = readHeaders(calls[0].init);
92
- expect(headers.Authorization).toBe(`Bearer ${accessToken}`);
93
- expect(headers['X-CSRF-Token']).toBeUndefined();
110
+ expect(calls.map((call) => call.url)).toEqual(['https://api.mention.earth/posts']);
111
+ expect(readHeaders(calls[0].init).Authorization).toBe(`Bearer ${accessToken}`);
94
112
  });
95
113
 
96
114
  it('does not use an expired bearer token when preflight refresh cannot refresh', async () => {
97
- const calls: FetchCall[] = [];
98
- globalThis.fetch = async (input, init) => {
99
- const url = String(input);
100
- calls.push({ url, init });
101
- if (url.endsWith('/csrf-token')) {
102
- return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
103
- status: 200,
104
- headers: { 'content-type': 'application/json' },
105
- });
106
- }
107
- return jsonResponse({ ok: true });
108
- };
115
+ const calls = recordFetches();
109
116
 
110
117
  const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
111
118
  const accessToken = createJwt({
@@ -122,48 +129,28 @@ describe('HttpService CSRF behavior', () => {
122
129
  await http.post('/posts', { text: 'hello' });
123
130
 
124
131
  expect(refreshAttempts).toBe(1);
125
- expect(calls.map((call) => call.url)).toEqual([
126
- 'https://api.mention.earth/csrf-token',
127
- 'https://api.mention.earth/posts',
128
- ]);
129
- const headers = readHeaders(calls[1].init);
130
- expect(headers.Authorization).toBeUndefined();
131
- expect(headers['X-CSRF-Token']).toBe('csrf_1');
132
+ expect(calls.map((call) => call.url)).toEqual(['https://api.mention.earth/posts']);
133
+ expect(readHeaders(calls[0].init).Authorization).toBeUndefined();
132
134
  });
133
135
 
134
- it('still fetches csrf-token for cookie-authenticated writes without bearer', async () => {
135
- const calls: FetchCall[] = [];
136
- globalThis.fetch = async (input, init) => {
137
- const url = String(input);
138
- calls.push({ url, init });
139
- if (url.endsWith('/csrf-token')) {
140
- return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
141
- status: 200,
136
+ it('does not retry a 403, whatever its code says', async () => {
137
+ const calls = recordFetches(
138
+ () =>
139
+ new Response(JSON.stringify({ code: 'CSRF_TOKEN_INVALID' }), {
140
+ status: 403,
141
+ statusText: 'Forbidden',
142
142
  headers: { 'content-type': 'application/json' },
143
- });
144
- }
145
- return jsonResponse({ ok: true });
146
- };
143
+ }),
144
+ );
147
145
 
148
146
  const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
149
147
 
150
- await http.post('/posts', { text: 'hello' });
151
-
152
- expect(calls.map((call) => call.url)).toEqual([
153
- 'https://api.mention.earth/csrf-token',
154
- 'https://api.mention.earth/posts',
155
- ]);
156
- const headers = readHeaders(calls[1].init);
157
- expect(headers.Authorization).toBeUndefined();
158
- expect(headers['X-CSRF-Token']).toBe('csrf_1');
148
+ await expect(http.post('/posts', { text: 'hello' })).rejects.toBeDefined();
149
+ expect(calls.map((call) => call.url)).toEqual(['https://api.mention.earth/posts']);
159
150
  });
160
151
 
161
152
  it('includes credentials for configured API origin requests', async () => {
162
- const calls: FetchCall[] = [];
163
- globalThis.fetch = async (input, init) => {
164
- calls.push({ url: String(input), init });
165
- return jsonResponse({ ok: true });
166
- };
153
+ const calls = recordFetches();
167
154
 
168
155
  const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
169
156
 
@@ -175,11 +162,7 @@ describe('HttpService CSRF behavior', () => {
175
162
  });
176
163
 
177
164
  it('omits credentials for caller-supplied absolute URLs outside the configured API origin', async () => {
178
- const calls: FetchCall[] = [];
179
- globalThis.fetch = async (input, init) => {
180
- calls.push({ url: String(input), init });
181
- return jsonResponse({ ok: true });
182
- };
165
+ const calls = recordFetches();
183
166
 
184
167
  const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
185
168
 
@@ -189,47 +172,4 @@ describe('HttpService CSRF behavior', () => {
189
172
  expect(calls[0].url).toBe('https://attacker.oxy.so/collect');
190
173
  expect(calls[0].init?.credentials).toBe('omit');
191
174
  });
192
-
193
- it('bypasses request deduplication for the internal CSRF retry', async () => {
194
- const calls: FetchCall[] = [];
195
- let csrfFetches = 0;
196
- let postFetches = 0;
197
-
198
- globalThis.fetch = async (input, init) => {
199
- const url = String(input);
200
- calls.push({ url, init });
201
-
202
- if (url.endsWith('/csrf-token')) {
203
- csrfFetches += 1;
204
- return new Response(JSON.stringify({ csrfToken: `csrf_${csrfFetches}` }), {
205
- status: 200,
206
- headers: { 'content-type': 'application/json' },
207
- });
208
- }
209
-
210
- postFetches += 1;
211
- if (postFetches === 1) {
212
- return new Response(JSON.stringify({ code: 'CSRF_TOKEN_INVALID' }), {
213
- status: 403,
214
- statusText: 'Forbidden',
215
- headers: { 'content-type': 'application/json' },
216
- });
217
- }
218
-
219
- return jsonResponse({ ok: true });
220
- };
221
-
222
- const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
223
-
224
- await expect(http.post('/posts', { text: 'hello' })).resolves.toEqual({ ok: true });
225
- expect(calls.map((call) => call.url)).toEqual([
226
- 'https://api.mention.earth/csrf-token',
227
- 'https://api.mention.earth/posts',
228
- 'https://api.mention.earth/csrf-token',
229
- 'https://api.mention.earth/posts',
230
- ]);
231
-
232
- expect(readHeaders(calls[1].init)['X-CSRF-Token']).toBe('csrf_1');
233
- expect(readHeaders(calls[3].init)['X-CSRF-Token']).toBe('csrf_2');
234
- });
235
175
  });