@oxy.so/core 1.2.1 → 1.3.1

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 (100) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/identityMove.js +123 -0
  3. package/dist/cjs/crypto/keyManager.js +60 -21
  4. package/dist/cjs/crypto/webIdentityCarrier.js +262 -0
  5. package/dist/cjs/i18n/locales/ar-SA.json +16 -13
  6. package/dist/cjs/i18n/locales/ca-ES.json +16 -13
  7. package/dist/cjs/i18n/locales/de-DE.json +16 -13
  8. package/dist/cjs/i18n/locales/en-US.json +16 -17
  9. package/dist/cjs/i18n/locales/es-ES.json +16 -17
  10. package/dist/cjs/i18n/locales/fr-FR.json +16 -13
  11. package/dist/cjs/i18n/locales/it-IT.json +16 -13
  12. package/dist/cjs/i18n/locales/ja-JP.json +16 -13
  13. package/dist/cjs/i18n/locales/ko-KR.json +16 -13
  14. package/dist/cjs/i18n/locales/locales/ar-SA.json +16 -13
  15. package/dist/cjs/i18n/locales/locales/ca-ES.json +16 -13
  16. package/dist/cjs/i18n/locales/locales/de-DE.json +16 -13
  17. package/dist/cjs/i18n/locales/locales/en-US.json +16 -17
  18. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -17
  19. package/dist/cjs/i18n/locales/locales/fr-FR.json +16 -13
  20. package/dist/cjs/i18n/locales/locales/it-IT.json +16 -13
  21. package/dist/cjs/i18n/locales/locales/ja-JP.json +16 -13
  22. package/dist/cjs/i18n/locales/locales/ko-KR.json +16 -13
  23. package/dist/cjs/i18n/locales/locales/pt-PT.json +16 -13
  24. package/dist/cjs/i18n/locales/locales/zh-CN.json +16 -13
  25. package/dist/cjs/i18n/locales/pt-PT.json +16 -13
  26. package/dist/cjs/i18n/locales/zh-CN.json +16 -13
  27. package/dist/cjs/index.js +38 -4
  28. package/dist/cjs/mixins/OxyServices.auth.js +14 -4
  29. package/dist/cjs/mixins/OxyServices.identity.js +14 -0
  30. package/dist/cjs/session/accountDialogController.js +333 -142
  31. package/dist/cjs/utils/authWebUrl.js +8 -1
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/crypto/identityMove.js +110 -0
  34. package/dist/esm/crypto/keyManager.js +60 -21
  35. package/dist/esm/crypto/webIdentityCarrier.js +243 -0
  36. package/dist/esm/i18n/locales/ar-SA.json +16 -13
  37. package/dist/esm/i18n/locales/ca-ES.json +16 -13
  38. package/dist/esm/i18n/locales/de-DE.json +16 -13
  39. package/dist/esm/i18n/locales/en-US.json +16 -17
  40. package/dist/esm/i18n/locales/es-ES.json +16 -17
  41. package/dist/esm/i18n/locales/fr-FR.json +16 -13
  42. package/dist/esm/i18n/locales/it-IT.json +16 -13
  43. package/dist/esm/i18n/locales/ja-JP.json +16 -13
  44. package/dist/esm/i18n/locales/ko-KR.json +16 -13
  45. package/dist/esm/i18n/locales/locales/ar-SA.json +16 -13
  46. package/dist/esm/i18n/locales/locales/ca-ES.json +16 -13
  47. package/dist/esm/i18n/locales/locales/de-DE.json +16 -13
  48. package/dist/esm/i18n/locales/locales/en-US.json +16 -17
  49. package/dist/esm/i18n/locales/locales/es-ES.json +16 -17
  50. package/dist/esm/i18n/locales/locales/fr-FR.json +16 -13
  51. package/dist/esm/i18n/locales/locales/it-IT.json +16 -13
  52. package/dist/esm/i18n/locales/locales/ja-JP.json +16 -13
  53. package/dist/esm/i18n/locales/locales/ko-KR.json +16 -13
  54. package/dist/esm/i18n/locales/locales/pt-PT.json +16 -13
  55. package/dist/esm/i18n/locales/locales/zh-CN.json +16 -13
  56. package/dist/esm/i18n/locales/pt-PT.json +16 -13
  57. package/dist/esm/i18n/locales/zh-CN.json +16 -13
  58. package/dist/esm/index.js +5 -1
  59. package/dist/esm/mixins/OxyServices.auth.js +14 -4
  60. package/dist/esm/mixins/OxyServices.identity.js +14 -0
  61. package/dist/esm/session/accountDialogController.js +334 -143
  62. package/dist/esm/utils/authWebUrl.js +7 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/crypto/identityMove.d.ts +59 -0
  65. package/dist/types/crypto/keyManager.d.ts +35 -5
  66. package/dist/types/crypto/webIdentityCarrier.d.ts +123 -0
  67. package/dist/types/index.d.ts +5 -2
  68. package/dist/types/mixins/OxyServices.auth.d.ts +12 -0
  69. package/dist/types/models/interfaces.d.ts +15 -0
  70. package/dist/types/session/accountDialogController.d.ts +169 -17
  71. package/dist/types/utils/authWebUrl.d.ts +7 -0
  72. package/package.json +3 -3
  73. package/src/crypto/__tests__/identityMove.test.ts +104 -0
  74. package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +150 -0
  75. package/src/crypto/__tests__/webIdentityCarrier.test.ts +211 -0
  76. package/src/crypto/identityMove.ts +133 -0
  77. package/src/crypto/keyManager.ts +71 -21
  78. package/src/crypto/webIdentityCarrier.ts +326 -0
  79. package/src/i18n/locales/ar-SA.json +16 -13
  80. package/src/i18n/locales/ca-ES.json +16 -13
  81. package/src/i18n/locales/de-DE.json +16 -13
  82. package/src/i18n/locales/en-US.json +16 -17
  83. package/src/i18n/locales/es-ES.json +16 -17
  84. package/src/i18n/locales/fr-FR.json +16 -13
  85. package/src/i18n/locales/it-IT.json +16 -13
  86. package/src/i18n/locales/ja-JP.json +16 -13
  87. package/src/i18n/locales/ko-KR.json +16 -13
  88. package/src/i18n/locales/pt-PT.json +16 -13
  89. package/src/i18n/locales/zh-CN.json +16 -13
  90. package/src/index.ts +38 -1
  91. package/src/mixins/OxyServices.auth.ts +16 -3
  92. package/src/mixins/OxyServices.identity.ts +14 -0
  93. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +47 -0
  94. package/src/mixins/__tests__/commonsSignIn.test.ts +3 -0
  95. package/src/mixins/__tests__/verifyChallenge.test.ts +31 -0
  96. package/src/models/interfaces.ts +15 -0
  97. package/src/session/__tests__/accountDialogController.test.ts +407 -10
  98. package/src/session/accountDialogController.ts +406 -145
  99. package/src/utils/__tests__/oauthPkce.test.ts +50 -26
  100. package/src/utils/authWebUrl.ts +8 -0
