@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
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Linked-accounts SDK surface: the paths, methods and bodies it sends, and the
3
+ * `{ data }` unwrapping. The flow itself is exercised in the API's route tests.
4
+ */
5
+
6
+ import { OxyServices } from '../../OxyServices';
7
+
8
+ function makeJwt(payload: Record<string, unknown>): string {
9
+ const b64url = (obj: Record<string, unknown>): string => Buffer.from(JSON.stringify(obj)).toString('base64url');
10
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
11
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
12
+ }
13
+
14
+ function jsonResponse(data: unknown, status = 200): Response {
15
+ return new Response(status === 204 ? null : JSON.stringify({ data }), {
16
+ status,
17
+ headers: { 'content-type': 'application/json' },
18
+ });
19
+ }
20
+
21
+ const LINK = {
22
+ id: 'link-1',
23
+ network: 'activitypub' as const,
24
+ accountKey: 'nate@mastodon.social',
25
+ actorUri: 'https://mastodon.social/users/nate',
26
+ handle: '@nate@mastodon.social',
27
+ host: 'mastodon.social',
28
+ proofMethod: 'oauth' as const,
29
+ verifiedAt: '2026-09-25T00:00:00.000Z',
30
+ createdAt: '2026-09-25T00:00:00.000Z',
31
+ };
32
+
33
+ describe('linked accounts', () => {
34
+ let originalFetch: typeof globalThis.fetch;
35
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
36
+ let oxy: OxyServices;
37
+
38
+ beforeEach(() => {
39
+ originalFetch = globalThis.fetch;
40
+ fetchMock = jest.fn();
41
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
42
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
43
+ oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
44
+ });
45
+
46
+ afterEach(() => {
47
+ globalThis.fetch = originalFetch;
48
+ jest.clearAllMocks();
49
+ });
50
+
51
+ it('starts a link with POST /linked-accounts/:network/start and returns the authorize URL', async () => {
52
+ fetchMock.mockResolvedValueOnce(
53
+ jsonResponse({ authorizeUrl: 'https://mastodon.social/oauth/authorize?x=1', expiresAt: '2026-09-25T00:10:00.000Z' }),
54
+ );
55
+ const started = await oxy.startLinkedAccount('activitypub', {
56
+ instance: 'mastodon.social',
57
+ clientId: 'oxy_dk_move',
58
+ returnTo: 'https://move.oxy.so/linked',
59
+ });
60
+ expect(started.authorizeUrl).toBe('https://mastodon.social/oauth/authorize?x=1');
61
+ const [url, init] = fetchMock.mock.calls[0];
62
+ expect(String(url)).toBe('http://test.invalid/linked-accounts/activitypub/start');
63
+ expect(init?.method).toBe('POST');
64
+ expect(JSON.parse(String(init?.body))).toEqual({
65
+ instance: 'mastodon.social',
66
+ clientId: 'oxy_dk_move',
67
+ returnTo: 'https://move.oxy.so/linked',
68
+ });
69
+ });
70
+
71
+ it('completes a link with POST /linked-accounts/complete and returns the link', async () => {
72
+ fetchMock.mockImplementation(async (url) =>
73
+ String(url).endsWith('/linked-accounts/complete') ? jsonResponse({ linkedAccount: LINK }) : jsonResponse({ csrfToken: 't'.repeat(32) }),
74
+ );
75
+ expect(await oxy.completeLinkedAccount('one-time-code')).toEqual(LINK);
76
+ const completion = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/linked-accounts/complete'));
77
+ expect(completion?.[1]?.method).toBe('POST');
78
+ expect(JSON.parse(String(completion?.[1]?.body))).toEqual({ code: 'one-time-code' });
79
+ });
80
+
81
+ it('lists the live links, unwrapping the envelope, without caching', async () => {
82
+ fetchMock.mockImplementation(async () => jsonResponse({ linkedAccounts: [LINK] }));
83
+ expect(await oxy.listLinkedAccounts()).toEqual([LINK]);
84
+ await oxy.listLinkedAccounts();
85
+ expect(fetchMock).toHaveBeenCalledTimes(2);
86
+ expect(String(fetchMock.mock.calls[0][0])).toBe('http://test.invalid/linked-accounts');
87
+ });
88
+
89
+ it('revokes with DELETE /linked-accounts/:id', async () => {
90
+ // A state-changing request may first fetch a CSRF token; answer anything.
91
+ fetchMock.mockImplementation(async (_url, init) =>
92
+ init?.method === 'DELETE' ? new Response(null, { status: 204 }) : jsonResponse({ csrfToken: 't'.repeat(32) }),
93
+ );
94
+ await oxy.revokeLinkedAccount('link-1');
95
+ const deletion = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
96
+ expect(String(deletion?.[0])).toBe('http://test.invalid/linked-accounts/link-1');
97
+ });
98
+
99
+ it('reads a user\'s links on the service lane with a service token', async () => {
100
+ const service = new OxyServices({ baseURL: 'http://test.invalid' });
101
+ jest.spyOn(service, 'getServiceToken').mockResolvedValue('service-token');
102
+ fetchMock.mockResolvedValueOnce(jsonResponse({ userId: 'u1', linkedAccounts: [{ ...LINK, federatedUserId: null }] }));
103
+ const result = await service.getLinkedAccountsForUser('u1');
104
+ expect(result.linkedAccounts[0].federatedUserId).toBeNull();
105
+ const [url, init] = fetchMock.mock.calls[0];
106
+ expect(String(url)).toBe('http://test.invalid/linked-accounts/by-user/u1');
107
+ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer service-token');
108
+ });
109
+ });
@@ -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
 
