@tanstack/ai-client 0.25.2 → 0.28.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 (51) hide show
  1. package/README.md +2 -1
  2. package/dist/esm/byok/client.d.ts +52 -0
  3. package/dist/esm/byok/client.js +300 -0
  4. package/dist/esm/byok/client.js.map +1 -0
  5. package/dist/esm/byok/passkey.d.ts +43 -0
  6. package/dist/esm/byok/passkey.js +251 -0
  7. package/dist/esm/byok/passkey.js.map +1 -0
  8. package/dist/esm/byok/resolve.d.ts +8 -0
  9. package/dist/esm/byok/resolve.js +20 -0
  10. package/dist/esm/byok/resolve.js.map +1 -0
  11. package/dist/esm/byok/storage.d.ts +15 -0
  12. package/dist/esm/byok/storage.js +16 -0
  13. package/dist/esm/byok/storage.js.map +1 -0
  14. package/dist/esm/byok.d.ts +8 -0
  15. package/dist/esm/byok.js +5 -0
  16. package/dist/esm/chat-client.d.ts +18 -5
  17. package/dist/esm/chat-client.js +96 -59
  18. package/dist/esm/chat-client.js.map +1 -1
  19. package/dist/esm/connection-adapters.d.ts +2 -0
  20. package/dist/esm/connection-adapters.js +70 -30
  21. package/dist/esm/connection-adapters.js.map +1 -1
  22. package/dist/esm/generation-client.d.ts +3 -1
  23. package/dist/esm/generation-client.js +27 -6
  24. package/dist/esm/generation-client.js.map +1 -1
  25. package/dist/esm/generation-types.d.ts +14 -0
  26. package/dist/esm/generation-types.js +4 -2
  27. package/dist/esm/generation-types.js.map +1 -1
  28. package/dist/esm/interrupt-manager.d.ts +1 -0
  29. package/dist/esm/interrupt-manager.js +15 -6
  30. package/dist/esm/interrupt-manager.js.map +1 -1
  31. package/dist/esm/sse-parser.js +8 -1
  32. package/dist/esm/sse-parser.js.map +1 -1
  33. package/dist/esm/types.d.ts +46 -3
  34. package/dist/esm/types.js.map +1 -1
  35. package/dist/esm/video-generation-client.d.ts +3 -1
  36. package/dist/esm/video-generation-client.js +29 -8
  37. package/dist/esm/video-generation-client.js.map +1 -1
  38. package/package.json +7 -3
  39. package/src/byok/client.ts +360 -0
  40. package/src/byok/passkey.ts +399 -0
  41. package/src/byok/resolve.ts +30 -0
  42. package/src/byok/storage.ts +29 -0
  43. package/src/byok.ts +20 -0
  44. package/src/chat-client.ts +138 -77
  45. package/src/connection-adapters.ts +108 -53
  46. package/src/generation-client.ts +53 -9
  47. package/src/generation-types.ts +24 -2
  48. package/src/interrupt-manager.ts +20 -6
  49. package/src/sse-parser.ts +10 -0
  50. package/src/types.ts +48 -3
  51. package/src/video-generation-client.ts +50 -9