@@ -175,6 +175,53 @@ describe('OxyServices.rotateKey', () => {
175
175
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
176
176
  });
177
177
 
178
+ // The shared slot is what every cross-app reader takes first, including
179
+ // `deriveScopedSeed` — the FairCoin wallet Peable derives. A rotation that
180
+ // wrote only the primary left that wallet on the replaced key, watching
181
+ // addresses the rotated identity will never be paid at, with no error
182
+ // anywhere. Shared is written FIRST so a crash between the two writes leaves
183
+ // the money path on the new key and the primary stale, which fails loudly.
184
+ it('writes the new key to the shared slot too, before the primary', async () => {
185
+ const order: string[] = [];
186
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
187
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
188
+ jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
189
+ jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
190
+ jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
191
+ const sharedSpy = jest
192
+ .spyOn(KeyManager, 'importSharedIdentity')
193
+ .mockImplementation(async () => { order.push('shared'); return NEW_PUBLIC; });
194
+ const importSpy = jest
195
+ .spyOn(KeyManager, 'importKeyPair')
196
+ .mockImplementation(async () => { order.push('primary'); return NEW_PUBLIC; });
197
+ makeRequestSpy
198
+ .mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
199
+ .mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
200
+
201
+ await oxy.rotateKey({ proof: 'device' });
202
+
203
+ expect(sharedSpy).toHaveBeenCalledWith(NEW_PRIVATE);
204
+ expect(importSpy).toHaveBeenCalledWith(NEW_PRIVATE, { overwrite: true });
205
+ expect(order).toEqual(['shared', 'primary']);
206
+ });
207
+
208
+ it('leaves the shared slot alone on a device that has none', async () => {
209
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
210
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
211
+ jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
212
+ jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
213
+ jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(false);
214
+ const sharedSpy = jest.spyOn(KeyManager, 'importSharedIdentity');
215
+ jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
216
+ makeRequestSpy
217
+ .mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
218
+ .mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
219
+
220
+ await oxy.rotateKey({ proof: 'device' });
221
+
222
+ expect(sharedSpy).not.toHaveBeenCalled();
223
+ });
224
+
178
225
  it('throws (no network) when the device holds no identity', async () => {
179
226
  jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
180
227
  jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
@@ -878,6 +878,8 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
878
878
  'iPad',
879
879
  'fp-1',
880
880
  undefined,
881
+ // Planting stays the default: only a caller that asks otherwise opts out.
882
+ { plantTokens: undefined },
881
883
  );
882
884
  expect(result).toEqual(sessionFixture);
883
885
  });
