@oxy.so/core 1.5.0 → 1.6.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 (131) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +72 -9
  3. package/dist/cjs/crypto/identityMove.js +53 -34
  4. package/dist/cjs/crypto/identityProof.js +28 -0
  5. package/dist/cjs/crypto/webIdentityCarrier.js +145 -82
  6. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  7. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  9. package/dist/cjs/i18n/locales/en-US.json +1 -0
  10. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  11. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  12. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  13. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  14. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  23. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  24. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  25. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  26. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  27. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  28. package/dist/cjs/index.js +22 -14
  29. package/dist/cjs/inference/OxyInferenceClient.js +62 -5
  30. package/dist/cjs/mixins/OxyServices.auth.js +129 -0
  31. package/dist/cjs/mixins/OxyServices.identity.js +11 -44
  32. package/dist/cjs/mixins/index.js +0 -2
  33. package/dist/cjs/server/index.js +10 -1
  34. package/dist/cjs/server/securityHeaders.js +9 -2
  35. package/dist/cjs/server/workloadIdentity.js +170 -0
  36. package/dist/cjs/session/refresh.js +12 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/HttpService.js +72 -9
  39. package/dist/esm/crypto/identityMove.js +51 -33
  40. package/dist/esm/crypto/identityProof.js +24 -0
  41. package/dist/esm/crypto/webIdentityCarrier.js +137 -77
  42. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  43. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  44. package/dist/esm/i18n/locales/de-DE.json +1 -0
  45. package/dist/esm/i18n/locales/en-US.json +1 -0
  46. package/dist/esm/i18n/locales/es-ES.json +1 -0
  47. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  48. package/dist/esm/i18n/locales/it-IT.json +1 -0
  49. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  50. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  51. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  52. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  54. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  55. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  56. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  59. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  60. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  61. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  62. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  63. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  64. package/dist/esm/index.js +5 -4
  65. package/dist/esm/inference/OxyInferenceClient.js +63 -6
  66. package/dist/esm/mixins/OxyServices.auth.js +96 -0
  67. package/dist/esm/mixins/OxyServices.identity.js +11 -44
  68. package/dist/esm/mixins/index.js +0 -2
  69. package/dist/esm/server/index.js +7 -0
  70. package/dist/esm/server/securityHeaders.js +9 -2
  71. package/dist/esm/server/workloadIdentity.js +166 -0
  72. package/dist/esm/session/refresh.js +12 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/HttpService.d.ts +24 -0
  75. package/dist/types/OxyServices.d.ts +3 -0
  76. package/dist/types/crypto/identityMove.d.ts +48 -32
  77. package/dist/types/crypto/identityProof.d.ts +16 -0
  78. package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
  79. package/dist/types/index.d.ts +7 -6
  80. package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
  81. package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
  82. package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
  83. package/dist/types/mixins/OxyServices.utility.d.ts +2 -0
  84. package/dist/types/mixins/index.d.ts +1 -2
  85. package/dist/types/server/index.d.ts +8 -0
  86. package/dist/types/server/securityHeaders.d.ts +8 -0
  87. package/dist/types/server/workloadIdentity.d.ts +32 -0
  88. package/package.json +1 -1
  89. package/src/HttpService.ts +74 -9
  90. package/src/OxyServices.ts +9 -0
  91. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
  92. package/src/__tests__/publicInterfaceParity.test.ts +75 -0
  93. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
  94. package/src/crypto/__tests__/identityMove.test.ts +93 -51
  95. package/src/crypto/__tests__/identityProof.test.ts +81 -0
  96. package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
  97. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
  98. package/src/crypto/identityMove.ts +77 -43
  99. package/src/crypto/identityProof.ts +36 -0
  100. package/src/crypto/webIdentityCarrier.ts +185 -117
  101. package/src/i18n/locales/ar-SA.json +1 -0
  102. package/src/i18n/locales/ca-ES.json +1 -0
  103. package/src/i18n/locales/de-DE.json +1 -0
  104. package/src/i18n/locales/en-US.json +1 -0
  105. package/src/i18n/locales/es-ES.json +1 -0
  106. package/src/i18n/locales/fr-FR.json +1 -0
  107. package/src/i18n/locales/it-IT.json +1 -0
  108. package/src/i18n/locales/ja-JP.json +1 -0
  109. package/src/i18n/locales/ko-KR.json +1 -0
  110. package/src/i18n/locales/pt-PT.json +1 -0
  111. package/src/i18n/locales/zh-CN.json +1 -0
  112. package/src/index.ts +26 -15
  113. package/src/inference/OxyInferenceClient.ts +84 -7
  114. package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
  115. package/src/mixins/OxyServices.auth.ts +100 -1
  116. package/src/mixins/OxyServices.identity.ts +17 -67
  117. package/src/mixins/OxyServices.utility.ts +2 -0
  118. package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
  119. package/src/mixins/index.ts +0 -3
  120. package/src/server/__tests__/securityHeaders.test.ts +15 -0
  121. package/src/server/__tests__/workloadIdentity.test.ts +128 -0
  122. package/src/server/index.ts +9 -0
  123. package/src/server/securityHeaders.ts +19 -6
  124. package/src/server/workloadIdentity.ts +207 -0
  125. package/src/session/__tests__/refresh.test.ts +53 -3
  126. package/src/session/refresh.ts +12 -0
  127. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
  128. package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
  129. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
  130. package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
  131. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
