@oxyhq/core 12.10.6 → 12.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 (126) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  3. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  4. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  5. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  6. package/dist/cjs/i18n/locales/en-US.json +13 -4
  7. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  8. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  9. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  10. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  11. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  23. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  24. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  25. package/dist/cjs/index.js +31 -5
  26. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  27. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  28. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  29. package/dist/cjs/mixins/index.js +5 -0
  30. package/dist/cjs/session/SessionClient.js +40 -4
  31. package/dist/cjs/session/accountDialogController.js +288 -41
  32. package/dist/cjs/session/identityPin.js +256 -0
  33. package/dist/cjs/session/identitySession.js +135 -0
  34. package/dist/cjs/session/projectSessionState.js +54 -15
  35. package/dist/cjs/session/refresh.js +73 -8
  36. package/dist/cjs/utils/commonsDelivery.js +59 -0
  37. package/dist/cjs/utils/oauthPkce.js +33 -3
  38. package/dist/esm/.tsbuildinfo +1 -1
  39. package/dist/esm/boot/sessionColdBoot.js +142 -37
  40. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  41. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  42. package/dist/esm/i18n/locales/de-DE.json +13 -1
  43. package/dist/esm/i18n/locales/en-US.json +13 -4
  44. package/dist/esm/i18n/locales/es-ES.json +13 -4
  45. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  46. package/dist/esm/i18n/locales/it-IT.json +13 -1
  47. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  48. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  49. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  50. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  51. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  52. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  53. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  54. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  55. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  56. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  57. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  58. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  59. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  60. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  61. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  62. package/dist/esm/index.js +14 -1
  63. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  64. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  65. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  66. package/dist/esm/mixins/index.js +5 -0
  67. package/dist/esm/session/SessionClient.js +40 -4
  68. package/dist/esm/session/accountDialogController.js +288 -41
  69. package/dist/esm/session/identityPin.js +249 -0
  70. package/dist/esm/session/identitySession.js +131 -0
  71. package/dist/esm/session/projectSessionState.js +54 -15
  72. package/dist/esm/session/refresh.js +73 -8
  73. package/dist/esm/utils/commonsDelivery.js +54 -0
  74. package/dist/esm/utils/oauthPkce.js +31 -2
  75. package/dist/types/.tsbuildinfo +1 -1
  76. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  77. package/dist/types/index.d.ts +12 -4
  78. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  79. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  80. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  81. package/dist/types/mixins/index.d.ts +2 -1
  82. package/dist/types/session/SessionClient.d.ts +28 -0
  83. package/dist/types/session/accountDialogController.d.ts +187 -9
  84. package/dist/types/session/identityPin.d.ts +87 -0
  85. package/dist/types/session/identitySession.d.ts +105 -0
  86. package/dist/types/session/projectSessionState.d.ts +21 -19
  87. package/dist/types/session/refresh.d.ts +32 -1
  88. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  89. package/dist/types/utils/oauthPkce.d.ts +21 -1
  90. package/package.json +2 -2
  91. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  92. package/src/boot/sessionColdBoot.ts +183 -37
  93. package/src/i18n/locales/ar-SA.json +13 -1
  94. package/src/i18n/locales/ca-ES.json +13 -1
  95. package/src/i18n/locales/de-DE.json +13 -1
  96. package/src/i18n/locales/en-US.json +13 -4
  97. package/src/i18n/locales/es-ES.json +13 -4
  98. package/src/i18n/locales/fr-FR.json +13 -1
  99. package/src/i18n/locales/it-IT.json +13 -1
  100. package/src/i18n/locales/ja-JP.json +13 -1
  101. package/src/i18n/locales/ko-KR.json +13 -1
  102. package/src/i18n/locales/pt-PT.json +13 -1
  103. package/src/i18n/locales/zh-CN.json +13 -1
  104. package/src/index.ts +49 -0
  105. package/src/mixins/OxyServices.auth.ts +464 -24
  106. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  107. package/src/mixins/OxyServices.notifications.ts +142 -0
  108. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  109. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  110. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  111. package/src/mixins/index.ts +6 -0
  112. package/src/session/SessionClient.ts +57 -4
  113. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  114. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  115. package/src/session/__tests__/identityPin.test.ts +131 -0
  116. package/src/session/__tests__/identitySession.test.ts +194 -0
  117. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  118. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  119. package/src/session/accountDialogController.ts +435 -44
  120. package/src/session/identityPin.ts +310 -0
  121. package/src/session/identitySession.ts +206 -0
  122. package/src/session/projectSessionState.ts +61 -13
  123. package/src/session/refresh.ts +103 -9
  124. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  125. package/src/utils/commonsDelivery.ts +132 -0
  126. package/src/utils/oauthPkce.ts +56 -3
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `mintFromDeviceSecret` — the pinned (identity-bound) mint.
3
+ *
4
+ * The pinned form adds `accountId` to the zero-cookie mint body so an identity
5
+ * vault gets ITS account's token without the device's `activeAccountId` being
6
+ * touched. Its one new failure mode — `401 account_not_on_device` — must be
7
+ * distinguishable from a bad device secret, because the remedies are opposite:
8
+ * a bad secret is dropped, a stale identity binding is re-established while the
9
+ * (healthy) secret is kept.
10
+ */
11
+ import { OxyServices } from '../../OxyServices';
12
+ import { AccountNotOnDeviceError } from '../OxyServices.deviceBoot';
13
+
14
+ const MINT_RESPONSE = {
15
+ accessToken: 'access-1',
16
+ expiresAt: '2030-01-01T00:00:00.000Z',
17
+ nextDeviceSecret: 'ds-next',
18
+ state: {
19
+ deviceId: 'dev-1',
20
+ accounts: [{ accountId: 'vault-user', sessionId: 'sess-vault', authuser: 0 }],
21
+ activeAccountId: 'other-user',
22
+ revision: 3,
23
+ updatedAt: 1_700_000_000_000,
24
+ },
25
+ };
26
+
27
+ describe('OxyServices.mintFromDeviceSecret', () => {
28
+ let oxy: OxyServices;
29
+ let makeRequestSpy: jest.SpyInstance;
30
+
31
+ beforeEach(() => {
32
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
33
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
34
+ });
35
+
36
+ afterEach(() => {
37
+ jest.restoreAllMocks();
38
+ });
39
+
40
+ it('omits accountId from the body when unpinned', async () => {
41
+ makeRequestSpy.mockResolvedValue(MINT_RESPONSE);
42
+
43
+ await oxy.mintFromDeviceSecret('dev-1', 'ds-1');
44
+
45
+ expect(makeRequestSpy).toHaveBeenCalledWith(
46
+ 'POST',
47
+ '/session/device/token',
48
+ { deviceId: 'dev-1', deviceSecret: 'ds-1' },
49
+ expect.objectContaining({ cache: false, skipAuth: true, retry: false, bypassQueue: true }),
50
+ );
51
+ });
52
+
53
+ it('sends accountId when pinned', async () => {
54
+ makeRequestSpy.mockResolvedValue(MINT_RESPONSE);
55
+
56
+ const mint = await oxy.mintFromDeviceSecret('dev-1', 'ds-1', { accountId: 'vault-user' });
57
+
58
+ expect(makeRequestSpy).toHaveBeenCalledWith(
59
+ 'POST',
60
+ '/session/device/token',
61
+ { deviceId: 'dev-1', deviceSecret: 'ds-1', accountId: 'vault-user' },
62
+ expect.anything(),
63
+ );
64
+ // The response still reports the device's TRUE active account — a pinned
65
+ // mint never mutates it.
66
+ expect(mint.state.activeAccountId).toBe('other-user');
67
+ });
68
+
69
+ it('maps a 401 account_not_on_device to the typed AccountNotOnDeviceError', async () => {
70
+ makeRequestSpy.mockRejectedValue(
71
+ Object.assign(new Error('account_not_on_device'), { status: 401, code: 'UNAUTHORIZED' }),
72
+ );
73
+
74
+ const error = await oxy
75
+ .mintFromDeviceSecret('dev-1', 'ds-1', { accountId: 'vault-user' })
76
+ .then(
77
+ () => null,
78
+ (thrown: unknown) => thrown,
79
+ );
80
+
81
+ expect(error).toBeInstanceOf(AccountNotOnDeviceError);
82
+ expect(error).toMatchObject({ name: 'AccountNotOnDeviceError', accountId: 'vault-user', status: 401 });
83
+ });
84
+
85
+ it('leaves every OTHER 401 as an ordinary error (a bad secret is not an identity problem)', async () => {
86
+ makeRequestSpy.mockRejectedValue(
87
+ Object.assign(new Error('invalid_device_secret'), { status: 401, code: 'UNAUTHORIZED' }),
88
+ );
89
+
90
+ const error = await oxy
91
+ .mintFromDeviceSecret('dev-1', 'ds-1', { accountId: 'vault-user' })
92
+ .then(
93
+ () => null,
94
+ (thrown: unknown) => thrown,
95
+ );
96
+
97
+ expect(error).not.toBeInstanceOf(AccountNotOnDeviceError);
98
+ expect(error).toMatchObject({ message: 'invalid_device_secret', status: 401 });
99
+ });
100
+
101
+ it('does not classify an UNPINNED mint failure as account_not_on_device', async () => {
102
+ makeRequestSpy.mockRejectedValue(
103
+ Object.assign(new Error('account_not_on_device'), { status: 401, code: 'UNAUTHORIZED' }),
104
+ );
105
+
106
+ const error = await oxy.mintFromDeviceSecret('dev-1', 'ds-1').then(
107
+ () => null,
108
+ (thrown: unknown) => thrown,
109
+ );
110
+
111
+ expect(error).not.toBeInstanceOf(AccountNotOnDeviceError);
112
+ });
113
+ });
@@ -128,6 +128,7 @@ describe('pre-session public endpoints use skipAuth', () => {
128
128
  makeRequest.mockResolvedValueOnce({
129
129
  sessionId: 's1',
130
130
  deviceId: 'd1',
131
+ deviceSecret: 'ds_secret',
131
132
  accessToken: 'tok',
132
133
  user: { id: 'u1' },
133
134
  });
@@ -149,4 +150,21 @@ describe('pre-session public endpoints use skipAuth', () => {
149
150
  expect.objectContaining({ skipAuth: true }),
150
151
  );
151
152
  });
153
+
154
+ it('exchangeOAuthCode rejects a response without deviceSecret', async () => {
155
+ makeRequest.mockResolvedValueOnce({
156
+ sessionId: 's1',
157
+ deviceId: 'd1',
158
+ accessToken: 'tok',
159
+ user: { id: 'u1' },
160
+ });
161
+ await expect(
162
+ oxy.exchangeOAuthCode({
163
+ code: 'code-1',
164
+ clientId: 'oxy_dk_test',
165
+ redirectUri: 'https://app.example/callback',
166
+ codeVerifier: 'verifier',
167
+ }),
168
+ ).rejects.toThrow(/incomplete session payload/i);
169
+ });
152
170
  });
