@oxyhq/core 20.1.0 → 21.0.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 (70) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  3. package/dist/cjs/i18n/locales/en-US.json +19 -2
  4. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  5. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  7. package/dist/cjs/index.js +50 -16
  8. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  9. package/dist/cjs/session/SessionClient.js +361 -1
  10. package/dist/cjs/session/accountDialogController.js +121 -147
  11. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  12. package/dist/cjs/session/deviceDirectory.js +143 -0
  13. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  14. package/dist/cjs/session/projectSessionState.js +8 -1
  15. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/boot/sessionColdBoot.js +107 -8
  18. package/dist/esm/i18n/locales/en-US.json +19 -2
  19. package/dist/esm/i18n/locales/es-ES.json +19 -2
  20. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  21. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  22. package/dist/esm/index.js +32 -10
  23. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  24. package/dist/esm/session/SessionClient.js +362 -2
  25. package/dist/esm/session/accountDialogController.js +121 -147
  26. package/dist/esm/session/accountSwitchTargets.js +71 -0
  27. package/dist/esm/session/deviceDirectory.js +135 -0
  28. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  29. package/dist/esm/session/projectSessionState.js +8 -2
  30. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  33. package/dist/types/index.d.ts +8 -3
  34. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  35. package/dist/types/models/session.d.ts +11 -0
  36. package/dist/types/session/SessionClient.d.ts +202 -1
  37. package/dist/types/session/accountDialogController.d.ts +76 -64
  38. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  39. package/dist/types/session/deviceDirectory.d.ts +182 -0
  40. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  41. package/dist/types/session/projectSessionState.d.ts +29 -0
  42. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  43. package/package.json +3 -3
  44. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  45. package/src/boot/sessionColdBoot.ts +133 -9
  46. package/src/i18n/locales/en-US.json +19 -2
  47. package/src/i18n/locales/es-ES.json +19 -2
  48. package/src/index.ts +75 -18
  49. package/src/mixins/OxyServices.auth.ts +67 -5
  50. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  51. package/src/models/session.ts +11 -0
  52. package/src/session/SessionClient.ts +386 -1
  53. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  54. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  55. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  56. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  57. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  58. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  59. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  60. package/src/session/accountDialogController.ts +141 -179
  61. package/src/session/accountSwitchTargets.ts +87 -0
  62. package/src/session/deviceDirectory.ts +269 -0
  63. package/src/session/deviceSwitcherRows.ts +145 -0
  64. package/src/session/projectSessionState.ts +9 -3
  65. package/src/session/sharedDeviceCredential.ts +349 -0
  66. package/dist/cjs/session/accountProjection.js +0 -213
  67. package/dist/esm/session/accountProjection.js +0 -207
  68. package/dist/types/session/accountProjection.d.ts +0 -198
  69. package/src/session/__tests__/accountProjection.test.ts +0 -447
  70. package/src/session/accountProjection.ts +0 -354
@@ -15,10 +15,17 @@
15
15
  * origin persisted a `deviceId` + `deviceSecret`, mint a short access token
16
16
  * with a single bearer-less POST to `/session/device/token` (no cookie, no
17
17
  * navigation) and rotate the secret in-use.
18
- * 3. `shared-key-signin` (native, ACCOUNT mode) — re-mint from the
19
- * shared-keychain identity OR `identity-key-signin` (IDENTITY mode)
20
- * re-mint from THIS device's primary identity key.
21
- * 4. Signed out.
18
+ * 3. `shared-device-adopt` (native, ACCOUNT mode) — this app has no credential
19
+ * of its own but a sibling official app already put one in the shared native
20
+ * slot: adopt it and mint. This is how a newly installed official app joins
21
+ * the device's existing session WITHOUT another QR and without ever touching
22
+ * the Commons private key.
23
+ * 4. `shared-key-signin` (native, ACCOUNT mode) — the legacy lane: re-mint by
24
+ * signing with the shared-keychain IDENTITY key. Retained as a recovery /
25
+ * compatibility path for devices whose apps have not yet published a shared
26
+ * device credential — OR `identity-key-signin` (IDENTITY mode) — re-mint
27
+ * from THIS device's primary identity key.
28
+ * 5. Signed out.
22
29
  *
