@oxyhq/core 5.1.1 → 5.2.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 (114) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +27 -5
  3. package/dist/cjs/OxyServices.base.js +71 -0
  4. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  5. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  6. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  7. package/dist/cjs/i18n/locales/en-US.json +1 -0
  8. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  9. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  10. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  11. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  12. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  24. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  25. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  26. package/dist/cjs/index.js +3 -1
  27. package/dist/cjs/mixins/OxyServices.accounts.js +10 -25
  28. package/dist/cjs/mixins/OxyServices.assets.js +76 -0
  29. package/dist/cjs/utils/cache.js +2 -0
  30. package/dist/cjs/utils/ssoBounce.js +59 -0
  31. package/dist/esm/.tsbuildinfo +1 -1
  32. package/dist/esm/AuthManager.js +27 -5
  33. package/dist/esm/OxyServices.base.js +71 -0
  34. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  35. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  36. package/dist/esm/i18n/locales/de-DE.json +1 -0
  37. package/dist/esm/i18n/locales/en-US.json +1 -0
  38. package/dist/esm/i18n/locales/es-ES.json +1 -0
  39. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  40. package/dist/esm/i18n/locales/it-IT.json +1 -0
  41. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  42. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  43. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  44. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  45. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  46. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  47. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  48. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  49. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  50. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  51. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  52. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  53. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  54. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  55. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  56. package/dist/esm/index.js +1 -1
  57. package/dist/esm/mixins/OxyServices.accounts.js +10 -25
  58. package/dist/esm/mixins/OxyServices.assets.js +76 -0
  59. package/dist/esm/utils/cache.js +2 -0
  60. package/dist/esm/utils/ssoBounce.js +57 -0
  61. package/dist/types/.tsbuildinfo +1 -1
  62. package/dist/types/OxyServices.base.d.ts +38 -0
  63. package/dist/types/index.d.ts +2 -2
  64. package/dist/types/mixins/OxyServices.accounts.d.ts +1 -0
  65. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  66. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  67. package/dist/types/mixins/OxyServices.assets.d.ts +36 -1
  68. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  69. package/dist/types/mixins/OxyServices.civic.d.ts +1 -0
  70. package/dist/types/mixins/OxyServices.connectedApps.d.ts +1 -0
  71. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  72. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  73. package/dist/types/mixins/OxyServices.features.d.ts +1 -0
  74. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  75. package/dist/types/mixins/OxyServices.identity.d.ts +1 -0
  76. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  77. package/dist/types/mixins/OxyServices.links.d.ts +1 -0
  78. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  79. package/dist/types/mixins/OxyServices.nodes.d.ts +1 -0
  80. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  83. package/dist/types/mixins/OxyServices.reputation.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.silent.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.sso.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.user.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  90. package/dist/types/models/interfaces.d.ts +23 -0
  91. package/dist/types/utils/ssoBounce.d.ts +47 -0
  92. package/package.json +1 -1
  93. package/src/AuthManager.ts +29 -5
  94. package/src/OxyServices.base.ts +79 -0
  95. package/src/__tests__/establishDeviceRefreshSlot.test.ts +221 -0
  96. package/src/i18n/locales/ar-SA.json +1 -0
  97. package/src/i18n/locales/ca-ES.json +1 -0
  98. package/src/i18n/locales/de-DE.json +1 -0
  99. package/src/i18n/locales/en-US.json +1 -0
  100. package/src/i18n/locales/es-ES.json +1 -0
  101. package/src/i18n/locales/fr-FR.json +1 -0
  102. package/src/i18n/locales/it-IT.json +1 -0
  103. package/src/i18n/locales/ja-JP.json +1 -0
  104. package/src/i18n/locales/ko-KR.json +1 -0
  105. package/src/i18n/locales/pt-PT.json +1 -0
  106. package/src/i18n/locales/zh-CN.json +1 -0
  107. package/src/index.ts +3 -0
  108. package/src/mixins/OxyServices.accounts.ts +10 -33
  109. package/src/mixins/OxyServices.assets.ts +92 -1
  110. package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +135 -0
  111. package/src/models/interfaces.ts +24 -0
  112. package/src/utils/__tests__/ssoBounce.test.ts +41 -0
  113. package/src/utils/cache.ts +2 -0
  114. package/src/utils/ssoBounce.ts +61 -0