@@ -108,6 +108,7 @@
108
108
  "loading": "جارٍ تحميل الحسابات...",
109
109
  "qrHeadline": "سجّل الدخول بهوية Oxy الخاصة بك",
110
110
  "signInWithOxy": "تسجيل الدخول باستخدام Oxy",
111
+ "signUpWithOxy": "التسجيل باستخدام Oxy",
111
112
  "useIdentityOnDevice": "لا تملك Commons؟ استخدم مفتاح مرور على هذا الجهاز بدلاً من ذلك",
112
113
  "getCommons": "احصل على Commons",
113
114
  "commonsNotInstalled": "لا تملك Commons؟ احصل على التطبيق لتسجيل الدخول بمعرّف Oxy الخاص بك.",
@@ -108,6 +108,7 @@
108
108
  "loading": "S'estan carregant els comptes...",
109
109
  "qrHeadline": "Inicia sessió amb la teva identitat d'Oxy",
110
110
  "signInWithOxy": "Inicia sessió amb Oxy",
111
+ "signUpWithOxy": "Registra't amb Oxy",
111
112
  "useIdentityOnDevice": "No tens Commons? Fes servir una clau d'accés en aquest dispositiu",
112
113
  "getCommons": "Descarrega Commons",
113
114
  "commonsNotInstalled": "No tens Commons? Descarrega l'app per iniciar sessió amb el teu Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "Konten werden geladen...",
109
109
  "qrHeadline": "Melden Sie sich mit Ihrer Oxy-Identität an",
110
110
  "signInWithOxy": "Mit Oxy anmelden",
111
+ "signUpWithOxy": "Mit Oxy registrieren",
111
112
  "useIdentityOnDevice": "Kein Commons? Verwenden Sie stattdessen einen Passkey auf diesem Gerät",
112
113
  "getCommons": "Commons herunterladen",
113
114
  "commonsNotInstalled": "Kein Commons? Holen Sie sich die App, um sich mit Ihrer Oxy-ID anzumelden.",
@@ -416,6 +416,7 @@
416
416
  "scanQr": "Scan QR code",
417
417
  "qrHeadline": "Sign in with your Oxy identity",
418
418
  "signInWithOxy": "Sign in with Oxy",
419
+ "signUpWithOxy": "Sign up with Oxy",
419
420
  "useIdentityOnDevice": "No Commons? Use a passkey on this device instead",
420
421
  "getCommons": "Get Commons",
421
422
  "commonsNotInstalled": "Don't have Commons? Get the app to sign in with your Oxy ID.",
@@ -656,6 +656,7 @@
656
656
  "scanQr": "Escanear código QR",
657
657
  "qrHeadline": "Inicia sesión con tu identidad de Oxy",
658
658
  "signInWithOxy": "Inicia sesión con Oxy",
659
+ "signUpWithOxy": "Regístrate con Oxy",
659
660
  "useIdentityOnDevice": "¿No tienes Commons? Usa una clave de acceso en este dispositivo",
660
661
  "getCommons": "Descargar Commons",
661
662
  "commonsNotInstalled": "¿No tienes Commons? Descarga la app para iniciar sesión con tu Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "Chargement des comptes...",
109
109
  "qrHeadline": "Connectez-vous avec votre identité Oxy",
110
110
  "signInWithOxy": "Se connecter avec Oxy",
111
+ "signUpWithOxy": "S'inscrire avec Oxy",
111
112
  "useIdentityOnDevice": "Pas de Commons ? Utilisez une clé d'accès sur cet appareil",
112
113
  "getCommons": "Obtenir Commons",
113
114
  "commonsNotInstalled": "Pas de Commons ? Téléchargez l'app pour vous connecter avec votre Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "Caricamento account...",
109
109
  "qrHeadline": "Accedi con la tua identità Oxy",
110
110
  "signInWithOxy": "Accedi con Oxy",
111
+ "signUpWithOxy": "Registrati con Oxy",
111
112
  "useIdentityOnDevice": "Non hai Commons? Usa una passkey su questo dispositivo",
112
113
  "getCommons": "Ottieni Commons",
113
114
  "commonsNotInstalled": "Non hai Commons? Scarica l'app per accedere con il tuo Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "アカウントを読み込み中...",
109
109
  "qrHeadline": "Oxy IDでサインイン",
110
110
  "signInWithOxy": "Oxyでサインイン",