@@ -0,0 +1,399 @@
1
+ import { isProviderId } from '@tanstack/ai/byok'
2
+ import { memoryStorage } from './storage'
3
+ import type { KeyPreview, Keyring, KeyringStorage } from './storage'
4
+
5
+ /**
6
+ * Passkey-encrypted keyring storage (WebAuthn PRF → HKDF → AES-256-GCM).
7
+ *
8
+ * The keyring is encrypted at rest in IndexedDB with an AES-256-GCM key derived
9
+ * from a passkey's PRF output, unwrapped on demand with a biometric/PIN tap.
10
+ * Decryption happens entirely client-side with the user present — no server,
11
+ * no custodian.
12
+ *
13
+ * Honest scope: this protects against at-rest theft (stolen device,
14
+ * storage-dumping extension, backups). It does NOT defeat live in-page XSS —
15
+ * an attacker running JS in the origin after the user unlocks can read the
16
+ * decrypted keys from memory.
17
+ */
18
+
19
+ const STORE_NAME = 'keyring'
20
+ const RECORD_ID = 'default'
21
+ const HKDF_INFO = 'byok:keyring:v1'
22
+ const DEFAULT_DB = 'byok'
23
+
24
+ interface StoredRecord {
25
+ id: string
26
+ /** The passkey's raw credential id, replayed in the unlock ceremony. */
27
+ credentialId: ArrayBuffer
28
+ /** Fixed per-install PRF evaluation input (not secret). */
29
+ salt: ArrayBuffer
30
+ /** AES-GCM initialization vector for this ciphertext. */
31
+ iv: ArrayBuffer
32
+ /** Encrypted keyring JSON. */
33
+ ciphertext: ArrayBuffer
34
+ /**
35
+ * Unencrypted presence metadata (`provider → last 4`). Non-sensitive, so it
36
+ * can be read via {@link KeyringStorage.peek} without an unlock ceremony to
37
+ * show saved keys as "locked" after a refresh.
38
+ */
39
+ preview: KeyPreview
40
+ }
41
+
42
+ function sanitizeKeyring(value: unknown): Keyring {
43
+ if (typeof value !== 'object' || value === null) return {}
44
+ const keys: Keyring = {}
45
+ for (const [provider, key] of Object.entries(value)) {
46
+ if (isProviderId(provider) && typeof key === 'string' && key.length > 0) {
47
+ keys[provider] = key
48
+ }
49
+ }
50
+ return keys
51
+ }
52
+
53
+ /** Build the non-sensitive `provider → last 4` preview from a keyring. */
54
+ function previewOf(keys: Keyring): KeyPreview {
55
+ const preview: KeyPreview = {}
56
+ for (const [provider, key] of Object.entries(keys)) {
57
+ if (!key || !isProviderId(provider)) continue
58
+ // Keys of length ≤ 4 would make last-4 the whole secret — store presence only.
59
+ preview[provider] = key.length > 4 ? key.slice(-4) : ''
60
+ }
61
+ return preview
62
+ }
63
+
64
+ /**
65
+ * Whether the current environment exposes WebAuthn. Actual PRF support can
66
+ * only be confirmed during registration; `passkeyStorage` throws if the
67
+ * chosen authenticator does not support PRF.
68
+ */
69
+ export function isPasskeyStorageSupported(): boolean {
70
+ return (
71
+ typeof globalThis !== 'undefined' &&
72
+ typeof globalThis.PublicKeyCredential !== 'undefined' &&
73
+ typeof globalThis.navigator !== 'undefined' &&
74
+ typeof globalThis.navigator.credentials.create === 'function'
75
+ )
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Crypto (exported for testing; the WebAuthn ceremony below feeds `deriveAesKey`)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /** Derive a non-extractable AES-256-GCM key from a 32-byte PRF output. */
83
+ export async function deriveAesKey(
84
+ prfOutput: BufferSource,
85
+ ): Promise<CryptoKey> {
86
+ const base = await crypto.subtle.importKey('raw', prfOutput, 'HKDF', false, [
87
+ 'deriveKey',
88
+ ])
89
+ return crypto.subtle.deriveKey(
90
+ {
91
+ name: 'HKDF',
92
+ hash: 'SHA-256',
93
+ salt: new Uint8Array(0),
94
+ info: new TextEncoder().encode(HKDF_INFO),
95
+ },
96
+ base,
97
+ { name: 'AES-GCM', length: 256 },
98
+ false,
99
+ ['encrypt', 'decrypt'],
100
+ )
101
+ }
102
+
103
+ export async function encryptKeyring(
104
+ key: CryptoKey,
105
+ keys: Keyring,
106
+ ): Promise<{ iv: ArrayBuffer; ciphertext: ArrayBuffer }> {
107
+ const iv = crypto.getRandomValues(new Uint8Array(12))
108
+ const plaintext = new TextEncoder().encode(JSON.stringify(keys))
109
+ const ciphertext = await crypto.subtle.encrypt(
110
+ { name: 'AES-GCM', iv },
111
+ key,
112
+ plaintext,
113
+ )
114
+ return { iv: iv.buffer, ciphertext }
115
+ }
116
+
117
+ export async function decryptKeyring(
118
+ key: CryptoKey,
119
+ iv: BufferSource,
120
+ ciphertext: BufferSource,
121
+ ): Promise<Keyring> {
122
+ const plaintext = await crypto.subtle.decrypt(
123
+ { name: 'AES-GCM', iv },
124
+ key,
125
+ ciphertext,
126
+ )
127
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(plaintext))
128
+ return sanitizeKeyring(parsed)
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // IndexedDB
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function openDb(dbName: string): Promise<IDBDatabase> {
136
+ return new Promise((resolve, reject) => {
137
+ const request = indexedDB.open(dbName, 1)
138
+ request.onupgradeneeded = () => {
139
+ request.result.createObjectStore(STORE_NAME, { keyPath: 'id' })
140
+ }
141
+ request.onsuccess = () => resolve(request.result)
142
+ request.onerror = () => reject(request.error)
143
+ })
144
+ }
145
+
146
+ function idbGet(dbName: string): Promise<StoredRecord | null> {
147
+ return openDb(dbName).then(
148
+ (db) =>
149
+ new Promise((resolve, reject) => {
150
+ const request = db
151
+ .transaction(STORE_NAME, 'readonly')
152
+ .objectStore(STORE_NAME)
153
+ .get(RECORD_ID)
154
+ request.onsuccess = () => resolve(request.result ?? null)
155
+ request.onerror = () => reject(request.error)
156
+ }),
157
+ )
158
+ }
159
+
160
+ function idbPut(dbName: string, record: StoredRecord): Promise<void> {
161
+ return openDb(dbName).then(
162
+ (db) =>
163
+ new Promise((resolve, reject) => {
164
+ const tx = db.transaction(STORE_NAME, 'readwrite')
165
+ tx.objectStore(STORE_NAME).put(record)
166
+ tx.oncomplete = () => resolve()
167
+ tx.onerror = () => reject(tx.error)
168
+ }),
169
+ )
170
+ }
171
+
172
+ function idbClear(dbName: string): Promise<void> {
173
+ return openDb(dbName).then(
174
+ (db) =>
175
+ new Promise((resolve, reject) => {
176
+ const tx = db.transaction(STORE_NAME, 'readwrite')
177
+ tx.objectStore(STORE_NAME).delete(RECORD_ID)
178
+ tx.oncomplete = () => resolve()
179
+ tx.onerror = () => reject(tx.error)
180
+ }),
181
+ )
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // WebAuthn ceremonies
186
+ // ---------------------------------------------------------------------------
187
+
188
+ function requirePublicKeyCredential(
189
+ credential: Credential | null,
190
+ action: string,
191
+ ): PublicKeyCredential {
192
+ if (!credential) throw new Error(`Passkey ${action} was cancelled`)
193
+ if (!(credential instanceof PublicKeyCredential)) {
194
+ throw new Error(`Unexpected credential type during ${action}`)
195
+ }
196
+ return credential
197
+ }
198
+
199
+ async function registerPasskey(
200
+ rpName: string,
201
+ userName: string,
202
+ rpId?: string,
203
+ ): Promise<{
204
+ credentialId: ArrayBuffer
205
+ salt: Uint8Array<ArrayBuffer>
206
+ prf?: BufferSource
207
+ }> {
208
+ const salt = crypto.getRandomValues(new Uint8Array(32))
209
+ const credential = requirePublicKeyCredential(
210
+ await navigator.credentials.create({
211
+ publicKey: {
212
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
213
+ // Omit `id` to let the browser bind the passkey to the current origin's
214
+ // effective domain; set it to scope across subdomains of a self-host.
215
+ rp: rpId ? { name: rpName, id: rpId } : { name: rpName },
216
+ user: {
217
+ id: crypto.getRandomValues(new Uint8Array(16)),
218
+ name: userName,
219
+ displayName: userName,
220
+ },
221
+ pubKeyCredParams: [
222
+ { type: 'public-key', alg: -7 },
223
+ { type: 'public-key', alg: -257 },
224
+ ],
225
+ authenticatorSelection: {
226
+ residentKey: 'required',
227
+ userVerification: 'required',
228
+ },
229
+ extensions: { prf: { eval: { first: salt } } },
230
+ },
231
+ }),
232
+ 'registration',
233
+ )
234
+
235
+ const prf = credential.getClientExtensionResults().prf
236
+ if (!prf?.enabled) {
237
+ throw new Error(
238
+ 'This authenticator does not support the WebAuthn PRF extension',
239
+ )
240
+ }
241
+ return { credentialId: credential.rawId, salt, prf: prf.results?.first }
242
+ }
243
+
244
+ async function evaluatePrf(
245
+ credentialId: BufferSource,
246
+ salt: BufferSource,
247
+ ): Promise<BufferSource> {
248
+ const credential = requirePublicKeyCredential(
249
+ await navigator.credentials.get({
250
+ publicKey: {
251
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
252
+ allowCredentials: [{ type: 'public-key', id: credentialId }],
253
+ userVerification: 'required',
254
+ extensions: { prf: { eval: { first: salt } } },
255
+ },
256
+ }),
257
+ 'unlock',
258
+ )
259
+ const result = credential.getClientExtensionResults().prf?.results?.first
260
+ if (!result) {
261
+ throw new Error('Authenticator did not return a PRF result')
262
+ }
263
+ return result
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Storage strategy
268
+ // ---------------------------------------------------------------------------
269
+
270
+ export interface PasskeyStorageOptions {
271
+ /** Relying-party name shown in the passkey prompt. */
272
+ rpName?: string
273
+ /** Username label attached to the created passkey. */
274
+ userName?: string
275
+ /**
276
+ * WebAuthn Relying Party ID. Omit to bind the passkey to the current origin's
277
+ * effective domain (the default — no central/hardcoded domain). Set it to a
278
+ * registrable parent domain to share the credential across subdomains of your
279
+ * own deployment. The encrypted keyring is never portable across unrelated
280
+ * domains.
281
+ */
282
+ rpId?: string
283
+ /** IndexedDB database name. Defaults to `byok`. */
284
+ dbName?: string
285
+ }
286
+
287
+ /**
288
+ * Passkey-encrypted persistence. `ByokClient` treats this as `unlockable`, so
289
+ * nothing is decrypted until the user calls `unlock()` (or saves a key, which
290
+ * registers a passkey on first use). The derived key is cached in memory for
291
+ * the session so repeated saves don't re-prompt.
292
+ */
293
+ export function passkeyStorage(
294
+ options: PasskeyStorageOptions = {},
295
+ ): KeyringStorage {
296
+ const rpName = options.rpName ?? 'BYOK'
297
+ const userName = options.userName ?? 'byok-keyring'
298
+ const { rpId } = options
299
+ const dbName = options.dbName ?? DEFAULT_DB
300
+
301
+ let cachedKey: CryptoKey | null = null
302
+ let cachedMeta: {
303
+ credentialId: ArrayBuffer
304
+ salt: Uint8Array<ArrayBuffer>
305
+ } | null = null
306
+
307
+ // Obtain the AES key, running exactly one WebAuthn ceremony if it isn't
308
+ // already cached for this session (unlock if a passkey exists, else register).
309
+ async function ensureKey(): Promise<{
310
+ key: CryptoKey
311
+ credentialId: ArrayBuffer
312
+ salt: Uint8Array<ArrayBuffer>
313
+ }> {
314
+ if (cachedKey && cachedMeta) {
315
+ return { key: cachedKey, ...cachedMeta }
316
+ }
317
+ const existing = await idbGet(dbName)
318
+ if (existing) {
319
+ const prf = await evaluatePrf(existing.credentialId, existing.salt)
320
+ cachedKey = await deriveAesKey(prf)
321
+ cachedMeta = {
322
+ credentialId: existing.credentialId,
323
+ salt: new Uint8Array(existing.salt),
324
+ }
325
+ } else {
326
+ const reg = await registerPasskey(rpName, userName, rpId)
327
+ const prf = reg.prf ?? (await evaluatePrf(reg.credentialId, reg.salt))
328
+ cachedKey = await deriveAesKey(prf)
329
+ cachedMeta = { credentialId: reg.credentialId, salt: reg.salt }
330
+ }
331
+ return { key: cachedKey, ...cachedMeta }
332
+ }
333
+
334
+ return {
335
+ id: 'passkey',
336
+ label: 'Passkey-encrypted (this device)',
337
+ persistent: true,
338
+ unlockable: true,
339
+ warning:
340
+ 'Keys are encrypted with your passkey and unlocked with biometrics. ' +
341
+ 'This protects saved keys if your device is stolen, but not against code ' +
342
+ 'running on this page after you unlock.',
343
+ peek: async () => {
344
+ // Unencrypted last-4 sidecar — no key material, no unlock ceremony.
345
+ const existing = await idbGet(dbName)
346
+ return existing?.preview ?? {}
347
+ },
348
+ load: async () => {
349
+ const existing = await idbGet(dbName)
350
+ if (!existing) return {}
351
+ const { key } = await ensureKey()
352
+ return decryptKeyring(key, existing.iv, existing.ciphertext)
353
+ },
354
+ save: async (keys) => {
355
+ const existing = await idbGet(dbName)
356
+ const hasKeys = Object.values(keys).some(Boolean)
357
+ // First save with an empty keyring is a no-op — avoids a passkey ceremony
358
+ // when another storage tier writes an empty ring.
359
+ if (!hasKeys && !existing) return
360
+
361
+ const { key, credentialId, salt } = await ensureKey()
362
+ const { iv, ciphertext } = await encryptKeyring(key, keys)
363
+ await idbPut(dbName, {
364
+ id: RECORD_ID,
365
+ credentialId,
366
+ salt: salt.buffer,
367
+ iv,
368
+ ciphertext,
369
+ preview: previewOf(keys),
370
+ })
371
+ },
372
+ clear: async () => {
373
+ cachedKey = null
374
+ cachedMeta = null
375
+ await idbClear(dbName)
376
+ },
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Passkey-encrypted storage when WebAuthn is available in a secure context.
382
+ * Otherwise session memory, with a warning — this is not an automatic PRF
383
+ * fallback. First save still throws if the authenticator lacks PRF.
384
+ */
385
+ export function defaultByokStorage(
386
+ options?: PasskeyStorageOptions,
387
+ ): KeyringStorage {
388
+ const secure =
389
+ typeof globalThis.isSecureContext !== 'boolean' ||
390
+ globalThis.isSecureContext
391
+ if (!isPasskeyStorageSupported() || !secure) {
392
+ return {
393
+ ...memoryStorage(),
394
+ warning:
395
+ 'Passkeys are unavailable in this context. Keys stay in memory for this tab only.',
396
+ }
397
+ }
398
+ return passkeyStorage(options)
399
+ }
@@ -0,0 +1,30 @@
1
+ import { ByokUnresolvedProviderError, isProviderId } from '@tanstack/ai/byok'
2
+ import type { ProviderId } from '@tanstack/ai/byok'
3
+ import type { ByokClient } from './client'
4
+
5
+ export function resolveByokProviderId(
6
+ byokProvider: (() => string | undefined) | undefined,
7
+ ...candidates: Array<unknown>
8
+ ): ProviderId | undefined {
9
+ const fromFn = byokProvider?.()
10
+ if (isProviderId(fromFn)) return fromFn
11
+ for (const candidate of candidates) {
12
+ if (isProviderId(candidate)) return candidate
13
+ }
14
+ return undefined
15
+ }
16
+
17
+ /**
18
+ * Prepare and stamp headers for one resolved slug. Throws instead of
19
+ * attaching every stored key when the slug is missing.
20
+ */
21
+ export async function prepareResolvedByokHeaders(
22
+ byok: ByokClient,
23
+ provider: ProviderId | undefined,
24
+ ): Promise<Record<string, string>> {
25
+ if (!provider) {
26
+ throw new ByokUnresolvedProviderError()
27
+ }
28
+ await byok.prepare(provider)
29
+ return byok.headers(provider)
30
+ }
@@ -0,0 +1,29 @@
1
+ import type { ProviderId } from '@tanstack/ai/byok'
2
+
3
+ export type Keyring = Partial<Record<ProviderId, string>>
4
+ export type KeyPreview = Partial<Record<ProviderId, string>>
5
+
6
+ export interface KeyringStorage {
7
+ readonly id: string
8
+ readonly label: string
9
+ readonly persistent: boolean
10
+ readonly unlockable?: boolean
11
+ readonly warning?: string
12
+ peek?: () => KeyPreview | Promise<KeyPreview>
13
+ load: () => Keyring | Promise<Keyring>
14
+ save: (keys: Keyring) => void | Promise<void>
15
+ clear: () => void | Promise<void>
16
+ }
17
+
18
+ export function memoryStorage(): KeyringStorage {
19
+ return {
20
+ id: 'memory',
21
+ label: 'Session only (not saved)',
22
+ persistent: false,
23
+ warning:
24
+ 'Keys stay in memory for this tab only. They are not saved across reloads.',
25
+ load: () => ({}),
26
+ save: () => {},
27
+ clear: () => {},
28
+ }
29
+ }
package/src/byok.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { defineByok, ByokClient, EMPTY_BYOK_SNAPSHOT } from './byok/client'
2
+ export type {
3
+ ByokPrompt,
4
+ ByokSnapshot,
5
+ DefineByokOptions,
6
+ KeyStatus,
7
+ } from './byok/client'
8
+ export { defineByokProvider } from '@tanstack/ai/byok'
9
+ export type { ByokProvider, ByokProviderInit } from '@tanstack/ai/byok'
10
+ export { memoryStorage } from './byok/storage'
11
+ export type { KeyPreview, Keyring, KeyringStorage } from './byok/storage'
12
+ export {
13
+ defaultByokStorage,
14
+ decryptKeyring,
15
+ deriveAesKey,
16
+ encryptKeyring,
17
+ isPasskeyStorageSupported,
18
+ passkeyStorage,
19
+ } from './byok/passkey'
20
+ export type { PasskeyStorageOptions } from './byok/passkey'