@@ -913,6 +915,7 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
913
915
  undefined,
914
916
  undefined,
915
917
  { retry: false },
918
+ { plantTokens: undefined },
916
919
  );
917
920
  expect(result).toEqual(sessionFixture);
918
921
  });
@@ -127,4 +127,35 @@ describe('OxyServices.verifyChallenge token planting', () => {
127
127
  expect(oxy.hasValidToken()).toBe(true);
128
128
  expect(oxy.getAccessToken()).toBe('access_claim');
129
129
  });
130
+
131
+ /**
132
+ * `plantTokens: false` is for a caller that must decide AFTER the response
133
+ * whether the session is still wanted — the account dialog, whose user may
134
+ * have cancelled while the request was in flight. It still gets the bearer in
135
+ * the response; the client just is not switched to it.
136
+ */
137
+ it('leaves the client untouched with plantTokens: false, on both paths', async () => {
138
+ const oxy = makeOxy();
139
+
140
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
141
+ const body = {
142
+ sessionId: 'sess_x',
143
+ deviceId: 'dev_x',
144
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
145
+ user: { id: 'user_x', username: 'x' },
146
+ };
147
+ if (url === '/auth/verify') return { ...body, accessToken: 'access_verify' } as never;
148
+ if (url === '/auth/session/claim') return { ...body, accessToken: 'access_claim' } as never;
149
+ throw new Error(`unexpected request to ${url}`);
150
+ });
151
+
152
+ const verified = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 1, undefined, undefined, undefined, {
153
+ plantTokens: false,
154
+ });
155
+ const claimed = await oxy.claimSessionByToken('session-token-abc', { plantTokens: false });
156
+
157
+ expect(verified.accessToken).toBe('access_verify');
158
+ expect(claimed.accessToken).toBe('access_claim');
159
+ expect(oxy.hasValidToken()).toBe(false);
160
+ });
130
161
  });
