@oxyhq/core 12.10.6 → 12.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +66 -6
  3. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  4. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  5. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  6. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  7. package/dist/cjs/i18n/locales/en-US.json +13 -4
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  9. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  10. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  11. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  12. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  16. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  24. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  25. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  26. package/dist/cjs/index.js +31 -5
  27. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  28. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  29. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  30. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  31. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  32. package/dist/cjs/mixins/index.js +5 -0
  33. package/dist/cjs/session/SessionClient.js +40 -4
  34. package/dist/cjs/session/accountDialogController.js +288 -41
  35. package/dist/cjs/session/identityPin.js +256 -0
  36. package/dist/cjs/session/identitySession.js +135 -0
  37. package/dist/cjs/session/projectSessionState.js +54 -15
  38. package/dist/cjs/session/refresh.js +73 -8
  39. package/dist/cjs/utils/commonsDelivery.js +59 -0
  40. package/dist/cjs/utils/oauthPkce.js +33 -3
  41. package/dist/esm/.tsbuildinfo +1 -1
  42. package/dist/esm/HttpService.js +66 -6
  43. package/dist/esm/boot/sessionColdBoot.js +142 -37
  44. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  45. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  46. package/dist/esm/i18n/locales/de-DE.json +13 -1
  47. package/dist/esm/i18n/locales/en-US.json +13 -4
  48. package/dist/esm/i18n/locales/es-ES.json +13 -4
  49. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  50. package/dist/esm/i18n/locales/it-IT.json +13 -1
  51. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  52. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  53. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  54. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  55. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  56. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  57. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  58. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  61. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  62. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  63. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  64. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  65. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  66. package/dist/esm/index.js +14 -1
  67. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  68. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  69. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  70. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  71. package/dist/esm/mixins/OxyServices.user.js +1 -0
  72. package/dist/esm/mixins/index.js +5 -0
  73. package/dist/esm/session/SessionClient.js +40 -4
  74. package/dist/esm/session/accountDialogController.js +288 -41
  75. package/dist/esm/session/identityPin.js +249 -0
  76. package/dist/esm/session/identitySession.js +131 -0
  77. package/dist/esm/session/projectSessionState.js +54 -15
  78. package/dist/esm/session/refresh.js +73 -8
  79. package/dist/esm/utils/commonsDelivery.js +54 -0
  80. package/dist/esm/utils/oauthPkce.js +31 -2
  81. package/dist/types/.tsbuildinfo +1 -1
  82. package/dist/types/HttpService.d.ts +18 -1
  83. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  84. package/dist/types/index.d.ts +12 -4
  85. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  86. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  87. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  88. package/dist/types/mixins/OxyServices.user.d.ts +2 -0
  89. package/dist/types/mixins/index.d.ts +2 -1
  90. package/dist/types/session/SessionClient.d.ts +28 -0
  91. package/dist/types/session/accountDialogController.d.ts +187 -9
  92. package/dist/types/session/identityPin.d.ts +87 -0
  93. package/dist/types/session/identitySession.d.ts +105 -0
  94. package/dist/types/session/projectSessionState.d.ts +21 -19
  95. package/dist/types/session/refresh.d.ts +32 -1
  96. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  97. package/dist/types/utils/oauthPkce.d.ts +21 -1
  98. package/package.json +3 -3
  99. package/src/HttpService.ts +67 -6
  100. package/src/__tests__/inSessionRefresh.test.ts +67 -0
  101. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  102. package/src/boot/sessionColdBoot.ts +183 -37
  103. package/src/i18n/locales/ar-SA.json +13 -1
  104. package/src/i18n/locales/ca-ES.json +13 -1
  105. package/src/i18n/locales/de-DE.json +13 -1
  106. package/src/i18n/locales/en-US.json +13 -4
  107. package/src/i18n/locales/es-ES.json +13 -4
  108. package/src/i18n/locales/fr-FR.json +13 -1
  109. package/src/i18n/locales/it-IT.json +13 -1
  110. package/src/i18n/locales/ja-JP.json +13 -1
  111. package/src/i18n/locales/ko-KR.json +13 -1
  112. package/src/i18n/locales/pt-PT.json +13 -1
  113. package/src/i18n/locales/zh-CN.json +13 -1
  114. package/src/index.ts +49 -0
  115. package/src/mixins/OxyServices.auth.ts +464 -24
  116. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  117. package/src/mixins/OxyServices.notifications.ts +142 -0
  118. package/src/mixins/OxyServices.privacy.ts +6 -0
  119. package/src/mixins/OxyServices.user.ts +3 -0
  120. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  121. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  122. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  123. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
  124. package/src/mixins/index.ts +6 -0
  125. package/src/session/SessionClient.ts +57 -4
  126. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  127. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  128. package/src/session/__tests__/identityPin.test.ts +131 -0
  129. package/src/session/__tests__/identitySession.test.ts +194 -0
  130. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  131. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  132. package/src/session/accountDialogController.ts +435 -44
  133. package/src/session/identityPin.ts +310 -0
  134. package/src/session/identitySession.ts +206 -0
  135. package/src/session/projectSessionState.ts +61 -13
  136. package/src/session/refresh.ts +103 -9
  137. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  138. package/src/utils/commonsDelivery.ts +132 -0
  139. package/src/utils/oauthPkce.ts +56 -3