@@ -35,6 +35,7 @@ import { OxyServicesNodesMixin } from './OxyServices.nodes';
35
35
  import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
36
36
  import { OxyServicesInferenceMixin } from './OxyServices.inference';
37
37
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
38
+ import { OxyServicesLinkedAccountsMixin } from './OxyServices.linkedAccounts';
38
39
 
39
40
  /**
40
41
  * Instance shape of every mixin in the pipeline, intersected. The runtime
@@ -74,6 +75,7 @@ type AllMixinInstances =
74
75
  & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
75
76
  & InstanceType<ReturnType<typeof OxyServicesInferenceMixin<typeof OxyServicesBase>>>
76
77
  & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
78
+ & InstanceType<ReturnType<typeof OxyServicesLinkedAccountsMixin<typeof OxyServicesBase>>>
77
79
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
78
80
 
79
81
  /**
@@ -165,6 +167,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
165
167
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
166
168
  OxyServicesDeviceBootMixin,
167
169
 
170
+ // Linked external accounts (Mastodon-API, Bluesky) proven by OAuth; the
171
+ // service read uses the auth mixin's `makeServiceRequest`.
172
+ OxyServicesLinkedAccountsMixin,
173
+
168
174
  // Device-to-device identity transfer ("add a device"): E2E-encrypted key
169
175
  // clone over a short-lived relay (b3 Feature 2).
170
176
 
@@ -115,6 +115,13 @@ export interface User {
115
115
  * lists, profile search) — the two are separate fields on the User document.
116
116
  */
117
117
  description?: string;
118
+ /**
119
+ * ActivityPub actor URIs this account is also known as — its live,
120
+ * OAuth-proven linked Mastodon-API accounts. Emitted by
121
+ * `GET /profiles/username/:username`; a relying app passes it to the shared
122
+ * actor builder (`@oxy.so/federation`) so its actor publishes it.
123
+ */
124
+ alsoKnownAs?: string[];
118
125
  phone?: string;
119
126
  address?: string;
120
127
  /** Legacy free-text birthday, kept for backward compatibility. Prefer `dateOfBirth`. */