111
+ "signUpWithOxy": "Oxyで登録",
111
112
  "useIdentityOnDevice": "Commonsがありませんか?このデバイスのパスキーを使用",
112
113
  "getCommons": "Commonsを入手",
113
114
  "commonsNotInstalled": "Commonsがありませんか?アプリを入手してOxy IDでサインインしましょう。",
@@ -108,6 +108,7 @@
108
108
  "loading": "계정을 불러오는 중...",
109
109
  "qrHeadline": "Oxy ID로 로그인",
110
110
  "signInWithOxy": "Oxy로 로그인",
111
+ "signUpWithOxy": "Oxy로 가입하기",
111
112
  "useIdentityOnDevice": "Commons가 없으신가요? 이 기기의 패스키를 사용하세요",
112
113
  "getCommons": "Commons 다운로드",
113
114
  "commonsNotInstalled": "Commons가 없으신가요? 앱을 다운로드하여 Oxy ID로 로그인하세요.",
@@ -108,6 +108,7 @@
108
108
  "loading": "A carregar contas...",
109
109
  "qrHeadline": "Inicie sessão com a sua identidade Oxy",
110
110
  "signInWithOxy": "Iniciar sessão com Oxy",
111
+ "signUpWithOxy": "Registar-se com Oxy",
111
112
  "useIdentityOnDevice": "Não tem o Commons? Use uma chave de acesso neste dispositivo",
112
113
  "getCommons": "Obter o Commons",
113
114
  "commonsNotInstalled": "Não tem o Commons? Obtenha a app para iniciar sessão com o seu Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "正在加载账户...",
109
109
  "qrHeadline": "使用您的 Oxy 身份登录",
110
110
  "signInWithOxy": "使用 Oxy 登录",
111
+ "signUpWithOxy": "使用 Oxy 注册",
111
112
  "useIdentityOnDevice": "没有 Commons?改用此设备上的通行密钥",
112
113
  "getCommons": "获取 Commons",
113
114
  "commonsNotInstalled": "没有 Commons?获取应用以使用您的 Oxy ID 登录。",
package/src/index.ts CHANGED
@@ -86,10 +86,6 @@ export type {
86
86
  ContactDiscoveryMatch,
87
87
  ContactDiscoveryResponse,
88
88
  } from './mixins/OxyServices.contacts';