23
30
  * Two session modes (see {@link RunSessionColdBootOptions.sessionMode}):
24
31
  * - `account` (default) — the device's ACTIVE account owns the session. Every
@@ -42,8 +49,12 @@ import {
42
49
  type IdentityBinding,
43
50
  } from '../session/identitySession';
44
51
  import type { IdentityPin } from '../session/identityPin';
52
+ import {
53
+ decideSharedDeviceJoin,
54
+ type SharedDeviceCredentialStore,
55
+ } from '../session/sharedDeviceCredential';
45
56
  import type { OxyServices } from '../OxyServices';
46
- import type { AuthStateStore } from '../session/authStateStore';
57
+ import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
47
58
 
48
59
  /**
49
60
  * Who owns the session this boot resolves.
@@ -116,6 +127,18 @@ export interface RunSessionColdBootOptions {
116
127
  * `sessionMode: 'identity'`. Ignored in `'account'` mode.
117
128
  */
118
129
  identity?: IdentityBinding;
130
+ /**
131
+ * The cross-app native slot holding this device's shared DeviceSession
132
+ * credential, enabling the `shared-device-adopt` lane. Supplied by
133
+ * `@oxyhq/services` on native; absent on web, where each origin is its own
134
+ * device by design.
135
+ *
136
+ * IGNORED in `sessionMode: 'identity'`. The shared slot belongs to whichever
137
+ * principal signed in on this device; an identity-bound client must resolve
138
+ * its session from the local key alone, and adopting a device credential is
139
+ * exactly the drift that mode exists to prevent.
140
+ */
141
+ sharedDeviceCredential?: SharedDeviceCredentialStore;
119
142
  }
120
143
 