@@ -393,11 +393,33 @@ export class AuthManager {
393
393
  this.currentUser = session.user;
394
394
  }
395
395
  this.currentAuthMethod = method;
396
- const decodedAuthuser = session.accessToken
397
- ? AuthManager.decodeAuthuserFromAccessToken(session.accessToken)
396
+ // Register this primary session in the device's first-party multi-account
397
+ // refresh-cookie set (web only). Every web primary commit funnels through
398
+ // here (FedCM, central `/sso` return, credentials), but only a same-apex
399
+ // `/fedcm/exchange` plants an `oxy_rt_<authuser>` slot as a side effect — the
400
+ // cross-origin/credential-less restores (`/sso/exchange`, the IdP
401
+ // `/auth/silent` postMessage) cannot set an `api.oxy.so` cookie. WITHOUT this
402
+ // call those primaries never join the device set, so `refresh-all` returns no
403
+ // accounts and account-switch persistence has no foundation. The shared
404
+ // `POST /auth/session` primitive plants/reuses this user's slot where the
405
+ // cookies ARE visible, re-plants the rotated access token, and returns the
406
+ // AUTHORITATIVE `authuser` slot (the one actually written) — preferred over the
407
+ // JWT-decoded guess, which may not correspond to a real cookie. No-op on native
408
+ // (returns `null`); best-effort on transient failure.
409
+ const establishedAuthuser = await this.oxyServices.establishDeviceRefreshSlot();
410
+ // The slot mint rotated the access token; pick it up so the registry and the
411
+ // refresh authority track the cookie just written. Falls back to the original
412
+ // session token when the slot was a no-op (native) or failed.
413
+ const rotatedToken = (establishedAuthuser !== null ? this.oxyServices.getAccessToken() : null) ??
414
+ session.accessToken;
415
+ if (rotatedToken) {
416
+ this._lastKnownAccessToken = rotatedToken;
417
+ }
418
+ const decodedAuthuser = rotatedToken
419
+ ? AuthManager.decodeAuthuserFromAccessToken(rotatedToken)
398
420
  : null;