@@ -25,6 +25,7 @@ import { OxyServicesUtilityMixin } from './OxyServices.utility';
25
25
  import { OxyServicesFeaturesMixin } from './OxyServices.features';
26
26
  import { OxyServicesTopicsMixin } from './OxyServices.topics';
27
27
  import { OxyServicesContactsMixin } from './OxyServices.contacts';
28
+ import { OxyServicesNotificationsMixin } from './OxyServices.notifications';
28
29
  import { OxyServicesAppDataMixin } from './OxyServices.appData';
29
30
  import { OxyServicesCivicMixin } from './OxyServices.civic';
30
31
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
@@ -60,6 +61,7 @@ type AllMixinInstances =
60
61
  & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>>
61
62
  & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>>
62
63
  & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>>
64
+ & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>>
63
65
  & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
64
66
  & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
65
67
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
@@ -126,6 +128,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
126
128
  OxyServicesFeaturesMixin,
127
129
  OxyServicesTopicsMixin,
128
130
  OxyServicesContactsMixin,
131
+ // Push-token registration: the one SDK-owned register/unregister pair every
132
+ // Oxy app uses, and what lets a "Sign in with Oxy" request be delivered to a
133
+ // known Commons installation instead of falling back to a QR.
134
+ OxyServicesNotificationsMixin,
129
135
  OxyServicesAppDataMixin,