@@ -263,7 +270,22 @@ export interface LoginResponse {
263
270
  export interface Notification {
264
271
  id: string;
265
272
  message: string;
266
- // Add other notification fields as needed
273
+ /** One of `OXY_NOTIFICATION_TYPES` (`@oxy.so/contracts`). */
274
+ type?: string;
275
+ /**
276
+ * `system` notifications only (with `message`): the text an Oxy service sent
277
+ * about the recipient's own account. Render these two instead of composing a
278
+ * sentence from actor + entity.
279
+ */
280
+ title?: string;
281
+ /** `system` notifications only: an optional deep link (https or the sending app's scheme). */
282
+ url?: string;
283
+ /**
284
+ * What `entityId` names: `post` | `reply` | `profile`, or `app` — an opaque id
285
+ * in the notifying application's namespace (`system` notifications only).
286
+ */
287
+ entityType?: string;
288
+ entityId?: string;
267
289
  }
268
290
 
269
291
  export interface Wallet {
@@ -666,6 +688,13 @@ export interface ServiceAssetMetadata {
666
688
  * play. Treat absence as "no adaptive stream, play the progressive original".
667
689
  */
668
690
  hlsReadyAt?: string;
691
+ /**
692
+ * The Oxy user who owns the file (`null` for a system-owned file such as the
693
+ * federated media cache). PRESENT ONLY for Oxy's own applications (service
694
+ * tokens with `tier: 'internal'`), so a first-party service can check that an
695
+ * asset a user attached is really that user's. Absent for every other caller.
696
+ */
697
+ ownerUserId?: string | null;
669
698
  }
670
699
 
671
700
  /**
@@ -211,6 +211,13 @@ describe('@oxy.so/core/server buildOxyPagesHeaders on a sensitive origin (ADR 00
211
211
  expect(sensitive).toContain('https://api.oxy.so');
212
212
  expect(sensitive).toContain("frame-ancestors 'none'");
213
213
  });
214
+
215
+ it('sends no-transform so the edge never injects the beacon, and only there', () => {
216
+ expect(buildOxyPagesHeaders({ sensitive: true })).toContain(
217
+ 'Cache-Control: public, max-age=0, must-revalidate, no-transform',
218
+ );
219
+ expect(buildOxyPagesHeaders()).not.toContain('Cache-Control');
220
+ });
214
221
  });
215
222
 
216
223
  describe('@oxy.so/core/server extractInlineScripts', () => {
@@ -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
  ];
@@ -366,6 +366,11 @@ export interface OxyPagesHeadersOptions {
366
366
  * the app — is REMOVED from `script-src` and `connect-src`, so third-party
367
367
  * measurement cannot run there even when the zone has Web Analytics enabled.
368
368
  * A structural block in the policy the page is served with, not a build flag.
369
+ *
370
+ * It also sends `Cache-Control: no-transform`, which stops the edge from
371
+ * injecting the beacon at all — otherwise every page load logs a CSP
372
+ * violation for a script we never asked for. The rest of the value is Pages'
373
+ * own default, so caching is unchanged.
369
374
  */
370
375
  sensitive?: boolean;
371
376
  /**
@@ -429,6 +434,9 @@ export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): stri
429
434
  if (options.hsts !== false) {
430
435
  lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
431
436
  }
437
+ if (options.sensitive) {
438
+ lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
439
+ }
432
440
  lines.push('');
433
441
  return lines.join('\n');
434
442
  }
@@ -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
@@ -114,6 +114,11 @@ export interface RefreshDeps {
114
114
  * The device secret is FINE — it is the identity binding that went stale, so
115
115
  * the caller must re-establish from the local key, never drop the credential.
116
116
  * - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
117
+ * - `session-ended` — the mint succeeded, but the local session was ended
118
+ * (`HttpService.endSession`, i.e. a sign-out) while it was in flight. Nothing
119
+ * is planted. The rotated secret is persisted only if the store still holds
120
+ * the credential that was presented — a store the sign-out cleared stays
121
+ * clear.
117
122
  * - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
118
123
  * durably persisted. The token is deliberately NOT planted: advertising a
119
124
  * healthy session on a secret that will not survive a reload is exactly the
@@ -126,6 +131,7 @@ export type DeviceSecretMintOutcome =
126
131
  | { status: 'no-session' }
127
132
  | { status: 'account-not-on-device' }
128
133
  | { status: 'transient' }
134
+ | { status: 'session-ended' }
129
135
  | { status: 'persist-failed' };
130
136
 
131
137
  /**
@@ -159,6 +165,7 @@ export async function refreshDeviceSecretArm(deps: {
159
165
  const { oxy, store } = deps;
160
166
  const pin = deps.pin ?? null;
161
167
  return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
168
+ const epoch = oxy.httpService.getSessionEpoch();
162
169
  const persisted = await store.load();
163
170
  if (!persisted?.deviceId || !persisted?.deviceSecret) {
164
171
  return { status: 'no-secret' };
@@ -222,12 +229,26 @@ export async function refreshDeviceSecretArm(deps: {
222
229
  expiresAt: mint.expiresAt,
223
230
  ...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
224
231
  };
232
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
233
+ // Signed out while minting. The server has rotated the secret, so a store
234
+ // that still holds the presented one must learn the rotation (the
235
+ // token-null lane keeps the store so a reload can restore); a store the
236
+ // sign-out cleared must NOT be refilled. Either way, plant nothing.
237
+ const current = await store.load();
238
+ if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
239
+ await store.save(next);
240
+ }
241
+ return { status: 'session-ended' };
242
+ }
225
243
  // Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
226
244
  // A failed durable persist must NOT plant.
227
245
  const persistedOk = await store.save(next);
228
246
  if (!persistedOk) {
229
247
  return { status: 'persist-failed' };
230
248
  }
249
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
250
+ return { status: 'session-ended' };
251
+ }
231
252
  oxy.setTokens(mint.accessToken);
232
253
  return {
233
254
  status: 'ok',
@@ -266,6 +287,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
266
287
  const identity = deps.identity ?? null;
267
288
  // The shared keychain is never an identity-bound client's recovery path.
268
289
  const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? isNative());
290
+ const epoch = oxy.httpService.getSessionEpoch();
269
291
  // Resolved per call: a re-established identity session can move the pin, and a
270
292
  // replaced/removed local key clears it (in which case arm 1 must NOT mint —
271
293
  // an unpinned mint would adopt whatever account the device switched to).
@@ -278,6 +300,8 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
278
300
  switch (arm1.status) {
279
301
  case 'ok':
280
302
  return arm1.token;
303
+ case 'session-ended':
304
+ return null;
281
305
  case 'transient':
282
306
  logger.debug(
283
307
  'Persisted deviceSecret mint failed (transient) — keeping store',
@@ -327,10 +351,18 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
327
351
  return recoverIdentitySession(oxy, store, identity);
328
352
  }
329
353
 
330
- if (allowSharedKeyFallback) {
354
+ // Never after a sign-out: the shared keychain holds an identity KEY, not a
355
+ // session, and using it here would sign the user straight back in.
356
+ if (allowSharedKeyFallback && !oxy.httpService.hasSessionEnded()) {
331
357
  try {
332
- const session = await oxy.signInWithSharedIdentity();
358
+ // Planted here, not by the sign-in: a sign-out that lands while the
359
+ // challenge round-trips must win, or the shared keychain signs the user
360
+ // straight back in.
361
+ const session = await oxy.signInWithSharedIdentity({ plantTokens: false });
333
362
  if (session?.accessToken) {
363
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
364
+ return null;
365
+ }
334
366
  // Repopulate the fast device-secret lane from the shared-key re-mint.
335
367
  if (session.deviceId && session.deviceSecret) {
336
368
  await store.save({
@@ -342,6 +374,10 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
342
374
  expiresAt: session.expiresAt,
343
375
  });
344
376
  }
377
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
378
+ return null;
379
+ }
380
+ oxy.setTokens(session.accessToken);
345
381
  return session.accessToken;
346
382
  }
347
383
  } catch (error) {
@@ -121,7 +121,7 @@ const nonEmptyString = (value: unknown): string | undefined =>
121
121
  * - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
122
122
  * OAuth token and userinfo endpoints. `error_description` is the human text
123
123
  * and `error` is the machine code, so both survive.
124
- * - `{ message, code }` — e.g. the API's CSRF rejections.
124
+ * - `{ message, code }` — e.g. the API's middleware rejections.
125
125
  * - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
126
126
  * `message`/`error_description` the string is the message, not a code: a bare
127
127
  * `error` string is not machine-readable enough to promote to `code`.