@oxyhq/core 5.4.3 → 6.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 (186) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -3
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +47 -43
  12. package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
  15. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  16. package/dist/cjs/mixins/index.js +13 -17
  17. package/dist/cjs/session/SessionClient.js +44 -2
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/createSessionClient.js +8 -2
  20. package/dist/cjs/session/refresh.js +264 -0
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +6 -15
  23. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/ssoBounce.js +15 -340
  26. package/dist/cjs/utils/validationUtils.js +57 -0
  27. package/dist/esm/.tsbuildinfo +1 -1
  28. package/dist/esm/HttpService.js +6 -3
  29. package/dist/esm/OxyServices.base.js +7 -102
  30. package/dist/esm/boot/coldBootV2.js +344 -0
  31. package/dist/esm/boot/deviceBootReturn.js +146 -0
  32. package/dist/esm/crypto/keyManager.js +95 -0
  33. package/dist/esm/i18n/locales/en-US.json +13 -1
  34. package/dist/esm/i18n/locales/es-ES.json +13 -1
  35. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  36. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  37. package/dist/esm/index.js +28 -18
  38. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  39. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  40. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +13 -17
  43. package/dist/esm/session/SessionClient.js +44 -2
  44. package/dist/esm/session/authStateStore.js +278 -0
  45. package/dist/esm/session/createSessionClient.js +8 -2
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/utils/accountUtils.js +0 -53
  48. package/dist/esm/utils/authWebUrl.js +6 -14
  49. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  50. package/dist/esm/utils/platform.js +18 -0
  51. package/dist/esm/utils/ssoBounce.js +14 -324
  52. package/dist/esm/utils/validationUtils.js +56 -0
  53. package/dist/types/.tsbuildinfo +1 -1
  54. package/dist/types/HttpService.d.ts +13 -0
  55. package/dist/types/OxyServices.base.d.ts +0 -52
  56. package/dist/types/OxyServices.d.ts +0 -25
  57. package/dist/types/boot/coldBootV2.d.ts +76 -0
  58. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  59. package/dist/types/crypto/keyManager.d.ts +21 -0
  60. package/dist/types/index.d.ts +16 -21
  61. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  64. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  66. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  67. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  71. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  85. package/dist/types/mixins/index.d.ts +6 -9
  86. package/dist/types/models/interfaces.d.ts +0 -67
  87. package/dist/types/session/SessionClient.d.ts +38 -1
  88. package/dist/types/session/authStateStore.d.ts +119 -0
  89. package/dist/types/session/createSessionClient.d.ts +8 -1
  90. package/dist/types/session/refresh.d.ts +93 -0
  91. package/dist/types/utils/accountUtils.d.ts +0 -14
  92. package/dist/types/utils/authWebUrl.d.ts +6 -12
  93. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  94. package/dist/types/utils/platform.d.ts +14 -0
  95. package/dist/types/utils/ssoBounce.d.ts +14 -262
  96. package/dist/types/utils/validationUtils.d.ts +15 -0
  97. package/package.json +2 -2
  98. package/src/HttpService.ts +19 -3
  99. package/src/OxyServices.base.ts +7 -112
  100. package/src/OxyServices.ts +0 -38
  101. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  102. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  103. package/src/boot/coldBootV2.ts +426 -0
  104. package/src/boot/deviceBootReturn.ts +195 -0
  105. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  106. package/src/crypto/keyManager.ts +101 -0
  107. package/src/i18n/locales/en-US.json +13 -1
  108. package/src/i18n/locales/es-ES.json +13 -1
  109. package/src/index.ts +78 -64
  110. package/src/mixins/OxyServices.accounts.ts +6 -13
  111. package/src/mixins/OxyServices.auth.ts +78 -253
  112. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  113. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  114. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  115. package/src/mixins/__tests__/accounts.test.ts +17 -44
  116. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  117. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  118. package/src/mixins/index.ts +16 -22
  119. package/src/models/interfaces.ts +0 -79
  120. package/src/session/SessionClient.ts +69 -3
  121. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  122. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  123. package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
  124. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  125. package/src/session/__tests__/authStateStore.test.ts +209 -0
  126. package/src/session/__tests__/refresh.test.ts +256 -0
  127. package/src/session/authStateStore.ts +335 -0
  128. package/src/session/createSessionClient.ts +9 -1
  129. package/src/session/refresh.ts +334 -0
  130. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  131. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  132. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  133. package/src/utils/accountUtils.ts +0 -62
  134. package/src/utils/authWebUrl.ts +6 -15
  135. package/src/utils/fapiAutoDetect.ts +16 -60
  136. package/src/utils/platform.ts +21 -0
  137. package/src/utils/ssoBounce.ts +14 -371
  138. package/src/utils/validationUtils.ts +62 -0
  139. package/dist/cjs/AuthManager.js +0 -1110
  140. package/dist/cjs/AuthManagerTypes.js +0 -13
  141. package/dist/cjs/CrossDomainAuth.js +0 -206
  142. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  143. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  144. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  145. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  146. package/dist/cjs/utils/ssoEstablish.js +0 -110
  147. package/dist/cjs/utils/ssoReturn.js +0 -267
  148. package/dist/esm/AuthManager.js +0 -1105
  149. package/dist/esm/AuthManagerTypes.js +0 -12
  150. package/dist/esm/CrossDomainAuth.js +0 -201
  151. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  152. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  153. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  154. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  155. package/dist/esm/utils/ssoEstablish.js +0 -107
  156. package/dist/esm/utils/ssoReturn.js +0 -263
  157. package/dist/types/AuthManager.d.ts +0 -380
  158. package/dist/types/AuthManagerTypes.d.ts +0 -81
  159. package/dist/types/CrossDomainAuth.d.ts +0 -164
  160. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  161. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  162. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  163. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  164. package/dist/types/utils/ssoReturn.d.ts +0 -146
  165. package/src/AuthManager.ts +0 -1269
  166. package/src/AuthManagerTypes.ts +0 -86
  167. package/src/CrossDomainAuth.ts +0 -243
  168. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  169. package/src/__tests__/authManager.security.test.ts +0 -377
  170. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  171. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  172. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  173. package/src/mixins/OxyServices.redirect.ts +0 -122
  174. package/src/mixins/OxyServices.silent.ts +0 -272
  175. package/src/mixins/OxyServices.sso.ts +0 -261
  176. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  177. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  178. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  179. package/src/mixins/__tests__/silent.test.ts +0 -102
  180. package/src/mixins/__tests__/sso.test.ts +0 -228
  181. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  182. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  183. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  184. package/src/utils/__tests__/ssoReturn.test.ts +0 -251
  185. package/src/utils/ssoEstablish.ts +0 -174
  186. package/src/utils/ssoReturn.ts +0 -372