130
136
  // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
131
137
  OxyServicesCivicMixin,
@@ -79,6 +79,22 @@ export interface SessionClientOptions {
79
79
  * absent it falls back to `getSocketIO()`.
80
80
  */
81
81
  socketFactory?: SocketIOFactory;
82
+ /**
83
+ * The PINNED account id for an IDENTITY-BOUND client (the identity vault),
84
+ * or `null` for every ordinary account-mode client. Read as a function because
85
+ * the pin is resolved asynchronously at boot and can move when the identity
86
+ * session is re-established.
87
+ *
88
+ * While pinned this client tracks device state TRUTHFULLY — other apps'
89
+ * accounts and the device's real `activeAccountId` stay visible in `getState()`
90
+ * — but it NEVER re-binds its bearer to that active account: no `activeToken`
91
+ * is planted for a non-pinned account (from a sync response or a pushed
92
+ * `session_state`), the `TokenTransport` (whose entire job is converging on
93
+ * `activeAccountId`) is bypassed, and a push that switches the device does not
94
+ * trigger a token re-fetch. The pinned token's lifecycle belongs solely to the
95
+ * cold boot / re-mint lane, which mints it with an explicit `accountId`.
96
+ */
97
+ getPinnedAccountId?: () => string | null;
82
98
  }
