@oxyhq/core 7.1.1 → 8.1.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 (93) hide show
  1. package/README.md +48 -24
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +6 -6
  4. package/dist/cjs/boot/coldBootV2.js +97 -2
  5. package/dist/cjs/boot/deviceBootReturn.js +15 -0
  6. package/dist/cjs/i18n/locales/en-US.json +44 -1
  7. package/dist/cjs/i18n/locales/es-ES.json +44 -1
  8. package/dist/cjs/i18n/locales/locales/en-US.json +45 -2
  9. package/dist/cjs/i18n/locales/locales/es-ES.json +45 -2
  10. package/dist/cjs/index.js +19 -16
  11. package/dist/cjs/mixins/OxyServices.deviceBoot.js +28 -0
  12. package/dist/cjs/server/index.js +1 -7
  13. package/dist/cjs/session/accountDialogController.js +1 -1
  14. package/dist/cjs/session/accountProjection.js +1 -1
  15. package/dist/cjs/session/authStateStore.js +6 -0
  16. package/dist/cjs/session/projectSessionState.js +1 -1
  17. package/dist/cjs/session/refresh.js +9 -0
  18. package/dist/cjs/session/sessionClientHost.js +1 -2
  19. package/dist/cjs/utils/accountUtils.js +1 -1
  20. package/dist/cjs/utils/oauthPkce.js +142 -0
  21. package/dist/cjs/utils/platform.js +1 -1
  22. package/dist/esm/.tsbuildinfo +1 -1
  23. package/dist/esm/HttpService.js +6 -6
  24. package/dist/esm/boot/coldBootV2.js +97 -2
  25. package/dist/esm/boot/deviceBootReturn.js +15 -0
  26. package/dist/esm/i18n/locales/en-US.json +44 -1
  27. package/dist/esm/i18n/locales/es-ES.json +44 -1
  28. package/dist/esm/i18n/locales/locales/en-US.json +45 -2
  29. package/dist/esm/i18n/locales/locales/es-ES.json +45 -2
  30. package/dist/esm/index.js +11 -13
  31. package/dist/esm/mixins/OxyServices.deviceBoot.js +29 -1
  32. package/dist/esm/server/index.js +0 -5
  33. package/dist/esm/session/accountDialogController.js +1 -1
  34. package/dist/esm/session/accountProjection.js +1 -1
  35. package/dist/esm/session/authStateStore.js +6 -0
  36. package/dist/esm/session/projectSessionState.js +1 -1
  37. package/dist/esm/session/refresh.js +9 -0
  38. package/dist/esm/session/sessionClientHost.js +1 -2
  39. package/dist/esm/utils/accountUtils.js +1 -1
  40. package/dist/esm/utils/oauthPkce.js +135 -0
  41. package/dist/esm/utils/platform.js +1 -1
  42. package/dist/types/.tsbuildinfo +1 -1
  43. package/dist/types/HttpService.d.ts +1 -1
  44. package/dist/types/index.d.ts +3 -2
  45. package/dist/types/mixins/OxyServices.accounts.d.ts +13 -3
  46. package/dist/types/mixins/OxyServices.connectedApps.d.ts +4 -0
  47. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +17 -1
  48. package/dist/types/mixins/OxyServices.devices.d.ts +3 -2
  49. package/dist/types/models/interfaces.d.ts +4 -4
  50. package/dist/types/server/index.d.ts +0 -1
  51. package/dist/types/session/accountDialogController.d.ts +1 -1
  52. package/dist/types/session/accountProjection.d.ts +1 -1
  53. package/dist/types/session/authStateStore.d.ts +20 -0
  54. package/dist/types/session/projectSessionState.d.ts +1 -1
  55. package/dist/types/session/refresh.d.ts +4 -8
  56. package/dist/types/session/sessionClientHost.d.ts +1 -2
  57. package/dist/types/utils/accountUtils.d.ts +1 -1
  58. package/dist/types/utils/oauthPkce.d.ts +74 -0
  59. package/dist/types/utils/platform.d.ts +1 -1
  60. package/package.json +3 -3
  61. package/src/HttpService.ts +6 -6
  62. package/src/boot/__tests__/coldBootV2.test.ts +215 -1
  63. package/src/boot/__tests__/deviceBootReturn.test.ts +32 -0
  64. package/src/boot/coldBootV2.ts +117 -2
  65. package/src/boot/deviceBootReturn.ts +15 -0
  66. package/src/i18n/locales/en-US.json +45 -2
  67. package/src/i18n/locales/es-ES.json +45 -2
  68. package/src/index.ts +23 -16
  69. package/src/mixins/OxyServices.accounts.ts +12 -0
  70. package/src/mixins/OxyServices.connectedApps.ts +4 -0
  71. package/src/mixins/OxyServices.deviceBoot.ts +38 -0
  72. package/src/mixins/OxyServices.devices.ts +6 -5
  73. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +44 -1
  74. package/src/mixins/__tests__/accounts.test.ts +1 -1
  75. package/src/models/interfaces.ts +7 -5
  76. package/src/server/index.ts +0 -6
  77. package/src/session/__tests__/authStateStore.test.ts +27 -0
  78. package/src/session/__tests__/refresh.test.ts +14 -0
  79. package/src/session/accountDialogController.ts +1 -1
  80. package/src/session/accountProjection.ts +1 -1
  81. package/src/session/authStateStore.ts +26 -0
  82. package/src/session/projectSessionState.ts +1 -1
  83. package/src/session/refresh.ts +13 -8
  84. package/src/session/sessionClientHost.ts +1 -2
  85. package/src/utils/__tests__/coldBoot.test.ts +55 -65
  86. package/src/utils/__tests__/oauthPkce.test.ts +154 -0
  87. package/src/utils/accountUtils.ts +1 -1
  88. package/src/utils/oauthPkce.ts +189 -0
  89. package/src/utils/platform.ts +1 -1
  90. package/dist/cjs/utils/ssoBounce.js +0 -24
  91. package/dist/esm/utils/ssoBounce.js +0 -21
  92. package/dist/types/utils/ssoBounce.d.ts +0 -21
  93. package/src/utils/ssoBounce.ts +0 -22