@@ -117,7 +117,22 @@ export interface User {
117
117
  description?: string;
118
118
  phone?: string;
119
119
  address?: string;
120
+ /** Legacy free-text birthday, kept for backward compatibility. Prefer `dateOfBirth`. */
120
121
  birthday?: string;
122
+ /**
123
+ * The structured date of birth, `YYYY-MM-DD`. The new source of truth going
124
+ * forward — `birthday` above is unparsed free text with no guaranteed
125
+ * shape. Present only on the account's OWN profile (never on another
126
+ * account's public profile), same visibility as `phone`/`address`/`birthday`.
127
+ */
128
+ dateOfBirth?: string | null;
129
+ /**
130
+ * Derived, non-PII signal: whether the account holder is at least 18.
131
+ * `undefined` when `dateOfBirth` is unset ("unknown"), distinct from
132
+ * `false` ("known, not yet 18"). Never stored — computed fresh by the API
133
+ * on every read of the owner's own profile.
134
+ */
135
+ isAdult?: boolean;
121
136
  website?: string;
122
137
  createdAt?: string;
123
138
  updatedAt?: string;
@@ -137,6 +137,9 @@ interface OxyMock {
137
137
  denyCommonsSignIn: jest.Mock;
138
138
  claimSessionByToken: jest.Mock;
139
139
  signInWithSharedIdentity: jest.Mock;
140
+ /** Plant / clear the bearer — both fire `onTokensChanged`, like `OxyServices`. */
141
+ setTokens: jest.Mock;
142
+ clearTokens: jest.Mock;
140
143
  /**
141
144
  * Test helper: set the current access token and fire every registered
142
145
  * `onTokensChanged` listener (mirrors `OxyServices.setTokens`/`clearTokens`).
@@ -149,6 +152,12 @@ function makeOxy(): OxyMock {
149
152
  const tokenListeners = new Set<(token: string | null) => void>();
150
153
  // Authenticated by default (mirrors a warm start with a planted bearer).
151
154
  let currentToken: string | null = 'access-token';
155
+ const emitTokenChange = (token: string | null) => {
156
+ currentToken = token;
157
+ for (const listener of tokenListeners) {
158
+ listener(token);
159
+ }
160
+ };
152
161
  return {
153
162
  getAccessToken: jest.fn(() => currentToken),
154
163
  getBaseURL: jest.fn(() => 'http://test.invalid'),
@@ -168,12 +177,9 @@ function makeOxy(): OxyMock {
168
177
  denyCommonsSignIn: jest.fn().mockResolvedValue({ success: true }),
169
178
  claimSessionByToken: jest.fn(),
170
179
  signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
171
- emitTokenChange: (token: string | null) => {
172
- currentToken = token;
173
- for (const listener of tokenListeners) {
174
- listener(token);
175
- }
176
- },
180
+ setTokens: jest.fn((token: string) => emitTokenChange(token)),
181
+ clearTokens: jest.fn(() => emitTokenChange(null)),
182
+ emitTokenChange,
177
183
  };
178
184
  }
179
185
 
@@ -494,6 +500,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
494
500
  const snap = controller.getSnapshot();
495
501
  expect(snap.signIn.phase).toBe('error');
496
502
  expect(snap.signIn.error).toMatch(/clientId/);
503
+ expect(snap.signIn.failure).toBe('not-configured');
497
504
  });
498
505
 
499
506
  it('polls, claims, and commits when the QR flow is authorized', async () => {
@@ -526,7 +533,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
526
533
  expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
527
534
 
528
535
  await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
529
- expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
536
+ expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok', { plantTokens: false });
530
537
  expect(commitSession).toHaveBeenCalledWith(
531
538
  expect.objectContaining({
532
539
  sessionId: 'sess-1',
@@ -559,6 +566,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
559
566
 
560
567
  expect(controller.getSnapshot().signIn.phase).toBe('error');
561
568
  expect(controller.getSnapshot().signIn.error).toMatch(/denied/i);
569
+ expect(controller.getSnapshot().signIn.failure).toBe('denied');
562
570
 
563
571
  // No further polls after the terminal error.
564
572
  await jest.advanceTimersByTimeAsync(5000);
@@ -618,7 +626,7 @@ describe('AccountDialogController — startPasskeyHubSignIn (b2 passkey hub popu
618
626
  it('opens the popup synchronously, then navigates it to the hub URL with the authorizeCode once the session exists', async () => {
619
627
  const popup = fakePopup();
620
628
  const openPopup = jest.fn(() => popup);
621
- const { controller, oxy } = makeHarness({ openPopup, hubBaseUrl: 'https://auth.oxy.so' });
629
+ const { controller, oxy } = makeHarness({ openPopup, hubBaseUrl: 'https://id.oxy.so' });
622
630
  oxy.startCommonsSignIn.mockResolvedValue({
623
631
  sessionToken: 'secret-tok',
624
632
  authorizeCode: 'AUTH-CODE',
@@ -630,7 +638,7 @@ describe('AccountDialogController — startPasskeyHubSignIn (b2 passkey hub popu
630
638
  await controller.startPasskeyHubSignIn();
631
639
 
632
640
  expect(openPopup).toHaveBeenCalledTimes(1);
633
- expect(popup.location.href).toBe('https://auth.oxy.so/hub-passkey?code=AUTH-CODE');
641
+ expect(popup.location.href).toBe('https://id.oxy.so/continue?code=AUTH-CODE');
634
642
  // Same underlying device-flow session showQr would create — the QR view
635
643
  // still renders as a fallback/alternative alongside the popup.
636
644
  const snap = controller.getSnapshot();
@@ -698,6 +706,8 @@ describe('AccountDialogController — startPasskeyHubSignIn (b2 passkey hub popu
698
706
  const snap = controller.getSnapshot();
699
707
  expect(snap.signIn.phase).toBe('error');
700
708
  expect(snap.signIn.error).toMatch(/cancelled/i);
709
+ // A VOLUNTARY exit — the surface must be able to tell it from a failure.
710
+ expect(snap.signIn.failure).toBe('cancelled');
701
711
  } finally {
702
712
  jest.useRealTimers();
703
713
  }
@@ -891,7 +901,7 @@ describe('AccountDialogController — /auth-session socket (instant QR wake)', (
891
901
  await flush();
892
902
 
893
903
  expect(oxy.pollCommonsSignIn).toHaveBeenCalledWith('secret-tok');
894
- expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
904
+ expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok', { plantTokens: false });
895
905
  expect(commitSession).toHaveBeenCalled();
896
906
  expect(controller.getSnapshot().view).toBe('accounts');
897
907
  expect(sock.disconnected).toBe(true); // torn down on completion
@@ -1307,11 +1317,13 @@ describe('AccountDialogController — automatic delivery selection (#691 phase 5
1307
1317
  'qrPayload',
1308
1318
  'expiresAt',
1309
1319
  'error',
1320
+ 'failure',
1310
1321
  'route',
1311
1322
  'routeFailed',
1312
1323
  'pushSentAt',
1313
1324
  'openedAt',
1314
1325
  'progress',
1326
+ 'attempt',
1315
1327
  ]);
1316
1328
  // Only the PUBLIC handles are exposed.
1317
1329
  expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
@@ -1560,11 +1572,13 @@ describe('AccountDialogController — cancellation converges (#691 phase 5)', ()
1560
1572
  qrPayload: null,
1561
1573
  expiresAt: null,
1562
1574
  error: null,
1575
+ failure: null,
1563
1576
  route: null,
1564
1577
  routeFailed: false,
1565
1578
  pushSentAt: null,
1566
1579
  openedAt: null,
1567
1580
  progress: 'idle',
1581
+ attempt: expect.any(Number),
1568
1582
  });
1569
1583
  await jest.advanceTimersByTimeAsync(10_000);
1570
1584
  expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
@@ -1959,6 +1973,389 @@ describe('AccountDialogController — the directory (ADR 0002)', () => {
1959
1973
  });
1960
1974
  });
1961
1975
 
1976
+ /** A promise whose settlement the test controls — a slow network, on demand. */
1977
+ function deferred<T>(): {
1978
+ promise: Promise<T>;
1979
+ resolve: (value: T) => void;
1980
+ reject: (error: unknown) => void;
1981
+ } {
1982
+ let resolve: (value: T) => void = () => undefined;
1983
+ let reject: (error: unknown) => void = () => undefined;
1984
+ const promise = new Promise<T>((res, rej) => {
1985
+ resolve = res;
1986
+ reject = rej;
1987
+ });
1988
+ return { promise, resolve, reject };
1989
+ }
1990
+
1991
+ const CLAIMED_SESSION = {
1992
+ accessToken: 'access-claimed',
1993
+ sessionId: 'sess-claimed',
1994
+ deviceId: 'device-1',
1995
+ expiresAt: '2030-01-01T00:00:00Z',
1996
+ user: user('b1'),
1997
+ };
1998
+
1999
+ describe('AccountDialogController — an abandoned attempt stays abandoned', () => {
2000
+ it('withdraws a request that is created only after the user closed the dialog, and wires nothing up', async () => {
2001
+ jest.useFakeTimers();
2002
+ try {
2003
+ const { controller, oxy } = makeHarness();
2004
+ const created = deferred<typeof DELIVERY_HANDLE & { expiresAt: number }>();
2005
+ oxy.startCommonsSignIn.mockReturnValue(created.promise);
2006
+
2007
+ const running = controller.showQr();
2008
+ expect(controller.getSnapshot().signIn.phase).toBe('starting');
2009
+ // Closed on a slow connection: there is no authorize code to withdraw yet.
2010
+ controller.cancelSignIn();
2011
+ expect(oxy.denyCommonsSignIn).not.toHaveBeenCalled();
2012
+
2013
+ created.resolve({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2014
+ await running;
2015
+
2016
+ // The late request is withdrawn the moment it exists, and nothing revives:
2017
+ // no waiting surface, no delivery, no poll.
2018
+ expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
2019
+ expect(controller.getSnapshot().signIn.phase).toBe('idle');
2020
+ expect(controller.getSnapshot().signIn.authorizeCode).toBeNull();
2021
+ expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
2022
+ await jest.advanceTimersByTimeAsync(10_000);
2023
+ expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
2024
+ } finally {
2025
+ jest.useRealTimers();
2026
+ }
2027
+ });
2028
+
2029
+ it('lets a newer attempt win over a slower earlier one', async () => {
2030
+ const { controller, oxy } = makeHarness();
2031
+ const first = deferred<unknown>();
2032
+ oxy.startCommonsSignIn
2033
+ .mockReturnValueOnce(first.promise)
2034
+ .mockResolvedValueOnce({
2035
+ sessionToken: 'tok-2',
2036
+ authorizeCode: 'CODE-2',
2037
+ qrPayload: 'oxycommons://approve?code=CODE-2',
2038
+ expiresAt: Date.now() + 600_000,
2039
+ status: 'pending',
2040
+ });
2041
+
2042
+ const slow = controller.showQr();
2043
+ await controller.showQr();
2044
+ first.resolve({
2045
+ sessionToken: 'tok-1',
2046
+ authorizeCode: 'CODE-1',
2047
+ qrPayload: 'oxycommons://approve?code=CODE-1',
2048
+ expiresAt: Date.now() + 600_000,
2049
+ status: 'pending',
2050
+ });
2051
+ await slow;
2052
+
2053
+ expect(controller.getSnapshot().signIn.authorizeCode).toBe('CODE-2');
2054
+ expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('CODE-1');
2055
+ expect(oxy.denyCommonsSignIn).not.toHaveBeenCalledWith('CODE-2');
2056
+ controller.cancelSignIn();
2057
+ });
2058
+
2059
+ it('never installs a shared-identity session that was minted after the user cancelled', async () => {
2060
+ const { controller, oxy, commitSession, onSignedIn } = makeHarness();
2061
+ const minted = deferred<SessionLoginResponse>();
2062
+ oxy.signInWithSharedIdentity.mockReturnValue(minted.promise);
2063
+
2064
+ const running = controller.signInWithOxy();
2065
+ // Minted without planting: the controller decides whether to install it.
2066
+ expect(oxy.signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
2067
+ controller.cancelSignIn();
2068
+ minted.resolve({
2069
+ sessionId: 'sess-shared',
2070
+ deviceId: 'device-1',
2071
+ expiresAt: '2030-01-01T00:00:00Z',
2072
+ accessToken: 'access-shared',
2073
+ user: { id: 'a1', username: 'user_a1' },
2074
+ });
2075
+ await running;
2076
+
2077
+ expect(oxy.setTokens).not.toHaveBeenCalled();
2078
+ expect(commitSession).not.toHaveBeenCalled();
2079
+ expect(onSignedIn).not.toHaveBeenCalled();
2080
+ // …and the abandoned attempt does not fall through to a QR request either.
2081
+ expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
2082
+ expect(controller.getSnapshot().signIn.phase).toBe('idle');
2083
+ });
2084
+
2085
+ it('never installs a session whose claim returned after the user cancelled', async () => {
2086
+ jest.useFakeTimers();
2087
+ try {
2088
+ const { controller, oxy, commitSession, onSignedIn } = makeHarness();
2089
+ oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2090
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
2091
+ const claim = deferred<typeof CLAIMED_SESSION>();
2092
+ oxy.claimSessionByToken.mockReturnValue(claim.promise);
2093
+
2094
+ await controller.showQr();
2095
+ await jest.advanceTimersByTimeAsync(1000); // poll → authorized → claim in flight
2096
+ expect(controller.getSnapshot().signIn.phase).toBe('authorized');
2097
+
2098
+ controller.cancelSignIn();
2099
+ claim.resolve(CLAIMED_SESSION);
2100
+ await jest.advanceTimersByTimeAsync(0);
2101
+
2102
+ expect(oxy.setTokens).not.toHaveBeenCalled();
2103
+ expect(commitSession).not.toHaveBeenCalled();
2104
+ expect(onSignedIn).not.toHaveBeenCalled();
2105
+ expect(controller.getSnapshot().signIn.phase).toBe('idle');
2106
+ } finally {
2107
+ jest.useRealTimers();
2108
+ }
2109
+ });
2110
+
2111
+ it('plants the claimed bearer at the install point, immediately before the commit', async () => {
2112
+ jest.useFakeTimers();
2113
+ try {
2114
+ const { controller, oxy, commitSession } = makeHarness();
2115
+ oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2116
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
2117
+ oxy.claimSessionByToken.mockResolvedValue(CLAIMED_SESSION);
2118
+
2119
+ await controller.showQr();
2120
+ await jest.advanceTimersByTimeAsync(1000);
2121
+
2122
+ expect(oxy.setTokens).toHaveBeenCalledWith('access-claimed');
2123
+ expect(oxy.setTokens.mock.invocationCallOrder[0]).toBeLessThan(
2124
+ commitSession.mock.invocationCallOrder[0],
2125
+ );
2126
+ expect(controller.getSnapshot().signIn.phase).toBe('completed');
2127
+ } finally {
2128
+ jest.useRealTimers();
2129
+ }
2130
+ });
2131
+
2132
+ it('restores the previous bearer when the commit fails, and fails the attempt', async () => {
2133
+ jest.useFakeTimers();
2134
+ try {
2135
+ const { controller, oxy, commitSession, onSignedIn } = makeHarness();
2136
+ commitSession.mockRejectedValue(new Error('persist failed'));
2137
+ oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2138
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
2139
+ oxy.claimSessionByToken.mockResolvedValue(CLAIMED_SESSION);
2140
+
2141
+ await controller.showQr();
2142
+ await jest.advanceTimersByTimeAsync(1000);
2143
+
2144
+ // Requests must not keep going out as an account the device never took.
2145
+ expect(oxy.getAccessToken()).toBe('access-token');
2146
+ expect(onSignedIn).not.toHaveBeenCalled();
2147
+ expect(controller.getSnapshot().signIn.phase).toBe('error');
2148
+ expect(controller.getSnapshot().signIn.failure).toBe('unknown');
2149
+ } finally {
2150
+ jest.useRealTimers();
2151
+ }
2152
+ });
2153
+
2154
+ it('falls through to the QR handoff when a shared-identity session cannot be committed', async () => {
2155
+ const { controller, oxy, commitSession } = makeHarness();
2156
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
2157
+ commitSession.mockRejectedValue(new Error('persist failed'));
2158
+ oxy.signInWithSharedIdentity.mockResolvedValue({
2159
+ sessionId: 'sess-shared',
2160
+ deviceId: 'device-1',
2161
+ expiresAt: '2030-01-01T00:00:00Z',
2162
+ accessToken: 'access-shared',
2163
+ user: { id: 'a1', username: 'user_a1' },
2164
+ });
2165
+ oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2166
+
2167
+ await controller.signInWithOxy();
2168
+
2169
+ expect(oxy.getAccessToken()).toBe('access-token');
2170
+ expect(controller.getSnapshot().signIn.phase).toBe('waiting');
2171
+ controller.cancelSignIn();
2172
+ warnSpy.mockRestore();
2173
+ });
2174
+
2175
+ it('classifies an unreachable server as a network failure', async () => {
2176
+ const { controller, oxy } = makeHarness();
2177
+ oxy.startCommonsSignIn.mockRejectedValue(
2178
+ Object.assign(new Error('Network error - failed to connect to server'), { code: 'NETWORK_ERROR', status: 0 }),
2179
+ );
2180
+
2181
+ await controller.showQr();
2182
+
2183
+ expect(controller.getSnapshot().signIn.phase).toBe('error');
2184
+ expect(controller.getSnapshot().signIn.failure).toBe('network');
2185
+ });
2186
+
2187
+ it('moves the attempt identity on every new attempt, and keeps it on the failure it belongs to', async () => {
2188
+ const { controller, oxy } = makeHarness({ clientId: null });
2189
+ oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2190
+
2191
+ await controller.showQr();
2192
+ const firstFailure = controller.getSnapshot().signIn.attempt;
2193
+ await controller.showQr();
2194
+
2195
+ // Same message, different attempt — a surface can tell a repeat of the
2196
+ // failure it already reported from a new one.
2197
+ expect(controller.getSnapshot().signIn.failure).toBe('not-configured');
2198
+ expect(controller.getSnapshot().signIn.attempt).not.toBe(firstFailure);
2199
+ });
2200
+ });
2201
+
2202
+ describe('AccountDialogController — "Try again" repeats the user\'s choice', () => {
2203
+ it('reopens the passkey hub after a failed hub attempt', async () => {
2204
+ // A real window stays closed once closed, so every open is a fresh one.
2205
+ let popup = fakePopup();
2206
+ const openPopup = jest.fn(() => {
2207
+ popup = fakePopup();
2208
+ return popup;
2209
+ });
2210
+ const { controller, oxy } = makeHarness({ openPopup });
2211
+ oxy.startCommonsSignIn.mockRejectedValue(new Error('boom'));
2212
+
2213
+ await controller.startPasskeyHubSignIn();
2214
+ expect(controller.getSnapshot().signIn.phase).toBe('error');
2215
+
2216
+ oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2217
+ const retry = controller.retrySignIn();
2218
+ // Opened synchronously, inside the press — before any await.
2219
+ expect(openPopup).toHaveBeenCalledTimes(2);
2220
+ await retry;
2221
+
2222
+ expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
2223
+ expect(popup.location.href).toContain('/continue?code=AUTH-CODE');
2224
+ controller.cancelSignIn();
2225
+ });
2226
+
2227
+ it('retries "Sign in with Oxy" (never the hub) after a failed Oxy attempt', async () => {
2228
+ const openPopup = jest.fn(() => fakePopup());
2229
+ const { controller, oxy } = makeHarness({ openPopup });
2230
+ oxy.startCommonsSignIn.mockRejectedValueOnce(new Error('boom'));
2231
+
2232
+ await controller.signInWithOxy();
2233
+ expect(controller.getSnapshot().signIn.phase).toBe('error');
2234
+
2235
+ oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2236
+ await controller.retrySignIn();
2237
+
2238
+ expect(openPopup).not.toHaveBeenCalled();
2239
+ expect(oxy.signInWithSharedIdentity).toHaveBeenCalledTimes(2);
2240
+ expect(controller.getSnapshot().signIn.phase).toBe('waiting');
2241
+ controller.cancelSignIn();
2242
+ });
2243
+
2244
+ it('closes a hub popup that was opened for an attempt the user cancelled while its request was created', async () => {
2245
+ const popup = fakePopup();
2246
+ const { controller, oxy } = makeHarness({ openPopup: () => popup });
2247
+ const created = deferred<unknown>();
2248
+ oxy.startCommonsSignIn.mockReturnValue(created.promise);
2249
+
2250
+ const running = controller.startPasskeyHubSignIn();
2251
+ controller.cancelSignIn();
2252
+ // Closed at once, not left blank until the slow request returns.
2253
+ expect(popup.close).toHaveBeenCalled();
2254
+
2255
+ created.resolve({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2256
+ await running;
2257
+ expect(popup.location.href).toBe('');
2258
+ });
2259
+ });
2260
+
2261
+ describe('AccountDialogController — device mutations report their own outcome', () => {
2262
+ function directoryClient() {
2263
+ const sc = new TestSessionClient(host().host);
2264
+ const controller = createAccountDialogController({
2265
+ oxyServices: makeOxy() as unknown as OxyServices,
2266
+ sessionClient: sc,
2267
+ clientId: 'oxy_dk_test',
2268
+ commitSession: jest.fn().mockResolvedValue(undefined),
2269
+ });
2270
+ return { controller, sc };
2271
+ }
2272
+
2273
+ it('reports a switch that happened as a switch, even when the re-read after it fails', async () => {
2274
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
2275
+ const { controller, sc } = directoryClient();
2276
+ jest.spyOn(sc, 'activateContext').mockResolvedValue(undefined);
2277
+ jest.spyOn(sc, 'refreshDirectory').mockRejectedValue(new Error('directory boom'));
2278
+
2279
+ // The subject DID change. Calling that a failed switch would invite the user
2280
+ // to repeat it — and skip the cache reset a switch requires.
2281
+ expect(await controller.activateContext('ctx-b')).toBe(true);
2282
+ // The re-read's failure is still reported, as the directory's.
2283
+ expect(controller.getSnapshot().error).toBe('directory boom');
2284
+ expect(controller.getSnapshot().activatingContextId).toBeNull();
2285
+ warnSpy.mockRestore();
2286
+ });
2287
+
2288
+ it('never lets an older refresh failure overwrite a newer successful read', async () => {
2289
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
2290
+ const { controller, sc } = directoryClient();
2291
+ const older = deferred<void>();
2292
+ jest
2293
+ .spyOn(sc, 'refreshDirectory')
2294
+ .mockReturnValueOnce(older.promise)
2295
+ .mockResolvedValueOnce(undefined);
2296
+
2297
+ const first = controller.refresh();
2298
+ expect(await controller.refresh()).toBe(true);
2299
+ older.reject(new Error('stale boom'));
2300
+ expect(await first).toBe(false);
2301
+
2302
+ expect(controller.getSnapshot().error).toBeNull();
2303
+ expect(controller.getSnapshot().loading).toBe(false);
2304
+ warnSpy.mockRestore();
2305
+ });
2306
+
2307
+ it('refuses a removal while a switch is in flight, and a switch while a removal is', async () => {
2308
+ const { controller, sc } = directoryClient();
2309
+ const switching = deferred<void>();
2310
+ jest.spyOn(sc, 'activateContext').mockReturnValue(switching.promise);
2311
+ const removeContext = jest.spyOn(sc, 'signOutContext').mockResolvedValue(undefined);
2312
+ const removePrincipal = jest.spyOn(sc, 'signOutPrincipal').mockResolvedValue(undefined);
2313
+ jest.spyOn(sc, 'refreshDirectory').mockResolvedValue(undefined);
2314
+
2315
+ const activation = controller.activateContext('ctx-b');
2316
+ expect(controller.isDeviceMutationInFlight()).toBe(true);
2317
+ // Refused, not queued: nothing destructive may fire later, after the switch.
2318
+ expect(await controller.signOutContext('ctx-a')).toBe(false);
2319
+ expect(await controller.signOutPrincipal('p-a')).toBe(false);
2320
+ expect(removeContext).not.toHaveBeenCalled();
2321
+ expect(removePrincipal).not.toHaveBeenCalled();
2322
+
2323
+ switching.resolve();
2324
+ expect(await activation).toBe(true);
2325
+ expect(controller.isDeviceMutationInFlight()).toBe(false);
2326
+
2327
+ const removing = deferred<void>();
2328
+ removeContext.mockReturnValue(removing.promise);
2329
+ const removal = controller.signOutContext('ctx-a');
2330
+ expect(await controller.activateContext('ctx-c')).toBe(false);
2331
+ removing.resolve();
2332
+ expect(await removal).toBe(true);
2333
+ await flush();
2334
+ expect(removeContext).toHaveBeenCalledTimes(1);
2335
+ });
2336
+
2337
+ it('runs a host operation under the same gate, and releases it when the operation rejects', async () => {
2338
+ const { controller, sc } = directoryClient();
2339
+ const signingOut = deferred<string>();
2340
+ const activate = jest.spyOn(sc, 'activateContext').mockResolvedValue(undefined);
2341
+ jest.spyOn(sc, 'refreshDirectory').mockResolvedValue(undefined);
2342
+
2343
+ const exclusive = controller.runDeviceMutation(() => signingOut.promise);
2344
+ expect(await controller.activateContext('ctx-b')).toBe(false);
2345
+ expect(await controller.runDeviceMutation(async () => 'second')).toEqual({ ran: false });
2346
+ expect(activate).not.toHaveBeenCalled();
2347
+ signingOut.resolve('done');
2348
+ expect(await exclusive).toEqual({ ran: true, value: 'done' });
2349
+
2350
+ await expect(
2351
+ controller.runDeviceMutation(async () => {
2352
+ throw new Error('boom');
2353
+ }),
2354
+ ).rejects.toThrow('boom');
2355
+ expect(controller.isDeviceMutationInFlight()).toBe(false);
2356
+ });
2357
+ });
2358
+
1962
2359
  it('createAccountDialogController returns an AccountDialogController instance', () => {
1963
2360
  const { controller } = makeHarness();
1964
2361
  expect(controller).toBeInstanceOf(AccountDialogController);