@oxy.so/core 1.9.1 → 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 (44) 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/mixins/OxyServices.user.js +2 -5
  7. package/dist/cjs/server/cors.js +0 -1
  8. package/dist/cjs/session/refresh.js +32 -2
  9. package/dist/cjs/utils/errorUtils.js +1 -1
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/HttpService.js +56 -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 +32 -2
  17. package/dist/esm/utils/errorUtils.js +1 -1
  18. package/dist/types/.tsbuildinfo +1 -1
  19. package/dist/types/HttpService.d.ts +31 -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 +7 -0
  24. package/dist/types/utils/errorUtils.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/HttpService.ts +59 -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/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
  33. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
  34. package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
  35. package/src/boot/sessionColdBoot.ts +3 -0
  36. package/src/mixins/OxyServices.user.ts +2 -5
  37. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
  38. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
  39. package/src/mixins/__tests__/topics.test.ts +2 -3
  40. package/src/server/cors.ts +0 -1
  41. package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
  42. package/src/session/__tests__/refresh.test.ts +115 -2
  43. package/src/session/refresh.ts +38 -2
  44. package/src/utils/errorUtils.ts +1 -1
@@ -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) {
@@ -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
  ];
@@ -82,7 +82,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
82
82
  overrides.requestChallenge
83
83
  ?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
84
84
  verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
85
- httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
85
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
86
86
  } as unknown as OxyServices;
87
87
  return { oxy, setTokens };
88
88
  }
@@ -59,8 +59,18 @@ interface RefreshMockOverrides {
59
59
 
60
60
  function makeOxy(
61
61
  overrides: RefreshMockOverrides = {},
62
- ): { oxy: OxyServices; setTokens: jest.Mock; noteRefreshRateLimited: jest.Mock } {
62
+ ): {
63
+ oxy: OxyServices;
64
+ setTokens: jest.Mock;
65
+ noteRefreshRateLimited: jest.Mock;
66
+ /** `HttpService`'s session epoch; bump it to simulate a sign-out mid-refresh. */
67
+ epoch: { current: number };
68
+ /** `HttpService.hasSessionEnded()`: a sign-out with no token planted since. */
69
+ ended: { current: boolean };
70
+ } {
63
71
  const setTokens = jest.fn();
72
+ const epoch = { current: 0 };
73
+ const ended = { current: false };
64
74
  const noteRefreshRateLimited = jest.fn();
65
75
  const oxy = {
66
76
  setTokens,
@@ -71,9 +81,11 @@ function makeOxy(
71
81
  httpService: {
72
82
  runSingleFlightDeviceSecretMint: makeMintSingleFlight(),
73
83
  noteRefreshRateLimited,
84
+ getSessionEpoch: () => epoch.current,
85
+ hasSessionEnded: () => ended.current,
74
86
  },
75
87
  } as unknown as OxyServices;
76
- return { oxy, setTokens, noteRefreshRateLimited };
88
+ return { oxy, setTokens, noteRefreshRateLimited, epoch, ended };
77
89
  }
78
90
 
79
91
  describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
@@ -297,6 +309,107 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
297
309
  });
298
310
  });
299
311
 
312
+ describe('refreshPersistedSession — a sign-out mid-refresh', () => {
313
+ it('arm 1: plants nothing and leaves a cleared store cleared', async () => {
314
+ const store = createMemoryAuthStateStore();
315
+ await store.save(STORED);
316
+ let respond!: () => void;
317
+ const { oxy, setTokens, epoch } = makeOxy({
318
+ mintFromDeviceSecret: () =>
319
+ new Promise((resolve) => {
320
+ respond = () => resolve(MINT);
321
+ }),
322
+ });
323
+
324
+ const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
325
+ await new Promise((resolve) => setTimeout(resolve, 0));
326
+ // The sign-out: the credential is dropped and the session epoch moves.
327
+ await store.clear();
328
+ epoch.current += 1;
329
+ respond();
330
+
331
+ await expect(pending).resolves.toBeNull();
332
+ expect(setTokens).not.toHaveBeenCalled();
333
+ expect(await store.load()).toBeNull();
334
+ });
335
+
336
+ it('arm 1: still persists the rotated secret when the store kept the credential', async () => {
337
+ const store = createMemoryAuthStateStore();
338
+ await store.save(STORED);
339
+ let respond!: () => void;
340
+ const { oxy, setTokens, epoch } = makeOxy({
341
+ mintFromDeviceSecret: () =>
342
+ new Promise((resolve) => {
343
+ respond = () => resolve(MINT);
344
+ }),
345
+ });
346
+
347
+ const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
348
+ await new Promise((resolve) => setTimeout(resolve, 0));
349
+ // A local teardown that keeps the store (so a reload can restore): the
350
+ // server rotated the secret, and the store must not be left holding the old one.
351
+ epoch.current += 1;
352
+ respond();
353
+
354
+ await expect(pending).resolves.toBeNull();
355
+ expect(setTokens).not.toHaveBeenCalled();
356
+ expect((await store.load())?.deviceSecret).toBe('ds-next-secret');
357
+ });
358
+
359
+ it('arm 2: the shared keychain does not sign the user back in', async () => {
360
+ const store = createMemoryAuthStateStore();
361
+ let respond!: () => void;
362
+ const signInWithSharedIdentity = jest.fn(
363
+ (_opts?: { plantTokens?: boolean }) =>
364
+ new Promise<SessionLoginResponse>((resolve) => {
365
+ respond = () =>
366
+ resolve({
367
+ sessionId: 'sess-shared',
368
+ deviceId: 'dev-1',
369
+ deviceSecret: 'ds-shared',
370
+ expiresAt: '2030-01-01T00:00:00.000Z',
371
+ user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
372
+ accessToken: 'access-shared',
373
+ });
374
+ }),
375
+ );
376
+ const { oxy, setTokens, epoch } = makeOxy({ signInWithSharedIdentity });
377
+
378
+ const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
379
+ await new Promise((resolve) => setTimeout(resolve, 0));
380
+ epoch.current += 1;
381
+ respond();
382
+
383
+ await expect(pending).resolves.toBeNull();
384
+ expect(signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
385
+ expect(setTokens).not.toHaveBeenCalled();
386
+ expect(await store.load()).toBeNull();
387
+ });
388
+ });
389
+
390
+ describe('refreshPersistedSession — after a sign-out', () => {
391
+ it('does not re-sign-in with the shared keychain', async () => {
392
+ const store = createMemoryAuthStateStore();
393
+ const signInWithSharedIdentity = jest.fn(async () => null);
394
+ const { oxy, setTokens, ended } = makeOxy({ signInWithSharedIdentity });
395
+ ended.current = true;
396
+
397
+ await expect(refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).resolves.toBeNull();
398
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
399
+ expect(setTokens).not.toHaveBeenCalled();
400
+ });
401
+
402
+ it('still mints from a device credential the store holds (a sign-in made in another tab)', async () => {
403
+ const store = createMemoryAuthStateStore();
404
+ await store.save(STORED);
405
+ const { oxy, setTokens, ended } = makeOxy();
406
+ ended.current = true;
407
+
408
+ await expect(refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).resolves.toBe('access-new');
409
+ expect(setTokens).toHaveBeenCalledWith('access-new');
410
+ });
411
+ });
412
+
300
413
  describe('refreshPersistedSession — single-flight (no double-rotation)', () => {
301
414
  it('coalesces two concurrent mints into ONE server rotation; the store holds the final current secret', async () => {
302
415
  // The server rotates the secret on every mint. Two concurrent lanes must