@oxyhq/core 12.10.5 → 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 (130) 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/hubSync.js +1 -1
  33. package/dist/cjs/session/identityPin.js +256 -0
  34. package/dist/cjs/session/identitySession.js +135 -0
  35. package/dist/cjs/session/projectSessionState.js +54 -15
  36. package/dist/cjs/session/refresh.js +73 -8
  37. package/dist/cjs/utils/commonsDelivery.js +59 -0
  38. package/dist/cjs/utils/oauthPkce.js +33 -3
  39. package/dist/esm/.tsbuildinfo +1 -1
  40. package/dist/esm/boot/sessionColdBoot.js +142 -37
  41. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  42. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  43. package/dist/esm/i18n/locales/de-DE.json +13 -1
  44. package/dist/esm/i18n/locales/en-US.json +13 -4
  45. package/dist/esm/i18n/locales/es-ES.json +13 -4
  46. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  47. package/dist/esm/i18n/locales/it-IT.json +13 -1
  48. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  49. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  50. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  51. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  52. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  53. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  54. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  55. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  56. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  57. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  58. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  61. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  62. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  63. package/dist/esm/index.js +14 -1
  64. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  65. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  66. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  67. package/dist/esm/mixins/index.js +5 -0
  68. package/dist/esm/session/SessionClient.js +40 -4
  69. package/dist/esm/session/accountDialogController.js +288 -41
  70. package/dist/esm/session/hubSync.js +1 -1
  71. package/dist/esm/session/identityPin.js +249 -0
  72. package/dist/esm/session/identitySession.js +131 -0
  73. package/dist/esm/session/projectSessionState.js +54 -15
  74. package/dist/esm/session/refresh.js +73 -8
  75. package/dist/esm/utils/commonsDelivery.js +54 -0
  76. package/dist/esm/utils/oauthPkce.js +31 -2
  77. package/dist/types/.tsbuildinfo +1 -1
  78. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  79. package/dist/types/index.d.ts +12 -4
  80. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  81. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  82. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  83. package/dist/types/mixins/index.d.ts +2 -1
  84. package/dist/types/session/SessionClient.d.ts +28 -0
  85. package/dist/types/session/accountDialogController.d.ts +187 -9
  86. package/dist/types/session/identityPin.d.ts +87 -0
  87. package/dist/types/session/identitySession.d.ts +105 -0
  88. package/dist/types/session/projectSessionState.d.ts +21 -19
  89. package/dist/types/session/refresh.d.ts +32 -1
  90. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  91. package/dist/types/utils/oauthPkce.d.ts +21 -1
  92. package/package.json +2 -2
  93. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  94. package/src/boot/sessionColdBoot.ts +183 -37
  95. package/src/i18n/locales/ar-SA.json +13 -1
  96. package/src/i18n/locales/ca-ES.json +13 -1
  97. package/src/i18n/locales/de-DE.json +13 -1
  98. package/src/i18n/locales/en-US.json +13 -4
  99. package/src/i18n/locales/es-ES.json +13 -4
  100. package/src/i18n/locales/fr-FR.json +13 -1
  101. package/src/i18n/locales/it-IT.json +13 -1
  102. package/src/i18n/locales/ja-JP.json +13 -1
  103. package/src/i18n/locales/ko-KR.json +13 -1
  104. package/src/i18n/locales/pt-PT.json +13 -1
  105. package/src/i18n/locales/zh-CN.json +13 -1
  106. package/src/index.ts +49 -0
  107. package/src/mixins/OxyServices.auth.ts +464 -24
  108. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  109. package/src/mixins/OxyServices.notifications.ts +142 -0
  110. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  111. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  112. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  113. package/src/mixins/index.ts +6 -0
  114. package/src/session/SessionClient.ts +57 -4
  115. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  116. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  117. package/src/session/__tests__/hubSync.test.ts +51 -0
  118. package/src/session/__tests__/identityPin.test.ts +131 -0
  119. package/src/session/__tests__/identitySession.test.ts +194 -0
  120. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  121. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  122. package/src/session/accountDialogController.ts +435 -44
  123. package/src/session/hubSync.ts +1 -1
  124. package/src/session/identityPin.ts +310 -0
  125. package/src/session/identitySession.ts +206 -0
  126. package/src/session/projectSessionState.ts +61 -13
  127. package/src/session/refresh.ts +103 -9
  128. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  129. package/src/utils/commonsDelivery.ts +132 -0
  130. package/src/utils/oauthPkce.ts +56 -3
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AccountNotOnDeviceError = void 0;
3
4
  exports.OxyServicesDeviceBootMixin = OxyServicesDeviceBootMixin;