121
144
  /**
@@ -342,10 +365,111 @@ export async function runSessionColdBoot(
342
365
  };
343
366
  },
344
367
  });
345
- } else {
346
- // 3. shared-key-signin (native) — re-mint from the shared identity. Native
347
- // AND online: it is a network step (challenge + verify round-trips), so it
348
- // is gated by the same offline hint as the mint lane. `{ retry: false }`
368
+ } else if (opts.sharedDeviceCredential) {
369
+ // 3. shared-device-adopt (native, ACCOUNT mode) — join the device's existing
370
+ // session through the shared native credential slot.
371
+ //
372
+ // This is the lane that separates identity from session transport. What it
373
+ // reads is an ordinary, individually revocable `deviceId` + `deviceSecret`
374
+ // put there by a sibling official app — never the Commons private key. It
375
+ // is what lets a freshly installed official app land signed in with no QR,
376
+ // and it is why an ordinary app never needs identity-key access at all.
377
+ //
378
+ // `decideSharedDeviceJoin` gates it: an app that already holds its own
379
+ // credential is never moved, and an UNREADABLE slot is never mistaken for
380
+ // an empty one. The lane therefore cannot sign anyone out, in either
381
+ // upgrade order.
382
+ const sharedSlot = opts.sharedDeviceCredential;
383
+ steps.push({
384
+ id: 'shared-device-adopt',
385
+ // The adoption itself is local, but it is only worth committing alongside
386
+ // a mint that proves the credential — so the whole lane is online-gated
387
+ // like every other network step.
388
+ enabled: () => isNative && !isOffline(),
389
+ run: async () => {
390
+ const before = await store.load();
391
+ const decision = decideSharedDeviceJoin(before, await sharedSlot.read());
392
+ if (decision.action === 'skip') {
393
+ logger.debug(
394
+ `shared device credential not adopted (${decision.reason})`,
395
+ { component: 'sessionColdBoot', method: 'shared-device-adopt' },
396
+ );
397
+ return { kind: 'skip' };
398
+ }
399
+
400
+ // Restore the store to exactly what it held before this lane touched it.
401
+ // A credential we adopted and could not prove must not be left behind for
402
+ // the next boot's mint lane to keep retrying.
403
+ const revert = async (): Promise<void> => {
404
+ if (before) {
405
+ await store.save(before);
406
+ } else {
407
+ await store.clear();
408
+ }
409
+ };
410
+
411
+ const adopted: PersistedAuthState = {
412
+ // The mint fills both in from the device's live state; carrying the
413
+ // previous session's ids into a different device session would be a
414
+ // lie for however long the mint takes.
415
+ sessionId: '',
416
+ userId: '',
417
+ deviceId: decision.credential.deviceId,
418
+ deviceSecret: decision.credential.deviceSecret,
419
+ };
420
+ if (!(await store.save(adopted))) {
421
+ logger.error(
422
+ 'adopted the shared device credential but it could not be durably persisted — reverting',
423
+ undefined,
424
+ { component: 'sessionColdBoot', method: 'shared-device-adopt' },
425
+ );
426
+ await revert();
427
+ return { kind: 'skip' };
428
+ }
429
+
430
+ const result = await refreshDeviceSecretArm({ oxy, store, pin: null });
431
+ if (result.status === 'ok') {
432
+ return {
433
+ kind: 'session',
434
+ session: {
435
+ sessionId: result.sessionId,
436
+ userId: result.userId,
437
+ accessToken: result.token,
438
+ },
439
+ };
440
+ }
441
+
442
+ if (result.status === 'invalid-secret') {
443
+ // The one place we hold POSITIVE proof that the exact bytes in the
444
+ // shared slot are dead — the server rejected them by name. Clearing it
445
+ // signs nobody out (a credential the server does not recognise cannot
446
+ // be minting for anyone) and it is what stops a dead credential from
447
+ // blocking every future install: a stale slot owned by a different
448
+ // `deviceId` is otherwise never overwritten, by design.
449
+ await sharedSlot.clear();
450
+ } else if (result.status === 'no-session') {
451
+ signedOutReason = 'no_session';
452
+ }
453
+ await revert();
454
+ return { kind: 'skip' };
455
+ },
456
+ });
457
+ }
458
+
459
+ if (identityBinding === null) {
460
+ // 4. shared-key-signin (native) — the RECOVERY / COMPATIBILITY lane: sign a
461
+ // challenge with the shared-keychain IDENTITY key to re-mint a session.
462
+ //
463
+ // It runs LAST on purpose. Using the self-custody key to obtain an ordinary
464
+ // session is the over-sharing #937 sets out to end, so it is now reachable
465
+ // only on a device where no sibling app has published a shared device
466
+ // credential yet — an install that predates this lane, or one where the
467
+ // shared slot is unreadable. Its own `store.save` below feeds the shared
468
+ // slot through the mirroring store, so the FIRST boot that takes this lane
469
+ // is also the last one that needs to: every later app joins by credential.
470
+ //
471
+ // Native AND online: it is a network step (challenge + verify round-trips),
472
+ // so it is gated by the same offline hint as the mint lane. `{ retry: false }`
349
473
  // keeps the two round-trips as single attempts — the refresh scheduler /
350
474
  // 401 lane own later retries — so this step cannot multiply boot latency.
351
475
  steps.push({
@@ -396,13 +396,22 @@
396
396
  "remoteSignOutFailed": "There was a problem signing out from the device. Please try again.",
397
397
  "noOtherDeviceSessions": "No other device sessions found to sign out from.",
398
398
  "signOutOthersSuccess": "Signed out from all other devices successfully!",
399
- "signOutOthersFailed": "There was a problem signing out from other devices. Please try again."
399
+ "signOutOthersFailed": "There was a problem signing out from other devices. Please try again.",
400
+ "contextRemoved": "Removed {{account}}",
401
+ "contextRemoveFailed": "There was a problem removing that account. Please try again.",
402
+ "principalRemoved": "Signed {{name}} out of this device",
403
+ "principalRemoveFailed": "There was a problem signing that person out. Please try again.",
404
+ "activateFailed": "There was a problem switching accounts. Please try again."
400
405
  },
401
406
  "confirms": {
402
407
  "remove": "Are you sure you want to remove {{displayName}} from this device? You'll need to sign in again to access this account.",
403
408
  "logoutAll": "Are you sure you want to sign out of all accounts? This will remove all saved accounts from this device.",
404
409
  "remoteLogout": "Are you sure you want to sign out from \"{{deviceName}}\"? This will end the session on that device.",
405
- "logoutOthers": "Are you sure you want to sign out from all {{count}} other device(s)? This will end sessions on all other devices except this one."
410
+ "logoutOthers": "Are you sure you want to sign out from all {{count}} other device(s)? This will end sessions on all other devices except this one.",
411
+ "removeContextTitle": "Remove this account?",
412
+ "removeContext": "Stop {{person}} acting as {{account}} on this device? Anyone else who reaches {{account}} here keeps their access.",
413
+ "removePrincipalTitle": "Sign out of this device?",
414
+ "removePrincipal": "Sign {{name}} out of this device? Every account they reach here is removed. Nobody else is affected."
406
415
  },
407
416
  "device": {
408
417
  "loadingTitle": "Loading device sessions...",
@@ -439,6 +448,14 @@
439
448
  "openedInCommons": "Opened in Commons",
440
449
  "confirming": "Confirming identity",
441
450
  "confirmed": "Identity confirmed"
451
+ },
452
+ "context": {
453
+ "unavailable": "Unavailable right now",
454
+ "operatedBy": "Operated by {{name}}",
455
+ "remove": "Remove {{account}} from {{person}}"
456
+ },
457
+ "principal": {
458
+ "signOut": "Sign {{name}} out of this device"
442
459
  }
443
460
  },
444
461
  "reputation": {
@@ -636,13 +636,22 @@
636
636
  "remoteSignOutFailed": "Hubo un problema al cerrar sesión en el dispositivo. Inténtalo de nuevo.",
637
637
  "noOtherDeviceSessions": "No se encontraron otras sesiones de dispositivo para cerrar.",
638
638
  "signOutOthersSuccess": "¡Sesiones cerradas correctamente en los demás dispositivos!",
639
- "signOutOthersFailed": "Hubo un problema al cerrar sesión en otros dispositivos. Inténtalo de nuevo."
639
+ "signOutOthersFailed": "Hubo un problema al cerrar sesión en otros dispositivos. Inténtalo de nuevo.",
640
+ "contextRemoved": "Se quitó {{account}}",
641
+ "contextRemoveFailed": "Hubo un problema al quitar esa cuenta. Inténtalo de nuevo.",
642
+ "principalRemoved": "Se cerró la sesión de {{name}} en este dispositivo",
643
+ "principalRemoveFailed": "Hubo un problema al cerrar esa sesión. Inténtalo de nuevo.",
644
+ "activateFailed": "Hubo un problema al cambiar de cuenta. Inténtalo de nuevo."
640
645
  },
641
646
  "confirms": {
642
647
  "remove": "¿Seguro que quieres eliminar a {{displayName}} de este dispositivo? Tendrás que iniciar sesión de nuevo para acceder a esta cuenta.",
643
648
  "logoutAll": "¿Seguro que quieres cerrar sesión en todas las cuentas? Esto eliminará todas las cuentas guardadas de este dispositivo.",
644
649
  "remoteLogout": "¿Seguro que quieres cerrar sesión en \"{{deviceName}}\"? Esto finalizará la sesión en ese dispositivo.",
645
- "logoutOthers": "¿Seguro que quieres cerrar sesión en los otros {{count}} dispositivo(s)? Esto finalizará las sesiones en todos los demás dispositivos excepto en este."
650
+ "logoutOthers": "¿Seguro que quieres cerrar sesión en los otros {{count}} dispositivo(s)? Esto finalizará las sesiones en todos los demás dispositivos excepto en este.",
651
+ "removeContextTitle": "¿Quitar esta cuenta?",
652
+ "removeContext": "¿Dejar de usar {{account}} como {{person}} en este dispositivo? Quien más acceda a {{account}} aquí lo conserva.",
653
+ "removePrincipalTitle": "¿Cerrar sesión en este dispositivo?",
654
+ "removePrincipal": "¿Cerrar la sesión de {{name}} en este dispositivo? Se quitan todas las cuentas a las que llega desde aquí. No afecta a nadie más."
646
655
  },
647
656
  "device": {
648
657
  "loadingTitle": "Cargando sesiones de dispositivo...",
@@ -679,6 +688,14 @@
679
688
  "openedInCommons": "Abierto en Commons",
680
689
  "confirming": "Confirmando identidad",
681
690
  "confirmed": "Identidad confirmada"
691
+ },
692
+ "context": {
693
+ "unavailable": "No disponible ahora mismo",
694
+ "operatedBy": "Gestionada por {{name}}",
695
+ "remove": "Quitar {{account}} de {{person}}"
696
+ },
697
+ "principal": {
698
+ "signOut": "Cerrar la sesión de {{name}} en este dispositivo"
682
699
  }
683
700
  },
684
701
  "feedback": {
package/src/index.ts CHANGED
@@ -49,7 +49,11 @@ export type {
49
49
  CommonsDeliveryPlatform,
50
50
  CommonsDeliveryRoute,
51
51
  } from './utils/commonsDelivery';
52
- export type { ServiceTokenResponse, OAuthUserInfoResponse } from './mixins/OxyServices.auth';
52
+ export type {
53
+ ServiceTokenResponse,
54
+ OAuthUserInfoResponse,
55
+ OAuthTokenExchangeResult,
56
+ } from './mixins/OxyServices.auth';
53
57
  // "Sign in with Oxy" — handoff (Workstream C)
54
58
  export type {
55
59
  CommonsSignInHandle,
@@ -680,26 +684,55 @@ export {
680
684
  accountIdsOf,
681
685
  } from './session/projectSessionState';
682
686
 
683
- // Unified account-list projection (THE single source of truth for the account
684
- // chooser: device sign-ins account graph, deduped by accountId). Pure +
685
- // I/O-free the caller hydrates profiles via `getUsersByIds`. Shared by
686
- // `@oxyhq/services` and auth.oxy.so so the list can't diverge.
687
- // `isSwitchTargetAccount` is the structural half ("is this kind switchable at
688
- // all?"); `canSwitchIntoAccount` adds the caller's `account:act_as` permission.
689
- // Both are exported so surfaces that render `AccountNode`s rather than the
690
- // projection the Console workspace switcher, managed-accounts rows ask the
691
- // SAME questions instead of testing a kind literal.
687
+ // Pure projections over the device DIRECTORY (`GET /session/device/directory`,
688
+ // ADR 0002) the read model that keeps the actor (the human who authenticated)
689
+ // and the subject (the account being acted as) apart. The flat
690
+ // `DeviceSessionState` collapses them into one row, so it can neither tell
691
+ // "signed in as an org" from "a person operating that org" nor hold two people
692
+ // reaching the same org on one device.
693
+ // `canActivateContext` is the switchability question `available` alone, never
694
+ // composed with `onDevice`, which is a different fact in both directions.
695
+ // `projectDevicePrincipals` is the switcher's shape: people, each with what
696
+ // they may become. Grouped rather than flat because the same organization
697
+ // reached through two people is TWO rows under two humans, which a list keyed
698
+ // by account cannot say.
699
+ export {
700
+ canActivateContext,
701
+ directoryDisplayName,
702
+ directoryHandle,
703
+ projectDevicePrincipals,
704
+ resolveActiveContext,
705
+ resolveDeviceContext,
706
+ } from './session/deviceDirectory';
707
+ export type {
708
+ DeviceContext,
709
+ DeviceContextActor,
710
+ DeviceContextSubject,
711
+ DevicePrincipalGroup,
712
+ } from './session/deviceDirectory';
713
+
714
+ // The switcher's RENDER model over that projection — names, handles and avatar
715
+ // URLs resolved once. Shared by `@oxyhq/services`' account dialog and the
716
+ // auth.oxy.so chooser so the two cannot drift, the same reason the flat
717
+ // projection lived here before it.
718
+ export { buildSwitcherRows, showsPrincipalHeaders } from './session/deviceSwitcherRows';
719
+ export type {
720
+ ResolveAvatarUrl,
721
+ SwitcherContextRow,
722
+ SwitcherPrincipalRow,
723
+ } from './session/deviceSwitcherRows';
724
+
725
+ // The switch-target predicates over the account GRAPH — a list of accounts to
726
+ // manage, not the device's list of identities to become (that is the directory
727
+ // above). `isSwitchTargetAccount` is the structural half ("is this kind
728
+ // switchable at all?"); `canSwitchIntoAccount` adds the caller's
729
+ // `account:act_as` permission. Exported so the surfaces that render
730
+ // `AccountNode`s — the Console workspace switcher, managed-accounts rows — ask
731
+ // the SAME questions instead of testing a kind literal.
692
732
  export {
693
733
  isSwitchTargetAccount,
694
734
  canSwitchIntoAccount,
695
- projectSwitchableAccounts,
696
- switchableAccountIds,
697
- } from './session/accountProjection';
698
- export type {
699
- SwitchableAccount,
700
- SwitchableAccountUser,
701
- ProjectSwitchableAccountsInput,
702
- } from './session/accountProjection';
735
+ } from './session/accountSwitchTargets';
703
736
 
704
737
  // Headless controller for the unified account dialog. Framework-agnostic
705
738
  // state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
@@ -741,6 +774,30 @@ export type {
741
774
  NativeKeyValueStorage,
742
775
  } from './session/authStateStore';
743
776
 
777
+ // The shared NATIVE DeviceSession credential — how several official apps on one
778
+ // device end up on ONE `DeviceSession` and therefore one active context. It is an
779
+ // ordinary rotatable/revocable `deviceId` + `deviceSecret`, deliberately NOT the
780
+ // Commons private identity key: an app that only needs a session must never be
781
+ // handed the key that signs identity approvals.
782
+ export {
783
+ createSharedMirroringAuthStateStore,
784
+ decideSharedDeviceJoin,
785
+ decideSharedDevicePublish,
786
+ normalizeSharedDeviceSessionRead,
787
+ publishProvenDeviceCredential,
788
+ readLocalDeviceCredential,
789
+ } from './session/sharedDeviceCredential';
790
+ export type {
791
+ SharedDeviceCredential,
792
+ SharedDeviceCredentialRead,
793
+ SharedDeviceCredentialStore,
794
+ SharedDeviceJoinDecision,
795
+ SharedDeviceJoinSkipReason,
796
+ SharedDevicePublishDecision,
797
+ SharedDevicePublishOutcome,
798
+ SharedDevicePublishSkipReason,
799
+ } from './session/sharedDeviceCredential';
800
+
744
801
  // Identity-bound sessions (the identity vault). The pin is the durable
745
802
  // `{publicKey, accountId}` binding between this device's PRIMARY identity key
746
803
  // and the account it authenticates as; it is what keeps such a client from
@@ -7,7 +7,6 @@ import type { User } from '../models/interfaces';
7
7
  import type {
8
8
  UserNameResponse,
9
9
  LoginResult,
10
- LoginSessionResult,
11
10
  CommonsDenyReason,
12
11
  } from '@oxyhq/contracts';
13
12
  import { loginResultSchema, safeParseContract } from '@oxyhq/contracts';
@@ -74,6 +73,42 @@ export interface OAuthUserInfoResponse {
74
73
  picture?: string;
75
74
  }
76
75
 
76
+ /**
77
+ * The session an OAuth authorization-code exchange yields.
78
+ *
79
+ * Deliberately NOT `LoginSessionResult`. That type mirrors the API's
80
+ * `buildSessionAuthResponse`, which every FIRST-PARTY sign-in lane emits, and it
81
+ * requires `deviceId` because those lanes always join the origin's DeviceSession.
82
+ * `POST /auth/oauth/token` is the RFC 6749 token endpoint and serves third
83
+ * parties, whose grant is deliberately ISOLATED: an untrusted application must be
84
+ * able to receive a session carrying NO DeviceSession credential at all.
85
+ *
86
+ * Both device fields are therefore optional here, and a response omitting them is
87
+ * a well-formed device-less grant rather than a malformed payload. What that
88
+ * costs the session is spelled out on `exchangeOAuthCode` below.
89
+ */
90
+ export interface OAuthTokenExchangeResult {
91
+ sessionId: string;
92
+ /** ISO-8601 expiry of {@link accessToken}, derived from RFC 6749 `expires_in`. */
93
+ expiresAt: string;
94
+ accessToken?: string;
95
+ /**
96
+ * The DeviceSession this grant joined, when the server issued one. ABSENT for
97
+ * an isolated third-party grant — never assume a string.
98
+ */
99
+ deviceId?: string;
100
+ /**
101
+ * The zero-cookie mint credential for {@link deviceId}. Present only alongside
102
+ * it; absent for an isolated third-party grant.
103
+ */
104
+ deviceSecret?: string;
105
+ user: {
106
+ id: string;
107
+ username?: string;
108
+ avatar?: string;
109
+ };
110
+ }
111
+
77
112
  // ===========================================================================
