@oxyhq/core 11.0.1 → 12.1.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 (30) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +7 -5
  3. package/dist/cjs/mixins/OxyServices.auth.js +6 -117
  4. package/dist/cjs/mixins/OxyServices.identity.js +16 -12
  5. package/dist/cjs/session/accountDialogController.js +84 -75
  6. package/dist/cjs/utils/officialOrigins.js +6 -0
  7. package/dist/esm/.tsbuildinfo +1 -1
  8. package/dist/esm/index.js +6 -5
  9. package/dist/esm/mixins/OxyServices.auth.js +6 -117
  10. package/dist/esm/mixins/OxyServices.identity.js +16 -12
  11. package/dist/esm/session/accountDialogController.js +84 -75
  12. package/dist/esm/utils/officialOrigins.js +6 -1
  13. package/dist/types/.tsbuildinfo +1 -1
  14. package/dist/types/index.d.ts +3 -3
  15. package/dist/types/mixins/OxyServices.auth.d.ts +4 -48
  16. package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
  17. package/dist/types/session/accountDialogController.d.ts +84 -49
  18. package/dist/types/utils/officialOrigins.d.ts +6 -0
  19. package/package.json +2 -2
  20. package/src/index.ts +7 -4
  21. package/src/mixins/OxyServices.auth.ts +6 -144
  22. package/src/mixins/OxyServices.identity.ts +19 -15
  23. package/src/mixins/__tests__/OxyServices.identity.test.ts +26 -14
  24. package/src/mixins/__tests__/webauthnAuth.test.ts +1 -1
  25. package/src/session/__tests__/accountDialogController.test.ts +75 -64
  26. package/src/session/__tests__/accountProjection.test.ts +30 -0
  27. package/src/session/accountDialogController.ts +131 -104
  28. package/src/utils/__tests__/officialOrigins.test.ts +14 -0
  29. package/src/utils/officialOrigins.ts +6 -1
  30. package/src/mixins/__tests__/passwordSignIn.test.ts +0 -115
@@ -53,7 +53,7 @@ const OXY_IDENTITY_APEX = 'oxy.so';
53
53
  export type IdentityRecordType = OxySignedRecordType;
54
54
 
55
55
  /** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
56
- export type UnlinkableAuthMethodType = 'identity' | 'password' | 'google' | 'apple' | 'github';
56
+ export type UnlinkableAuthMethodType = 'identity' | 'webauthn';
57
57
 
58
58
  /**
59
59
  * Result of a link/unlink auth-method mutation (`POST /auth/link`,
@@ -207,18 +207,18 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
207
207
  }
208
208
 
209
209
  /**
210
- * Link password authentication to the current account. Adds a `password`
211
- * auth method (does not remove existing methods).
210
+ * Unlink an authentication method from the current account. The server
211
+ * refuses to remove the last remaining method (the account would become
212
+ * inaccessible). Unlinking `identity` downgrades the account to custodial.
212
213
  *
213
- * @param email - The email to associate with password auth.
214
- * @param password - The new password (server enforces strength rules).
214
+ * @param type - The auth-method type to remove.
215
215
  */