89
- export type {
90
- InitDeviceTransferResult,
91
- DeviceTransferOutcome,
92
- } from './mixins/OxyServices.deviceTransfer';
93
89
  export type {
94
90
  BulkFollowEntry,
95
91
  BulkFollowResult,
@@ -234,7 +230,6 @@ export {
234
230
  export { buildUserDid } from './mixins/OxyServices.identity';
235
231
  export type {
236
232
  IdentityRecordType,
237
- UnlinkableAuthMethodType,
238
233
  LinkAuthMethodResult,
239
234
  PublishRecordResult,
240
235
  VerifyRecordResult,
@@ -338,41 +333,55 @@ export {
338
333
  export type { AeadResult } from './crypto/aead';
339
334
  export { deriveSharedSecret } from './crypto/ecdh';
340
335
 
341
- // Web identity carrier — the same identity as Commons, sealed under a passkey's
342
- // PRF output (docs/superpowers/specs/2026-09-15-one-identity-two-carriers-design.md)
336
+ // Web identity holder — the same root as Commons, sealed under a passkey's PRF
337
+ // output; identity proofs; transfer to Commons (docs/adr/0024-one-oxy-account-root-holders.md)
343
338
  export {
344
339
  WEB_IDENTITY_PRF_INPUT,
345
340
  WebIdentityUnlockError,
341
+ WEB_IDENTITY_PRF_OUTPUT_LENGTH,
346
342
  addWrap,
347
- buildIdentityActionMessage,
348
343
  deriveIdentityFromMnemonic,
344
+ deriveIdentityFromPrivateKey,
345
+ deriveIdentityFromRecoveryMaterial,
349
346
  deriveKeyEncryptionKey,
350
- deriveTransferSas,
351
347
  generateDataKey,
352
348
  generateWebIdentity,
349
+ isUsablePrfOutput,
350
+ markWrapVerified,
351
+ normalizeMnemonic,
353
352
  openWebIdentity,
353
+ parseRecoveryMaterial,
354
354
  removeWrap,
355
355
  sealWebIdentity,
356
- signIdentityAction,
357
356
  unlockWebIdentity,
358
357
  unwrapDataKey,
359
358
  wipeBytes,
360
- wrapDataKey,
359
+ wipeOpenedIdentity,
360
+ } from './crypto/webIdentityCarrier';
361
+ export type {
362
+ OpenedMnemonicIdentity,
363
+ OpenedRawKeyIdentity,
364
+ OpenedWebIdentity,
365
+ WebIdentityRecoveryMaterial,
366
+ WebIdentityUnlockFailure,
367
+ WrapInput,
361
368
  } from './crypto/webIdentityCarrier';
362
- export type { OpenedWebIdentity, WebIdentityUnlockFailure } from './crypto/webIdentityCarrier';
369
+ export { digestIdentityPayload, signIdentityProof } from './crypto/identityProof';
363
370
  export {
364
- IDENTITY_MOVE_ACTIONS,
365
- buildMoveMessage,
366
371
  buildMoveQrPayload,
372
+ createMoveCommitment,
367
373
  deriveMoveKey,
368
374
  deriveMoveSas,
375
+ digestMoveCiphertext,
369
376
  generateMoveEphemeralKeyPair,
370
377
  openMovedIdentity,
371
378
  parseMoveQrPayload,
372
379
  sealIdentityForMove,
373
- signMoveAction,
380
+ signMoveReceipt,
381
+ verifyMoveCommitment,
374
382
  verifyMoveReceipt,
375
383
  } from './crypto/identityMove';
384
+ export type { MoveReceiptClaims } from './crypto/identityMove';
376
385
 
377
386
  // ---------------------------------------------------------------------------
378
387
  // Devices
@@ -909,6 +918,8 @@ export type {
909
918
  OxyInferenceResponse,
910
919
  OxyGenerationReceipt,
911
920
  OxyResponsesRequest,
921
+ OxySpeechRequest,
922
+ OxySpeechResponse,
912
923
  } from './inference/OxyInferenceClient';
913
924
 
914
925
  export { runSessionColdBoot } from './boot/sessionColdBoot';
@@ -58,7 +58,9 @@ import type {
58
58
  InferenceFinishReason,
59
59
  InferenceMessage,
60
60
  InferenceRequestOutcome,
61
+ InferenceAudioMediaType,
61
62
  InferenceStreamEvent,
63
+ InferenceSpeechParameters,
62
64
  ModelCatalogueEntry,
63
65
  ResponseFormat,
64
66
  RoutingPolicyReference,
@@ -69,7 +71,13 @@ import type {
69
71
  UsageQuantity,
70
72
  UsageSource,
71
73
  } from '@oxy.so/contracts';
72
- import { INFERENCE_ERROR_CODES, inferenceStreamEventSchema, modelIdSchema } from '@oxy.so/contracts';
74
+ import {
75
+ INFERENCE_ERROR_CODES,
76
+ MAX_INFERENCE_AUDIO_BYTES,
77
+ inferenceAudioMediaTypeSchema,
78
+ inferenceStreamEventSchema,
79
+ modelIdSchema,
80
+ } from '@oxy.so/contracts';
73
81
 
74
82
  /** The base URL of the Oxy API, when a caller names none. */
75
83
  export const OXY_INFERENCE_BASE_URL = 'https://api.oxy.so';
@@ -138,6 +146,23 @@ export interface OxyResponsesRequest {
138
146
  readonly clientRequestId?: string;
139
147
  }
140
148
 
149
+ /** Speech uses the same credential and delegation lane as responses. */
150
+ export type OxySpeechRequest = (
151
+ | { readonly model: string; readonly routingProfileId?: never }
152
+ | { readonly routingProfileId: string; readonly model?: never }
153
+ ) & {
154
+ readonly input: string;
155
+ readonly voice: string;
156
+ readonly response_format?: InferenceSpeechParameters['responseFormat'];
157
+ readonly speed?: number;
158
+ };
159
+
160
+ export interface OxySpeechResponse {
161
+ readonly audio: Uint8Array;
162
+ readonly mediaType: InferenceAudioMediaType;
163
+ readonly requestId: string;
164
+ }
165
+
141
166
  export interface OxyInferenceRequestOptions {
142
167
  /**
143
168
  * Abort the request. The edge treats a client disconnect as a cancellation:
@@ -434,6 +459,57 @@ export class OxyInferenceClient {
434
459
  });
435
460
  }
436
461
 
462
+ /** Generate audio bytes through Oxy's authenticated inference edge. */
463
+ async speech(
464
+ request: OxySpeechRequest,
465
+ options: OxyInferenceRequestOptions = {},
466
+ ): Promise<OxySpeechResponse> {
467
+ const response = await this.#fetch(`${this.#baseURL}/v1/audio/speech`, {
468
+ method: 'POST',
469
+ headers: await this.#headers('audio/*', {
470
+ hasBody: true,
471
+ ...(options.idempotencyKey === undefined
472
+ ? {}
473
+ : { idempotencyKey: options.idempotencyKey }),
474
+ ...(options.delegatedUserId === undefined
475
+ ? {}
476
+ : { delegatedUserId: options.delegatedUserId }),
477
+ }),
478
+ body: JSON.stringify(request),
479
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
480
+ });
481
+ const requestId = response.headers.get('X-Oxy-Request-Id') ?? '';
482
+ if (!response.ok) {
483
+ throw toInferenceError(await response.json().catch(() => undefined), response.status, requestId);
484
+ }
485
+ const mediaType = inferenceAudioMediaTypeSchema.safeParse(mediaTypeOf(response));
486
+ if (!mediaType.success || !response.body) {
487
+ await cancelUnreadResponse(response);
488
+ throw protocolError('The inference API returned no supported audio body.', requestId);
489
+ }
490
+ const reader = response.body.getReader();
491
+ const chunks: Uint8Array[] = [];
492
+ let size = 0;
493
+ let completed = false;
494
+ try {
495
+ while (true) {
496
+ const next = await reader.read();
497
+ if (next.done) { completed = true; break; }
498
+ size += next.value.byteLength;
499
+ if (size > MAX_INFERENCE_AUDIO_BYTES) throw protocolError('The inference audio exceeds the response limit.', requestId);
500
+ chunks.push(next.value);
501
+ }
502
+ } finally {
503
+ if (!completed) await reader.cancel().catch(() => undefined);
504
+ reader.releaseLock();
505
+ }
506
+ if (size === 0) throw protocolError('The inference API returned empty audio.', requestId);
507
+ const audio = new Uint8Array(size);
508
+ let offset = 0;
509
+ for (const chunk of chunks) { audio.set(chunk, offset); offset += chunk.byteLength; }
510
+ return { audio, mediaType: mediaType.data, requestId };
511
+ }
512
+
437
513
  /**
438
514
  * Stream one inference request from `POST /v1/responses`.
439
515
  *
@@ -481,11 +557,7 @@ export class OxyInferenceClient {
481
557
  }
482
558
 
483
559
  const requestId = response.headers.get('X-Oxy-Request-Id') ?? undefined;
484
- const mediaType = response.headers
485
- .get('Content-Type')
486
- ?.split(';', 1)[0]
487
- ?.trim()
488
- .toLowerCase();
560
+ const mediaType = mediaTypeOf(response);
489
561
  if (mediaType !== 'text/event-stream') {
490
562
  await cancelUnreadResponse(response);
491
563
  throw protocolError(
@@ -607,7 +679,7 @@ export class OxyInferenceClient {
607
679
 
608
680
  /** Build the authenticated headers for one request, re-reading its bearer. */
609
681
  async #headers(
610
- accept: 'application/json' | 'text/event-stream',
682
+ accept: 'application/json' | 'text/event-stream' | 'audio/*',
611
683
  options: {
612
684
  hasBody: boolean;
613
685
  idempotencyKey?: string;
@@ -694,6 +766,11 @@ function protocolError(message: string, requestId?: string): OxyInferenceProtoco
694
766
  return new OxyInferenceProtocolError(message, requestId);
695
767
  }
696
768
 
769
+ /** The bare, lowercased media type of a response's `Content-Type`, if any. */
770
+ function mediaTypeOf(response: Response): string | undefined {
771
+ return response.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase();
772
+ }
773
+
697
774
  /** Cancel a nominally successful response whose stream contract is unreadable. */
698
775
  async function cancelUnreadResponse(response: Response): Promise<void> {
699
776
  await response.body?.cancel().catch(() => undefined);
@@ -832,3 +832,51 @@ describe('OxyInferenceClient', () => {
832
832
  expect(calls[0].url).toBe('http://test.invalid/v1/models');
833
833
  });
834
834
  });
835
+
836
+
837
+ describe('speech', () => {
838
+ it('preserves audio bytes, exact profile and delegated user with the service credential', async () => {
839
+ const bytes = new Uint8Array([73, 68, 51, 0, 255, 4]);
840
+ const transport = jest.fn(async () => new Response(bytes, { headers: {
841
+ 'Content-Type': 'audio/mpeg', 'X-Oxy-Request-Id': 'req_speech',
842
+ } }));
843
+ const client = new OxyInferenceClient({ credential: async () => 'service-test', fetch: transport });
844
+ const input = { routingProfileId: 'opaque-reviewed-id', input: 'Hola 👋', voice: 'female', speed: 1.15 };
845
+ const result = await client.speech(input, { delegatedUserId: 'user_test', idempotencyKey: 'speech_test' });
846
+ expect(result).toEqual({ audio: bytes, mediaType: 'audio/mpeg', requestId: 'req_speech' });
847
+ const [url, init] = (transport.mock.calls as unknown as Array<[string, RequestInit]>)[0];
848
+ expect(url).toBe('https://api.oxy.so/v1/audio/speech');
849
+ expect(JSON.parse(String(init.body))).toEqual(input);
850
+ const headers = new Headers(init.headers);
851
+ expect(headers.get('Authorization')).toBe('Bearer service-test');
852
+ expect(headers.get('X-Oxy-User-Id')).toBe('user_test');
853
+ expect(headers.get('Idempotency-Key')).toBe('speech_test');
854
+ });
855
+ it('preserves retryable refusal metadata without retrying a paid request', async () => {
856
+ const transport = jest.fn(async () => new Response(JSON.stringify({ schemaVersion: 1,
857
+ code: 'rate_limited', message: 'Capacity unavailable', requestId: 'req_throttled',
858
+ retryable: true, retryAfterMs: 1500 }), { status: 429 }));
859
+ const client = new OxyInferenceClient({ credential: 'test', fetch: transport });
860
+ await expect(client.speech({ model: 'xai/tts', input: 'Hola', voice: 'female' }))
861
+ .rejects.toMatchObject({ code: 'rate_limited', requestId: 'req_throttled', retryAfterMs: 1500 });
862
+ expect(transport).toHaveBeenCalledTimes(1);
863
+ });
864
+ it.each([
865
+ { body: '', type: 'audio/mpeg' }, { body: '<html>unavailable</html>', type: 'text/html' },
866
+ { body: '{}', type: 'application/json' },
867
+ ])('refuses successful HTTP responses without audio: %p', async ({ body, type }) => {
868
+ const client = new OxyInferenceClient({ credential: 'test', fetch: async () =>
869
+ new Response(body, { headers: { 'Content-Type': type } }) });
870
+ await expect(client.speech({ model: 'xai/tts', input: 'Hola', voice: 'female' }))
871
+ .rejects.toBeInstanceOf(OxyInferenceProtocolError);
872
+ });
873
+ it('bounds streamed binary output and cancels the remaining body', async () => {
874
+ const cancelled = jest.fn();
875
+ const client = new OxyInferenceClient({ credential: 'test', fetch: async () => new Response(
876
+ new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(20 * 1024 * 1024 + 1)); }, cancel: cancelled }),
877
+ { headers: { 'Content-Type': 'audio/mpeg' } }) });
878
+ await expect(client.speech({ model: 'xai/tts', input: 'Hola', voice: 'female' }))
879
+ .rejects.toBeInstanceOf(OxyInferenceProtocolError);
880
+ expect(cancelled).toHaveBeenCalledTimes(1);
881
+ });
882
+ });
@@ -9,7 +9,7 @@ import type {
9
9
  LoginResult,
10
10
  CommonsDenyReason,
11
11
  } from '@oxy.so/contracts';