78
113
  // "Sign in with Oxy" — cross-device QR / app-to-app handoff (Workstream C)
79
114
  // ===========================================================================
@@ -1699,13 +1734,30 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1699
1734
  * response this method used before were an Oxy invention no OAuth library
1700
1735
  * could interoperate with; the endpoint no longer accepts them. The method's
1701
1736
  * OWN signature is unchanged, so callers are unaffected.
1737
+ *
1738
+ * `deviceId` + `deviceSecret` are OPTIONAL and their absence is a valid
1739
+ * outcome, not an error. A third-party grant is meant to be isolated from the
1740
+ * browser's shared DeviceSession, so the token endpoint must be free to return
1741
+ * no device credential at all — the guard that used to require the pair made
1742
+ * that omission unshippable, since it turned every third-party sign-in through
1743
+ * the SDK into a silent `exchange-failed`.
1744
+ *
1745
+ * The cost is real and deliberate: a DEVICE-LESS session cannot use the
1746
+ * zero-cookie mint lane (`POST /session/device/token`), because that lane's
1747
+ * whole proof is possession of a `deviceSecret`. Its lifetime is therefore the
1748
+ * access token itself — nothing persists a restore credential, the cold boot's
1749
+ * `device-secret-mint` step reports `no-secret` and skips, and the refresh
1750
+ * scheduler has nothing to re-mint from. When the token expires the session
1751
+ * ends LOUDLY: the 401 lane clears the tokens and the provider resolves signed
1752
+ * out, so the app can run the OAuth flow again. It never degrades into a
1753
+ * session that looks alive and cannot refresh.
1702
1754
  */