216
- async linkPassword(email: string, password: string): Promise<LinkAuthMethodResult> {
216
+ async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
217
217
  try {
218
218
  const result = await this.makeRequest<LinkAuthMethodResult>(
219
- 'POST',
220
- '/auth/link',
221
- { type: 'password', email, password },
219
+ 'DELETE',
220
+ `/auth/link/${encodeURIComponent(type)}`,
221
+ undefined,
222
222
  { cache: false },
223
223
  );
224
224
  this._invalidateIdentityCaches(this.getCurrentUserId());
@@ -229,17 +229,21 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
229
229
  }
230
230
 
231
231
  /**
232
- * Unlink an authentication method from the current account. The server
233
- * refuses to remove the last remaining method (the account would become
234
- * inaccessible). Unlinking `identity` downgrades the account to custodial.
232
+ * Remove ONE passkey (WebAuthn credential) from the current account.
235
233
  *
236
- * @param type - The auth-method type to remove.
234
+ * Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
235
+ * removes an auth method by type) this targets a specific credential id.
236
+ * The server refuses to remove the last remaining auth method (the account
237
+ * would become inaccessible) and deletes the stored `WebauthnCredential`.
238
+ *
239
+ * @param credentialId - The passkey's public credential id
240
+ * (`AuthMethodEntry.credentialId`).
237
241
  */
238
- async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
242
+ async removePasskey(credentialId: string): Promise<LinkAuthMethodResult> {
239
243
  try {
240
244
  const result = await this.makeRequest<LinkAuthMethodResult>(
241
245
  'DELETE',
242
- `/auth/link/${encodeURIComponent(type)}`,
246
+ `/auth/link/webauthn/${encodeURIComponent(credentialId)}`,
243
247
  undefined,
244
248
  { cache: false },
245
249
  );
@@ -186,37 +186,49 @@ describe('OxyServices.identity', () => {
186
186
  });
187
187
  });
188
188
 
189
- describe('linkPassword', () => {
190
- it('POSTs /auth/link with the password method and sweeps cache', async () => {
191
- makeRequestSpy.mockResolvedValue({ success: true, message: 'Password auth linked successfully' });
189
+ describe('unlinkAuthMethod', () => {
190
+ it('DELETEs /auth/link/:type and sweeps cache', async () => {
191
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
192
192
 
193
- await oxy.linkPassword('nate@oxy.so', 'sup3r-secret');
193
+ await oxy.unlinkAuthMethod('identity');
194
194
 
195
195
  expect(makeRequestSpy).toHaveBeenCalledWith(
196
- 'POST',
197
- '/auth/link',
198
- { type: 'password', email: 'nate@oxy.so', password: 'sup3r-secret' },
196
+ 'DELETE',
197
+ '/auth/link/identity',
198
+ undefined,
199
199
  expect.objectContaining({ cache: false }),
200
200
  );
201
- expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
202
- expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
201
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
203
202
  });
204
203
  });
205
204
 
206
- describe('unlinkAuthMethod', () => {
207
- it('DELETEs /auth/link/:type and sweeps cache', async () => {
208
- makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
205
+ describe('removePasskey', () => {
206
+ it('DELETEs /auth/link/webauthn/:credentialID and sweeps cache', async () => {
207
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
209
208
 
210
- await oxy.unlinkAuthMethod('identity');
209
+ await oxy.removePasskey('cred-abc');
211
210
 
212
211
  expect(makeRequestSpy).toHaveBeenCalledWith(
213
212
  'DELETE',
214
- '/auth/link/identity',
213
+ '/auth/link/webauthn/cred-abc',
215
214
  undefined,
216
215
  expect.objectContaining({ cache: false }),
217
216
  );
218
217
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
219
218
  });
219
+
220
+ it('URL-encodes the credential id', async () => {
221
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
222
+
223
+ await oxy.removePasskey('a/b+c=');
224
+
225
+ expect(makeRequestSpy).toHaveBeenCalledWith(
226
+ 'DELETE',
227
+ '/auth/link/webauthn/a%2Fb%2Bc%3D',
228
+ undefined,
229
+ expect.objectContaining({ cache: false }),
230
+ );
231
+ });
220
232
  });
221
233
 
222
234
  describe('signRecord (client-only)', () => {
@@ -3,7 +3,7 @@
3
3
  * (`webauthnRegisterOptions` / `webauthnRegisterVerify` /
4
4
  * `webauthnLoginOptions` / `webauthnLoginVerify`).
5
5
  *
6
- * Stubs `makeRequest` (HTTP-mock style, like `passwordSignIn.test.ts`) and
6
+ * Stubs `makeRequest` (HTTP-mock style) and
7
7
  * asserts: each method hits the right endpoint with the right body; the opaque
8
8
  * ceremony `response` is forwarded verbatim; the login/verify + register/verify
9
9
  * (signup) paths parse the login contract and plant the access token on the
@@ -152,6 +152,7 @@ describe('AccountDialogController — initial + views', () => {
152
152
  expect(snap.loading).toBe(false);
153
153
  expect(snap.switchingAccountId).toBeNull();
154
154
  expect(snap.signIn.phase).toBe('idle');
155
+ expect(snap.commonsAvailability).toBe('unknown');
155
156
  });
156
157
 
157
158
  it('setView / add / close move between views and notify subscribers', () => {
@@ -169,6 +170,19 @@ describe('AccountDialogController — initial + views', () => {
169
170
  expect(seen).toEqual(['add', 'signin', 'accounts']);
170
171
  });
171
172
 
173
+ it('startSignup moves to the signup view and notifies subscribers', () => {
174
+ const { controller } = makeHarness();
175
+ const seen: string[] = [];
176
+ controller.subscribe((s) => seen.push(s.view));
177
+
178
+ controller.startSignup();
179
+ expect(controller.getSnapshot().view).toBe('signup');
180
+ controller.close();
181
+ expect(controller.getSnapshot().view).toBe('accounts');
182
+
183
+ expect(seen).toEqual(['signup', 'accounts']);
184
+ });
185
+
172
186
  it('getSnapshot returns a stable reference until a change occurs', () => {
173
187
  const { controller } = makeHarness();
174
188
  const a = controller.getSnapshot();
@@ -391,6 +405,54 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
391
405
  expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
392
406
  });
393
407
 
408
+ it('commits a graph switch via the IN-PLACE commitSwitchedSession — never the hub-syncing commitSession', async () => {
409
+ // PROBLEM 2: an account switch must not run the cross-origin hub-sync
410
+ // full-page redirect. When both funnels are wired, the mint-switch must use
411
+ // commitSwitchedSession (in-place) and NEVER commitSession (which may
412
+ // redirect on an official web origin).
413
+ const oxy = makeOxy();
414
+ const sc = new TestSessionClient(host());
415
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
416
+ jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
417
+ oxy.switchToAccount.mockResolvedValue({
418
+ sessionId: 'sess-org',
419
+ deviceId: 'device-1',
420
+ expiresAt: '2030-01-01T00:00:00Z',
421
+ accessToken: 'access-org',
422
+ user: user('org1'),
423
+ });
424
+ const commitSession = jest.fn().mockResolvedValue(undefined);
425
+ const commitSwitchedSession = jest.fn().mockResolvedValue(undefined);
426
+ const controller = new AccountDialogController({
427
+ oxyServices: oxy as unknown as OxyServices,
428
+ sessionClient: sc,
429
+ clientId: 'oxy_dk_test',
430
+ commitSession,
431
+ commitSwitchedSession,
432
+ });
433
+
434
+ await controller.switchTo('org1');
435
+
436
+ expect(commitSwitchedSession).toHaveBeenCalledTimes(1);
437
+ expect(commitSwitchedSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
438
+ expect(commitSession).not.toHaveBeenCalled();
439
+ });
440
+
441
+ it('surfaces a failed switch as snapshot.error instead of silently no-op\'ing', async () => {
442
+ // PROBLEM 1: switching into an account the server refuses (e.g. a 403 for a
443
+ // personal-kind target) must tell the user why — the error is recorded on
444
+ // the snapshot, the switch does not throw, and switchingAccountId resets.
445
+ const { controller, oxy, sc } = makeHarness();
446
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
447
+ oxy.switchToAccount.mockRejectedValue(new Error('Cannot switch into a personal account'));
448
+
449
+ await expect(controller.switchTo('org1')).resolves.toBeUndefined();
450
+
451
+ const snap = controller.getSnapshot();
452
+ expect(snap.error).toBe('Cannot switch into a personal account');
453
+ expect(snap.switchingAccountId).toBeNull();
454
+ });
455
+
394
456
  it('falls back to SessionClient.registerAndActivate when no commitSession is supplied', async () => {
395
457
  const oxy = makeOxy();
396
458
  const sc = new TestSessionClient(host());
@@ -579,7 +641,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
579
641
  });
580
642
  });
581
643
 
582
- describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
644
+ describe('AccountDialogController — Commons availability (canOpenApp)', () => {
583
645
  const START_HANDLE = {
584
646
  sessionToken: 'secret-tok',
585
647
  authorizeCode: 'AUTH-CODE',
@@ -621,6 +683,7 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
621
683
  expect(snap.view).toBe('qr');
622
684
  expect(snap.signIn.phase).toBe('waiting');
623
685
  expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
686
+ expect(snap.commonsAvailability).toBe('available');
624
687
  controller.cancelSignIn();
625
688
  });
626
689
 
@@ -635,6 +698,7 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
635
698
  expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
636
699
  expect(openUrl).not.toHaveBeenCalled();
637
700
  expect(controller.getSnapshot().signIn.phase).toBe('waiting');
701
+ expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
638
702
  controller.cancelSignIn();
639
703
  });
640
704
 
@@ -647,10 +711,11 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
647
711
 
648
712
  expect(openUrl).not.toHaveBeenCalled();
649
713
  expect(controller.getSnapshot().signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
714
+ expect(controller.getSnapshot().commonsAvailability).toBe('unknown');
650
715
  controller.cancelSignIn();
651
716
  });
652
717
 
653
- it('swallows a canOpenApp probe rejection and keeps the QR fallback', async () => {
718
+ it('swallows a canOpenApp probe rejection, keeps the QR fallback, and records unavailable', async () => {
654
719
  const openUrl = jest.fn();
655
720
  const canOpenApp = jest.fn().mockRejectedValue(new Error('probe boom'));
656
721
  const { controller } = makeController({ openUrl, canOpenApp });
@@ -660,73 +725,19 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
660
725
 
661
726
  expect(openUrl).not.toHaveBeenCalled();
662
727
  expect(controller.getSnapshot().signIn.phase).toBe('waiting');
728
+ expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
663
729
  controller.cancelSignIn();
664
730
  });
665
- });
666
-
667
- describe('AccountDialogController — openPasswordAtOxyAuth', () => {
668
- beforeEach(() => {
669
- const store = new Map<string, string>();
670
- Object.defineProperty(globalThis, 'sessionStorage', {
671
- value: {
672
- getItem: (key: string) => store.get(key) ?? null,
673
- setItem: (key: string, value: string) => {
674
- store.set(key, value);
675
- },
676
- removeItem: (key: string) => {
677
- store.delete(key);
678
- },
679
- },
680
- configurable: true,
681
- });
682
- });
683
-
684
- it('builds the IdP sign-in URL with redirect_uri + client_id and invokes openUrl', async () => {
685
- const oxy = makeOxy();
686
- const sc = new TestSessionClient(host());
687
- const openUrl = jest.fn();
688
- const controller = new AccountDialogController({
689
- oxyServices: oxy as unknown as OxyServices,
690
- sessionClient: sc,
691
- clientId: 'oxy_dk_test',
692
- openUrl,
693
- });
694
-
695
- const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://mention.earth/dashboard' });
696
- const parsed = new URL(url);
697
- expect(parsed.origin).toBe('https://auth.oxy.so');
698
- expect(parsed.pathname).toBe('/login');
699
- expect(parsed.searchParams.get('redirect_uri')).toBe('https://mention.earth');
700
- expect(parsed.searchParams.get('client_id')).toBe('oxy_dk_test');
701
- expect(parsed.searchParams.get('state')).toBeTruthy();
702
- expect(parsed.searchParams.get('code_challenge')).toBeTruthy();
703
- expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
704
- expect(openUrl).toHaveBeenCalledWith(url);
705
- });
706
731
 
707
- it('honors authRedirectUri over returnUrl', async () => {
708
- const oxy = makeOxy();
709
- const sc = new TestSessionClient(host());
710
- const controller = new AccountDialogController({
711
- oxyServices: oxy as unknown as OxyServices,
712
- sessionClient: sc,
713
- authRedirectUri: 'https://inbox.oxy.so',
714
- });
732
+ it('start() eagerly resolves commonsAvailability without requiring a QR flow', async () => {
733
+ const canOpenApp = jest.fn().mockResolvedValue(true);
734
+ const { controller } = makeController({ canOpenApp });
715
735
 
716
- const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://inbox.oxy.so/mail' });
717
- expect(new URL(url).searchParams.get('redirect_uri')).toBe('https://inbox.oxy.so');
718
- });
736
+ controller.start();
737
+ await flush();
719
738
 
720
- it('honors an idpApex override', async () => {
721
- const oxy = makeOxy();
722
- const sc = new TestSessionClient(host());
723
- const controller = new AccountDialogController({
724
- oxyServices: oxy as unknown as OxyServices,
725
- sessionClient: sc,
726
- idpApex: 'alia.onl',
727
- });
728
- const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://alia.onl/' });
729
- expect(new URL(url).origin).toBe('https://auth.alia.onl');
739
+ expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
740
+ expect(controller.getSnapshot().commonsAvailability).toBe('available');
730
741
  });
731
742
  });
732
743
 
@@ -164,6 +164,36 @@ describe('projectSwitchableAccounts', () => {
164
164
  });
165
165
  expect(rows.every((r) => !r.isCurrent)).toBe(true);
166
166
  });
167
+
168
+ it('keeps the OPERATOR full set when acting-as a sub-account (no collapse to the active account)', () => {
169
+ // nate operates albert: albert is the active on-device account, nate is also
170
+ // on-device, and the graph is OPERATOR-anchored (the server returns nate's
171
+ // full forest regardless of which account is active). The projection must
172
+ // faithfully union — switching in only changes which row is `isCurrent`,
173
+ // never which accounts are listed. This is the switcher-collapse regression.
174
+ const rows = projectSwitchableAccounts({
175
+ state: state([{ accountId: 'nate', sessionId: 's-nate' }, { accountId: 'albert', sessionId: 's-albert' }], 'albert'),
176
+ graph: [
177
+ graphNode('nate', { kind: 'personal', relationship: 'self' }),
178
+ graphNode('albert', { relationship: 'owner' }),
179
+ graphNode('oxy', { relationship: 'owner' }),
180
+ graphNode('faircoin', { relationship: 'owner' }),
181
+ ],
182
+ profilesById: mapOf(user('nate'), user('albert'), user('oxy'), user('faircoin')),
183
+ resolveAvatarUrl: noAvatar,
184
+ });
185
+
186
+ // The full operable set is present even though a leaf sub-account is active.
187
+ expect(rows.map((r) => r.accountId).sort()).toEqual(['albert', 'faircoin', 'nate', 'oxy']);
188
+ // Exactly the active sub-account is flagged current; operator + siblings stay.
189
+ expect(rows.find((r) => r.accountId === 'albert')?.isCurrent).toBe(true);
190
+ expect(rows.filter((r) => r.isCurrent)).toHaveLength(1);
191
+ // The operator's own personal account is not dropped by acting-as.
192
+ expect(rows.find((r) => r.accountId === 'nate')).toBeDefined();
193
+ // Graph-only siblings (operable but not yet on this device) still appear.
194
+ expect(rows.find((r) => r.accountId === 'oxy')?.onDevice).toBe(false);
195
+ expect(rows.find((r) => r.accountId === 'faircoin')?.onDevice).toBe(false);
196
+ });
167
197
  });
168
198
 
169
199
  describe('switchableAccountIds', () => {