12
- import { loginResultSchema, safeParseContract } from '@oxy.so/contracts';
12
+ import { loginResultSchema, safeParseContract, type IdentityProof, type WebIdentityEnvelope } from '@oxy.so/contracts';
13
13
  import type { SessionLoginResponse } from '../models/session';
14
14
  import type { OxyServicesBase } from '../OxyServices.base';
15
15
  import type { PublicApplication } from './OxyServices.connectedApps';
@@ -546,6 +546,24 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
546
546
  const secret = apiSecret || this._serviceApiSecret;
547
547
 
548
548
  if (!key || !secret) {
549
+ /**
550
+ * No credential configured — so prove what this process IS instead
551
+ * (ADR 0026).
552
+ *
553
+ * This is what lets an official service drop its api key and secret
554
+ * WITHOUT a code change: remove the two environment variables, and the
555
+ * next token comes from the workload path. It is deliberately the
556
+ * fallback rather than the preference, so a deployment that still has a
557
+ * credential keeps using it and the migration is one service at a time.
558
+ *
559
+ * A process with no credential AND no attestation still gets the
560
+ * original error: "nothing to authenticate with" is the truth there, and
561
+ * a local checkout must not be told to go looking for a container
562
+ * credentials endpoint that is not there.
563
+ */
564
+ if (await this._canUseWorkloadIdentity()) {
565
+ return this._getWorkloadServiceToken();
566
+ }
549
567
  throw new Error('Service credentials not provided. Call configureServiceAuth() or pass apiKey and apiSecret.');
550
568
  }
