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