@oxy.so/core 1.9.0 → 1.10.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 (45) hide show
  1. package/README.md +4 -4
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +112 -163
  4. package/dist/cjs/OxyServices.base.js +4 -2
  5. package/dist/cjs/boot/sessionColdBoot.js +3 -0
  6. package/dist/cjs/mixins/OxyServices.user.js +2 -5
  7. package/dist/cjs/server/cors.js +0 -1
  8. package/dist/cjs/session/refresh.js +71 -7
  9. package/dist/cjs/utils/errorUtils.js +1 -1
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/HttpService.js +112 -163
  12. package/dist/esm/OxyServices.base.js +4 -2
  13. package/dist/esm/boot/sessionColdBoot.js +3 -0
  14. package/dist/esm/mixins/OxyServices.user.js +2 -5
  15. package/dist/esm/server/cors.js +0 -1
  16. package/dist/esm/session/refresh.js +70 -6
  17. package/dist/esm/utils/errorUtils.js +1 -1
  18. package/dist/types/.tsbuildinfo +1 -1
  19. package/dist/types/HttpService.d.ts +59 -7
  20. package/dist/types/OxyServices.base.d.ts +3 -1
  21. package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
  22. package/dist/types/mixins/OxyServices.user.d.ts +1 -4
  23. package/dist/types/session/refresh.d.ts +21 -0
  24. package/dist/types/utils/errorUtils.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/HttpService.ts +116 -187
  27. package/src/OxyServices.base.ts +4 -2
  28. package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
  29. package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
  30. package/src/__tests__/inSessionRefresh.test.ts +53 -0
  31. package/src/__tests__/linkedClient.test.ts +0 -7
  32. package/src/__tests__/nearExpiryMintStorm.test.ts +165 -0
  33. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
  34. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
  35. package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
  36. package/src/boot/sessionColdBoot.ts +3 -0
  37. package/src/mixins/OxyServices.user.ts +2 -5
  38. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
  39. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
  40. package/src/mixins/__tests__/topics.test.ts +2 -3
  41. package/src/server/cors.ts +0 -1
  42. package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
  43. package/src/session/__tests__/refresh.test.ts +115 -2
  44. package/src/session/refresh.ts +77 -6
  45. package/src/utils/errorUtils.ts +1 -1
@@ -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
  });
@@ -233,6 +233,59 @@ describe('HttpService in-session refresh handler', () => {
233
233
  });
234
234
  });
235
235
 