1703
1755
  async exchangeOAuthCode(params: {
1704
1756
  code: string;
1705
1757
  clientId: string;
1706
1758
  redirectUri: string;
1707
1759
  codeVerifier: string;
1708
- }): Promise<LoginSessionResult> {
1760
+ }): Promise<OAuthTokenExchangeResult> {
1709
1761
  try {
1710
1762
  const form = new URLSearchParams({
1711
1763
  grant_type: 'authorization_code',
@@ -1730,7 +1782,9 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1730
1782
  const deviceId = typeof record.deviceId === 'string' ? record.deviceId : undefined;
1731
1783
  const deviceSecret = typeof record.deviceSecret === 'string' ? record.deviceSecret : undefined;
1732
1784
  const userRaw = record.user;
1733
- if (!sessionId || !deviceId || !deviceSecret || !userRaw || typeof userRaw !== 'object') {
1785
+ // The device pair is NOT part of this guard see the note above. What is
1786
+ // still mandatory is what identifies the session at all.
1787
+ if (!sessionId || !userRaw || typeof userRaw !== 'object') {
1734
1788
  throw new Error('auth/oauth/token returned an incomplete session payload');
1735
1789
  }
1736
1790
  const userObj = userRaw as Record<string, unknown>;
@@ -1744,12 +1798,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1744
1798
  if (accessToken) {
1745
1799
  this.setTokens(accessToken);
1746
1800
  }
1801
+ if (!deviceId || !deviceSecret) {
1802
+ logger.debug(
1803
+ 'auth/oauth/token returned no device credential — this session lives only as long as its access token',
1804
+ { component: 'oxy.auth', method: 'exchangeOAuthCode' },
1805
+ );
1806
+ }
1747
1807
  return {
1748
1808
  sessionId,
1749
- deviceId,
1750
1809
  expiresAt,
1751
1810
  accessToken,
1752
- deviceSecret,
1811
+ // Omitted rather than set to `undefined` when the server sent no device
1812
+ // credential, so a device-less grant serializes as the absence it is.
1813
+ ...(deviceId ? { deviceId } : {}),
1814
+ ...(deviceSecret ? { deviceSecret } : {}),
1753
1815
  user: {
1754
1816
  id: userId,
1755
1817
  username: typeof userObj.username === 'string' ? userObj.username : undefined,
@@ -158,7 +158,40 @@ describe('pre-session public endpoints use skipAuth', () => {
158
158
  });
159
159
  });
160
160
 
161
- it('exchangeOAuthCode rejects a response without deviceSecret', async () => {
161
+ // A third-party grant is meant to be ISOLATED from the browser's shared
162
+ // DeviceSession, so the token endpoint must be free to return no device
163
+ // credential. Core used to require the pair, which made that omission
164
+ // unshippable: every third-party sign-in through the SDK would have collapsed
165
+ // into a silent `exchange-failed` (issue #954).
166
+ it('exchangeOAuthCode accepts a device-less grant and still plants the token', async () => {
167
+ makeRequest.mockResolvedValueOnce({
168
+ access_token: 'tok',
169
+ token_type: 'Bearer',
170
+ expires_in: 900,
171
+ session_id: 's1',
172
+ user: { id: 'u1', username: 'alice' },
173
+ });
174
+
175
+ const result = await oxy.exchangeOAuthCode({
176
+ code: 'code-1',
177
+ clientId: 'oxy_dk_test',
178
+ redirectUri: 'https://app.example/callback',
179
+ codeVerifier: 'verifier',
180
+ });
181
+
182
+ expect(result).toMatchObject({
183
+ sessionId: 's1',
184
+ accessToken: 'tok',
185
+ user: { id: 'u1', username: 'alice' },
186
+ });
187
+ // Absent, not `undefined`-valued: a device-less grant carries no device keys
188
+ // at all, so nothing downstream can read one and persist an empty credential.
189
+ expect(result).not.toHaveProperty('deviceId');
190
+ expect(result).not.toHaveProperty('deviceSecret');
191
+ expect(oxy.getAccessToken()).toBe('tok');
192
+ });
193
+
194
+ it('exchangeOAuthCode accepts a deviceId with no deviceSecret', async () => {
162
195
  makeRequest.mockResolvedValueOnce({
163
196
  access_token: 'tok',
164
197
  token_type: 'Bearer',
@@ -167,6 +200,26 @@ describe('pre-session public endpoints use skipAuth', () => {
167
200
  deviceId: 'd1',
168
201
  user: { id: 'u1' },
169
202
  });
203
+
204
+ const result = await oxy.exchangeOAuthCode({
205
+ code: 'code-1',
206
+ clientId: 'oxy_dk_test',
207
+ redirectUri: 'https://app.example/callback',
208
+ codeVerifier: 'verifier',
209
+ });
210
+
211
+ expect(result).toMatchObject({ sessionId: 's1', deviceId: 'd1' });
212
+ expect(result).not.toHaveProperty('deviceSecret');
213
+ });
214
+
215
+ // The device pair left the guard; what identifies the session did NOT. Without
216
+ // these two the whole guard could be deleted and every test above would stay
217
+ // green — `sessionId` would silently become `undefined` on the returned session.
218
+ it.each([
219
+ ['session_id', { access_token: 'tok', expires_in: 900, user: { id: 'u1' } }],
220
+ ['user', { access_token: 'tok', expires_in: 900, session_id: 's1' }],
221
+ ])('exchangeOAuthCode still rejects a response missing %s', async (_field, response) => {
222
+ makeRequest.mockResolvedValueOnce(response);
170
223
  await expect(
171
224
  oxy.exchangeOAuthCode({
172
225
  code: 'code-1',
@@ -14,6 +14,17 @@ export interface ClientSession {
14
14
  * account-chooser ordering, not for any token-refresh mechanism.
15
15
  */
16
16
  authuser?: number;
17
+ /**
18
+ * The HUMAN operating this account, when it is a delegated session — the
19
+ * audit actor behind "The Oxy Collective". Absent when the session belongs to
20
+ * the account itself.
21
+ *
22
+ * The flat wire shape has carried it since the multi-account model shipped and
23
+ * nothing read it, so an operated org rendered exactly like a directly
24
+ * signed-in one. `SessionClient.getActiveContext()` is the richer answer
25
+ * (ADR 0002); this is the same fact on the compatibility lane.
26
+ */
27
+ operatedByUserId?: string;
17
28
  }
18
29
 
19
30
  export interface StorageKeys {