399
- const authuser = decodedAuthuser ?? 0;
400
- if (session.accessToken && session.sessionId) {
421
+ const authuser = establishedAuthuser ?? decodedAuthuser ?? 0;
422
+ if (rotatedToken && session.sessionId) {
401
423
  this.accounts.set(authuser, {
402
424
  authuser,
403
425
  sessionId: session.sessionId,
@@ -407,7 +429,7 @@ export class AuthManager {
407
429
  name: session.user.name,
408
430
  avatar: session.user.avatar ?? null,
409
431
  },
410
- accessToken: session.accessToken,
432
+ accessToken: rotatedToken,
411
433
  expiresAt: session.expiresAt,
412
434
  });
413
435
  this.activeAuthuser = authuser;
@@ -8,6 +8,9 @@ import { handleHttpError } from './utils/errorUtils.js';
8
8
  import { HttpService } from './HttpService.js';
9
9
  import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors.js';
10
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';
11
14
  /**
12
15
  * Base class for OxyServices with core infrastructure
13
16
  */
@@ -250,6 +253,74 @@ export class OxyServicesBase {
250
253
  getAccessToken() {
251
254
  return this.httpService.getAccessToken();
252
255
  }
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
+ }
253
324
  /**
254
325
  * Decode the current access token and return its `exp` claim in SECONDS since
255
326
  * the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "قائمة الحساب",
209
209
  "manage": "إدارة حساب Oxy الخاص بك",
210
+ "viewProfile": "عرض الملف الشخصي",
210
211
  "addAnother": "إضافة حساب آخر",
211
212
  "signOutAll": "تسجيل الخروج من جميع الحسابات",
212
213
  "open": "قائمة الحساب",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menú del compte",
209
209
  "manage": "Gestiona el teu compte d'Oxy",
210
+ "viewProfile": "Mostra el perfil",
210
211
  "addAnother": "Afegeix un altre compte",
211
212
  "signOutAll": "Tanca la sessió de tots els comptes",
212
213
  "open": "Menú del compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Kontomenü",
209
209
  "manage": "Ihr Oxy-Konto verwalten",
210
+ "viewProfile": "Profil ansehen",
210
211
  "addAnother": "Weiteres Konto hinzufügen",
211
212
  "signOutAll": "Von allen Konten abmelden",
212
213
  "open": "Kontomenü",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Account menu",
1557
1557
  "manage": "Manage your Oxy Account",
1558
+ "viewProfile": "View profile",
1558
1559
  "addAnother": "Add another account",
1559
1560
  "signOutAll": "Sign out of all accounts",
1560
1561
  "open": "Account menu",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Menú de cuenta",
1557
1557
  "manage": "Gestiona tu cuenta de Oxy",
1558
+ "viewProfile": "Ver perfil",
1558
1559
  "addAnother": "Añadir otra cuenta",
1559
1560
  "signOutAll": "Cerrar sesión en todas las cuentas",
1560
1561
  "open": "Menú de cuenta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu du compte",
209
209
  "manage": "Gérer votre compte Oxy",
210
+ "viewProfile": "Voir le profil",
210
211
  "addAnother": "Ajouter un autre compte",
211
212
  "signOutAll": "Se déconnecter de tous les comptes",
212
213
  "open": "Menu du compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu account",
209
209
  "manage": "Gestisci il tuo account Oxy",
210
+ "viewProfile": "Visualizza profilo",
210
211
  "addAnother": "Aggiungi un altro account",
211
212
  "signOutAll": "Esci da tutti gli account",
212
213
  "open": "Menu account",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "アカウントメニュー",
209
209
  "manage": "Oxy アカウントを管理",
210
+ "viewProfile": "プロフィールを表示",
210
211
  "addAnother": "別のアカウントを追加",
211
212
  "signOutAll": "すべてのアカウントからログアウト",
212
213
  "open": "アカウントメニュー",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "계정 메뉴",
209
209
  "manage": "Oxy 계정 관리",
210
+ "viewProfile": "프로필 보기",
210
211
  "addAnother": "다른 계정 추가",
211
212
  "signOutAll": "모든 계정에서 로그아웃",
212
213
  "open": "계정 메뉴",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "قائمة الحساب",
209
209
  "manage": "إدارة حساب Oxy الخاص بك",
210
+ "viewProfile": "عرض الملف الشخصي",
210
211
  "addAnother": "إضافة حساب آخر",
211
212
  "signOutAll": "تسجيل الخروج من جميع الحسابات",
212
213
  "open": "قائمة الحساب",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menú del compte",
209
209
  "manage": "Gestiona el teu compte d'Oxy",
210
+ "viewProfile": "Mostra el perfil",
210
211
  "addAnother": "Afegeix un altre compte",
211
212
  "signOutAll": "Tanca la sessió de tots els comptes",
212
213
  "open": "Menú del compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Kontomenü",
209
209
  "manage": "Ihr Oxy-Konto verwalten",
210
+ "viewProfile": "Profil ansehen",
210
211
  "addAnother": "Weiteres Konto hinzufügen",
211
212
  "signOutAll": "Von allen Konten abmelden",
212
213
  "open": "Kontomenü",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Account menu",
1557
1557
  "manage": "Manage your Oxy Account",
1558
+ "viewProfile": "View profile",
1558
1559
  "addAnother": "Add another account",
1559
1560
  "signOutAll": "Sign out of all accounts",
1560
1561
  "open": "Account menu",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Menú de cuenta",
1557
1557
  "manage": "Gestiona tu cuenta de Oxy",
1558
+ "viewProfile": "Ver perfil",
1558
1559
  "addAnother": "Añadir otra cuenta",
1559
1560
  "signOutAll": "Cerrar sesión en todas las cuentas",
1560
1561
  "open": "Menú de cuenta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu du compte",
209
209
  "manage": "Gérer votre compte Oxy",
210
+ "viewProfile": "Voir le profil",
210
211
  "addAnother": "Ajouter un autre compte",
211
212
  "signOutAll": "Se déconnecter de tous les comptes",
212
213
  "open": "Menu du compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu account",
209
209
  "manage": "Gestisci il tuo account Oxy",
210
+ "viewProfile": "Visualizza profilo",
210
211
  "addAnother": "Aggiungi un altro account",
211
212
  "signOutAll": "Esci da tutti gli account",
212
213
  "open": "Menu account",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "アカウントメニュー",
209
209
  "manage": "Oxy アカウントを管理",
210
+ "viewProfile": "プロフィールを表示",
210
211
  "addAnother": "別のアカウントを追加",
211
212
  "signOutAll": "すべてのアカウントからログアウト",
212
213
  "open": "アカウントメニュー",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "계정 메뉴",
209
209
  "manage": "Oxy 계정 관리",
210
+ "viewProfile": "프로필 보기",
210
211
  "addAnother": "다른 계정 추가",
211
212
  "signOutAll": "모든 계정에서 로그아웃",
212
213
  "open": "계정 메뉴",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu da conta",
209
209
  "manage": "Gerir a sua conta Oxy",
210
+ "viewProfile": "Ver perfil",
210
211
  "addAnother": "Adicionar outra conta",
211
212
  "signOutAll": "Terminar sessão em todas as contas",
212
213
  "open": "Menu da conta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "账户菜单",
209
209
  "manage": "管理您的 Oxy 账户",
210
+ "viewProfile": "查看个人资料",
210
211
  "addAnother": "添加其他账户",
211
212
  "signOutAll": "退出所有账户",
212
213
  "open": "账户菜单",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu da conta",
209
209
  "manage": "Gerir a sua conta Oxy",
210
+ "viewProfile": "Ver perfil",
210
211
  "addAnother": "Adicionar outra conta",
211
212
  "signOutAll": "Terminar sessão em todas as contas",
212
213
  "open": "Menu da conta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "账户菜单",
209
209
  "manage": "管理您的 Oxy 账户",
210
+ "viewProfile": "查看个人资料",
210
211
  "addAnother": "添加其他账户",
211
212
  "signOutAll": "退出所有账户",
212
213
  "open": "账户菜单",
package/dist/esm/index.js CHANGED
@@ -127,7 +127,7 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
127
127
  export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn.js';
128
128
  export { generateSsoState } from './mixins/OxyServices.sso.js';
129
129
  // SSO bounce — per-origin sessionStorage keys, bounce URL builder, predicates
130
- export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, allowSsoBounce, } from './utils/ssoBounce.js';
130
+ export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoSignedOutKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, silentRestoreSuppressed, allowSsoBounce, } from './utils/ssoBounce.js';
131
131
  export { runColdBoot } from './utils/coldBoot.js';
132
132
  // API response contracts (request/response Zod schemas + inferred types) live in
133
133
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every
@@ -1,6 +1,4 @@
1
1
  import { normalizeUserIdentity } from '../utils/userIdentity.js';
2
- import { isWeb } from '../utils/platform.js';
3
- import { logger } from '../utils/loggerUtils.js';
4
2
  import { CACHE_TIMES } from './mixinHelpers.js';
5
3
  export function OxyServicesAccountsMixin(Base) {
6
4
  return class extends Base {
@@ -91,29 +89,16 @@ export function OxyServicesAccountsMixin(Base) {
91
89
  }
92
90
  // Register the switched session in the device's multi-account set by
93
91
  // establishing its first-party refresh cookie. This MUST be a separate
94
- // call to `POST /auth/session`: the switch route is at `/accounts/*`,
95
- // outside the `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can
96
- // never read the device's existing slots and would overwrite slot 0
97
- // (destroying the operator's own session). `/auth/session` runs where the
98
- // cookies ARE visible, so the server allocates a NEW slot that coexists
99
- // with the operator's and returns its `authuser`. Web-only; best-effort.
100
- let authuser = res.authuser;
101
- if (isWeb()) {
102
- try {
103
- const established = await this.makeRequest('POST', '/auth/session', undefined, { cache: false });
104
- if (typeof established?.authuser === 'number') {
105
- authuser = established.authuser;
106
- }
107
- // /auth/session mints a fresh access token off the same session;
108
- // re-plant it so the active token matches the rotated cookie.
109
- if (established?.accessToken) {
110
- this.setTokens(established.accessToken);
111
- }
112
- }
113
- catch (error) {
114
- logger.warn('[OxyServices] Failed to establish device refresh cookie after account switch; the switch is active in-session but may not survive a reload', { component: 'OxyServices', method: 'switchToAccount' }, error);
115
- }
116
- }
92
+ // call to `POST /auth/session` (the shared `establishDeviceRefreshSlot`
93
+ // primitive): the switch route is at `/accounts/*`, outside the
94
+ // `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can never read
95
+ // the device's existing slots and would overwrite slot 0 (destroying the
96
+ // operator's own session). `/auth/session` runs where the cookies ARE
97
+ // visible, so the server allocates a NEW slot that coexists with the
98
+ // operator's and returns its `authuser`. Web-only; best-effort (the helper
99
+ // re-plants the rotated access token and returns `null` on native/failure).
100
+ const establishedAuthuser = await this.establishDeviceRefreshSlot();
101
+ const authuser = establishedAuthuser ?? res.authuser;
117
102
  // Identity changed → drop the entire GET response cache so no entry
118
103
  // personalised for the previous identity is reused. Cache keys are
119
104
  // identity-scoped, so a different identity could not READ the old
@@ -8,6 +8,19 @@ import { extractErrorStatus } from '../utils/errorUtils.js';
8
8
  * `getUsersByIds`'s `USERS_BY_IDS_CHUNK_SIZE`.
9
9
  */
10
10
  const SERVICE_ASSET_METADATA_CHUNK_SIZE = 100;
11
+ /**
12
+ * Maximum number of content hashes sent per `POST /assets/service/by-sha256`
13
+ * request. Matches the server-side cap (the route rejects empty or > 100 hash
14
+ * arrays with a 400); larger inputs are chunked and merged. Same ceiling as
15
+ * {@link SERVICE_ASSET_METADATA_CHUNK_SIZE} for the forward id lookup.
16
+ */
17
+ const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
18
+ /**
19
+ * Lowercase hex SHA-256 digest matcher (exactly 64 hex chars). The reverse
20
+ * lookup drops non-conforming hashes client-side so a single malformed value
21
+ * never 400s an otherwise-valid chunk on the server.
22
+ */
23
+ const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
11
24
  export function OxyServicesAssetsMixin(Base) {
12
25
  return class extends Base {
13
26
  constructor(...args) {
@@ -210,6 +223,69 @@ export function OxyServicesAssetsMixin(Base) {
210
223
  }));
211
224
  return settled.flat();
212
225
  }
226
+ /**
227
+ * Reverse content-address lookup: resolve many content `sha256` digests to
228
+ * the servable Oxy asset holding each, in one round-trip per chunk via
229
+ * `POST /assets/service/by-sha256` (body `{ sha256s }`).
230
+ *
231
+ * This is the INVERSE of {@link getServiceAssetMetadataByIds}: given a
232
+ * record's `blob.sha256`, it returns the asset's `id`, `mime`, byte `size`,
233
+ * `status`, and — for active, public, CDN-reachable assets only — a public
234
+ * `url` (`cloud.oxy.so`). Built for server-to-server callers (e.g. Mention's
235
+ * MTN materializer / node-blob sync) that hold a content hash and need to
236
+ * map it back to a servable asset. Hashes are lowercased, validated against
237
+ * a 64-char hex pattern (malformed entries dropped client-side), and
238
+ * deduplicated before being split into chunks of
239
+ * {@link SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE} (the server-side cap). The
240
+ * server omits unknown/deleted hashes from each chunk's `data`, so the
241
+ * merged result may be shorter than the requested list and the caller is
242
+ * expected to map by `sha256`.
243
+ *
244
+ * **Service-token auth (required).** `/assets/service/by-sha256` is guarded
245
+ * by `serviceAuthMiddleware` + the `files:read` scope and is called via
246
+ * `makeServiceRequest` (`Authorization: Bearer <serviceToken>`, `cache:false`).
247
+ * The calling client MUST be service-configured (`configureServiceAuth`)
248
+ * before invoking; a plain user-session request is rejected by the route's
249
+ * service-auth guard.
250
+ *
251
+ * Resilience: chunks are independent. A failed chunk is logged and skipped —
252
+ * every entry that resolved is still returned. An empty input (or one whose
253
+ * every value is malformed) resolves immediately with `[]` and performs no
254
+ * network call.
255
+ *
256
+ * Not cached at the SDK layer: it's a POST keyed on a multi-hash body (low
257
+ * hit rate), mirroring the sibling service/POST methods which never cache.
258
+ */
259
+ async getServiceAssetMetadataBySha256(sha256s) {
260
+ const uniqueShas = Array.from(new Set(sha256s
261
+ .filter((sha) => typeof sha === 'string')
262
+ .map((sha) => sha.trim().toLowerCase())
263
+ .filter((sha) => SHA256_HEX_PATTERN.test(sha))));
264
+ if (uniqueShas.length === 0) {
265
+ return [];
266
+ }
267
+ const chunks = [];
268
+ for (let i = 0; i < uniqueShas.length; i += SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE) {
269
+ chunks.push(uniqueShas.slice(i, i + SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE));
270
+ }
271
+ // Run chunks concurrently; a single chunk failure must not sink the rest.
272
+ const settled = await Promise.all(chunks.map(async (chunk) => {
273
+ try {
274
+ const entries = await this.makeServiceRequest('POST', '/assets/service/by-sha256', { sha256s: chunk });
275
+ return Array.isArray(entries) ? entries : [];
276
+ }
277
+ catch (error) {
278
+ logger.warn('getServiceAssetMetadataBySha256: chunk failed, continuing with remaining chunks', {
279
+ method: 'getServiceAssetMetadataBySha256',
280
+ chunkSize: chunk.length,
281
+ status: extractErrorStatus(error),
282
+ error: error instanceof Error ? error.message : String(error),
283
+ });
284
+ return [];
285
+ }
286
+ }));
287
+ return settled.flat();
288
+ }
213
289
  /**
214
290
  * Upload raw file data
215
291
  */
@@ -198,6 +198,8 @@ export function registerCacheForCleanup(cache) {
198
198
  cache.cleanup();
199
199
  }
200
200
  }, 60000); // Every minute