236
+ describe('HttpService.endSession — a sign-out outranks a refresh', () => {
237
+ it('plants nothing when the session ends while a re-mint is in flight', async () => {
238
+ const oldToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'old' });
239
+ const lateToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'late' });
240
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
241
+ http.setTokens(oldToken);
242
+
243
+ let finish!: (token: string | null) => void;
244
+ http.setAuthRefreshHandler(
245
+ () =>
246
+ new Promise((resolve) => {
247
+ finish = resolve;
248
+ }),
249
+ );
250
+ const seen: Array<string | null> = [];
251
+ http.addTokenChangeListener((token) => seen.push(token));
252
+
253
+ const pending = http.refreshAccessToken('preflight');
254
+ http.endSession();
255
+ finish(lateToken);
256
+
257
+ await expect(pending).resolves.toBeNull();
258
+ expect(http.getAccessToken()).toBeNull();
259
+ expect(seen).toEqual([null]);
260
+ });
261
+
262
+ it('abandons only what was in flight: a later re-mint (another tab signed in) still runs', async () => {
263
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
264
+ const fresh = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'fresh' });
265
+ const handler = jest.fn(async () => fresh);
266
+ http.setAuthRefreshHandler(handler);
267
+
268
+ http.endSession();
269
+ expect(http.hasSessionEnded()).toBe(true);
270
+ await expect(http.refreshAccessToken('preflight')).resolves.toBe(fresh);
271
+ expect(http.getAccessToken()).toBe(fresh);
272
+ expect(http.hasSessionEnded()).toBe(false);
273
+ });
274
+
275
+ it('is what OxyServices.clearTokens does; a plain HttpService.clearTokens (a mirror) is not', () => {
276
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
277
+ const before = oxy.httpService.getSessionEpoch();
278
+ oxy.clearTokens();
279
+ expect(oxy.httpService.getSessionEpoch()).toBe(before + 1);
280
+ expect(oxy.httpService.hasSessionEnded()).toBe(true);
281
+
282
+ const mirror = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
283
+ mirror.clearTokens();
284
+ expect(mirror.getSessionEpoch()).toBe(0);
285
+ expect(mirror.hasSessionEnded()).toBe(false);
286
+ });
287
+ });
288
+
236
289
  describe('OxyServices.getAccessTokenExpiry', () => {
237
290
  it('returns the JWT exp (seconds) of the current access token', () => {
238
291
  const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
@@ -208,13 +208,6 @@ describe('OxyServices.createLinkedClient', () => {
208
208
  const url = String(input);
209
209
  calls.push({ url, init });
210
210
 
211
- if (url.endsWith('/csrf-token')) {
212
- return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
213
- status: 200,
214
- headers: { 'content-type': 'application/json' },
215
- });
216
- }
217
-
218
211
  if (url.endsWith('/api/queue/current')) {
219
212
  queueWriteAttempts += 1;
220
213
  if (queueWriteAttempts === 1) {
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The near-expiry mint storm that signed a busy app out (OxyHQ/Mention#1140).
3
+ *
4
+ * What the server does: `POST /session/device/token` answers with the session's
5
+ * STORED access token for as long as that token has not expired, and mints a
6
+ * new one only once it is past `exp`. The mint is budgeted at 30/min per device.
7
+ *
8
+ * What the client did with that: the scheduler and the request-time preflight
9
+ * both re-mint 60s before `exp`. The server handed the same token back, which
10
+ * counted as a success (no cooldown), so the scheduler re-armed at its 1s floor
11
+ * and every request re-ran the preflight: ~30 mints in the token's last minute,
12
+ * then a 429, whose 60s cooldown straddled the real expiry. The next request
13
+ * carried no bearer, drew a 401, found the refresh cooling down, and the
14
+ * HttpService cleared the token — which the provider reads as a sign-out.
15
+ *
16
+ * Production logs for the device showed exactly this: a burst of ~30 mints at
17
+ * ~1.4s intervals every ~16 minutes, a single mint ~78s later (the scheduler's
18
+ * 5+10+20+40s backoff through the 60s rate-limit cooldown), and a sign-out on
19
+ * the one cycle where the app was in active use across the expiry.
20
+ *
21
+ * This suite simulates that server against the real HttpService, refresh
22
+ * scheduler and 401 lane.
23
+ */
24
+ import { OxyServices } from '../OxyServices';
25
+ import { startTokenRefreshScheduler } from '../session/refresh';
26
+
27
+ const TOKEN_TTL_SECONDS = 15 * 60;
28
+ const MINT_BUDGET_PER_MINUTE = 30;
29
+
30
+ function createJwt(payload: Record<string, unknown>): string {
31
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
32
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
33
+ }
34
+
35
+ function expOf(token: string): number {
36
+ const [, payload] = token.split('.');
37
+ return (JSON.parse(Buffer.from(payload, 'base64url').toString()) as { exp: number }).exp;
38
+ }
39
+
40
+ /**
41
+ * The device mint as the server implements it, including its rate limiter.
42
+ *
43
+ * `rotateWithinSeconds` is how close to `exp` the stored token may be before the
44
+ * server rotates instead of handing it back: `0` is the server as it was (it
45
+ * rotated only a token already past `exp`), a positive value is the fixed one.
46
+ */
47
+ function createMintServer(rotateWithinSeconds: number): {
48
+ mint: () => { status: 200; token: string } | { status: 429 };
49
+ storedToken: () => string;
50
+ mints: number[];
51
+ rateLimited: number;
52
+ } {
53
+ let serial = 0;
54
+ const issue = (): string => {
55
+ serial += 1;
56
+ return createJwt({ userId: 'u', jti: `t${serial}`, exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS });
57
+ };
58
+ let stored = issue();
59
+ const mints: number[] = [];
60
+ const state = { rateLimited: 0 };
61
+ return {
62
+ mint: () => {
63
+ const now = Date.now();
64
+ if (mints.filter((at) => now - at < 60_000).length >= MINT_BUDGET_PER_MINUTE) {
65
+ state.rateLimited += 1;
66
+ return { status: 429 };
67
+ }
68
+ mints.push(now);
69
+ // `getAccessToken`: hand back the stored token unless it has expired.
70
+ if (expOf(stored) - Math.floor(now / 1000) < (rotateWithinSeconds > 0 ? rotateWithinSeconds : 0)) {
71
+ stored = issue();
72
+ }
73
+ return { status: 200, token: stored };
74
+ },
75
+ storedToken: () => stored,
76
+ mints,
77
+ get rateLimited() {
78
+ return state.rateLimited;
79
+ },
80
+ };
81
+ }
82
+
83
+ describe('a busy app across an access token expiry', () => {
84
+ const originalFetch = globalThis.fetch;
85
+
86
+ afterEach(() => {
87
+ globalThis.fetch = originalFetch;
88
+ jest.useRealTimers();
89
+ });
90
+
91
+ async function runBusyApp(rotateWithinSeconds: number): Promise<{
92
+ tokenEvents: Array<string | null>;
93
+ finalToken: string | null;
94
+ server: ReturnType<typeof createMintServer>;
95
+ }> {
96
+ jest.useFakeTimers({ now: new Date('2026-09-25T13:48:00Z') });
97
+ const server = createMintServer(rotateWithinSeconds);
98
+
99
+ // The app's own API: 401 without a live bearer, as any resource server does.
100
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
101
+ const headers = (init?.headers ?? {}) as Record<string, string>;
102
+ const bearer = headers.Authorization ?? headers.authorization;
103
+ const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : null;
104
+ if (!token || expOf(token) <= Math.floor(Date.now() / 1000)) {
105
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
106
+ status: 401,
107
+ headers: { 'content-type': 'application/json' },
108
+ });
109
+ }
110
+ return new Response(JSON.stringify({ data: { ok: true } }), {
111
+ status: 200,
112
+ headers: { 'content-type': 'application/json' },
113
+ });
114
+ }) as typeof fetch;
115
+
116
+ const oxy = new OxyServices({ baseURL: 'https://api.mention.earth', enableCache: false, enableRetry: false });
117
+ oxy.setTokens(server.storedToken());
118
+ oxy.httpService.setAuthRefreshHandler(async (reason) => {
119
+ const answer = server.mint();
120
+ if (answer.status === 429) {
121
+ // What `refreshDeviceSecretArm` does with a 429.
122
+ oxy.httpService.noteRefreshRateLimited();
123
+ return null;
124
+ }
125
+ oxy.setTokens(answer.token);
126
+ return answer.token;
127
+ });
128
+
129
+ const tokenEvents: Array<string | null> = [];
130
+ oxy.onTokensChanged((token) => tokenEvents.push(token));
131
+ const scheduler = startTokenRefreshScheduler(oxy);
132
+
133
+ // Twenty minutes of an app in use: a request every 700ms, straight across
134
+ // the first token's expiry at 14:03:00.
135
+ for (let elapsed = 0; elapsed < 20 * 60_000; elapsed += 700) {
136
+ await oxy.httpService.get('/feed').catch(() => undefined);
137
+ await jest.advanceTimersByTimeAsync(700);
138
+ }
139
+ scheduler.dispose();
140
+ return { tokenEvents, finalToken: oxy.getAccessToken(), server };
141
+ }
142
+
143
+ it('against the server that hands back an unexpired token: never cleared, never rate limited', async () => {
144
+ const { tokenEvents, finalToken, server } = await runBusyApp(0);
145
+
146
+ // Before the fix: ~30 mints of the same token, a 429, then the token was
147
+ // cleared (a run of `null` events) and the app was signed out.
148
+ expect(tokenEvents).not.toContain(null);
149
+ expect(finalToken).not.toBeNull();
150
+ expect(server.rateLimited).toBe(0);
151
+ // One rotation. The server still hands the expired token back for the
152
+ // second it expires in (it compares `exp < now`), which costs a few mints in
153
+ // that one second — nowhere near the budget.
154
+ expect(server.mints.length).toBeLessThan(MINT_BUDGET_PER_MINUTE / 3);
155
+ });
156
+
157
+ it('against the server that rotates a token near expiry: one mint per rotation', async () => {
158
+ const { tokenEvents, finalToken, server } = await runBusyApp(120);
159
+
160
+ expect(tokenEvents).not.toContain(null);
161
+ expect(finalToken).not.toBeNull();
162
+ expect(server.rateLimited).toBe(0);
163
+ expect(server.mints).toHaveLength(1);
164
+ });
165
+ });
@@ -104,7 +104,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
104
104
  overrides.requestChallenge
105
105
  ?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
106
106
  verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
107
- httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
107
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
108
108
  } as unknown as OxyServices;
109
109
  return { oxy, setTokens };
110
110
  }
@@ -79,7 +79,7 @@ function makeOxy(overrides: {
79
79
  setTokens,
80
80
  mintFromDeviceSecret,
81
81
  signInWithSharedIdentity,
82
- httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
82
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
83
83
  } as unknown as OxyServices;
84
84
  return { oxy, setTokens, mintFromDeviceSecret, signInWithSharedIdentity };
85
85
  }
@@ -45,7 +45,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
45
45
  throw new Error('mintFromDeviceSecret not stubbed');
46
46
  }),