@@ -273,8 +273,11 @@ class HttpService {
273
273
  try {
274
274
  // Build URL with params
275
275
  const fullUrl = this.buildURL(url, params);
276
- // Get auth token (with auto-refresh)
277
- const authHeader = await this.getAuthHeader();
276
+ // Get auth token (with auto-refresh). `skipAuth` requests (the
277
+ // body-authenticated refresh endpoint) send NO bearer and skip the
278
+ // near-expiry preflight — see RequestOptions.skipAuth for the deadlock
279
+ // this avoids.
280
+ const authHeader = config.skipAuth ? null : await this.getAuthHeader();
278
281
  // CSRF protects cookie-authenticated browser writes. Bearer-authenticated
279
282
  // SDK clients are not vulnerable to ambient-cookie CSRF, and linked app
280
283
  // APIs should not need to implement a duplicate `/csrf-token` route.
@@ -361,7 +364,7 @@ class HttpService {
361
364
  // On 401, delegate refresh to AuthManager and retry once before
362
365
  // giving up. HttpService deliberately does not know any session
363
366
  // routes; the AuthManager is the single session authority.
364
- if (response.status === 401 && !config._isAuthRetry) {
367
+ if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
365
368
  const refreshed = await this.refreshAccessToken('response-401');
366
369
  if (refreshed) {
367
370
  // `deduplicate: false` is REQUIRED on the retry (mirrors the 403
@@ -10,10 +10,6 @@ const jwt_decode_1 = require("jwt-decode");
10
10
  const errorUtils_1 = require("./utils/errorUtils");
11
11
  const HttpService_1 = require("./HttpService");
12
12
  const OxyServices_errors_1 = require("./OxyServices.errors");
13
- const authWebUrl_1 = require("./utils/authWebUrl");
14
- const platform_1 = require("./utils/platform");
15
- const fapiAutoDetect_1 = require("./utils/fapiAutoDetect");
16
- const loggerUtils_1 = require("./utils/loggerUtils");
17
13
  /**
18
14
  * Base class for OxyServices with core infrastructure
19
15
  */
@@ -25,21 +21,14 @@ class OxyServicesBase {
25
21
  if (!config || typeof config !== 'object') {
26
22
  throw new Error('OxyConfig is required');
27
23
  }
28
- // Default `authWebUrl` to the CENTRAL IdP (`auth.oxy.so`) when the caller
29
- // did not pin it explicitly. TRUE central cross-domain SSO (Google/Meta/
30
- // Clerk style) routes every RP through the one central IdP it owns the
31
- // host-only `fedcm_session` cookie and the central session store so the
32
- // SDK no longer derives a per-apex `auth.<rp-apex>` IdP by default.
33
- // `autoDetectAuthWebUrl` is still exported for any call site that opts into
34
- // per-apex resolution, but it is NOT the constructor default anymore.
35
- // An explicit `authWebUrl` always wins (we only fill it when absent).
36
- const resolvedConfig = config.authWebUrl
37
- ? config
38
- : { ...config, authWebUrl: (0, authWebUrl_1.resolveCentralAuthUrl)(config.authWebUrl) };
39
- this.config = resolvedConfig;
40
- this.cloudURL = resolvedConfig.cloudURL || 'https://cloud.oxy.so';
24
+ // `authWebUrl` is a plain optional config value now (used only for building
25
+ // third-party "Sign in with Oxy" OAuth links). The SDK no longer derives or
26
+ // defaults an IdP host the device-first cold boot restores sessions from
27
+ // the persisted refresh store, not an `auth.<apex>` bounce.
28
+ this.config = config;
29
+ this.cloudURL = config.cloudURL || 'https://cloud.oxy.so';
41
30
  // Initialize unified HTTP service (handles auth, caching, deduplication, queuing, retry)
42
- this.httpService = new HttpService_1.HttpService(resolvedConfig);
31
+ this.httpService = new HttpService_1.HttpService(config);
43
32
  }
44
33
  // Test-only utility to reset tokens on this instance between jest tests
45
34
  // Note: tokens are now per-instance, so create new instances in tests for isolation
@@ -68,22 +57,6 @@ class OxyServicesBase {
68
57
  getBaseURL() {
69
58
  return this.httpService.getBaseURL();
70
59
  }
71
- /**
72
- * Get the base URL the SDK's first-party session/refresh calls should target.
73
- *
74
- * Returns the configured `sessionBaseUrl` when provided, otherwise falls back
75
- * to the API `baseURL` (`getBaseURL()`). Per the 2026 session architecture
76
- * (docs/SESSION-ARCHITECTURE.md), non-`oxy.so` apps point this at their own
77
- * same-site backend (e.g. `https://api.mention.earth`) whose session bridge
78
- * forwards the user's refresh credential to `api.oxy.so`; `*.oxy.so` apps
79
- * leave it unset so it resolves to `https://api.oxy.so` and nothing changes.
80
- *
81
- * This is additive: it only exposes configuration for `@oxyhq/services` to
82
- * consume in a later phase. No refresh/auth logic in core reads it yet.
83
- */
84
- getSessionBaseUrl() {
85
- return this.config.sessionBaseUrl ?? this.getBaseURL();
86
- }
87
60
  /**
88
61
  * Get the HTTP service instance
89
62
  * Useful for advanced use cases where direct access to the HTTP service is needed
@@ -256,74 +229,6 @@ class OxyServicesBase {
256
229
  getAccessToken() {
257
230
  return this.httpService.getAccessToken();
258
231
  }
259
- /**
260
- * Register the CURRENTLY-ACTIVE session in the device's first-party
261
- * multi-account refresh-cookie set by calling `POST /auth/session`.
262
- *
263
- * This is the single, shared primitive every web primary-session commit and the
264
- * account switch use to plant their `oxy_rt_<authuser>` slot. It MUST be a
265
- * dedicated call to `/auth/session` rather than relying on whichever endpoint
266
- * established the session: that endpoint is frequently OUTSIDE the cookie's
267
- * `Path=/auth` scope (`/accounts/:id/switch`) or is a cross-origin/credential-
268
- * less restore (`/sso/exchange`, the IdP `/auth/silent` postMessage) that cannot
269
- * set an `api.oxy.so` cookie at all. `/auth/session` runs where the device's
270
- * existing slots ARE visible, so the server resolves this user's slot (reusing an
271
- * existing one or allocating a new one) without clobbering a sibling account,
272
- * mints a fresh access token bound to the same session, and returns the resolved
273
- * `authuser`.
274
- *
275
- * Behaviour:
276
- * - Requires a planted bearer (the caller must have already installed the
277
- * session's access token); `/auth/session` derives the session from it.
278
- * - On success re-plants the rotated access token (so the active token matches
279
- * the freshly-rotated cookie) and returns the device `authuser` slot.
280
- * - WEB-ONLY: on native there are no first-party refresh cookies → returns
281
- * `null`.
282
- * - FIRST-PARTY-ONLY: the cookie is host-only on the API host with
283
- * `SameSite=Lax`, so it only sticks when the page is SAME-SITE (same
284
- * registrable apex) as the API. On a cross-apex RP (`mention.earth` calling
285
- * `api.oxy.so`) the browser rejects the `Set-Cookie` as a third-party cookie,
286
- * so a returned slot would be a phantom never enumerated by `refresh-all`.
287
- * Those RPs durably restore via the per-apex `/auth/silent` iframe + `/sso`
288
- * bounce, NOT this device set → returns `null` without calling the API.
289
- * - BEST-EFFORT: a failure (e.g. transient network) never throws — the session
290
- * stays active in-memory; only its reload durability via the device set is at
291
- * risk. The caller treats `null` as "not registered in the device set".
292
- *
293
- * @returns The resolved device `authuser` slot, or `null` on native / cross-apex
294
- * / failure.
295
- */
296
- async establishDeviceRefreshSlot() {
297
- if (!(0, platform_1.isWeb)()) {
298
- return null;
299
- }
300
- if (typeof window !== 'undefined' && window.location?.hostname) {
301
- const pageApex = (0, fapiAutoDetect_1.registrableApex)(window.location.hostname);
302
- let apiApex = null;
303
- try {
304
- apiApex = (0, fapiAutoDetect_1.registrableApex)(new URL(this.getBaseURL()).hostname);
305
- }
306
- catch {
307
- apiApex = null;
308
- }
309
- if (!pageApex || !apiApex || pageApex !== apiApex) {
310
- return null;
311
- }
312
- }
313
- try {
314
- const established = await this.makeRequest('POST', '/auth/session', undefined, { cache: false });
315
- // `/auth/session` mints a fresh access token off the same session; re-plant
316
- // it so the active token matches the rotated cookie.
317
- if (established?.accessToken) {
318
- this.setTokens(established.accessToken);
319
- }
320
- return typeof established?.authuser === 'number' ? established.authuser : null;
321
- }
322
- catch (error) {
323
- loggerUtils_1.logger.warn('[OxyServices] Failed to establish device refresh cookie via POST /auth/session; the session is active in-session but may not survive a reload as part of the device account set', { component: 'OxyServices', method: 'establishDeviceRefreshSlot' }, error);
324
- return null;
325
- }
326
- }
327
232
  /**
328
233
  * Decode the current access token and return its `exp` claim in SECONDS since
329
234
  * the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
@@ -0,0 +1,350 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BOOT_ATTEMPTED_KEY = void 0;
4
+ exports.createBrowserColdBootDom = createBrowserColdBootDom;
5
+ exports.isSameApex = isSameApex;
6
+ exports.runSessionColdBoot = runSessionColdBoot;
7
+ /**
8
+ * coldBootV2 — one device-first cold boot for every consumer.
9
+ *
10
+ * On a fresh page load / app launch this resolves the device's session in a
11
+ * deterministic order, built on the pure `runColdBoot` primitive. It NEVER
12
+ * redirects to a login page: an unresolved boot ends in a signed-out state that
13
+ * the app renders with a "Sign in with Oxy" button.
14
+ *
15
+ * Ordered steps (first to yield a session wins):
16
+ * 1. `bootstrap-return` (web) — consume a `#oxy_boot` return fragment from a
17
+ * just-completed cross-apex hop: strip it, verify state, persist the
18
+ * deviceToken, exchange the code.
19
+ * 2. `stored-tokens` — the persisted per-origin refresh family: warm-plant a
20
+ * still-valid access token, else rotate via `/auth/refresh-token`.
21
+ * 3. `shared-key-signin` (native) — re-mint from the shared-keychain identity,
22
+ * issuing + mirroring a shared deviceToken the first time.
23
+ * 4. `bootstrap-hop` (web) — same-apex: an inline credentialed
24
+ * `/auth/device/web-session` fetch (no redirect); cross-apex: ONE top-level
25
+ * navigation to `/auth/device/bootstrap`, guarded once-ever per origin.
26
+ * 5. Signed out.
27
+ *
28
+ * All mutable guard state lives in STORAGE (localStorage `oxy.boot.attempted` +
29
+ * sessionStorage `oxy.boot.state`), never in module scope, so the guard holds
30
+ * under Metro/bundler re-evaluation.
31
+ *
32
+ * ESM-safe (no `require()`); no react/react-native/expo imports.
33
+ */
34
+ const contracts_1 = require("@oxyhq/contracts");
35
+ const coldBoot_1 = require("../utils/coldBoot");
36
+ const platform_1 = require("../utils/platform");
37
+ const keyManager_1 = require("../crypto/keyManager");
38
+ const loggerUtils_1 = require("../utils/loggerUtils");
39
+ const refresh_1 = require("../session/refresh");
40
+ const deviceBootReturn_1 = require("./deviceBootReturn");
41
+ /**
42
+ * localStorage flag marking that the cross-apex bootstrap navigation has fired
43
+ * once for this origin. Persistent (not session) so the visible redirect
44
+ * happens AT MOST ONCE EVER per browser+origin — a signed-out user is never
45
+ * bounced again; they sign in explicitly.
46
+ */
47
+ exports.BOOT_ATTEMPTED_KEY = 'oxy.boot.attempted';
48
+ /**
49
+ * Do not warm-plant a stored access token with less than this remaining — it
50
+ * would need an immediate refresh anyway, so fall through to the rotate path.
51
+ * Matches the refresh lead window.
52
+ */
53
+ const WARM_MIN_REMAINING_MS = 60000;
54
+ /**
55
+ * Generate a 128-bit hex CSRF state token. Prefers Web Crypto
56
+ * (`crypto.getRandomValues`, present in browsers and modern Node); falls back
57
+ * to a time+`Math.random` mix only when no CSPRNG is reachable (this token
58
+ * gates a single-use CSRF echo, not a long-lived secret).
59
+ */
60
+ function generateStateToken() {
61
+ const cryptoObj = globalThis.crypto;
62
+ if (cryptoObj?.getRandomValues) {
63
+ const bytes = new Uint8Array(16);
64
+ cryptoObj.getRandomValues(bytes);
65
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
66
+ }
67
+ return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`;
68
+ }
69
+ /**
70
+ * The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
71
+ * missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
72
+ * or no-op, so the boot degrades to signed-out rather than crashing.
73
+ */
74
+ function createBrowserColdBootDom() {
75
+ const safe = (fn, fallback) => {
76
+ try {
77
+ return fn();
78
+ }
79
+ catch {
80
+ return fallback;
81
+ }
82
+ };
83
+ return {
84
+ getHash: () => safe(() => (typeof window !== 'undefined' ? window.location.hash : ''), ''),
85
+ stripFragment: () => safe(() => {
86
+ if (typeof window !== 'undefined' && window.history?.replaceState) {
87
+ const { pathname, search } = window.location;
88
+ window.history.replaceState(null, '', `${pathname}${search}`);
89
+ }
90
+ }, undefined),
91
+ getSessionItem: (key) => safe(() => (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null), null),
92
+ setSessionItem: (key, value) => safe(() => {
93
+ if (typeof sessionStorage !== 'undefined')
94
+ sessionStorage.setItem(key, value);
95
+ }, undefined),
96
+ removeSessionItem: (key) => safe(() => {
97
+ if (typeof sessionStorage !== 'undefined')
98
+ sessionStorage.removeItem(key);
99
+ }, undefined),
100
+ getLocalItem: (key) => safe(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null), null),
101
+ setLocalItem: (key, value) => safe(() => {
102
+ if (typeof localStorage !== 'undefined')
103
+ localStorage.setItem(key, value);
104
+ }, undefined),
105
+ getLocationHostname: () => safe(() => (typeof window !== 'undefined' ? window.location.hostname : null), null),
106
+ getReturnToHref: () => safe(() => {
107
+ if (typeof window === 'undefined')
108
+ return null;
109
+ const { origin, pathname, search } = window.location;
110
+ return `${origin}${pathname}${search}`;
111
+ }, null),
112
+ navigate: (url) => safe(() => {
113
+ if (typeof window !== 'undefined')
114
+ window.location.assign(url);
115
+ }, undefined),
116
+ randomState: generateStateToken,
117
+ };
118
+ }
119
+ /**
120
+ * Registrable domain = the last two labels of a host. A deliberately SMALL
121
+ * local helper (the plan forbids coupling the boot to `fapiAutoDetect`/`tldts`).
122
+ * Correct for every Oxy apex (all two-label domains: `oxy.so`, `mention.earth`,
123
+ * `alia.onl`, `homiio.com`); the only imprecision — a multi-part public suffix
124
+ * such as `co.uk` — never arises here because the compared API host is always
125
+ * `api.oxy.so`, so a page under a different registrable domain still classifies
126
+ * as cross-apex.
127
+ */
128
+ function registrableDomain(host) {
129
+ const labels = host.toLowerCase().split('.').filter(Boolean);
130
+ return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
131
+ }
132
+ /**
133
+ * Is `host` an IP literal (v4/v6) or a single-label host (`localhost`)? Such
134
+ * hosts have NO registrable domain — the last-two-labels heuristic would
135
+ * mis-group them (`192.168.1.1` and `10.0.1.1` both collapse to `1.1`; IPv6
136
+ * `::1` and `localhost` are single "labels"), so a LAN/dev page could be wrongly
137
+ * classified same-apex as a different LAN API and skip the cross-apex hop.
138
+ */
139
+ function isIpOrSingleLabel(host) {
140
+ if (host.includes(':')) {
141
+ return true; // IPv6 literal
142
+ }
143
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
144
+ return true; // IPv4 literal
145
+ }
146
+ return !host.includes('.'); // single-label (e.g. `localhost`)
147
+ }
148
+ /**
149
+ * True when both hosts are same-site / same-apex. For a normal multi-label host
150
+ * pair, that means sharing a registrable domain. For an IP literal or a
151
+ * single-label host (no registrable domain), same-apex requires the two hosts
152
+ * to be EXACTLY equal — never grouped by a spurious trailing-label match.
153
+ */
154
+ function isSameApex(pageHost, apiHost) {
155
+ const a = pageHost.toLowerCase();
156
+ const b = apiHost.toLowerCase();
157
+ if (isIpOrSingleLabel(a) || isIpOrSingleLabel(b)) {
158
+ return a === b;
159
+ }
160
+ const ra = registrableDomain(a);
161
+ const rb = registrableDomain(b);
162
+ return ra !== '' && ra === rb;
163
+ }
164
+ /** Build a `DeviceBootSession` from a persisted state (post-refresh/warm-plant). */
165
+ function sessionFromPersisted(state, accessToken) {
166
+ return { sessionId: state.sessionId, userId: state.userId, accessToken };
167
+ }
168
+ /**
169
+ * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
170
+ * a side effect, invokes `onSession` (winning session, token already planted)
171
+ * or `onSignedOut` (no session — unless the boot is navigating away for the
172
+ * cross-apex hop, in which case neither fires).
173
+ */
174
+ async function runSessionColdBoot(opts) {
175
+ const { oxy, store } = opts;
176
+ const dom = opts.dom ?? createBrowserColdBootDom();
177
+ const isWeb = opts.platform?.isWeb ?? (0, platform_1.isWeb)();
178
+ const isNative = opts.platform?.isNative ?? (0, platform_1.isNative)();
179
+ // Signed-out reason + navigating flag are boot-local (not module-level), so
180
+ // they cannot leak across boots or break under bundler re-evaluation.
181
+ let signedOutReason = 'no_session';
182
+ let navigating = false;
183
+ const steps = [];
184
+ // 1. bootstrap-return (web) — consume a #oxy_boot fragment.
185
+ steps.push({
186
+ id: 'bootstrap-return',
187
+ enabled: () => isWeb && (0, deviceBootReturn_1.hashHasBootFragment)(dom.getHash()),
188
+ run: async () => {
189
+ const outcome = await (0, deviceBootReturn_1.consumeDeviceBootReturn)({
190
+ hash: dom.getHash(),
191
+ stripFragment: () => dom.stripFragment(),
192
+ readExpectedState: () => dom.getSessionItem(deviceBootReturn_1.BOOT_STATE_SESSION_KEY),
193
+ clearExpectedState: () => dom.removeSessionItem(deviceBootReturn_1.BOOT_STATE_SESSION_KEY),
194
+ store,
195
+ exchangeBootCode: (code) => oxy.exchangeBootCode(code),
196
+ plantAccessToken: (accessToken) => oxy.setTokens(accessToken),
197
+ });
198
+ if (outcome.kind === 'session') {
199
+ return { kind: 'session', session: outcome.session };
200
+ }
201
+ if (outcome.kind === 'state-mismatch') {
202
+ signedOutReason = 'state-mismatch';
203
+ }
204
+ else if (outcome.kind === 'no-session') {
205
+ // `DeviceBootReason` also carries 'session'; a no-session outcome with
206
+ // that reason (a `session` reason but no code) is still signed out.
207
+ signedOutReason = outcome.reason === 'new_device' ? 'new_device' : 'no_session';
208
+ }
209
+ // Fall through: a stored refresh family (a prior same-origin session) may
210
+ // still recover below. The once-ever flag blocks a second hop.
211
+ return { kind: 'skip' };
212
+ },
213
+ });
214
+ // 2. stored-tokens — warm-plant or rotate the persisted refresh family.
215
+ steps.push({
216
+ id: 'stored-tokens',
217
+ run: async () => {
218
+ const persisted = await store.load();
219
+ if (!persisted) {
220
+ return { kind: 'skip' };
221
+ }
222
+ // Warm path: a still-valid access token → plant immediately (no network).
223
+ if (persisted.accessToken &&
224
+ persisted.expiresAt &&
225
+ Date.parse(persisted.expiresAt) - Date.now() > WARM_MIN_REMAINING_MS) {
226
+ oxy.setTokens(persisted.accessToken);
227
+ return { kind: 'session', session: sessionFromPersisted(persisted, persisted.accessToken) };
228
+ }
229
+ // Rotate path: refreshPersistedSession plants + persists, and clears the
230
+ // store on a family-revoked error.
231
+ const token = await (0, refresh_1.refreshPersistedSession)({ oxy, store, allowSharedKeyFallback: isNative });
232
+ if (!token) {
233
+ return { kind: 'skip' };
234
+ }
235
+ const after = await store.load();
236
+ const base = after ?? persisted;
237
+ return { kind: 'session', session: sessionFromPersisted(base, token) };
238
+ },
239
+ });
240
+ // 3. shared-key-signin (native) — re-mint from the shared identity.
241
+ steps.push({
242
+ id: 'shared-key-signin',
243
+ enabled: () => isNative,
244
+ run: async () => {
245
+ const session = await oxy.signInWithSharedIdentity();
246
+ if (!session?.accessToken) {
247
+ return { kind: 'skip' };
248
+ }
249
+ // First shared-key sign-in on this device: issue + persist + mirror a
250
+ // shared deviceToken so every native Oxy app joins one DeviceSession.
251
+ // Best-effort — never fail the sign-in over device-token issuance.
252
+ try {
253
+ const existing = await keyManager_1.KeyManager.getSharedDeviceToken();
254
+ if (!existing) {
255
+ const deviceToken = await oxy.issueNativeDeviceToken();
256
+ await store.saveDeviceToken(deviceToken);
257
+ await keyManager_1.KeyManager.setSharedDeviceToken(deviceToken);
258
+ }
259
+ }
260
+ catch (error) {
261
+ loggerUtils_1.logger.debug('Native deviceToken issuance skipped', { component: 'coldBootV2', method: 'shared-key-signin' }, error);
262
+ }
263
+ return {
264
+ kind: 'session',
265
+ session: {
266
+ sessionId: session.sessionId,
267
+ userId: session.user.id,
268
+ accessToken: session.accessToken,
269
+ },
270
+ };
271
+ },
272
+ });
273
+ // 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
274
+ steps.push({
275
+ id: 'bootstrap-hop',
276
+ enabled: () => isWeb,
277
+ run: async () => {
278
+ const pageHost = dom.getLocationHostname();
279
+ let apiHost = null;
280
+ try {
281
+ apiHost = new URL(oxy.getBaseURL()).hostname;
282
+ }
283
+ catch {
284
+ apiHost = null;
285
+ }
286
+ if (!pageHost || !apiHost) {
287
+ return { kind: 'skip' };
288
+ }
289
+ // Same-apex: inline credentialed fetch, no redirect, runs every boot.
290
+ if (isSameApex(pageHost, apiHost)) {
291
+ const result = await oxy.requestWebSession();
292
+ // The rotated deviceToken is on BOTH arms (it is device-level, not
293
+ // session-level) — persist it before branching on the session.
294
+ await store.saveDeviceToken(result.deviceToken);
295
+ if (result.reason === 'session') {
296
+ const bundle = result.session;
297
+ const userId = (0, contracts_1.resolveUserId)(bundle.user);
298
+ if (!userId) {
299
+ return { kind: 'skip' };
300
+ }
301
+ const next = {
302
+ sessionId: bundle.sessionId,
303
+ refreshToken: bundle.refreshToken,
304
+ userId,
305
+ deviceToken: result.deviceToken,
306
+ accessToken: bundle.accessToken,
307
+ expiresAt: bundle.expiresAt,
308
+ };
309
+ await store.save(next);
310
+ oxy.setTokens(bundle.accessToken);
311
+ return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
312
+ }
313
+ // Known device, signed out.
314
+ signedOutReason = result.reason;
315
+ return { kind: 'skip' };
316
+ }
317
+ // Cross-apex: ONE visible top-level navigation, once-ever per origin.
318
+ if (dom.getLocalItem(exports.BOOT_ATTEMPTED_KEY)) {
319
+ return { kind: 'skip' };
320
+ }
321
+ const returnTo = opts.returnTo ?? dom.getReturnToHref();
322
+ if (!returnTo) {
323
+ return { kind: 'skip' };
324
+ }
325
+ const state = dom.randomState();
326
+ dom.setSessionItem(deviceBootReturn_1.BOOT_STATE_SESSION_KEY, state);
327
+ dom.setLocalItem(exports.BOOT_ATTEMPTED_KEY, '1');
328
+ navigating = true;
329
+ dom.navigate(oxy.buildBootstrapUrl(returnTo, state));
330
+ return { kind: 'skip' };
331
+ },
332
+ });
333
+ const outcome = await (0, coldBoot_1.runColdBoot)({
334
+ steps,
335
+ onStepError: (id, error) => {
336
+ signedOutReason = 'error';
337
+ opts.onStepError?.(id, error);
338
+ },
339
+ });
340
+ if (outcome.kind === 'session') {
341
+ await opts.onSession?.({ ...outcome.session, via: outcome.via });
342
+ return outcome;
343
+ }
344
+ // Navigating away for the cross-apex hop: the page is unloading, so do not
345
+ // flash a signed-out state.
346
+ if (!navigating) {
347
+ await opts.onSignedOut?.(signedOutReason);
348
+ }
349
+ return outcome;
350
+ }
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BOOT_STATE_SESSION_KEY = exports.BOOT_FRAGMENT_PARAM = void 0;
4
+ exports.hashHasBootFragment = hashHasBootFragment;
5
+ exports.parseDeviceBootFragment = parseDeviceBootFragment;
6
+ exports.consumeDeviceBootReturn = consumeDeviceBootReturn;
7
+ /**
8
+ * Device-boot return-fragment consumption (web cross-apex hop).
9
+ *
10
+ * After the top-level `GET /auth/device/bootstrap` hop, the API 303s back to the
11
+ * RP with a `#oxy_boot=<base64url(JSON)>` fragment. This module parses and
12
+ * consumes it: it strips the fragment from the URL FIRST (so the opaque
13
+ * deviceToken / code never linger in history or a `Referer`), verifies the
14
+ * echoed CSRF `state` against the value the initiator stashed in
15
+ * `sessionStorage`, persists the deviceToken, and — when a session resolved —
16
+ * exchanges the single-use `code` for a token bundle.
17
+ *
18
+ * Pure/injectable: all DOM access (hash, `history.replaceState`,
19
+ * `sessionStorage`) is passed in as callbacks so the logic is unit-testable
20
+ * under the jest `node` environment and reusable by `coldBootV2`.
21
+ *
22
+ * ESM-safe (no `require()`).
23
+ */
24
+ const contracts_1 = require("@oxyhq/contracts");
25
+ /** The `#oxy_boot=` fragment parameter name the API appends on the return hop. */
26
+ exports.BOOT_FRAGMENT_PARAM = 'oxy_boot';
27
+ /**
28
+ * `sessionStorage` key under which the bootstrap-hop initiator stashes the
29
+ * 128-bit CSRF `state` before navigating, and which the return step reads back
30
+ * (single-use).
31
+ */
32
+ exports.BOOT_STATE_SESSION_KEY = 'oxy.boot.state';
33
+ /**
34
+ * Decode a base64url string to UTF-8 text, or `null` on any malformed input.
35
+ * Handles both web (`atob` + `TextDecoder`) and Node (`Buffer`) without a
36
+ * `require()` — the ESM build stays clean.
37
+ */
38
+ function base64UrlDecode(input) {
39
+ try {
40
+ let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
41
+ while (b64.length % 4 !== 0) {
42
+ b64 += '=';
43
+ }
44
+ if (typeof atob === 'function') {
45
+ const binary = atob(b64);
46
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
47
+ if (typeof TextDecoder !== 'undefined') {
48
+ return new TextDecoder().decode(bytes);
49
+ }
50
+ return binary;
51
+ }
52
+ if (typeof Buffer !== 'undefined') {
53
+ return Buffer.from(b64, 'base64').toString('utf-8');
54
+ }
55
+ return null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /** True when a location hash carries the `oxy_boot` return fragment. */
62
+ function hashHasBootFragment(hash) {
63
+ return new RegExp(`(^|[#&])${exports.BOOT_FRAGMENT_PARAM}=`).test(hash);
64
+ }
65
+ /**
66
+ * Extract + decode + validate the `oxy_boot` fragment from a location hash.
67
+ * Returns the parsed {@link DeviceBootFragment}, or `null` when the parameter
68
+ * is absent, not valid base64url, not JSON, or fails the contract schema.
69
+ */
70
+ function parseDeviceBootFragment(hash) {
71
+ const withoutHash = hash.startsWith('#') ? hash.slice(1) : hash;
72
+ const params = new URLSearchParams(withoutHash);
73
+ const raw = params.get(exports.BOOT_FRAGMENT_PARAM);
74
+ if (!raw) {
75
+ return null;
76
+ }
77
+ const json = base64UrlDecode(raw);
78
+ if (!json) {
79
+ return null;
80
+ }
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(json);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ return (0, contracts_1.safeParseContract)(contracts_1.deviceBootFragmentSchema, parsed);
89
+ }
90
+ /**
91
+ * Consume the device-boot return fragment.
92
+ *
93
+ * Order is load-bearing:
94
+ * 1. If no fragment is present, return `none` (no URL mutation).
95
+ * 2. STRIP the fragment from the URL immediately — before validation or any
96
+ * network — so the deviceToken/code never persist in history/referrer.
97
+ * 3. Verify the echoed `state` against the stashed (single-use) value; a
98
+ * mismatch returns `state-mismatch` without persisting or exchanging.
99
+ * 4. Persist the deviceToken (survives sign-out).
100
+ * 5. If a session resolved (`reason:'session'` + `code`), exchange the code,
101
+ * persist the rotated session, plant the token, and return `session`.
102
+ * Otherwise return `no-session` with the reason.
103
+ */
104
+ async function consumeDeviceBootReturn(deps) {
105
+ if (!hashHasBootFragment(deps.hash)) {
106
+ return { kind: 'none' };
107
+ }
108
+ // Strip FIRST — even a forged/malformed fragment must not linger in the URL.
109
+ deps.stripFragment();
110
+ const fragment = parseDeviceBootFragment(deps.hash);
111
+ if (!fragment) {
112
+ return { kind: 'none' };
113
+ }
114
+ const expected = deps.readExpectedState();
115
+ deps.clearExpectedState();
116
+ if (!expected || expected !== fragment.state) {
117
+ return { kind: 'state-mismatch' };
118
+ }
119
+ await deps.store.saveDeviceToken(fragment.deviceToken);
120
+ // `code` is guaranteed present on the `session` arm (the contract's
121
+ // discriminated union requires it; a session fragment without a code fails to
122
+ // parse and never reaches here).
123
+ if (fragment.reason === 'session') {
124
+ try {
125
+ const bundle = await deps.exchangeBootCode(fragment.code);
126
+ const userId = (0, contracts_1.resolveUserId)(bundle.user);
127
+ if (!userId) {
128
+ return { kind: 'no-session', reason: 'no_session' };
129
+ }
130
+ const next = {
131
+ sessionId: bundle.sessionId,
132
+ refreshToken: bundle.refreshToken,
133
+ userId,
134
+ deviceToken: fragment.deviceToken,
135
+ accessToken: bundle.accessToken,
136
+ expiresAt: bundle.expiresAt,
137
+ };
138
+ await deps.store.save(next);
139
+ deps.plantAccessToken(bundle.accessToken);
140
+ return {
141
+ kind: 'session',
142
+ session: { sessionId: bundle.sessionId, userId, accessToken: bundle.accessToken },
143
+ };
144
+ }
145
+ catch {
146
+ // The code burned/expired between hop and exchange — resolve signed-out
147
+ // rather than throwing (the once-ever hop already fired; do not retry).
148
+ return { kind: 'no-session', reason: 'no_session' };
149
+ }
150
+ }
151
+ return { kind: 'no-session', reason: fragment.reason };
152
+ }