4
5
  /**
5
6
  * Device-first token mint mixin.
@@ -17,6 +18,41 @@ exports.OxyServicesDeviceBootMixin = OxyServicesDeviceBootMixin;
17
18
  * primitive can be reused from either without double-planting.
18
19
  */
19
20
  const contracts_1 = require("@oxyhq/contracts");
21
+ /**
22
+ * The server's `401 account_not_on_device` for a PINNED mint: the requested
23
+ * `accountId` is not (or is no longer) a live account of this device session.
24
+ *
25
+ * Distinguished from every other mint 401 because the remedy is different: the
26
+ * device secret is FINE — it is the identity binding that went stale (the
27
+ * account was signed out on this device, or revoked). An identity-bound caller
28
+ * must re-establish its session from the local key rather than drop/clear the
29
+ * device credential.
30
+ */
31
+ class AccountNotOnDeviceError extends Error {
32
+ constructor(accountId, cause) {
33
+ super(`account_not_on_device: ${accountId} is not a live account of this device session`);
34
+ this.accountId = accountId;
35
+ this.cause = cause;
36
+ this.name = 'AccountNotOnDeviceError';
37
+ /** HTTP status of the originating response; mirrors the ApiError shape. */
38
+ this.status = 401;
39
+ }
40
+ }
41
+ exports.AccountNotOnDeviceError = AccountNotOnDeviceError;
42
+ /**
43
+ * Structural (never `instanceof`) read of a normalized mint error: the thrown
44
+ * value may be a plain ApiError-shaped object or come from another realm.
45
+ */
46
+ function isAccountNotOnDevice(error) {
47
+ if (!error || typeof error !== 'object') {
48
+ return false;
49
+ }
50
+ const { status, message } = error;
51
+ if (status !== 401) {
52
+ return false;
53
+ }
54
+ return typeof message === 'string' && message.includes('account_not_on_device');
55
+ }
20
56
  function OxyServicesDeviceBootMixin(Base) {
21
57
  return class extends Base {
22
58
  /**
@@ -40,11 +76,22 @@ function OxyServicesDeviceBootMixin(Base) {
40
76
  * boot's worst-case time-to-route. A transient failure surfaces once and the
41
77
  * scheduler/401 path retries it later.
42
78
  *
79
+ * `options.accountId` PINS the mint to one account of the device instead of
80
+ * whichever account is currently active. It exists for identity-bound
81
+ * clients (Commons), whose authenticated user is fixed by a local
82
+ * cryptographic key and must never follow an account switch made by another
83
+ * app on the same device. The server never mutates `activeAccountId` for a
84
+ * pinned mint — the returned `state` still reports the device's true active
85
+ * account — and rejects a non-member/dead account with
86
+ * `401 account_not_on_device`, surfaced here as {@link AccountNotOnDeviceError}.
87
+ *
88
+ * @throws {AccountNotOnDeviceError} when a pinned mint's account is not on the device.
43
89
  * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
44
90
  */
45
- async mintFromDeviceSecret(deviceId, deviceSecret) {
91
+ async mintFromDeviceSecret(deviceId, deviceSecret, options) {
92
+ const accountId = options?.accountId;
46
93
  try {
47
- const res = await this.makeRequest('POST', '/session/device/token', { deviceId, deviceSecret },
94
+ const res = await this.makeRequest('POST', '/session/device/token', { deviceId, deviceSecret, ...(accountId ? { accountId } : {}) },
48
95
  // `bypassQueue`: this mint is the control-plane call the auth lane
49
96
  // depends on — it must run even when every RequestQueue slot is parked
50
97
  // awaiting it, or the whole client deadlocks. See RequestOptions.bypassQueue.
@@ -56,7 +103,11 @@ function OxyServicesDeviceBootMixin(Base) {
56
103
  return parsed;
57
104
  }
58
105
  catch (error) {
59
- throw this.handleError(error);
106
+ const normalized = this.handleError(error);
107
+ if (accountId && isAccountNotOnDevice(normalized)) {
108
+ throw new AccountNotOnDeviceError(accountId, normalized);
109
+ }
110
+ throw normalized;
60
111
  }
61
112
  }
62
113
  /** Mint a one-time hub sync ticket (bearer required). */
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OxyServicesNotificationsMixin = OxyServicesNotificationsMixin;
4
+ /**
5
+ * Shape of an Expo push token: `ExponentPushToken[…]` (what
6
+ * `getExpoPushTokenAsync()` returns today) or the equivalent `ExpoPushToken[…]`
7
+ * spelling. Deliberately plain ASCII — `@oxyhq/core` ships to Hermes, where
8
+ * Unicode property escapes throw at runtime.
9
+ */
10
+ const EXPO_PUSH_TOKEN_PATTERN = /^Expo(nent)?PushToken\[[^[\]\s]+\]$/;
11
+ function OxyServicesNotificationsMixin(Base) {
12
+ return class extends Base {
13
+ /**
14
+ * Register this installation's **Expo** push token for the authenticated
15
+ * identity (`POST /notifications/push-token`, bearer required).
16
+ *
17
+ * Idempotent server-side: re-registering the same token for the same
18
+ * identity refreshes the existing row rather than creating a duplicate, so
19
+ * callers can safely re-register on every cold boot.
20
+ *
21
+ * @throws When `expoPushToken` is not an Expo push token — a raw APNs/FCM
22
+ * device token fails here rather than becoming a silently undeliverable
23
+ * registration.
24
+ */
25
+ async registerPushToken(input) {
26
+ try {
27
+ if (!EXPO_PUSH_TOKEN_PATTERN.test(input.expoPushToken)) {
28
+ throw new Error('registerPushToken expects an Expo push token ("ExponentPushToken[...]", from getExpoPushTokenAsync). ' +
29
+ 'A raw APNs/FCM device token from getDevicePushTokenAsync cannot be delivered to.');
30
+ }
31
+ await this.makeRequest('POST', '/notifications/push-token', {
32
+ token: input.expoPushToken,
33
+ platform: input.platform,
34
+ // Omitted entirely when absent so the body stays exactly what the
35
+ // endpoint has always received (the server reads presence).
36
+ ...(input.deviceId ? { deviceId: input.deviceId } : {}),
37
+ ...(input.clientId ? { clientId: input.clientId } : {}),
38
+ }, { cache: false });
39
+ }
40
+ catch (error) {
41
+ throw this.handleError(error);
42
+ }
43
+ }
44
+ /**
45
+ * Retire an **Expo** push token for the authenticated identity
46
+ * (`DELETE /notifications/push-token`, bearer required).
47
+ *
48
+ * Call it when the user turns notifications off, when the vault identity is
49
+ * replaced, and on sign-out — otherwise the installation keeps receiving
50
+ * approval requests for an identity it no longer holds.
51
+ *
52
+ * @param expoPushToken - The exact token previously registered.
53
+ */
54
+ async unregisterPushToken(expoPushToken) {
55
+ try {
56
+ await this.makeRequest('DELETE', '/notifications/push-token', { token: expoPushToken }, { cache: false });
57
+ }
58
+ catch (error) {
59
+ throw this.handleError(error);
60
+ }
61
+ }
62
+ };
63
+ }
@@ -28,6 +28,7 @@ const OxyServices_utility_1 = require("./OxyServices.utility");
28
28
  const OxyServices_features_1 = require("./OxyServices.features");
29
29
  const OxyServices_topics_1 = require("./OxyServices.topics");
30
30
  const OxyServices_contacts_1 = require("./OxyServices.contacts");
31
+ const OxyServices_notifications_1 = require("./OxyServices.notifications");
31
32
  const OxyServices_appData_1 = require("./OxyServices.appData");
32
33
  const OxyServices_civic_1 = require("./OxyServices.civic");
33
34
  const OxyServices_nodes_1 = require("./OxyServices.nodes");
@@ -75,6 +76,10 @@ const MIXIN_PIPELINE = [
75
76
  OxyServices_features_1.OxyServicesFeaturesMixin,
76
77
  OxyServices_topics_1.OxyServicesTopicsMixin,
77
78
  OxyServices_contacts_1.OxyServicesContactsMixin,
79
+ // Push-token registration: the one SDK-owned register/unregister pair every
80
+ // Oxy app uses, and what lets a "Sign in with Oxy" request be delivered to a
81
+ // known Commons installation instead of falling back to a QR.
82
+ OxyServices_notifications_1.OxyServicesNotificationsMixin,
78
83
  OxyServices_appData_1.OxyServicesAppDataMixin,
79
84
  // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
80
85
  OxyServices_civic_1.OxyServicesCivicMixin,
@@ -33,6 +33,15 @@ class SessionClient {
33
33
  getState() {
34
34
  return this.state;
35
35
  }
36
+ /**
37
+ * The account this client's bearer is pinned to, or `null` when it follows the
38
+ * device's active account (the default). Resolvers are expected to be a plain
39
+ * synchronous read of already-resolved state (see
40
+ * {@link SessionClientOptions.getPinnedAccountId}).
41
+ */
42
+ pinnedAccountId() {
43
+ return this.options.getPinnedAccountId?.() ?? null;
44
+ }
36
45
  subscribe(listener) {
37
46
  this.listeners.add(listener);
38
47
  return () => {
@@ -121,15 +130,26 @@ class SessionClient {
121
130
  }
122
131
  const previousState = this.state;
123
132
  this.state = next;
133
+ const pinnedAccountId = this.pinnedAccountId();
124
134
  // Plant the sync-supplied active token (it is for `next.activeAccountId`)
125
135
  // now — before the notify below — so the bearer matches the new active
126
136
  // account when subscribers observe it. Guarded on difference to avoid a
127
137
  // redundant token-change notification on an unchanged token (bootstrap
128
- // restate).
129
- if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
138
+ // restate). An identity-bound client only accepts it when the active account
139
+ // IS its pinned account otherwise the token belongs to somebody else's
140
+ // switch and must never displace the pinned bearer.
141
+ if (activeToken &&
142
+ next.activeAccountId !== null &&
143
+ (pinnedAccountId === null || next.activeAccountId === pinnedAccountId) &&
144
+ activeToken !== this.host.getAccessToken()) {
130
145
  this.host.setTokens(activeToken);
131
146
  }
132
- const transport = this.options.transport;
147
+ // The transport's entire job is converging the bearer on `activeAccountId`,
148
+ // which is precisely what an identity-bound client must not do: its token is
149
+ // minted for the pinned account by the cold boot / re-mint lane. Bypass it
150
+ // while pinned (also removing the mint-before-notify gate, which exists only
151
+ // to keep the bearer and the ACTIVE account in step).
152
+ const transport = pinnedAccountId === null ? this.options.transport : null;
133
153
  const activeAccountId = next.activeAccountId;
134
154
  // Mint before notifying when the bearer does not already belong to the new
135
155
  // active account: no bearer at all, an opaque bearer, OR a bearer for a
@@ -201,12 +221,15 @@ class SessionClient {
201
221
  // socket push): `applyState` no-ops without planting, but the token still
202
222
  // needs planting. Guard on the sync's active account STILL being the current
203
223
  // active account so a stale response cannot adopt a token for an account a
204
- // newer state already switched away from.
224
+ // newer state already switched away from — and, when pinned, on that account
225
+ // being the PINNED one (same rule as the applied path in `applyState`).
226
+ const pinnedAccountId = this.pinnedAccountId();
205
227
  if (!applied &&
206
228
  sync.activeToken &&
207
229
  this.state &&
208
230
  sync.state.activeAccountId !== null &&
209
231
  sync.state.activeAccountId === this.state.activeAccountId &&
232
+ (pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
210
233
  sync.activeToken.accessToken !== this.host.getAccessToken()) {
211
234
  this.host.setTokens(sync.activeToken.accessToken);
212
235
  }
@@ -240,12 +263,19 @@ class SessionClient {
240
263
  * sign-in. This adds, then switches to the target so the UI lands on the
241
264
  * account the user just authenticated.
242
265
  *
266
+ * An identity-bound (pinned) client only ADDS: its own session is minted for
267
+ * the pinned account explicitly, so switching the device would gratuitously
268
+ * re-elect the active account under every OTHER app on this device — a
269
+ * mutation a pinned client must never make.
270
+ *
243
271
  * @param accountId - The signed-in account id (e.g. `session.user.id`). When
244
272
  * omitted, falls back to the host's current-account ref. If neither
245
273
  * resolves, the add still applies and no switch is performed.
246
274
  */
247
275
  async registerAndActivate(accountId) {
248
276
  await this.addCurrentAccount();
277
+ if (this.pinnedAccountId() !== null)
278
+ return;
249
279
  const target = accountId ?? this.host.getCurrentAccountId();
250
280
  if (target && this.state?.activeAccountId !== target) {
251
281
  await this.switchAccount(target);
@@ -349,6 +379,12 @@ class SessionClient {
349
379
  const applied = this.applyState(payload, 'push');
350
380
  if (!applied)
351
381
  return;
382
+ // An identity-bound client tracks the pushed state but NEVER chases the
383
+ // device's active account: re-fetching here would only pull an
384
+ // `activeToken` for somebody else's switch, which the plant guards would
385
+ // then discard. Skip the round-trip entirely.
386
+ if (this.pinnedAccountId() !== null)
387
+ return;
352
388
  // A push changed the active account on another device/tab — re-fetch state
353
389
  // to plant the access token for the newly-active account. When this tab is
354
390
  // still signed out, applyState mints via ensureActiveToken first; bootstrap