551
569
 
@@ -625,6 +643,81 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
625
643
  }
626
644
  }
627
645
 
646
+ /**
647
+ * Whether this process can prove what it is (ADR 0026).
648
+ *
649
+ * The check and the module that performs it are loaded LAZILY and only on a
650
+ * Node host. `@oxy.so/core`'s root barrel reaches React Native and Expo, and
651
+ * `server/workloadIdentity` imports `node:crypto` — a static import here
652
+ * would pull it into every mobile bundle for a path a phone can never take.
653
+ *
654
+ * @internal
655
+ */
656
+ async _canUseWorkloadIdentity(): Promise<boolean> {
657
+ if (typeof process === 'undefined' || !process.versions?.node) return false;
658
+ try {
659
+ const { canAttestWorkloadIdentity } = await import('../server/workloadIdentity');
660
+ return canAttestWorkloadIdentity();
661
+ } catch {
662
+ // A bundler that dropped the server subpath, or a runtime without it.
663
+ // "We cannot attest here" is the correct reading, and the caller falls
664
+ // back to the credential error it would have thrown anyway.
665
+ return false;
666
+ }
667
+ }
668
+
669
+ /**
670
+ * A service token obtained by attestation, cached like a credential's.
671
+ *
672
+ * The cache key is a constant, not a credential hash: a process has exactly
673
+ * one identity, so there is nothing to key on and nothing to keep separate.
674
+ * The secret-comparison guard the credential path applies has nothing to
675
+ * compare here, which is the point — there is no secret.
676
+ *
677
+ * @internal
678
+ */
679
+ async _getWorkloadServiceToken(): Promise<string> {
680
+ const cacheKey = 'workload-identity';
681
+ const entry = this._serviceTokenCache.get(cacheKey);
682
+ const now = Date.now();
683
+ if (entry?.token && entry.expiresAt > now + 60_000) return entry.token;
684
+ if (entry?.pending) return entry.pending;
685
+
686
+ const { requestWorkloadServiceToken } = await import('../server/workloadIdentity');
687
+ const seeded = entry ?? {
688
+ token: '',
689
+ expiresAt: 0,
690
+ secretBuf: Buffer.alloc(0),
691
+ pending: null,
692
+ apiKey: cacheKey,
693
+ };
694
+ this._serviceTokenCache.set(cacheKey, seeded);
695
+
696
+ const pending = (async () => {
697
+ const granted = await requestWorkloadServiceToken({ baseUrl: this.getBaseURL() });
698
+ const current = this._serviceTokenCache.get(cacheKey);
699
+ if (current) {
700
+ current.token = granted.token;
701
+ current.expiresAt = Date.now() + granted.expiresIn * 1000;
702
+ }
703
+ return granted.token;
704
+ })();
705
+ seeded.pending = pending;
706
+
707
+ try {
708
+ return await pending;
709
+ } catch (error) {
710
+ // Never keep an entry that never held a token: the next caller must try
711
+ // again rather than inherit a failure.
712
+ const failed = this._serviceTokenCache.get(cacheKey);
713
+ if (failed?.pending === pending && !failed.token) this._serviceTokenCache.delete(cacheKey);
714
+ throw error;
715
+ } finally {
716
+ const settled = this._serviceTokenCache.get(cacheKey);
717
+ if (settled?.pending === pending) settled.pending = null;
718
+ }
719
+ }
720
+
628
721
  /**
629
722
  * Perform the actual /auth/service-token request and cache the result.
630
723
  * Separated so getServiceToken() can deduplicate concurrent calls.
@@ -1643,6 +1736,12 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1643
1736
  username?: string;
1644
1737
  deviceName?: string;
1645
1738
  deviceFingerprint?: string;
1739
+ /**
1740
+ * Sign-up only: the root created on the holder before this call, sealed
1741
+ * under the passkey being registered, plus its `enroll_identity` proof
1742
+ * (ADR 0024 D4). The account is created WITH it or not at all.
1743
+ */
1744
+ identity?: { envelope: WebIdentityEnvelope; proof: IdentityProof };
1646
1745
  } = {},