83
99
 
84
100
  type StateListener = (state: DeviceSessionState | null) => void;
@@ -122,6 +138,16 @@ export class SessionClient {
122
138
  return this.state;
123
139
  }
124
140
 
141
+ /**
142
+ * The account this client's bearer is pinned to, or `null` when it follows the
143
+ * device's active account (the default). Resolvers are expected to be a plain
144
+ * synchronous read of already-resolved state (see
145
+ * {@link SessionClientOptions.getPinnedAccountId}).
146
+ */
147
+ private pinnedAccountId(): string | null {
148
+ return this.options.getPinnedAccountId?.() ?? null;
149
+ }
150
+
125
151
  subscribe(listener: StateListener): () => void {
126
152
  this.listeners.add(listener);
127
153
  return () => {
@@ -212,15 +238,28 @@ export class SessionClient {
212
238
  }
213
239
  const previousState = this.state;
214
240
  this.state = next;
241
+ const pinnedAccountId = this.pinnedAccountId();
215
242
  // Plant the sync-supplied active token (it is for `next.activeAccountId`)
216
243
  // now — before the notify below — so the bearer matches the new active
217
244
  // account when subscribers observe it. Guarded on difference to avoid a
218
245
  // redundant token-change notification on an unchanged token (bootstrap
219
- // restate).
220
- if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
246
+ // restate). An identity-bound client only accepts it when the active account
247
+ // IS its pinned account otherwise the token belongs to somebody else's
248
+ // switch and must never displace the pinned bearer.
249
+ if (
250
+ activeToken &&
251
+ next.activeAccountId !== null &&
252
+ (pinnedAccountId === null || next.activeAccountId === pinnedAccountId) &&
253
+ activeToken !== this.host.getAccessToken()
254
+ ) {
221
255
  this.host.setTokens(activeToken);
222
256
  }
223
- const transport = this.options.transport;
257
+ // The transport's entire job is converging the bearer on `activeAccountId`,
258
+ // which is precisely what an identity-bound client must not do: its token is
259
+ // minted for the pinned account by the cold boot / re-mint lane. Bypass it
260
+ // while pinned (also removing the mint-before-notify gate, which exists only
261
+ // to keep the bearer and the ACTIVE account in step).
262
+ const transport = pinnedAccountId === null ? this.options.transport : null;
224
263
  const activeAccountId = next.activeAccountId;
225
264
  // Mint before notifying when the bearer does not already belong to the new
226
265
  // active account: no bearer at all, an opaque bearer, OR a bearer for a
@@ -296,13 +335,16 @@ export class SessionClient {
296
335
  // socket push): `applyState` no-ops without planting, but the token still
297
336
  // needs planting. Guard on the sync's active account STILL being the current
298
337
  // active account so a stale response cannot adopt a token for an account a
299
- // newer state already switched away from.
338
+ // newer state already switched away from — and, when pinned, on that account
339
+ // being the PINNED one (same rule as the applied path in `applyState`).
340
+ const pinnedAccountId = this.pinnedAccountId();
300
341
  if (
301
342
  !applied &&
302
343
  sync.activeToken &&
303
344
  this.state &&
304
345
  sync.state.activeAccountId !== null &&
305
346
  sync.state.activeAccountId === this.state.activeAccountId &&
347
+ (pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
306
348
  sync.activeToken.accessToken !== this.host.getAccessToken()
307
349
  ) {
308
350
  this.host.setTokens(sync.activeToken.accessToken);
@@ -342,12 +384,18 @@ export class SessionClient {
342
384
  * sign-in. This adds, then switches to the target so the UI lands on the
343
385
  * account the user just authenticated.
344
386
  *
387
+ * An identity-bound (pinned) client only ADDS: its own session is minted for
388
+ * the pinned account explicitly, so switching the device would gratuitously
389
+ * re-elect the active account under every OTHER app on this device — a
390
+ * mutation a pinned client must never make.
391
+ *
345
392
  * @param accountId - The signed-in account id (e.g. `session.user.id`). When
346
393
  * omitted, falls back to the host's current-account ref. If neither
347
394
  * resolves, the add still applies and no switch is performed.
348
395
  */
349
396
  async registerAndActivate(accountId?: string): Promise<void> {
350
397
  await this.addCurrentAccount();
398
+ if (this.pinnedAccountId() !== null) return;
351
399
  const target = accountId ?? this.host.getCurrentAccountId();
352
400
  if (target && this.state?.activeAccountId !== target) {
353
401
  await this.switchAccount(target);
@@ -449,6 +497,11 @@ export class SessionClient {
449
497
  // empty state here must not erase the durable device credential.
450
498
  const applied = this.applyState(payload, 'push');
451
499
  if (!applied) return;
500
+ // An identity-bound client tracks the pushed state but NEVER chases the
501
+ // device's active account: re-fetching here would only pull an
502
+ // `activeToken` for somebody else's switch, which the plant guards would
503
+ // then discard. Skip the round-trip entirely.
504
+ if (this.pinnedAccountId() !== null) return;
452
505
  // A push changed the active account on another device/tab — re-fetch state
453
506
  // to plant the access token for the newly-active account. When this tab is
454
507
  // still signed out, applyState mints via ensureActiveToken first; bootstrap
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `SessionClient` under an IDENTITY pin.
3
+ *
4
+ * The bug this guards: an account switch performed by ANOTHER app on the same
5
+ * device arrives as a `session_state` push (or as an `activeToken` on a sync
6
+ * response) and silently re-bound the identity vault's bearer to that account.
7
+ *
8
+ * The contract: device state is still tracked TRUTHFULLY (other apps' accounts
9
+ * and the real `activeAccountId` remain visible in `getState()`), but the bearer
10
+ * NEVER follows it. The pinned token's lifecycle belongs to the cold boot /
11
+ * re-mint lane, which mints it with an explicit `accountId`.
12
+ */
13
+ import type { DeviceSessionState } from '@oxyhq/contracts';
14
+ import { SessionClient, type SessionClientHost, type TokenTransport } from '../SessionClient';
15
+
16
+ const PINNED = 'vault-user';
17
+ const OTHER = 'other-user';
18
+
19
+ /** A minimal jwt-decode-able token whose `userId` claim is `accountId`. */
20
+ function jwtFor(accountId: string): string {
21
+ const payload = Buffer.from(JSON.stringify({ userId: accountId })).toString('base64url');
22
+ return `h.${payload}.s`;
23
+ }
24
+
25
+ const stateWith = (revision: number, active: string): DeviceSessionState => ({
26
+ deviceId: 'dev-1',
27
+ accounts: [
28
+ { accountId: PINNED, sessionId: 'sess-vault', authuser: 0 },
29
+ { accountId: OTHER, sessionId: 'sess-other', authuser: 1 },
30
+ ],
31
+ activeAccountId: active,
32
+ revision,
33
+ updatedAt: 1_720_000_000_000,
34
+ });
35
+
36
+ function makeHost(initialToken: string | null): SessionClientHost & { planted: () => string | null } {
37
+ let planted = initialToken;
38
+ return {
39
+ makeRequest: jest.fn(),
40
+ getBaseURL: () => 'http://test.invalid',
41
+ getAccessToken: () => planted,
42
+ getDeviceCredential: () => null,
43
+ onTokensChanged: () => () => undefined,
44
+ setTokens: (token: string) => {
45
+ planted = token;
46
+ },
47
+ getCurrentAccountId: () => PINNED,
48
+ planted: () => planted,
49
+ };
50
+ }
51
+
52
+ /** `applyState` is protected; a tiny subclass exposes it for the unit tests. */
53
+ class TestClient extends SessionClient {
54
+ public apply(raw: unknown, origin?: 'request' | 'push', activeToken?: string): boolean {
55
+ return this.applyState(raw, origin, activeToken);
56
+ }
57
+ }
58
+
59
+ describe('SessionClient — pinned bearer', () => {
60
+ // Node's real `BroadcastChannel` (which `postCommitPing` opens) is ref'd and
61
+ // would keep the Jest event loop alive forever. Feature-detect it away — the
62
+ // cross-tab wake has its own suite.
63
+ const realBroadcastChannel = (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel;
64
+ beforeAll(() => {
65
+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = undefined;
66
+ });
67
+ afterAll(() => {
68
+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = realBroadcastChannel;
69
+ });
70
+
71
+ it('tracks a pushed switch in state but does NOT change the planted token', () => {
72
+ const host = makeHost(jwtFor(PINNED));
73
+ const client = new TestClient(host, { getPinnedAccountId: () => PINNED });
74
+
75
+ expect(client.apply(stateWith(1, PINNED), 'push')).toBe(true);
76
+ // Another app switched the device to `other-user`.
77
+ expect(client.apply(stateWith(2, OTHER), 'push')).toBe(true);
78
+
79
+ // The state is truthful...
80
+ expect(client.getState()?.activeAccountId).toBe(OTHER);
81
+ // ...and the bearer is untouched.
82
+ expect(host.planted()).toBe(jwtFor(PINNED));
83
+ });
84
+
85
+ it('does NOT plant a sync-supplied activeToken for a non-pinned account', () => {
86
+ const host = makeHost(jwtFor(PINNED));
87
+ const client = new TestClient(host, { getPinnedAccountId: () => PINNED });
88
+
89
+ client.apply(stateWith(3, OTHER), 'request', jwtFor(OTHER));
90
+
91
+ expect(host.planted()).toBe(jwtFor(PINNED));
92
+ });
93
+
94
+ it('DOES plant a sync-supplied activeToken when the active account IS the pinned one', () => {
95
+ const host = makeHost(null);
96
+ const client = new TestClient(host, { getPinnedAccountId: () => PINNED });
97
+
98
+ client.apply(stateWith(4, PINNED), 'request', jwtFor(PINNED));
99
+
100
+ expect(host.planted()).toBe(jwtFor(PINNED));
101
+ });
102
+
103
+ it('never invokes the TokenTransport (whose job is converging on activeAccountId)', () => {
104
+ const host = makeHost(jwtFor(PINNED));
105
+ const transport: TokenTransport = { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
106
+ const client = new TestClient(host, { transport, getPinnedAccountId: () => PINNED });
107
+
108
+ client.apply(stateWith(5, OTHER), 'push');
109
+
110
+ expect(transport.ensureActiveToken).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('notifies subscribers synchronously on a foreign switch (no mint-before-notify gate)', () => {
114
+ const host = makeHost(jwtFor(PINNED));
115
+ const transport: TokenTransport = { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
116
+ const client = new TestClient(host, { transport, getPinnedAccountId: () => PINNED });
117
+
118
+ const seen: Array<string | null> = [];
119
+ client.subscribe((state) => seen.push(state?.activeAccountId ?? null));
120
+
121
+ client.apply(stateWith(6, OTHER), 'push');
122
+
123
+ expect(seen).toEqual([OTHER]);
124
+ });
125
+
126
+ it('account mode is unchanged: an activeToken for the new active account IS planted', () => {
127
+ const host = makeHost(jwtFor(PINNED));
128
+ const client = new TestClient(host);
129
+
130
+ client.apply(stateWith(7, OTHER), 'request', jwtFor(OTHER));
131
+
132
+ expect(host.planted()).toBe(jwtFor(OTHER));
133
+ });
134
+
135
+ it('registerAndActivate only ADDS while pinned (never re-elects the device active account)', async () => {
136
+ const host = makeHost(jwtFor(PINNED));
137
+ const makeRequest = jest.fn().mockResolvedValue({
138
+ state: stateWith(8, OTHER),
139
+ activeToken: null,
140
+ });
141
+ const client = new SessionClient(
142
+ { ...host, makeRequest },
143
+ { getPinnedAccountId: () => PINNED },
144
+ );
145
+
146
+ await client.registerAndActivate(PINNED);
147
+
148
+ expect(makeRequest).toHaveBeenCalledTimes(1);
149
+ expect(makeRequest).toHaveBeenCalledWith('POST', '/session/device/add', undefined, { cache: false });
150
+ expect(makeRequest).not.toHaveBeenCalledWith(
151
+ 'POST',
152
+ '/session/device/switch',
153
+ expect.anything(),
154
+ expect.anything(),
155
+ );
156
+ });
157
+
158
+ it('account mode is unchanged: registerAndActivate still switches to the target', async () => {
159
+ const host = makeHost(jwtFor(PINNED));
160
+ const makeRequest = jest.fn().mockResolvedValue({
161
+ state: stateWith(9, OTHER),
162
+ activeToken: null,
163
+ });
164
+ const client = new SessionClient({ ...host, makeRequest });
165
+
166
+ await client.registerAndActivate(PINNED);
167
+
168
+ expect(makeRequest).toHaveBeenCalledWith(
169
+ 'POST',
170
+ '/session/device/switch',
171
+ { accountId: PINNED },
172
+ { cache: false },
173
+ );
174
+ });
175
+ });