@@ -56,11 +56,30 @@ const CSRF_FETCH_MAX_ATTEMPTS = 2;
56
56
  const CSRF_FETCH_RETRY_DELAY_MS = 500;
57
57
  /**
58
58
  * Cooldown (ms) applied after a failed access-token refresh before another
59
- * refresh is attempted. Prevents a refresh storm (and server hammering) when
59
+ * refresh is attempted while the CURRENT token is still valid (a proactive,
60
+ * near-expiry refresh). Prevents a refresh storm (and server hammering) when
60
61
  * the auth refresh handler is failing — every in-flight request that
61
- * hits a 401 would otherwise trigger its own refresh.
62
+ * hits a 401 would otherwise trigger its own refresh. A still-valid token can
63
+ * afford to wait this out; the request keeps carrying it in the meantime.
62
64
  */
63
65
  const TOKEN_REFRESH_COOLDOWN_MS = 15000;
66
+ /**
67
+ * Cooldown (ms) applied after a failed refresh when the CURRENT access token is
68
+ * already past its `exp`. Much shorter than {@link TOKEN_REFRESH_COOLDOWN_MS}:
69
+ * an expired token is UNUSABLE, so the client must re-mint as soon as the mint
70
+ * endpoint is reachable again (e.g. a few seconds after an ECS rolling-deploy
71
+ * blip drains/restarts a task) instead of waiting out the full proactive
72
+ * cooldown while every request forwards or omits a stale bearer → server 401.
73
+ *
74
+ * Still NON-ZERO on purpose: it bounds the request-driven retry rate to at most
75
+ * one attempt per this interval so a PROLONGED outage cannot become a tight
76
+ * network storm. Combined with the process-wide single-flight below (concurrent
77
+ * requests coalesce to one in-flight mint) and the refresh handler's own
78
+ * terminal-state handling (a genuinely revoked session clears its device
79
+ * credential and stops issuing network mints), this recovers a transient blip
80
+ * ~15× faster without weakening the storm guard.
81
+ */
82
+ const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
64
83
  /**
65
84
  * Lead time (seconds) before access-token expiry at which a preflight refresh
66
85
  * is triggered. A token within this window of `exp` is treated as effectively
@@ -133,7 +152,15 @@ class TokenStore {
133
152
  class HttpService {
134
153
  constructor(config) {
135
154
  this.tokenRefreshPromise = null;
136
- this.tokenRefreshCooldownUntil = 0;
155
+ /**
156
+ * Epoch ms of the last FAILED refresh (0 = none since the last success). The
157
+ * post-failure cooldown is measured from here; its length depends on whether
158
+ * the current token is still valid ({@link TOKEN_REFRESH_COOLDOWN_MS}) or
159
+ * already expired ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}), so an expired
160
+ * token recovers promptly the instant it crosses `exp` — without storing a
161
+ * fixed deadline that could not shrink once the token expired mid-cooldown.
162
+ */
163
+ this.lastRefreshFailureAt = 0;
137
164
  this.authRefreshHandler = null;
138
165
  this.accessTokenProvider = null;
139
166
  this.deviceSecretMintInFlight = null;