1647
1746
  ): Promise<{ success: true; message: string } | LoginResult> {
1648
1747
  try {
@@ -4,9 +4,9 @@
4
4
  * Provides typed access to Oxy's AtProto/Bluesky-flavoured identity &
5
5
  * portability layer:
6
6
  * - DID resolution (`did:web:oxy.so:u:<userId>`, derived on demand by the API).
7
- * - The auth-method ↔ DID verification-method mapping and its reversibility
8
- * (link/unlink an identity key, link a password) via the existing
9
- * `/auth/link` surface.
7
+ * - The auth-method ↔ DID verification-method mapping (`GET /auth/methods`) and
8
+ * passkey removal. A root is linked by the holder flows (ADR 0024) and never
9
+ * unlinked; it is replaced by rotation.
10
10
  * - Signed records: clients sign an envelope with their own cryptographic key
11
11
  * (`SignatureService.signRecord` + the shared `canonicalize`) and publish it;
12
12
  * anyone can fetch and verify it.
@@ -19,12 +19,13 @@
19
19
  * the API validates its output against, so producer and consumer cannot drift.
20
20
  *
21
21
  * Identity signing is NATIVE-ONLY: the private key lives in native secure
22
- * storage, so `linkIdentityKey`, `signRecord`, and `publishRecord` require an
22
+ * storage, so `signRecord` and `publishRecord` require an
23
23
  * on-device identity and throw on web (where `KeyManager.getPublicKey()` is
24
24
  * always `null`).
25
25
  */
26
26
  import type {
27
27
  AuthMethodsResponse,
28
+ IdentityRootStatus,
28
29
  DidDocument,
29
30
  DomainVerificationInstructions,
30
31
  ExportBundle,
@@ -58,13 +59,7 @@ const OXY_IDENTITY_APEX = 'oxy.so';
58
59
  */
59
60
  export type IdentityRecordType = OxySignedRecordType;
60
61
 
61
- /** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
62
- export type UnlinkableAuthMethodType = 'identity' | 'webauthn';
63
-
64
- /**
65
- * Result of a link/unlink auth-method mutation (`POST /auth/link`,
66
- * `DELETE /auth/link/:type`).
67
- */
62
+ /** Result of an auth-method mutation (`DELETE /auth/link/webauthn/:id`). */
68
63
  export interface LinkAuthMethodResult {
69
64
  success: boolean;
70
65
  message: string;
@@ -224,62 +219,15 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
224
219
  }
225
220
 
226
221
  /**
227
- * Link the on-device cryptographic identity to the current account,
228
- * upgrading it from custodial to self-sovereign. Signs a proof of private
229
- * key ownership and posts it to `POST /auth/link`.
230
- *
231
- * NATIVE-ONLY: requires a stored identity (throws if `KeyManager` has no key
232
- * or no user is authenticated). The signed payload is
233
- * `JSON.stringify({ action: 'link_identity', userId, timestamp })` — the
234
- * exact bytes the server reconstructs and verifies.
222
+ * The signed-in account's root readiness (ADR 0024 D5): whether a root is
223
+ * linked, how many passkeys can open the web holder, whether the root has a
224
+ * phrase and whether it is saved and was shown to recover it. Metadata only —
225
+ * nothing here can open the root — so any first-party surface may show a
226
+ * "save your recovery phrase" reminder from it.
235
227
  */
236
- async linkIdentityKey(): Promise<LinkAuthMethodResult> {
228
+ async getIdentityRootStatus(): Promise<IdentityRootStatus> {
237
229
  try {
238
- const userId = this.getCurrentUserId();
239
- if (!userId) {
240
- throw new Error('No authenticated user — sign in before linking an identity key.');
241
- }
242
- const publicKey = await KeyManager.getPublicKey();
243
- if (!publicKey) {
244
- throw new Error('No identity found on this device. Create or import an identity first.');
245
- }
246
-
247
- const timestamp = Date.now();
248
- // The signed message MUST match the server's reconstruction byte-for-byte:
249
- // JSON.stringify with this exact key order (action, userId, timestamp).
250
- const message = JSON.stringify({ action: 'link_identity', userId, timestamp });
251
- const signature = await SignatureService.sign(message);
252
-
253
- const result = await this.makeRequest<LinkAuthMethodResult>(
254
- 'POST',
255
- '/auth/link',
256
- { type: 'identity', publicKey, signature, timestamp },
257
- { cache: false },
258
- );
259
- this._invalidateIdentityCaches(userId);
260
- return result;
261
- } catch (error) {
262
- throw this.handleError(error);
263
- }
264
- }
265
-
266
- /**
267
- * Unlink an authentication method from the current account. The server
268
- * refuses to remove the last remaining method (the account would become
269
- * inaccessible). Unlinking `identity` downgrades the account to custodial.
270
- *
271
- * @param type - The auth-method type to remove.
272
- */
273
- async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
274
- try {
275
- const result = await this.makeRequest<LinkAuthMethodResult>(
276
- 'DELETE',
277
- `/auth/link/${encodeURIComponent(type)}`,
278
- undefined,
279
- { cache: false },
280
- );
281
- this._invalidateIdentityCaches(this.getCurrentUserId());
282
- return result;
230
+ return await this.makeRequest<IdentityRootStatus>('GET', '/identity/root-status', undefined, { cache: false });
283
231
  } catch (error) {
284
232
  throw this.handleError(error);
285
233
  }
@@ -288,10 +236,12 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
288
236
  /**
289
237
  * Remove ONE passkey (WebAuthn credential) from the current account.
290
238
  *
291
- * Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
292
- * removes an auth method by type) this targets a specific credential id.
239
+ * Passkeys are per-credential, so this targets a specific credential id.
293
240
  * The server refuses to remove the last remaining auth method (the account
294
241
  * would become inaccessible) and deletes the stored `WebauthnCredential`.
242
+ * A passkey that also opens the account's root on the web takes its wrap with
243
+ * it, and the ONLY such passkey is refused with
244
+ * `code: 'IDENTITY_LAST_WEB_HOLDER'` (ADR 0024 D6).
295
245
  *
296
246
  * @param credentialId - The passkey's public credential id
297
247
  * (`AuthMethodEntry.credentialId`).
@@ -1178,6 +1178,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
1178
1178
  */
1179
1179
  serviceAuth(options: {
1180
1180
  debug?: boolean;
1181
+ /** Observe refusals on the service lane; forwarded straight to `auth()`. */
1182
+ onRefusal?: (refusal: OxyAuthRefusal) => void;
1181
1183
  /** @deprecated Oxy-API-only HS256 transition; external verifiers use JWKS. */
1182
1184
  jwtSecret?: string;
1183
1185
  serviceTokenJwksUrl?: string;