201
+ // Never block process/worker exit on a housekeeping timer.
202
+ cleanupInterval.unref?.();
201
203
  }
202
204
  }
203
205
  /**
@@ -65,6 +65,7 @@ const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
65
65
  const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
66
66
  const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
67
67
  const PRIOR_SESSION_KEY_PREFIX = 'oxy_sso_prior_session:';
68
+ const SIGNED_OUT_KEY_PREFIX = 'oxy_signed_out:';
68
69
  /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
69
70
  export function ssoStateKey(origin) {
70
71
  return `${STATE_KEY_PREFIX}${origin}`;
@@ -114,6 +115,36 @@ export function ssoAttemptedKey(origin) {
114
115
  export function ssoPriorSessionKey(origin) {
115
116
  return `${PRIOR_SESSION_KEY_PREFIX}${origin}`;
116
117
  }
118
+ /**
119
+ * Per-origin DURABLE "the user DELIBERATELY signed out on this device/origin"
120
+ * flag.
121
+ *
122
+ * Like {@link ssoPriorSessionKey} this lives in DURABLE storage (web
123
+ * `localStorage`; services uses its own `storageKeyPrefix`-scoped key), NOT the
124
+ * per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
125
+ *
126
+ * It exists purely to suppress AUTOMATIC silent restore after a deliberate
127
+ * sign-out: a still-live IdP session (the central `fedcm_session` / the FedCM
128
+ * credential association) would otherwise let `fedcm-silent` / the per-apex
129
+ * `/auth/silent` iframe re-mint a session on the very next cold boot, so a user
130
+ * who pressed "Sign out" gets silently signed back in on reload. With this flag
131
+ * set, those silent cold-boot steps are skipped while the Gmail-style
132
+ * returning-account fast-path is otherwise preserved.
133
+ *
134
+ * Lifecycle (mirrors the existing gate machinery — set on a definitive event,
135
+ * cleared on its inverse):
136
+ * - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
137
+ * and the SSO bounce state).
138
+ * - CLEARED on ANY deliberate sign-in (password, FedCM, account switch, device
139
+ * claim) so a real sign-in fully re-enables silent restore — there is no
140
+ * "stuck signed out" state.
141
+ *
142
+ * NOTE: this gates only AUTOMATIC/silent restore. An INTERACTIVE sign-in always
143
+ * clears it first, so the user can always sign back in.
144
+ */
145
+ export function ssoSignedOutKey(origin) {
146
+ return `${SIGNED_OUT_KEY_PREFIX}${origin}`;
147
+ }
117
148
  /**
118
149
  * Per-origin marker written by the pre-hydration callback bootstrap.
119
150
  *
@@ -244,6 +275,32 @@ export function guardActive(storage, origin, now = Date.now()) {
244
275
  }
245
276
  return now - ts < SSO_GUARD_TTL_MS;
246
277
  }
278
+ /**
279
+ * Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
280
+ * user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
281
+ *
282
+ * When `true`, the silent cold-boot steps that can re-mint a session from a
283
+ * still-live IdP session WITHOUT user intent — `fedcm-silent` and the per-apex
284
+ * `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
285
+ * not silently signed back in on the next reload. Interactive sign-in clears the
286
+ * flag, so this never blocks a deliberate re-sign-in.
287
+ *
288
+ * Defensive: a `getItem` that throws (locked/disabled storage) is treated as NOT
289
+ * suppressed, so the gate fails toward the normal restore behaviour rather than
290
+ * wedging the user out.
291
+ *
292
+ * @param storage - The DURABLE storage to read (web `localStorage`; injected for
293
+ * testability).
294
+ * @param origin - The page origin whose signed-out flag to evaluate.
295
+ */
296
+ export function silentRestoreSuppressed(storage, origin) {
297
+ try {
298
+ return storage.getItem(ssoSignedOutKey(origin)) === '1';
299
+ }
300
+ catch {
301
+ return false;
302
+ }
303
+ }
247
304
  /**
248
305
  * Decide whether the terminal `/sso` establish-bounce is ALLOWED for this
249
306
  * visitor (the smart `enabled` gate for the `sso-bounce` cold-boot step).