@@ -823,26 +850,38 @@ class HttpService {
823
850
  if (!this.authRefreshHandler) {
824
851
  return null;
825
852
  }
826
- if (Date.now() < this.tokenRefreshCooldownUntil) {
853
+ // Post-failure cooldown. A genuinely EXPIRED current token uses a much
854
+ // shorter cooldown than a still-valid (proactive, near-expiry) one: an
855
+ // expired token is unusable, so re-mint as soon as the endpoint is reachable
856
+ // again rather than waiting out the full window while requests carry a stale
857
+ // bearer. Both cooldowns are measured from the last failure, so the moment a
858
+ // still-valid token crosses `exp` mid-cooldown the shorter window applies.
859
+ const cooldownMs = this.isAccessTokenExpired()
860
+ ? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
861
+ : TOKEN_REFRESH_COOLDOWN_MS;
862
+ if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
827
863
  return null;
828
864
  }
829
865
  if (!this.tokenRefreshPromise) {
830
866
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
831
867
  .then((newToken) => {
832
868
  if (!newToken) {
833
- this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
869
+ this.lastRefreshFailureAt = Date.now();
834
870
  return null;
835
871
  }
836
872
  if (this.tokenStore.getAccessToken() !== newToken) {
837
873
  this.tokenStore.setTokens(newToken);
838
874
  this.notifyTokenChange();
839
875
  }
876
+ // A success clears the failure timestamp so the next refresh is never
877
+ // throttled by a stale cooldown.
878
+ this.lastRefreshFailureAt = 0;
840
879
  this.logger.debug('Token refreshed via the auth refresh handler');
841
880
  return newToken;
842
881
  })
843
882
  .catch((error) => {
844
883
  this.logger.warn('Token refresh failed:', error);
845
- this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
884
+ this.lastRefreshFailureAt = Date.now();
846
885
  return null;
847
886
  })
848
887
  .finally(() => {
@@ -851,6 +890,27 @@ class HttpService {
851
890
  }
852
891
  return this.tokenRefreshPromise;
853
892
  }
893
+ /**
894
+ * Whether the CURRENT stored access token is already past its `exp`. Drives
895
+ * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
896
+ * a still-valid (near-expiry) token can wait out the full cooldown, but an
897
+ * expired one must re-mint promptly. Returns `false` for an absent or
898
+ * opaque/no-`exp` token — no proof it is expired, so keep the conservative
899
+ * (longer) cooldown and avoid an unnecessary retry loop.
900
+ */
901
+ isAccessTokenExpired() {
902
+ const token = this.tokenStore.getAccessToken();
903
+ if (!token) {
904
+ return false;
905
+ }
906
+ try {
907
+ const decoded = (0, jwt_decode_1.jwtDecode)(token);
908
+ return typeof decoded.exp === 'number' && decoded.exp <= Math.floor(Date.now() / 1000);
909
+ }
910
+ catch {
911
+ return false;
912
+ }
913
+ }
854
914
  /**
855
915
  * PROCESS-WIDE single-flight for the rotating device-secret mint
856
916
  * (`POST /session/device/token`).
@@ -18,15 +18,28 @@ exports.runSessionColdBoot = runSessionColdBoot;
18
18
  * origin persisted a `deviceId` + `deviceSecret`, mint a short access token
19
19
  * with a single bearer-less POST to `/session/device/token` (no cookie, no
20
20
  * navigation) and rotate the secret in-use.
21
- * 3. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
21
+ * 3. `shared-key-signin` (native, ACCOUNT mode) — re-mint from the
22
+ * shared-keychain identity — OR `identity-key-signin` (IDENTITY mode) —
23
+ * re-mint from THIS device's primary identity key.
22
24
  * 4. Signed out.
23
25
  *
26
+ * Two session modes (see {@link RunSessionColdBootOptions.sessionMode}):
27
+ * - `account` (default) — the device's ACTIVE account owns the session. Every
28
+ * Oxy app but the identity vault boots this way; behaviour is unchanged.
29
+ * - `identity` — the owner of the local PRIMARY identity key owns the session,
30
+ * permanently, regardless of which account the device is switched to. Each
31
+ * step above is bound to the persisted identity pin, and the shared-keychain
32
+ * lane is replaced by the primary-key one (the shared slot is a CROSS-APP
33
+ * slot that may hold a different identity).
34
+ *
24
35
  * ESM-safe (no `require()`); no react/react-native/expo imports.
25
36
  */
26
37
  const coldBoot_1 = require("../utils/coldBoot");
27
38
  const platform_1 = require("../utils/platform");
28
39
  const logger_1 = require("../logger");
40
+ const cacheKey_1 = require("../utils/cacheKey");
29
41
  const refresh_1 = require("../session/refresh");
42
+ const identitySession_1 = require("../session/identitySession");
30
43
  /**
31
44
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
32
45
  * a side effect, invokes `onSession` (winning session, token already planted) or
@@ -35,6 +48,14 @@ const refresh_1 = require("../session/refresh");
35
48
  async function runSessionColdBoot(opts) {
36
49
  const { oxy, store } = opts;
37
50
  const isNative = opts.platform?.isNative ?? (0, platform_1.isNative)();
51
+ // Non-null ONLY in identity mode; every `!== null` test below therefore reads
52
+ // as "is this boot identity-bound?" while also narrowing the binding.
53
+ const identityBinding = opts.sessionMode === 'identity' ? opts.identity ?? null : null;
54
+ if (opts.sessionMode === 'identity' && identityBinding === null) {
55
+ logger_1.logger.error('runSessionColdBoot: sessionMode "identity" requires an `identity` binding — refusing to run any lane (an identity-bound client must never adopt the device active account)', undefined, { component: 'sessionColdBoot', method: 'runSessionColdBoot' });
56
+ await opts.onSignedOut?.('error');
57
+ return { kind: 'unauthenticated' };
58
+ }
38
59
  // Best-effort connectivity gate for the NETWORK steps only. A missing hint or
39
60
  // any non-`true` verdict means "assume online" — never falsely skip a real
40
61
  // sign-in on an ambiguous probe.
@@ -42,6 +63,22 @@ async function runSessionColdBoot(opts) {
42
63
  // Boot-local (not module-level) so it cannot leak across boots or break under
43
64
  // bundler re-evaluation.
44
65
  let signedOutReason = 'no_session';
66
+ // Boot-local memo for the identity pin. Resolved lazily INSIDE a step so the
67
+ // (local, but storage-backed) read is covered by `overallDeadlineMs`, and
68
+ // resolved at most once per boot so two steps cannot disagree. Always `null`
69
+ // in account mode.
70
+ let pinResolved = false;
71
+ let cachedPin = null;
72
+ const getIdentityPin = async () => {
73
+ if (identityBinding === null) {
74
+ return null;
75
+ }
76
+ if (!pinResolved) {
77
+ cachedPin = await (0, identitySession_1.resolveIdentityPin)(identityBinding);
78
+ pinResolved = true;
79
+ }
80
+ return cachedPin;
81
+ };
45
82
  const steps = [];
46
83
  // 1. warm-token-plant (web + native) — the fastest path. When the persisted
47
84
  // store already holds a still-valid warm access token (its expiry more than
@@ -69,6 +106,19 @@ async function runSessionColdBoot(opts) {
69
106
  if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + refresh_1.TOKEN_REFRESH_LEAD_MS) {
70
107
  return { kind: 'skip' };
71
108
  }
109
+ if (identityBinding !== null) {
110
+ // Identity-bound: a persisted token is only acceptable when it belongs to
111
+ // the PINNED account. Both the token's own claim (`computeIdentityTag`,
112
+ // the same derivation `SessionClient.applyState` uses) and the owning
113
+ // session identity must match — a token planted for another account by an
114
+ // earlier account-mode write is exactly the durable drift this gate stops.
115
+ const pin = await getIdentityPin();
116
+ if (pin === null ||
117
+ persisted.userId !== pin.accountId ||
118
+ (0, cacheKey_1.computeIdentityTag)(persisted.accessToken) !== pin.accountId) {
119
+ return { kind: 'skip' };
120
+ }
121
+ }
72
122
  oxy.setTokens(persisted.accessToken);
73
123
  return {
74
124
  kind: 'session',
@@ -94,7 +144,16 @@ async function runSessionColdBoot(opts) {
94
144
  // a doomed mint cannot burn the overall deadline before routing settles.
95
145
  enabled: () => !isOffline(),
96
146
  run: async () => {
97
- const result = await (0, refresh_1.refreshDeviceSecretArm)({ oxy, store });
147
+ const pin = await getIdentityPin();
148
+ if (identityBinding !== null && pin === null) {
149
+ // Identity-bound with no verified pin (never written, or cleared because
150
+ // the local key changed/vanished). An UNPINNED mint would adopt whichever
151
+ // account the device is currently switched to — the exact drift this mode
152
+ // exists to prevent. Fall through to `identity-key-signin`, which
153
+ // re-derives the account from the local key and rewrites the pin.
154
+ return { kind: 'skip' };
155
+ }
156
+ const result = await (0, refresh_1.refreshDeviceSecretArm)({ oxy, store, pin });
98
157
  switch (result.status) {
99
158
  case 'ok':
100
159
  // The arm persisted the rotated secret and planted the token.
@@ -128,6 +187,12 @@ async function runSessionColdBoot(opts) {
128
187
  // the secret; a later boot/attempt re-mints once storage recovers.
129
188
  logger_1.logger.error('device-secret mint rotated the secret but it could not be durably persisted — not planting', undefined, { component: 'sessionColdBoot', method: 'device-secret-mint' });
130
189
  return { kind: 'skip' };
190
+ case 'account-not-on-device':
191
+ // Pinned mint rejected: the pinned account is no longer a live account
192
+ // of this device session. The secret is healthy — keep it untouched and
193
+ // let `identity-key-signin` re-establish from the local key.
194
+ logger_1.logger.debug('pinned device-secret mint rejected (account_not_on_device) — keeping secret, re-establishing from the identity key', { component: 'sessionColdBoot', method: 'device-secret-mint' });
195
+ return { kind: 'skip' };
131
196
  case 'transient':
132
197
  // Network / 5xx: keep the secret; a later attempt can succeed.
133
198
  logger_1.logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' });
@@ -137,42 +202,82 @@ async function runSessionColdBoot(opts) {
137
202
  }
138
203
  },
139
204
  });
140
- // 3. shared-key-signin (native) — re-mint from the shared identity. Native
141
- // AND online: it is a network step (challenge + verify round-trips), so it
142
- // is gated by the same offline hint as the mint lane. `{ retry: false }`
143
- // keeps the two round-trips as single attempts the refresh scheduler /
144
- // 401 lane own later retries so this step cannot multiply boot latency.
145
- steps.push({
146
- id: 'shared-key-signin',
147
- enabled: () => isNative && !isOffline(),
148
- run: async () => {
149
- const session = await oxy.signInWithSharedIdentity({ requestOptions: { retry: false } });
150
- if (!session?.accessToken) {
151
- return { kind: 'skip' };
152
- }
153
- // `verifyChallenge` mints a rotating deviceSecret; persist it so the next
154
- // boot can use the faster device-secret-mint lane (sockets + tab-focus
155
- // re-mint depend on the credential being in the store).
156
- if (session.deviceId && session.deviceSecret) {
157
- await store.save({
158
- sessionId: session.sessionId,
159
- userId: session.user.id,
160
- deviceId: session.deviceId,
161
- deviceSecret: session.deviceSecret,
162
- accessToken: session.accessToken,
163
- expiresAt: session.expiresAt,
205
+ if (identityBinding !== null) {
206
+ // 3-identity. identity-key-signin re-mint from THIS device's PRIMARY
207
+ // identity key (`getPublicKey` challenge sign verify). It REPLACES
208
+ // `shared-key-signin`, which reads the CROSS-APP shared keychain slot and
209
+ // may therefore hold a different identity than the device's primary the
210
+ // one thing an identity-bound client must never adopt. The server resolves
211
+ // the account from the verified signer, so the winning session is
212
+ // identity-authoritative and the pin is (re)written from it.
213
+ //
214
+ // Online-gated like every network step; `{ retry: false }` keeps the two
215
+ // round-trips single attempts (the refresh scheduler / 401 lane own later
216
+ // retries) so this step cannot multiply boot latency. On web
217
+ // `KeyManager.getPublicKey()` resolves to `null`, so it skips.
218
+ steps.push({
219
+ id: 'identity-key-signin',
220
+ enabled: () => !isOffline(),
221
+ run: async () => {
222
+ const established = await (0, identitySession_1.establishIdentitySession)({
223
+ oxy,
224
+ store,
225
+ binding: identityBinding,
226
+ requestOptions: { retry: false },
164
227
  });
165
- }
166
- return {
167
- kind: 'session',
168
- session: {
169
- sessionId: session.sessionId,
170
- userId: session.user.id,
171
- accessToken: session.accessToken,
172
- },
173
- };
174
- },
175
- });
228
+ const accessToken = established?.session.accessToken;
229
+ if (!established || !accessToken) {
230
+ return { kind: 'skip' };
231
+ }
232
+ return {
233
+ kind: 'session',
234
+ session: {
235
+ sessionId: established.session.sessionId,
236
+ userId: established.session.user.id,
237
+ accessToken,
238
+ },
239
+ };
240
+ },
241
+ });
242
+ }
243
+ else {
244
+ // 3. shared-key-signin (native) — re-mint from the shared identity. Native
245
+ // AND online: it is a network step (challenge + verify round-trips), so it
246
+ // is gated by the same offline hint as the mint lane. `{ retry: false }`
247
+ // keeps the two round-trips as single attempts — the refresh scheduler /
248
+ // 401 lane own later retries — so this step cannot multiply boot latency.
249
+ steps.push({
250
+ id: 'shared-key-signin',
251
+ enabled: () => isNative && !isOffline(),
252
+ run: async () => {
253
+ const session = await oxy.signInWithSharedIdentity({ requestOptions: { retry: false } });
254
+ if (!session?.accessToken) {
255
+ return { kind: 'skip' };
256
+ }
257
+ // `verifyChallenge` mints a rotating deviceSecret; persist it so the next
258
+ // boot can use the faster device-secret-mint lane (sockets + tab-focus
259
+ // re-mint depend on the credential being in the store).
260
+ if (session.deviceId && session.deviceSecret) {
261
+ await store.save({
262
+ sessionId: session.sessionId,
263
+ userId: session.user.id,
264
+ deviceId: session.deviceId,
265
+ deviceSecret: session.deviceSecret,
266
+ accessToken: session.accessToken,
267
+ expiresAt: session.expiresAt,
268
+ });
269
+ }
270
+ return {
271
+ kind: 'session',
272
+ session: {
273
+ sessionId: session.sessionId,
274
+ userId: session.user.id,
275
+ accessToken: session.accessToken,
276
+ },
277
+ };
278
+ },
279
+ });
280
+ }
176
281
  const outcome = await (0, coldBoot_1.runColdBoot)({
177
282
  steps,
178
283
  overallDeadlineMs: opts.overallDeadlineMs,
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "لا تملك Commons؟ احصل على التطبيق لتسجيل الدخول بمعرّف Oxy الخاص بك.",
125
125
  "showQrAnyway": "لدي Commons على جهاز آخر",
126
126
  "switchWhileSignedInAs": "تبديل الحساب، مسجّل الدخول باسم {{name}}",
127
- "manageOnDevice": "إدارة الحسابات على هذا الجهاز"
127
+ "manageOnDevice": "إدارة الحسابات على هذا الجهاز",
128
+ "continueWithOxy": "المتابعة باستخدام Oxy",
129
+ "havingTrouble": "هل تواجه مشكلة؟",
130
+ "progress": {
131
+ "preparing": "جارٍ تحضير الطلب",
132
+ "awaitingApproval": "في انتظار الموافقة",
133
+ "scanWithCommons": "امسح الرمز باستخدام Commons على هاتفك",
134
+ "continueInCommons": "تابع في Commons",
135
+ "checkCommons": "تحقّق من Commons على هاتفك",
136
+ "openedInCommons": "تم الفتح في Commons",
137
+ "confirming": "جارٍ تأكيد الهوية",
138
+ "confirmed": "تم تأكيد الهوية"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "No tens Commons? Descarrega l'app per iniciar sessió amb el teu Oxy ID.",
125
125
  "showQrAnyway": "Tinc Commons en un altre dispositiu",
126
126
  "switchWhileSignedInAs": "Canvia de compte, amb la sessió iniciada com a {{name}}",
127
- "manageOnDevice": "Gestionar comptes en aquest dispositiu"
127
+ "manageOnDevice": "Gestionar comptes en aquest dispositiu",
128
+ "continueWithOxy": "Continua amb Oxy",
129
+ "havingTrouble": "Algun problema?",
130
+ "progress": {
131
+ "preparing": "Preparant la sol·licitud",
132
+ "awaitingApproval": "Esperant l'aprovació",
133
+ "scanWithCommons": "Escaneja amb Commons al teu mòbil",
134
+ "continueInCommons": "Continua a Commons",
135
+ "checkCommons": "Revisa Commons al teu mòbil",
136
+ "openedInCommons": "Obert a Commons",
137
+ "confirming": "Confirmant la identitat",
138
+ "confirmed": "Identitat confirmada"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "Kein Commons? Holen Sie sich die App, um sich mit Ihrer Oxy-ID anzumelden.",
125
125
  "showQrAnyway": "Ich habe Commons auf einem anderen Gerät",
126
126
  "switchWhileSignedInAs": "Konto wechseln, angemeldet als {{name}}",
127
- "manageOnDevice": "Konten auf diesem Gerät verwalten"
127
+ "manageOnDevice": "Konten auf diesem Gerät verwalten",
128
+ "continueWithOxy": "Mit Oxy fortfahren",
129
+ "havingTrouble": "Gibt es ein Problem?",
130
+ "progress": {
131
+ "preparing": "Anfrage wird vorbereitet",
132
+ "awaitingApproval": "Warten auf Freigabe",
133
+ "scanWithCommons": "Scannen Sie mit Commons auf Ihrem Telefon",
134
+ "continueInCommons": "In Commons fortfahren",
135
+ "checkCommons": "Prüfen Sie Commons auf Ihrem Telefon",
136
+ "openedInCommons": "In Commons geöffnet",
137
+ "confirming": "Identität wird bestätigt",
138
+ "confirmed": "Identität bestätigt"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {
@@ -412,9 +412,6 @@
412
412
  },
413
413
  "label": "Account switcher",
414
414
  "searchPlaceholder": "Search accounts",
415
- "scanTitle": "Scan to sign in",
416
- "scanSubtitle": "Open the Oxy app on your phone and scan this code",
417
- "scanWithOxy": "Scan with the Oxy app to continue",
418
415
  "scanQr": "Scan QR code",
419
416
  "qrHeadline": "Sign in with your Oxy identity",
420
417
  "signInWithOxy": "Sign in with Oxy",
@@ -424,7 +421,19 @@
424
421
  "commonsNotInstalled": "Don't have Commons? Get the app to sign in with your Oxy ID.",
425
422
  "showQrAnyway": "I have Commons on another device",
426
423
  "switchWhileSignedInAs": "Switch account, signed in as {{name}}",
427
- "manageOnDevice": "Manage accounts on this device"
424
+ "manageOnDevice": "Manage accounts on this device",
425
+ "continueWithOxy": "Continue with Oxy",
426
+ "havingTrouble": "Having trouble?",
427
+ "progress": {
428
+ "preparing": "Preparing request",
429
+ "awaitingApproval": "Waiting for approval",
430
+ "scanWithCommons": "Scan with Commons on your phone",
431
+ "continueInCommons": "Continue in Commons",
432
+ "checkCommons": "Check Commons on your phone",
433
+ "openedInCommons": "Opened in Commons",
434
+ "confirming": "Confirming identity",
435
+ "confirmed": "Identity confirmed"
436
+ }
428
437
  },
429
438
  "reputation": {
430
439
  "faq": {
@@ -652,9 +652,6 @@
652
652
  },
653
653
  "label": "Selector de cuentas",
654
654
  "searchPlaceholder": "Buscar cuentas",
655
- "scanTitle": "Escanea para iniciar sesión",
656
- "scanSubtitle": "Abre la app de Oxy en tu móvil y escanea este código",
657
- "scanWithOxy": "Escanea con la app de Oxy para continuar",
658
655
  "scanQr": "Escanear código QR",
659
656
  "qrHeadline": "Inicia sesión con tu identidad de Oxy",
660
657
  "signInWithOxy": "Inicia sesión con Oxy",
@@ -664,7 +661,19 @@
664
661
  "commonsNotInstalled": "¿No tienes Commons? Descarga la app para iniciar sesión con tu Oxy ID.",
665
662
  "showQrAnyway": "Tengo Commons en otro dispositivo",
666
663
  "switchWhileSignedInAs": "Cambiar de cuenta, con la sesión iniciada como {{name}}",
667
- "manageOnDevice": "Gestionar cuentas en este dispositivo"
664
+ "manageOnDevice": "Gestionar cuentas en este dispositivo",
665
+ "continueWithOxy": "Continuar con Oxy",
666
+ "havingTrouble": "¿Algún problema?",
667
+ "progress": {
668
+ "preparing": "Preparando la solicitud",
669
+ "awaitingApproval": "Esperando la aprobación",
670
+ "scanWithCommons": "Escanea con Commons en tu móvil",
671
+ "continueInCommons": "Continúa en Commons",
672
+ "checkCommons": "Revisa Commons en tu móvil",
673
+ "openedInCommons": "Abierto en Commons",
674
+ "confirming": "Confirmando identidad",
675
+ "confirmed": "Identidad confirmada"
676
+ }
668
677
  },
669
678
  "feedback": {
670
679
  "type": {
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "Pas de Commons ? Téléchargez l'app pour vous connecter avec votre Oxy ID.",
125
125
  "showQrAnyway": "J'ai Commons sur un autre appareil",
126
126
  "switchWhileSignedInAs": "Changer de compte, connecté en tant que {{name}}",
127
- "manageOnDevice": "Gérer les comptes sur cet appareil"
127
+ "manageOnDevice": "Gérer les comptes sur cet appareil",
128
+ "continueWithOxy": "Continuer avec Oxy",
129
+ "havingTrouble": "Un problème ?",
130
+ "progress": {
131
+ "preparing": "Préparation de la demande",
132
+ "awaitingApproval": "En attente d'approbation",
133
+ "scanWithCommons": "Scannez avec Commons sur votre téléphone",
134
+ "continueInCommons": "Continuez dans Commons",
135
+ "checkCommons": "Consultez Commons sur votre téléphone",
136
+ "openedInCommons": "Ouvert dans Commons",
137
+ "confirming": "Confirmation de l'identité",
138
+ "confirmed": "Identité confirmée"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "Non hai Commons? Scarica l'app per accedere con il tuo Oxy ID.",
125
125
  "showQrAnyway": "Ho Commons su un altro dispositivo",
126
126
  "switchWhileSignedInAs": "Cambia account, connesso come {{name}}",
127
- "manageOnDevice": "Gestisci account su questo dispositivo"
127
+ "manageOnDevice": "Gestisci account su questo dispositivo",
128
+ "continueWithOxy": "Continua con Oxy",
129
+ "havingTrouble": "Problemi?",
130
+ "progress": {
131
+ "preparing": "Preparazione della richiesta",
132
+ "awaitingApproval": "In attesa di approvazione",
133
+ "scanWithCommons": "Scansiona con Commons sul tuo telefono",
134
+ "continueInCommons": "Continua in Commons",
135
+ "checkCommons": "Controlla Commons sul tuo telefono",
136
+ "openedInCommons": "Aperto in Commons",
137
+ "confirming": "Conferma dell'identità in corso",
138
+ "confirmed": "Identità confermata"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "Commonsがありませんか?アプリを入手してOxy IDでサインインしましょう。",
125
125
  "showQrAnyway": "別のデバイスにCommonsがあります",
126
126
  "switchWhileSignedInAs": "アカウントを切り替え({{name}} としてログイン中)",
127
- "manageOnDevice": "このデバイスのアカウントを管理"
127
+ "manageOnDevice": "このデバイスのアカウントを管理",
128
+ "continueWithOxy": "Oxyで続ける",
129
+ "havingTrouble": "うまくいきませんか?",
130
+ "progress": {
131
+ "preparing": "リクエストを準備しています",
132
+ "awaitingApproval": "承認を待っています",
133
+ "scanWithCommons": "スマートフォンのCommonsでスキャンしてください",
134
+ "continueInCommons": "Commonsで続けてください",
135
+ "checkCommons": "スマートフォンのCommonsを確認してください",
136
+ "openedInCommons": "Commonsで開きました",
137
+ "confirming": "本人確認をしています",
138
+ "confirmed": "本人確認が完了しました"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "Commons가 없으신가요? 앱을 다운로드하여 Oxy ID로 로그인하세요.",
125
125
  "showQrAnyway": "다른 기기에 Commons가 있습니다",
126
126
  "switchWhileSignedInAs": "계정 전환, {{name}}(으)로 로그인됨",
127
- "manageOnDevice": "이 기기의 계정 관리"
127
+ "manageOnDevice": "이 기기의 계정 관리",
128
+ "continueWithOxy": "Oxy로 계속하기",
129
+ "havingTrouble": "문제가 있으신가요?",
130
+ "progress": {
131
+ "preparing": "요청 준비 중",
132
+ "awaitingApproval": "승인 대기 중",
133
+ "scanWithCommons": "휴대폰의 Commons로 스캔하세요",
134
+ "continueInCommons": "Commons에서 계속하세요",
135
+ "checkCommons": "휴대폰에서 Commons를 확인하세요",
136
+ "openedInCommons": "Commons에서 열림",
137
+ "confirming": "신원 확인 중",
138
+ "confirmed": "신원 확인 완료"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "لا تملك Commons؟ احصل على التطبيق لتسجيل الدخول بمعرّف Oxy الخاص بك.",
125
125
  "showQrAnyway": "لدي Commons على جهاز آخر",
126
126
  "switchWhileSignedInAs": "تبديل الحساب، مسجّل الدخول باسم {{name}}",
127
- "manageOnDevice": "إدارة الحسابات على هذا الجهاز"
127
+ "manageOnDevice": "إدارة الحسابات على هذا الجهاز",
128
+ "continueWithOxy": "المتابعة باستخدام Oxy",
129
+ "havingTrouble": "هل تواجه مشكلة؟",
130
+ "progress": {
131
+ "preparing": "جارٍ تحضير الطلب",
132
+ "awaitingApproval": "في انتظار الموافقة",
133
+ "scanWithCommons": "امسح الرمز باستخدام Commons على هاتفك",
134
+ "continueInCommons": "تابع في Commons",
135
+ "checkCommons": "تحقّق من Commons على هاتفك",
136
+ "openedInCommons": "تم الفتح في Commons",
137
+ "confirming": "جارٍ تأكيد الهوية",
138
+ "confirmed": "تم تأكيد الهوية"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "No tens Commons? Descarrega l'app per iniciar sessió amb el teu Oxy ID.",
125
125
  "showQrAnyway": "Tinc Commons en un altre dispositiu",
126
126
  "switchWhileSignedInAs": "Canvia de compte, amb la sessió iniciada com a {{name}}",
127
- "manageOnDevice": "Gestionar comptes en aquest dispositiu"
127
+ "manageOnDevice": "Gestionar comptes en aquest dispositiu",
128
+ "continueWithOxy": "Continua amb Oxy",
129
+ "havingTrouble": "Algun problema?",
130
+ "progress": {
131
+ "preparing": "Preparant la sol·licitud",
132
+ "awaitingApproval": "Esperant l'aprovació",
133
+ "scanWithCommons": "Escaneja amb Commons al teu mòbil",
134
+ "continueInCommons": "Continua a Commons",
135
+ "checkCommons": "Revisa Commons al teu mòbil",
136
+ "openedInCommons": "Obert a Commons",
137
+ "confirming": "Confirmant la identitat",
138
+ "confirmed": "Identitat confirmada"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {
@@ -124,7 +124,19 @@
124
124
  "commonsNotInstalled": "Kein Commons? Holen Sie sich die App, um sich mit Ihrer Oxy-ID anzumelden.",
125
125
  "showQrAnyway": "Ich habe Commons auf einem anderen Gerät",
126
126
  "switchWhileSignedInAs": "Konto wechseln, angemeldet als {{name}}",
127
- "manageOnDevice": "Konten auf diesem Gerät verwalten"
127
+ "manageOnDevice": "Konten auf diesem Gerät verwalten",
128
+ "continueWithOxy": "Mit Oxy fortfahren",
129
+ "havingTrouble": "Gibt es ein Problem?",
130
+ "progress": {
131
+ "preparing": "Anfrage wird vorbereitet",
132
+ "awaitingApproval": "Warten auf Freigabe",
133
+ "scanWithCommons": "Scannen Sie mit Commons auf Ihrem Telefon",
134
+ "continueInCommons": "In Commons fortfahren",
135
+ "checkCommons": "Prüfen Sie Commons auf Ihrem Telefon",
136
+ "openedInCommons": "In Commons geöffnet",
137
+ "confirming": "Identität wird bestätigt",
138
+ "confirmed": "Identität bestätigt"
139
+ }
128
140
  },
129
141
  "common": {
130
142
  "actions": {