47
47
  // The device-secret-mint step runs through the client's single-flight.
48
- httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
48
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
49
49
  } as unknown as OxyServices;
50
50
  return { oxy, setTokens };
51
51
  }
@@ -326,6 +326,9 @@ export async function runSessionColdBoot(
326
326
  { component: 'sessionColdBoot', method: 'device-secret-mint' },
327
327
  );
328
328
  return { kind: 'skip' };
329
+ case 'session-ended':
330
+ // Signed out while the boot was minting; the arm planted nothing.
331
+ return { kind: 'skip' };
329
332
  case 'no-secret':
330
333
  return { kind: 'skip' };
331
334
  }
@@ -470,10 +470,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
470
470
  * `PostHydrationService`) and is unchanged.
471
471
  * - **Plain client (browser / React Native with a user session):** when no
472
472
  * service credentials are configured, the chunk is fetched via
473
- * `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
474
- * middleware skips bearer-authenticated writes, and `makeRequest` only
475
- * fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
476
- * so the user-bearer POST is sent without CSRF and succeeds. Previously
473
+ * `makeRequest`, which attaches the configured user bearer. Previously
477
474
  * this method always used the service path, so every client-side caller
478
475
  * silently received `[]` because `getServiceToken()` had no credentials.
479
476
  *
@@ -504,7 +501,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
504
501
 
505
502
  // A backend that called configureServiceAuth() uses the bearer-service
506
503
  // path; any other caller (browser / RN with a user session) uses the
507
- // user-bearer path. See the method doc for why the user path is CSRF-safe.
504
+ // user-bearer path.
508
505
  const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
509
506
 
510
507
  // Run chunks concurrently; a single chunk failure must not sink the rest.
@@ -22,11 +22,9 @@ import { OxyServices } from '../../OxyServices';
22
22
  * hand-built `Object.assign(new Error(), { status: 404 })` proves only that
23
23
  * the branch reads the shape the test itself invented.
24
24
  *
25
- * The stub is URL-aware because a state-changing request through the real stack
26
- * fetches `GET /csrf-token` FIRST. A blanket stub answers that call too, which
27
- * both shifts the request under test out of `calls[0]` and (on a non-200 stub)
28
- * makes the CSRF fetch burn its own retries — so a naive call-count assertion
29
- * measures CSRF attempts rather than the route.
25
+ * Assertions select calls by URL rather than position, so a preflight added
26
+ * to the real stack later cannot make a call-count assertion measure it
27
+ * instead of the route.
30
28
  */
31
29
  const ROUTE = '/session/device/background-credential';
32
30
 
@@ -67,19 +65,16 @@ const ACCESS_TOKEN = (() => {
67
65
  describe('provisionBackgroundCredential over a real HttpService', () => {
68
66
  const originalFetch = global.fetch;
69
67
 
70
- /** Answers the CSRF preflight properly; answers the route under test with `body`/`status`. */
68
+ /** Answers every call with `body`/`status`. */
71
69
  const stubFetch = (body: unknown, status: number) => {
72
70
  const fetchMock = jest.fn(async (input: unknown) => {
73
- if (String(input).includes('/csrf-token')) {
74
- return jsonResponse({ csrfToken: 'csrf-test-token' }, 200);
75
- }
76
71
  return jsonResponse(body, status);
77
72
  });
78
73
  global.fetch = fetchMock as unknown as typeof fetch;
79
74
  return fetchMock;
80
75
  };
81
76
 
82
- /** Every stubbed call whose URL is the route under test (i.e. not the CSRF preflight). */
77
+ /** Every stubbed call whose URL is the route under test. */
83
78
  const routeCalls = (fetchMock: jest.Mock) =>
84
79
  fetchMock.mock.calls.filter(([input]) => String(input).includes(ROUTE));
85
80
 
@@ -27,7 +27,7 @@ function jsonResponse(data: unknown): Response {
27
27
  * Build a non-verified JWT whose payload decodes to the given claims.
28
28
  * `jwtDecode` only base64url-decodes the middle segment (no signature check).
29
29
  * Used to put the SDK in an authenticated state so a state-changing POST
30
- * carries a bearer header and skips the CSRF-token pre-fetch.
30
+ * carries a bearer header.
31
31
  */
32
32
  function makeJwt(payload: Record<string, unknown>): string {
33
33
  const b64url = (obj: Record<string, unknown>): string =>
@@ -142,9 +142,8 @@ describe('discovery error handling', () => {
142
142
  * `POST /profiles/recommendations` with the validated request body.
143
143
  */
144
144
  describe('getProfileRecommendations routing (GET vs POST)', () => {
145
- // Authenticate so the state-changing POST carries a bearer header and the
146
- // SDK skips the CSRF-token pre-fetch (which would otherwise consume a mock).
147
- // A bearer token does not affect GET routing or the request body, so the
145
+ // Authenticate so the state-changing POST carries a bearer header. A bearer
146
+ // token does not affect GET routing or the request body, so the
148
147
  // GET-path assertions below are unaffected.
149
148
  beforeEach(() => {
150
149
  oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
@@ -24,8 +24,7 @@ function rawResponse(body: unknown): Response {
24
24
 
25
25
  /**
26
26
  * A non-verified JWT whose payload decodes to the given claims. Puts the SDK in
27
- * an authenticated state so the `POST /topics/resolve` carries a bearer header
28
- * and skips the CSRF-token pre-fetch (which would otherwise consume a mock).
27
+ * an authenticated state so the `POST /topics/resolve` carries a bearer header.
29
28
  */
30
29
  function makeJwt(payload: Record<string, unknown>): string {
31
30
  const b64url = (obj: Record<string, unknown>): string =>
@@ -59,7 +58,7 @@ describe('topics mixin envelope unwrapping', () => {
59
58
  fetchMock = jest.fn();
60
59
  globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
61
60
  oxy = new OxyServices({ baseURL: 'http://test.invalid', enableRetry: false });
62
- // Authenticate so the resolve POST skips the CSRF pre-fetch. Harmless for GETs.
61
+ // Authenticate so the resolve POST carries a bearer. Harmless for GETs.
63
62
  oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
64
63
  });
65
64
 
@@ -40,7 +40,6 @@ const DEFAULT_ALLOWED_HEADERS = [
40
40
  'X-Requested-With',
41
41
  'X-Oxy-User-Id',
42
42
  'X-Oxy-Internal',
43
- 'X-CSRF-Token',
44
43
  'X-Oxy-Edge-Region',
45
44
  'X-Oxy-Activity-Id',
46
45
  ];