@@ -53,7 +53,7 @@ const CSRF_FETCH_RETRY_DELAY_MS = 500;
53
53
  /**
54
54
  * Cooldown (ms) applied after a failed access-token refresh before another
55
55
  * refresh is attempted. Prevents a refresh storm (and server hammering) when
56
- * the AuthManager's refresh handler is failing — every in-flight request that
56
+ * the auth refresh handler is failing — every in-flight request that
57
57
  * hits a 401 would otherwise trigger its own refresh.
58
58
  */
59
59
  const TOKEN_REFRESH_COOLDOWN_MS = 15000;
@@ -140,7 +140,7 @@ export class HttpService {
140
140
  this.cacheSizeWarningSilentUntil = 0;
141
141
  /**
142
142
  * Fan-out listeners notified on EVERY access-token change on this instance:
143
- * explicit `setTokens`, `clearTokens`, an AuthManager-owned refresh, and the
143
+ * explicit `setTokens`, `clearTokens`, a refresh-handler rotation, and the
144
144
  * internal 401-driven clear. This is a Set so multiple independent observers
145
145
  * can mirror token state without clobbering each other.
146
146
  *
@@ -358,9 +358,9 @@ export class HttpService {
358
358
  clearTimeout(timeoutId);
359
359
  // Handle response
360
360
  if (!response.ok) {
361
- // On 401, delegate refresh to AuthManager and retry once before
362
- // giving up. HttpService deliberately does not know any session
363
- // routes; the AuthManager is the single session authority.
361
+ // On 401, delegate to the installed auth refresh handler and retry
362
+ // once before giving up. HttpService deliberately does not know any
363
+ // session routes; the refresh handler owns session rotation.
364
364
  if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
365
365
  const refreshed = await this.refreshAccessToken('response-401');
366
366
  if (refreshed) {
@@ -820,7 +820,7 @@ export class HttpService {
820
820
  this.tokenStore.setTokens(newToken);
821
821
  this.notifyTokenChange();
822
822
  }
823
- this.logger.debug('Token refreshed via AuthManager');
823
+ this.logger.debug('Token refreshed via the auth refresh handler');
824
824
  return newToken;
825
825
  })
826
826
  .catch((error) => {
@@ -28,6 +28,7 @@
28
28
  import { resolveUserId } from '@oxyhq/contracts';
29
29
  import { runColdBoot } from '../utils/coldBoot.js';
30
30
  import { isWeb as detectWeb, isNative as detectNative } from '../utils/platform.js';
31
+ import { extractErrorStatus } from '../utils/errorUtils.js';
31
32
  import { KeyManager } from '../crypto/keyManager.js';
32
33
  import { logger } from '../utils/loggerUtils.js';
33
34
  import { refreshPersistedSession } from '../session/refresh.js';
@@ -159,6 +160,18 @@ export function isSameApex(pageHost, apiHost) {
159
160
  function sessionFromPersisted(state, accessToken) {
160
161
  return { sessionId: state.sessionId, userId: state.userId, accessToken };
161
162
  }
163
+ function classifyMintFailure(error) {
164
+ if (extractErrorStatus(error) === 401) {
165
+ // Structural read (not `instanceof Error`): the thrown value can be a plain
166
+ // ApiError-shaped object or come from another realm, where instanceof fails
167
+ // and a `no_active_session` would be misread as a stale secret and dropped.
168
+ const message = error?.message;
169
+ return typeof message === 'string' && message.includes('no_active_session')
170
+ ? 'no_active_session'
171
+ : 'invalid_secret';
172
+ }
173
+ return 'transient';
174
+ }
162
175
  /**
163
176
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
164
177
  * a side effect, invokes `onSession` (winning session, token already planted)
@@ -174,7 +187,73 @@ export async function runSessionColdBoot(opts) {
174
187
  // they cannot leak across boots or break under bundler re-evaluation.
175
188
  let signedOutReason = 'no_session';
176
189
  let navigating = false;
190
+ // Set when the zero-cookie mint reports `no_active_session` (phase 2c): the
191
+ // device is authoritatively signed out, so the migratory fallback lanes below
192
+ // (stored-tokens / shared-key / bootstrap-hop) must NOT run — we already know
193
+ // there is no session and must not bounce a known-signed-out device.
194
+ let deviceKnownSignedOut = false;
177
195
  const steps = [];
196
+ // 0. device-secret-mint (phase 2c) — the zero-cookie fast path. When the
197
+ // origin persisted a deviceId + deviceSecret, mint a short access token with
198
+ // a single bearer-less POST (no cookie, no navigation). FIRST in the chain
199
+ // so it wins over the migratory cookie lanes below. Gated OFF while a
200
+ // #oxy_boot return fragment is present so `bootstrap-return` still consumes
201
+ // + strips it first (a device holding a secret never triggers that hop, so
202
+ // this only defers a rare stale/forged fragment). The rest of the chain
203
+ // stays as the additive migratory fallback for devices not yet on the secret.
204
+ steps.push({
205
+ id: 'device-secret-mint',
206
+ enabled: () => !(isWeb && hashHasBootFragment(dom.getHash())),
207
+ run: async () => {
208
+ const persisted = await store.load();
209
+ if (!persisted?.deviceId || !persisted?.deviceSecret) {
210
+ return { kind: 'skip' };
211
+ }
212
+ try {
213
+ const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
214
+ // Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
215
+ // fields, + the server's authoritative active account) BEFORE planting
216
+ // the minted access token, so a multi-tab race that rotates again can
217
+ // never strand this tab with a superseded secret.
218
+ const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
219
+ const next = {
220
+ ...persisted,
221
+ deviceId: mint.state.deviceId,
222
+ deviceSecret: mint.nextDeviceSecret,
223
+ accessToken: mint.accessToken,
224
+ expiresAt: mint.expiresAt,
225
+ ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
226
+ };
227
+ await store.save(next);
228
+ oxy.setTokens(mint.accessToken);
229
+ return {
230
+ kind: 'session',
231
+ session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
232
+ };
233
+ }
234
+ catch (error) {
235
+ const failure = classifyMintFailure(error);
236
+ if (failure === 'invalid_secret') {
237
+ // Stale/diverged secret — drop it so the mint lane stops firing, then
238
+ // fall through to the migratory refresh/cookie lanes. Setting it
239
+ // undefined drops the key on the store's JSON serialization, and the
240
+ // mint guard treats undefined as absent.
241
+ await store.save({ ...persisted, deviceSecret: undefined });
242
+ return { kind: 'skip' };
243
+ }
244
+ if (failure === 'no_active_session') {
245
+ // Device known, no live session — authoritative signed-out. KEEP the
246
+ // secret and stop the chain (do not bounce a known-signed-out device).
247
+ deviceKnownSignedOut = true;
248
+ signedOutReason = 'no_session';
249
+ return { kind: 'skip' };
250
+ }
251
+ // Transient (network / 5xx): keep the secret, let the fallback lanes try.
252
+ logger.debug('device-secret mint failed (transient) — keeping secret, falling back', { component: 'coldBootV2', method: 'device-secret-mint' }, error);
253
+ return { kind: 'skip' };
254
+ }
255
+ },
256
+ });
178
257
  // 1. bootstrap-return (web) — consume a #oxy_boot fragment.
179
258
  steps.push({
180
259
  id: 'bootstrap-return',
@@ -208,6 +287,7 @@ export async function runSessionColdBoot(opts) {
208
287
  // 2. stored-tokens — warm-plant or rotate the persisted refresh family.
209
288
  steps.push({
210
289
  id: 'stored-tokens',
290
+ enabled: () => !deviceKnownSignedOut,
211
291
  run: async () => {
212
292
  const persisted = await store.load();
213
293
  if (!persisted) {
@@ -234,7 +314,7 @@ export async function runSessionColdBoot(opts) {
234
314
  // 3. shared-key-signin (native) — re-mint from the shared identity.
235
315
  steps.push({
236
316
  id: 'shared-key-signin',
237
- enabled: () => isNative,
317
+ enabled: () => isNative && !deviceKnownSignedOut,
238
318
  run: async () => {
239
319
  const session = await oxy.signInWithSharedIdentity();
240
320
  if (!session?.accessToken) {
@@ -267,7 +347,7 @@ export async function runSessionColdBoot(opts) {
267
347
  // 4. bootstrap-hop (web, terminal) — same-apex inline fetch OR cross-apex nav.
268
348
  steps.push({
269
349
  id: 'bootstrap-hop',
270
- enabled: () => isWeb,
350
+ enabled: () => isWeb && !deviceKnownSignedOut,
271
351
  run: async () => {
272
352
  const pageHost = dom.getLocationHostname();
273
353
  let apiHost = null;
@@ -300,6 +380,21 @@ export async function runSessionColdBoot(opts) {
300
380
  accessToken: bundle.accessToken,
301
381
  expiresAt: bundle.expiresAt,
302
382
  };
383
+ // Phase 2c: the web-session bundle may carry a rotating `deviceSecret`
384
+ // but NOT a deviceId. Persist the secret and carry any prior deviceId
385
+ // (from a deviceId-bearing login lane) forward so the mint lane stays
386
+ // usable — this overwrite must not orphan it.
387
+ const prior = await store.load();
388
+ if (prior?.deviceId) {
389
+ next.deviceId = prior.deviceId;
390
+ }
391
+ // Prefer the bundle's secret (the server just rotated onto it); keep the
392
+ // prior one when the bundle omits it — this lane also runs as the
393
+ // TRANSIENT-mint fallback, and must not orphan a still-valid secret.
394
+ const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
395
+ if (carriedSecret) {
396
+ next.deviceSecret = carriedSecret;
397
+ }
303
398
  await store.save(next);
304
399
  oxy.setTokens(bundle.accessToken);
305
400
  return { kind: 'session', session: sessionFromPersisted(next, bundle.accessToken) };
@@ -129,6 +129,21 @@ export async function consumeDeviceBootReturn(deps) {
129
129
  accessToken: bundle.accessToken,
130
130
  expiresAt: bundle.expiresAt,
131
131
  };
132
+ // Phase 2c: the cookie-bootstrap bundle may carry a rotating `deviceSecret`
133
+ // but NOT a deviceId. Persist the secret, and carry any prior deviceId
134
+ // forward (from a deviceId-bearing login lane) so the pair stays usable by
135
+ // the zero-cookie mint — an overwrite here must not orphan the mint lane.
136
+ const prior = await deps.store.load();
137
+ if (prior?.deviceId) {
138
+ next.deviceId = prior.deviceId;
139
+ }
140
+ // Prefer the bundle's secret (the server just rotated onto it); keep the
141
+ // prior one when the bundle omits it so a cookie-lane boot can never orphan
142
+ // a still-valid secret captured by an earlier login lane.
143
+ const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
144
+ if (carriedSecret) {
145
+ next.deviceSecret = carriedSecret;
146
+ }
132
147
  await deps.store.save(next);
133
148
  deps.plantAccessToken(bundle.accessToken);
134
149
  return {
@@ -384,7 +384,11 @@
384
384
  "subtitle": "Add another account to switch between them quickly"
385
385
  },
386
386
  "label": "Account switcher",
387
- "searchPlaceholder": "Search accounts"
387
+ "searchPlaceholder": "Search accounts",
388
+ "scanTitle": "Scan to sign in",
389
+ "scanSubtitle": "Open the Oxy app on your phone and scan this code",
390
+ "scanWithOxy": "Scan with the Oxy app to continue",
391
+ "scanQr": "Scan QR code"
388
392
  },
389
393
  "reputation": {
390
394
  "faq": {
@@ -1905,5 +1909,44 @@
1905
1909
  "earlyAdopter": "Early Adopter",
1906
1910
  "earlyAdopterDesc": "Been part of the community from the start"
1907
1911
  }
1912
+ },
1913
+ "consent": {
1914
+ "title": "Continue to {{app}}",
1915
+ "subtitle": "Use your Oxy account to sign in to {{app}}. Review what this connection means before you continue.",
1916
+ "provenance": {
1917
+ "title": "Who is requesting access",
1918
+ "official": "Official Oxy application",
1919
+ "developer": "Published by {{developer}}",
1920
+ "thirdParty": "Third-party application"
1921
+ },
1922
+ "permissions": {
1923
+ "title": "Permissions requested",
1924
+ "basic": "Sign you in and read your basic profile"
1925
+ },
1926
+ "scopes": {
1927
+ "openid": "Confirm your identity",
1928
+ "profile": "Read your basic profile",
1929
+ "email": "Read your email address",
1930
+ "offlineAccess": "Keep you signed in when you're away",
1931
+ "userRead": "Read your basic profile",
1932
+ "filesRead": "Read your files",
1933
+ "filesWrite": "Upload and modify your files",
1934
+ "filesDelete": "Delete your files",
1935
+ "webhooksReceive": "Receive webhooks",
1936
+ "chatCompletions": "Use AI chat on your behalf",
1937
+ "modelsRead": "List available AI models",
1938
+ "federationWrite": "Act across federated services"
1939
+ },
1940
+ "account": {
1941
+ "title": "Signing in as"
1942
+ },
1943
+ "links": {
1944
+ "website": "Website",
1945
+ "privacy": "Privacy Policy",
1946
+ "terms": "Terms of Service"
1947
+ },
1948
+ "allow": "Continue to {{app}}",
1949
+ "deny": "Cancel",
1950
+ "disclaimer": "By continuing, {{app}} will be able to sign in with your Oxy account. You can manage connected apps anytime in your Oxy account settings."
1908
1951
  }
1909
1952
  }
@@ -581,7 +581,11 @@
581
581
  "subtitle": "Añade otra cuenta para cambiar rápidamente entre ellas"
582
582
  },
583
583
  "label": "Selector de cuentas",
584
- "searchPlaceholder": "Buscar cuentas"
584
+ "searchPlaceholder": "Buscar cuentas",
585
+ "scanTitle": "Escanea para iniciar sesión",
586
+ "scanSubtitle": "Abre la app de Oxy en tu móvil y escanea este código",
587
+ "scanWithOxy": "Escanea con la app de Oxy para continuar",
588
+ "scanQr": "Escanear código QR"
585
589
  },
586
590
  "feedback": {
587
591
  "type": {
@@ -1905,5 +1909,44 @@
1905
1909
  "earlyAdopter": "Pionero",
1906
1910
  "earlyAdopterDesc": "Has formado parte de la comunidad desde el principio"
1907
1911
  }
1912
+ },
1913
+ "consent": {
1914
+ "title": "Continuar a {{app}}",
1915
+ "subtitle": "Usa tu cuenta de Oxy para iniciar sesión en {{app}}. Revisa qué implica esta conexión antes de continuar.",
1916
+ "provenance": {
1917
+ "title": "Quién solicita acceso",
1918
+ "official": "Aplicación oficial de Oxy",
1919
+ "developer": "Publicada por {{developer}}",
1920
+ "thirdParty": "Aplicación de terceros"
1921
+ },
1922
+ "permissions": {
1923
+ "title": "Permisos solicitados",
1924
+ "basic": "Iniciar sesión y leer tu perfil básico"
1925
+ },
1926
+ "scopes": {
1927
+ "openid": "Confirmar tu identidad",
1928
+ "profile": "Leer tu perfil básico",
1929
+ "email": "Leer tu dirección de correo",
1930
+ "offlineAccess": "Mantener tu sesión iniciada cuando no estés",
1931
+ "userRead": "Leer tu perfil básico",
1932
+ "filesRead": "Leer tus archivos",
1933
+ "filesWrite": "Subir y modificar tus archivos",
1934
+ "filesDelete": "Eliminar tus archivos",
1935
+ "webhooksReceive": "Recibir webhooks",
1936
+ "chatCompletions": "Usar el chat con IA en tu nombre",
1937
+ "modelsRead": "Ver los modelos de IA disponibles",
1938
+ "federationWrite": "Actuar en servicios federados"
1939
+ },
1940
+ "account": {
1941
+ "title": "Iniciando sesión como"
1942
+ },
1943
+ "links": {
1944
+ "website": "Sitio web",
1945
+ "privacy": "Política de privacidad",
1946
+ "terms": "Términos del servicio"
1947
+ },
1948
+ "allow": "Continuar a {{app}}",
1949
+ "deny": "Cancelar",
1950
+ "disclaimer": "Al continuar, {{app}} podrá iniciar sesión con tu cuenta de Oxy. Puedes gestionar las apps conectadas cuando quieras en los ajustes de tu cuenta de Oxy."
1908
1951
  }
1909
1952
  }
@@ -384,7 +384,11 @@
384
384
  "subtitle": "Add another account to switch between them quickly"
385
385
  },
386
386
  "label": "Account switcher",
387
- "searchPlaceholder": "Search accounts"
387
+ "searchPlaceholder": "Search accounts",
388
+ "scanTitle": "Scan to sign in",
389
+ "scanSubtitle": "Open the Oxy app on your phone and scan this code",
390
+ "scanWithOxy": "Scan with the Oxy app to continue",
391
+ "scanQr": "Scan QR code"
388
392
  },
389
393
  "reputation": {
390
394
  "faq": {
@@ -1905,5 +1909,44 @@
1905
1909
  "earlyAdopter": "Early Adopter",
1906
1910
  "earlyAdopterDesc": "Been part of the community from the start"
1907
1911
  }
1912
+ },
1913
+ "consent": {
1914
+ "title": "Continue to {{app}}",
1915
+ "subtitle": "Use your Oxy account to sign in to {{app}}. Review what this connection means before you continue.",
1916
+ "provenance": {
1917
+ "title": "Who is requesting access",
1918
+ "official": "Official Oxy application",
1919
+ "developer": "Published by {{developer}}",
1920
+ "thirdParty": "Third-party application"
1921
+ },
1922
+ "permissions": {
1923
+ "title": "Permissions requested",
1924
+ "basic": "Sign you in and read your basic profile"
1925
+ },
1926
+ "scopes": {
1927
+ "openid": "Confirm your identity",
1928
+ "profile": "Read your basic profile",
1929
+ "email": "Read your email address",
1930
+ "offlineAccess": "Keep you signed in when you're away",
1931
+ "userRead": "Read your basic profile",
1932
+ "filesRead": "Read your files",
1933
+ "filesWrite": "Upload and modify your files",
1934
+ "filesDelete": "Delete your files",
1935
+ "webhooksReceive": "Receive webhooks",
1936
+ "chatCompletions": "Use AI chat on your behalf",
1937
+ "modelsRead": "List available AI models",
1938
+ "federationWrite": "Act across federated services"
1939
+ },
1940
+ "account": {
1941
+ "title": "Signing in as"
1942
+ },
1943
+ "links": {
1944
+ "website": "Website",
1945
+ "privacy": "Privacy Policy",
1946
+ "terms": "Terms of Service"
1947
+ },
1948
+ "allow": "Continue to {{app}}",
1949
+ "deny": "Cancel",
1950
+ "disclaimer": "By continuing, {{app}} will be able to sign in with your Oxy account. You can manage connected apps anytime in your Oxy account settings."
1908
1951
  }
1909
- }
1952
+ }
@@ -581,7 +581,11 @@
581
581
  "subtitle": "Añade otra cuenta para cambiar rápidamente entre ellas"
582
582
  },
583
583
  "label": "Selector de cuentas",
584
- "searchPlaceholder": "Buscar cuentas"
584
+ "searchPlaceholder": "Buscar cuentas",
585
+ "scanTitle": "Escanea para iniciar sesión",
586
+ "scanSubtitle": "Abre la app de Oxy en tu móvil y escanea este código",
587
+ "scanWithOxy": "Escanea con la app de Oxy para continuar",
588
+ "scanQr": "Escanear código QR"
585
589
  },
586
590
  "feedback": {
587
591
  "type": {
@@ -1905,5 +1909,44 @@
1905
1909
  "earlyAdopter": "Pionero",
1906
1910
  "earlyAdopterDesc": "Has formado parte de la comunidad desde el principio"
1907
1911
  }
1912
+ },
1913
+ "consent": {
1914
+ "title": "Continuar a {{app}}",
1915
+ "subtitle": "Usa tu cuenta de Oxy para iniciar sesión en {{app}}. Revisa qué implica esta conexión antes de continuar.",
1916
+ "provenance": {
1917
+ "title": "Quién solicita acceso",
1918
+ "official": "Aplicación oficial de Oxy",
1919
+ "developer": "Publicada por {{developer}}",
1920
+ "thirdParty": "Aplicación de terceros"
1921
+ },
1922
+ "permissions": {
1923
+ "title": "Permisos solicitados",
1924
+ "basic": "Iniciar sesión y leer tu perfil básico"
1925
+ },
1926
+ "scopes": {
1927
+ "openid": "Confirmar tu identidad",
1928
+ "profile": "Leer tu perfil básico",
1929
+ "email": "Leer tu dirección de correo",
1930
+ "offlineAccess": "Mantener tu sesión iniciada cuando no estés",
1931
+ "userRead": "Leer tu perfil básico",
1932
+ "filesRead": "Leer tus archivos",
1933
+ "filesWrite": "Subir y modificar tus archivos",
1934
+ "filesDelete": "Eliminar tus archivos",
1935
+ "webhooksReceive": "Recibir webhooks",
1936
+ "chatCompletions": "Usar el chat con IA en tu nombre",
1937
+ "modelsRead": "Ver los modelos de IA disponibles",
1938
+ "federationWrite": "Actuar en servicios federados"
1939
+ },
1940
+ "account": {
1941
+ "title": "Iniciando sesión como"
1942
+ },
1943
+ "links": {
1944
+ "website": "Sitio web",
1945
+ "privacy": "Política de privacidad",
1946
+ "terms": "Términos del servicio"
1947
+ },
1948
+ "allow": "Continuar a {{app}}",
1949
+ "deny": "Cancelar",
1950
+ "disclaimer": "Al continuar, {{app}} podrá iniciar sesión con tu cuenta de Oxy. Puedes gestionar las apps conectadas cuando quieras en los ajustes de tu cuenta de Oxy."
1908
1951
  }
1909
- }
1952
+ }
package/dist/esm/index.js CHANGED
@@ -119,28 +119,26 @@ export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccou
119
119
  // ---------------------------------------------------------------------------
120
120
  // Registrable-domain + central-IdP-apex helpers.
121
121
  //
122
- // The client SSO-bounce / silent-iframe / FedCM machinery was removed in the
123
- // device-first cutover (wave 2, ecosystem-wide bump complete). `registrableApex`
124
- // (eTLD+1) and `CENTRAL_IDP_APEX` are still genuinely used: `registrableApex`
125
- // via the `@oxyhq/core/server` re-export consumed by
126
- // `packages/api/src/utils/sameSite.ts` for same-site origin checks, and
127
- // `CENTRAL_IDP_APEX` by `server/cors.ts`'s `createOxyCors` (auto-allows
128
- // `*.oxy.so`). `SSO_CALLBACK_PATH` has no remaining importer outside this
129
- // module as of wave 2 — kept exported for now rather than removed here (an
130
- // export deletion is a logic change, out of scope for a comment sweep); flag
131
- // for a follow-up dead-export cleanup pass.
122
+ // `registrableApex` (eTLD+1) is consumed via the `@oxyhq/core/server`
123
+ // re-export by `packages/api/src/utils/sameSite.ts` for same-site origin
124
+ // checks; `CENTRAL_IDP_APEX` by `server/cors.ts`'s `createOxyCors` (auto-allows
125
+ // `*.oxy.so`).
132
126
  // ---------------------------------------------------------------------------
133
127
  export { registrableApex } from './utils/registrableApex.js';
134
128
  export { CENTRAL_IDP_APEX } from './utils/authWebUrl.js';
135
- export { SSO_CALLBACK_PATH } from './utils/ssoBounce.js';
136
129
  export { runColdBoot } from './utils/coldBoot.js';
137
130
  // ---------------------------------------------------------------------------
131
+ // OAuth 2.0 Authorization Code + PKCE helpers ("Sign in with Oxy" third party).
132
+ // Standard OAuth against auth.oxy.so/authorize — no FedCM/cookies/SSO bounce.
133
+ // ---------------------------------------------------------------------------
134
+ export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, } from './utils/oauthPkce.js';
135
+ // ---------------------------------------------------------------------------
138
136
  // Session sync (device-scoped multi-account session client)
139
137
  // ---------------------------------------------------------------------------
140
138
  export { SessionClient } from './session/SessionClient.js';
141
139
  // Shared SessionClient integration layer: the host adapter, the pure
142
140
  // DeviceSessionState projection helpers, and the client factory are defined
143
- // ONCE here so `@oxyhq/services` and `@oxyhq/auth` both reuse them instead of
141
+ // ONCE here so every `@oxyhq/services` platform variant reuses them instead of
144
142
  // duplicating a local copy. Each consumer supplies its own `TokenTransport`
145
143
  // (native vs. web mint strategies differ) to `createSessionClient`.
146
144
  export { createSessionClientHost } from './session/sessionClientHost.js';
@@ -149,7 +147,7 @@ export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountId
149
147
  // Unified account-list projection (THE single source of truth for the account
150
148
  // chooser: device sign-ins ∪ account graph, deduped by accountId). Pure +
151
149
  // I/O-free — the caller hydrates profiles via `getUsersByIds`. Shared by
152
- // `@oxyhq/services`, `@oxyhq/auth`, and auth.oxy.so so the list can't diverge.
150
+ // `@oxyhq/services` and auth.oxy.so so the list can't diverge.
153
151
  export { projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection.js';
154
152
  // Headless controller for the unified account dialog. Framework-agnostic
155
153
  // state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
@@ -15,7 +15,7 @@
15
15
  * and `setTokens`, so the same network primitive can be reused from either
16
16
  * without double-planting.
17
17
  */
18
- import { authTokenBundleSchema, tokenRefreshResponseSchema, deviceTokenIssueResponseSchema, webSessionResultSchema, safeParseContract, } from '@oxyhq/contracts';
18
+ import { authTokenBundleSchema, tokenRefreshResponseSchema, deviceTokenIssueResponseSchema, deviceTokenMintResponseSchema, webSessionResultSchema, safeParseContract, } from '@oxyhq/contracts';
19
19
  export function OxyServicesDeviceBootMixin(Base) {
20
20
  return class extends Base {
21
21
  /**
@@ -102,6 +102,34 @@ export function OxyServicesDeviceBootMixin(Base) {
102
102
  throw this.handleError(error);
103
103
  }
104
104
  }
105
+ /**
106
+ * Zero-cookie mint (phase 2c). Present the first-party `deviceId` +
107
+ * `deviceSecret` to `POST /session/device/token` — NO bearer, NO cookies:
108
+ * possession of the secret IS the device-ownership proof. Returns a fresh
109
+ * short access token for the device's active account plus `nextDeviceSecret`
110
+ * (rotation-in-use) and the projected device-session `state`.
111
+ *
112
+ * `skipAuth` (like {@link refreshWithToken}): this call carries no bearer, so
113
+ * a 401 must surface DIRECTLY — never trigger `HttpService`'s 401→refresh→
114
+ * retry dance (which would pointlessly rotate the refresh family). The cold
115
+ * boot reads the 401 body (`invalid_device_secret` vs `no_active_session`) to
116
+ * decide whether to drop the secret and fall back or resolve signed-out.
117
+ *
118
+ * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
119
+ */
120
+ async mintFromDeviceSecret(deviceId, deviceSecret) {
121
+ try {
122
+ const res = await this.makeRequest('POST', '/session/device/token', { deviceId, deviceSecret }, { cache: false, skipAuth: true });
123
+ const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
124
+ if (!parsed) {
125
+ throw new Error('session/device/token returned an unexpected response shape');
126
+ }
127
+ return parsed;
128
+ }
129
+ catch (error) {
130
+ throw this.handleError(error);
131
+ }
132
+ }
105
133
  /**
106
134
  * Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
107
135
  * hop. The server validates `return_to` against the trusted-origin lane and
@@ -27,8 +27,3 @@ export { verifySecret } from './verifySecret.js';
27
27
  // Pure host handling (no browser deps), so it is safe on the server subpath and
28
28
  // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
29
29
  export { registrableApex } from '../utils/registrableApex.js';
30
- // The single RP callback path the IdP redirects back to. A pure wire-contract
31
- // constant (no browser deps at module top level), re-used server-side so the
32
- // `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
33
- // validates.
34
- export { SSO_CALLBACK_PATH } from '../utils/ssoBounce.js';
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A framework-agnostic state machine + subscribe/getSnapshot store (the same
5
5
  * pattern {@link SessionClient} uses — no React, no RN) that both
6
- * `@oxyhq/services` (RN `OxyProvider`) and `@oxyhq/auth` (web `WebOxyProvider`)
6
+ * every `OxyProvider` platform variant (Expo/RN and RN-Web)
7
7
  * bind to via `useSyncExternalStore`, so the account chooser is ONE
8
8
  * implementation across the ecosystem instead of the five drifting copies it
9
9
  * replaces.
@@ -5,7 +5,7 @@
5
5
  * merging the device's server-authoritative session set (`DeviceSessionState`
6
6
  * from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
7
7
  * from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
8
- * `@oxyhq/core` so `@oxyhq/services` (RN) and `@oxyhq/auth` (web) — and
8
+ * `@oxyhq/core` so every `@oxyhq/services` platform variant — and
9
9
  * `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
10
10
  * diverge.
11
11
  *
@@ -64,6 +64,12 @@ function deserialize(raw) {
64
64
  if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
65
65
  state.deviceToken = candidate.deviceToken;
66
66
  }
67
+ if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
68
+ state.deviceId = candidate.deviceId;
69
+ }
70
+ if (typeof candidate.deviceSecret === 'string' && candidate.deviceSecret.length > 0) {
71
+ state.deviceSecret = candidate.deviceSecret;
72
+ }
67
73
  if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
68
74
  state.accessToken = candidate.accessToken;
69
75
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pure projection helpers: `DeviceSessionState` (the device-scoped
3
3
  * multi-account session-sync state produced by `SessionClient`) -> the
4
- * shapes consumers (`@oxyhq/services`, `@oxyhq/auth`) render today
4
+ * shapes `@oxyhq/services` consumers render today
5
5
  * (`ClientSession[]`, an active session id, an active `User`).
6
6
  *
7
7
  * No I/O. The caller fetches profiles via
@@ -85,6 +85,15 @@ export async function refreshPersistedSession(deps) {
85
85
  if (persisted.deviceToken) {
86
86
  next.deviceToken = persisted.deviceToken;
87
87
  }
88
+ // The refresh response carries no device credentials — carry the persisted
89
+ // deviceId/deviceSecret (phase 2c) forward so a rotation never drops the
90
+ // zero-cookie mint lane (mirrors the deviceToken preservation above).
91
+ if (persisted.deviceId) {
92
+ next.deviceId = persisted.deviceId;
93
+ }
94
+ if (persisted.deviceSecret) {
95
+ next.deviceSecret = persisted.deviceSecret;
96
+ }
88
97
  await store.save(next);
89
98
  return rotated.accessToken;
90
99
  }
@@ -4,8 +4,7 @@
4
4
  * `SessionClient` is host-agnostic: it only needs a REST + token surface.
5
5
  * `OxyServices` already exposes all of that except `getCurrentAccountId`,
6
6
  * which has no direct equivalent — the adapter holds a mutable ref set by
7
- * the caller (`OxyContext` in `@oxyhq/services`, `WebOxyProvider` in
8
- * `@oxyhq/auth`) via `setCurrentAccountId`.
7
+ * the caller (`OxyContext` in `@oxyhq/services`) via `setCurrentAccountId`.
9
8
  *
10
9
  * Shared here (rather than duplicated per consumer) because it is entirely
11
10
  * platform-agnostic: every method it calls exists identically on
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared account types and pure helper functions.
3
- * Used by both @oxyhq/services (React Native) and @oxyhq/auth (Web) account stores.
3
+ * Used by the @oxyhq/services account stores (Expo/RN and RN-Web).
4
4
  */
5
5
  import { translate } from '../i18n/index.js';
6
6
  /**