From 209f22a7be66cd8a99da29b6237b1cc60c0f6973 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:02:26 +1000 Subject: [PATCH 01/12] feat(byok): add @tanstack/ai-byok bring-your-own-key toolkit Client keyring, per-provider request headers, and stateless server helpers that never persist or log provider keys. Keys live client-side and travel in an x-tanstack-byok- header, never the request body or message history. - @tanstack/ai-byok: provider registry, byokHeaders, pluggable storage (memory default, opt-in plaintext localStorage), validateKey - /react: , useByok, drop-in (last-4 display only) - /server: getByokKey (header-only, never logged), byokMissing (typed error), scrubSecrets/maskKey Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/byok-package.md | 9 + knip.json | 3 + packages/ai-byok/README.md | 95 +++++++++ packages/ai-byok/package.json | 80 +++++++ packages/ai-byok/src/client/keyring.ts | 31 +++ packages/ai-byok/src/client/storage.ts | 79 +++++++ packages/ai-byok/src/client/validate.ts | 40 ++++ packages/ai-byok/src/index.ts | 34 +++ packages/ai-byok/src/react.ts | 30 +++ packages/ai-byok/src/react/byok-context.tsx | 195 ++++++++++++++++++ .../ai-byok/src/react/byok-key-manager.tsx | 193 +++++++++++++++++ packages/ai-byok/src/react/use-byok.ts | 25 +++ packages/ai-byok/src/server.ts | 22 ++ packages/ai-byok/src/server/byok-missing.ts | 44 ++++ packages/ai-byok/src/server/get-byok-key.ts | 21 ++ packages/ai-byok/src/server/scrub.ts | 33 +++ packages/ai-byok/src/shared/providers.ts | 140 +++++++++++++ packages/ai-byok/tests/byok.test.ts | 68 ++++++ packages/ai-byok/tests/react.test.tsx | 49 +++++ packages/ai-byok/tsconfig.json | 10 + packages/ai-byok/vite.config.ts | 36 ++++ pnpm-lock.yaml | 49 +++-- 22 files changed, 1261 insertions(+), 25 deletions(-) create mode 100644 .changeset/byok-package.md create mode 100644 packages/ai-byok/README.md create mode 100644 packages/ai-byok/package.json create mode 100644 packages/ai-byok/src/client/keyring.ts create mode 100644 packages/ai-byok/src/client/storage.ts create mode 100644 packages/ai-byok/src/client/validate.ts create mode 100644 packages/ai-byok/src/index.ts create mode 100644 packages/ai-byok/src/react.ts create mode 100644 packages/ai-byok/src/react/byok-context.tsx create mode 100644 packages/ai-byok/src/react/byok-key-manager.tsx create mode 100644 packages/ai-byok/src/react/use-byok.ts create mode 100644 packages/ai-byok/src/server.ts create mode 100644 packages/ai-byok/src/server/byok-missing.ts create mode 100644 packages/ai-byok/src/server/get-byok-key.ts create mode 100644 packages/ai-byok/src/server/scrub.ts create mode 100644 packages/ai-byok/src/shared/providers.ts create mode 100644 packages/ai-byok/tests/byok.test.ts create mode 100644 packages/ai-byok/tests/react.test.tsx create mode 100644 packages/ai-byok/tsconfig.json create mode 100644 packages/ai-byok/vite.config.ts diff --git a/.changeset/byok-package.md b/.changeset/byok-package.md new file mode 100644 index 000000000..ac10bd598 --- /dev/null +++ b/.changeset/byok-package.md @@ -0,0 +1,9 @@ +--- +'@tanstack/ai-byok': minor +--- + +Add `@tanstack/ai-byok`: a bring-your-own-key toolkit for TanStack AI. Keys live client-side and travel to the relay in a per-provider header (`x-tanstack-byok-`), never the request body or message history. + +- **Client** (`@tanstack/ai-byok`): `byokHeaders`, a typed provider registry, pluggable storage (memory by default, opt-in plaintext localStorage), and `validateKey`. +- **React** (`@tanstack/ai-byok/react`): ``, `useByok()`, and a drop-in `` settings UI that only ever shows the last 4 characters of a saved key. +- **Server** (`@tanstack/ai-byok/server`): `getByokKey` (header-only, never logged), `byokMissing` (typed error response), and `scrubSecrets`/`maskKey` for keeping key material out of logs and errors. Stateless pass-through — no persistence, no central endpoint. diff --git a/knip.json b/knip.json index c7995c1aa..19c41a25c 100644 --- a/knip.json +++ b/knip.json @@ -48,6 +48,9 @@ "packages/ai-react": { "ignoreDependencies": ["@mcp-ui/client"] }, + "packages/ai-byok": { + "ignoreDependencies": ["react", "@types/react"] + }, "packages/ai-preact": { "ignoreDependencies": ["@mcp-ui/client"] }, diff --git a/packages/ai-byok/README.md b/packages/ai-byok/README.md new file mode 100644 index 000000000..8680ac549 --- /dev/null +++ b/packages/ai-byok/README.md @@ -0,0 +1,95 @@ +# @tanstack/ai-byok + +Bring-your-own-key toolkit for [TanStack AI](https://tanstack.com/ai). Users +supply their own provider API keys; the library collects them **client-side**, +attaches them **per-request in a header**, and uses them **server-side without +ever persisting or logging them**. + +## First principle: never be a custodian + +- **Keys live client-side.** The browser is the system of record. +- **The server piece is a stateless pass-through.** It reads the key off the + incoming request header, hands it to the TanStack AI adapter for one call, and + never writes it to a DB, cache, log, or observability stream. +- **No central endpoint.** The server helper is trivially self-hostable; there + is no hardcoded relay URL. + +Keys travel in the `x-tanstack-byok-` header — never the request body +or message history — so they stay out of persisted conversations and the +event/observability stream. + +## Client (React) + +```tsx +import { ByokProvider, ByokKeyManager, useByok } from '@tanstack/ai-byok/react' +import { byokHeaders, memoryStorage, localStorageStorage } from '@tanstack/ai-byok/react' +import { fetchServerSentEvents } from '@tanstack/ai-client' +import { useChat } from '@tanstack/ai-react' + +// Wrap your app. `storage` is chosen once. Defaults to the safest option +// (session-only, nothing persisted). Pass `localStorageStorage()` to persist. +function App({ children }) { + return ( + {children} + ) +} + +// Drop-in settings UI — shows the last 4 chars of a saved key, never the whole key. +function Settings() { + return +} + +// Attach the keys to the connection. +function Chat() { + const { keys } = useByok() + return useChat({ + connection: fetchServerSentEvents('/api/chat', { + headers: byokHeaders(keys), + }), + }) +} +``` + +## Server (stateless — no persist, no log) + +```ts +import { getByokKey, byokMissing } from '@tanstack/ai-byok/server' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai/adapters' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const apiKey = getByokKey(request, 'openai') + if (!apiKey) return byokMissing('openai') + + const stream = chat({ + adapter: createOpenaiChat('gpt-5.2', apiKey), + messages, + }) + return toServerSentEventsResponse(stream) +} +``` + +## Storage + +Pass one `storage` to ``. Two are built in: + +- **`memoryStorage()` (default)** — session / in-memory. Keys vanish on refresh. + Never persisted. Zero at-rest liability. +- **`localStorageStorage()` (opt-in)** — persists across refreshes in + `localStorage` as **plaintext. Keys are not encrypted**, so they are readable + by any XSS or extension on the origin. `` shows a warning while + this storage is active. + +`KeyringStorage` is a small interface (`id`, `label`, `persistent`, `load`, +`save`, `clear`), so you can supply your own. A passkey-encrypted +(WebAuthn PRF → AES-256-GCM) storage that encrypts at rest is planned as a +follow-up. + +## Validation + +`validateKey(provider, key)` pings the provider's cheapest authenticated +endpoint to confirm a key works before the user hits a wall mid-stream. It +returns `'valid' | 'invalid' | 'unsupported'`, and **throws** on a network/CORS +failure rather than guessing — some providers block browser origins entirely. diff --git a/packages/ai-byok/package.json b/packages/ai-byok/package.json new file mode 100644 index 000000000..1f235449b --- /dev/null +++ b/packages/ai-byok/package.json @@ -0,0 +1,80 @@ +{ + "name": "@tanstack/ai-byok", + "version": "0.1.0", + "description": "Bring-your-own-key toolkit for TanStack AI: client keyring, request headers, and stateless server helpers that never persist or log provider keys.", + "author": "Tanner Linsley", + "license": "MIT", + "homepage": "https://tanstack.com/ai", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-byok" + }, + "bugs": { + "url": "https://github.com/TanStack/ai/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./server": { + "types": "./dist/esm/server.d.ts", + "import": "./dist/esm/server.js" + }, + "./react": { + "types": "./dist/esm/react.d.ts", + "import": "./dist/esm/react.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:eslint": "eslint ./src", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:build": "publint --strict", + "build": "vite build" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "byok", + "api-key", + "react" + ], + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + }, + "devDependencies": { + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.7", + "@vitest/coverage-v8": "4.0.14", + "jsdom": "^27.2.0", + "react": "^19.2.3", + "vite": "^7.3.3" + } +} diff --git a/packages/ai-byok/src/client/keyring.ts b/packages/ai-byok/src/client/keyring.ts new file mode 100644 index 000000000..cdc45076c --- /dev/null +++ b/packages/ai-byok/src/client/keyring.ts @@ -0,0 +1,31 @@ +import { PROVIDER_IDS, byokHeaderName } from '../shared/providers' +import type { ProviderId } from '../shared/providers' + +/** + * In-memory map of `provider → key`. The browser is the system of record for + * keys; this is the shape held in client state and persisted (when a + * persisting storage tier is selected). + */ +export type Keyring = Partial> + +/** + * Turns a keyring into request headers for the connection layer — one header + * per present provider. Absent/empty keys are skipped. + * + * @example + * ```ts + * useChat({ + * connection: fetchServerSentEvents('/api/chat', { + * headers: byokHeaders(keys), + * }), + * }) + * ``` + */ +export function byokHeaders(keys: Keyring): Record { + const headers: Record = {} + for (const provider of PROVIDER_IDS) { + const key = keys[provider] + if (key) headers[byokHeaderName(provider)] = key + } + return headers +} diff --git a/packages/ai-byok/src/client/storage.ts b/packages/ai-byok/src/client/storage.ts new file mode 100644 index 000000000..5c8db33bd --- /dev/null +++ b/packages/ai-byok/src/client/storage.ts @@ -0,0 +1,79 @@ +import type { Keyring } from './keyring' + +/** + * A client-side persistence strategy for the keyring. Methods may be sync or + * async so a strategy backed by async crypto (e.g. a future passkey/PRF + * strategy) fits the same interface as the synchronous ones. + */ +export interface KeyringStorage { + /** A stable id for the strategy, surfaced in UI. */ + readonly id: string + /** Human-readable label. */ + readonly label: string + /** + * Whether keys written here survive a page refresh. `false` for the default + * memory storage; UI uses this to decide whether to show a persistence + * warning. + */ + readonly persistent: boolean + readonly load: () => Promise | Keyring + readonly save: (keys: Keyring) => Promise | void + readonly clear: () => Promise | void +} + +/** + * The default: session / in-memory. Keys live only in React state and vanish on + * refresh. Persists nothing, so it holds no state of its own — `load` always + * returns an empty keyring. Zero at-rest liability. + */ +export function memoryStorage(): KeyringStorage { + return { + id: 'memory', + label: 'Session only (not saved)', + persistent: false, + load: () => ({}), + save: () => {}, + clear: () => {}, + } +} + +/** + * Opt-in: `localStorage`, **plaintext — keys are NOT encrypted**. Convenient + * across refreshes, but readable by any XSS or browser extension on the origin. + * `` surfaces a warning while this storage is active (it is + * marked `persistent`). Use only when the convenience is worth that exposure; + * an encrypted-at-rest option is planned as a follow-up. + */ +export function localStorageStorage( + storageKey = 'tanstack-byok', +): KeyringStorage { + const store = (): Storage | null => { + // `localStorage` is typed as always-present via the DOM lib, but is absent + // in SSR / Node and can throw in sandboxed frames — guard at runtime. + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return globalThis.localStorage ?? null + } catch { + return null + } + } + + return { + id: 'localStorage', + label: 'This browser (localStorage, plaintext)', + persistent: true, + load: () => { + const raw = store()?.getItem(storageKey) + if (!raw) return {} + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== 'object' || parsed === null) return {} + return parsed + }, + save: (keys) => { + store()?.setItem(storageKey, JSON.stringify(keys)) + }, + clear: () => { + store()?.removeItem(storageKey) + }, + } +} diff --git a/packages/ai-byok/src/client/validate.ts b/packages/ai-byok/src/client/validate.ts new file mode 100644 index 000000000..9eee46468 --- /dev/null +++ b/packages/ai-byok/src/client/validate.ts @@ -0,0 +1,40 @@ +import { providerValidateConfig } from '../shared/providers' +import type { ProviderId } from '../shared/providers' + +/** + * Result of a key validation attempt. + * - `valid` — provider accepted the key. + * - `invalid` — provider rejected the key (401/403). + * - `unsupported` — provider has no browser-reachable validation endpoint. + */ +export type ValidationStatus = 'valid' | 'invalid' | 'unsupported' + +/** + * Pings the provider's cheapest authenticated endpoint (usually a models list) + * to confirm a key works before the user hits a wall mid-stream. + * + * Returns `'unsupported'` for providers with no browser-reachable endpoint. + * For any other failure (rate limit, 5xx, or a network/CORS error) this + * **throws** rather than guessing — the caller decides how to surface it. Note + * that some providers block browser origins entirely; a thrown `TypeError` + * ("Failed to fetch") is the honest signal there, not a silent `invalid`. + */ +export async function validateKey( + provider: ProviderId, + key: string, +): Promise { + const config = providerValidateConfig(provider) + if (!config) return 'unsupported' + + const response = await fetch(config.url, { + method: 'GET', + headers: config.headers(key), + }) + + if (response.ok) return 'valid' + if (response.status === 401 || response.status === 403) return 'invalid' + + throw new Error( + `Could not validate ${provider} key: ${response.status} ${response.statusText}`, + ) +} diff --git a/packages/ai-byok/src/index.ts b/packages/ai-byok/src/index.ts new file mode 100644 index 000000000..863cf6941 --- /dev/null +++ b/packages/ai-byok/src/index.ts @@ -0,0 +1,34 @@ +/** + * `@tanstack/ai-byok` — framework-agnostic BYOK client toolkit. + * + * Keys live client-side (the browser is the system of record) and travel to + * the relay in a per-provider header, never the request body. This entry has + * no framework or server dependencies; React bindings live in + * `@tanstack/ai-byok/react` and server helpers in `@tanstack/ai-byok/server`. + */ + +// Shared registry +export { + BYOK_PROVIDERS, + PROVIDER_IDS, + BYOK_HEADER_PREFIX, + byokHeaderName, + isProviderId, +} from './shared/providers' +export type { + ProviderId, + ProviderConfig, + ProviderValidateConfig, +} from './shared/providers' + +// Keyring + headers +export { byokHeaders } from './client/keyring' +export type { Keyring } from './client/keyring' + +// Storage tiers +export { memoryStorage, localStorageStorage } from './client/storage' +export type { KeyringStorage } from './client/storage' + +// Validation +export { validateKey } from './client/validate' +export type { ValidationStatus } from './client/validate' diff --git a/packages/ai-byok/src/react.ts b/packages/ai-byok/src/react.ts new file mode 100644 index 000000000..c31966c5d --- /dev/null +++ b/packages/ai-byok/src/react.ts @@ -0,0 +1,30 @@ +/** + * `@tanstack/ai-byok/react` — React bindings for the BYOK keyring. + */ +export { ByokProvider, ByokContext } from './react/byok-context' +export type { + ByokProviderProps, + ByokContextValue, + KeyStatus, +} from './react/byok-context' +export { useByok } from './react/use-byok' +export { ByokKeyManager } from './react/byok-key-manager' +export type { ByokKeyManagerProps } from './react/byok-key-manager' + +// Re-export the client toolkit so React consumers have one import path. +export { + byokHeaders, + memoryStorage, + localStorageStorage, + validateKey, + BYOK_PROVIDERS, + PROVIDER_IDS, + byokHeaderName, + isProviderId, +} from './index' +export type { + Keyring, + KeyringStorage, + ProviderId, + ValidationStatus, +} from './index' diff --git a/packages/ai-byok/src/react/byok-context.tsx b/packages/ai-byok/src/react/byok-context.tsx new file mode 100644 index 000000000..ff80d808d --- /dev/null +++ b/packages/ai-byok/src/react/byok-context.tsx @@ -0,0 +1,195 @@ +import { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { validateKey as pingProvider } from '../client/validate' +import { memoryStorage } from '../client/storage' +import { maskKey } from '../server/scrub' +import { PROVIDER_IDS } from '../shared/providers' +import type { ReactNode } from 'react' +import type { Keyring } from '../client/keyring' +import type { KeyringStorage } from '../client/storage' +import type { ProviderId } from '../shared/providers' +import type { ValidationStatus } from '../client/validate' + +/** + * Per-provider status surfaced to the UI. `masked` never contains more than the + * last 4 characters — the full key is never rendered back. + */ +export type KeyStatus = + | { state: 'empty' } + | { state: 'set'; masked: string } + | { state: 'validating'; masked: string } + | { state: ValidationStatus; masked: string } + | { state: 'error'; masked: string; message: string } + +const EMPTY: KeyStatus = { state: 'empty' } + +export interface ByokContextValue { + /** + * The live keyring. Pass to `byokHeaders(keys)` when building the connection. + * The UI never renders these; treat them as write-only from the UI's view. + */ + keys: Keyring + /** Set (or overwrite) a provider's key and persist it to the configured storage. */ + setKey: (provider: ProviderId, key: string) => Promise + /** Remove a single provider's key. */ + clearKey: (provider: ProviderId) => Promise + /** Remove every key. */ + clearAll: () => Promise + /** + * Validate a key against the provider. Validates the given key, or the stored + * key when omitted. Records the outcome in {@link status} and returns it; + * never throws — a network/CORS failure is reported as an `error` status. + */ + validateKey: (provider: ProviderId, key?: string) => Promise + /** Per-provider status map. Providers with no key report `{ state: 'empty' }`. */ + status: Record + /** The configured persistence storage. */ + storage: KeyringStorage + hasKey: (provider: ProviderId) => boolean +} + +export const ByokContext = createContext(null) + +export interface ByokProviderProps { + children: ReactNode + /** + * Where keys are persisted. Defaults to the safest option + * ({@link memoryStorage}) — keys vanish on refresh and nothing is persisted. + * Fixed for the life of the provider. + */ + storage?: KeyringStorage +} + +export function ByokProvider({ + children, + storage: initialStorage, +}: ByokProviderProps) { + // Storage is chosen once and fixed for the life of the provider. + const [storage] = useState( + () => initialStorage ?? memoryStorage(), + ) + const [keys, setKeys] = useState({}) + const [statuses, setStatuses] = useState>>( + {}, + ) + + // Keep a ref to the current keys so the persisting callbacks read the latest + // without re-creating on every keystroke. + const keysRef = useRef(keys) + keysRef.current = keys + + // Hydrate from storage on mount. + useEffect(() => { + let cancelled = false + void Promise.resolve(storage.load()).then((loaded) => { + if (cancelled) return + const loadedStatuses = Object.fromEntries( + Object.entries(loaded) + .filter(([, key]) => Boolean(key)) + .map(([provider, key]) => [ + provider, + { state: 'set', masked: maskKey(key) } satisfies KeyStatus, + ]), + ) + // Merge hydrated keys UNDER any edits the user made during the async + // load, so an early setKey is never clobbered by late hydration. + setKeys((current) => ({ ...loaded, ...current })) + setStatuses((current) => ({ ...loadedStatuses, ...current })) + }) + return () => { + cancelled = true + } + }, [storage]) + + const persist = useCallback( + (next: Keyring) => Promise.resolve(storage.save(next)), + [storage], + ) + + const setKey = useCallback( + async (provider: ProviderId, key: string) => { + const next = { ...keysRef.current, [provider]: key } + setKeys(next) + setStatuses((prev) => ({ + ...prev, + [provider]: { state: 'set', masked: maskKey(key) }, + })) + await persist(next) + }, + [persist], + ) + + const clearKey = useCallback( + async (provider: ProviderId) => { + const next = { ...keysRef.current } + delete next[provider] + setKeys(next) + setStatuses((prev) => ({ ...prev, [provider]: EMPTY })) + await persist(next) + }, + [persist], + ) + + const clearAll = useCallback(async () => { + setKeys({}) + setStatuses({}) + await Promise.resolve(storage.clear()) + }, [storage]) + + const validateKey = useCallback( + async (provider: ProviderId, key?: string): Promise => { + const target = key ?? keysRef.current[provider] + if (!target) { + setStatuses((prev) => ({ ...prev, [provider]: EMPTY })) + return EMPTY + } + const masked = maskKey(target) + setStatuses((prev) => ({ ...prev, [provider]: { state: 'validating', masked } })) + + let result: KeyStatus + try { + const status = await pingProvider(provider, target) + result = { state: status, masked } + } catch (error) { + result = { + state: 'error', + masked, + message: error instanceof Error ? error.message : String(error), + } + } + setStatuses((prev) => ({ ...prev, [provider]: result })) + return result + }, + [], + ) + + const status = useMemo(() => { + const full = {} as Record + for (const provider of PROVIDER_IDS) { + full[provider] = statuses[provider] ?? EMPTY + } + return full + }, [statuses]) + + const value = useMemo( + () => ({ + keys, + setKey, + clearKey, + clearAll, + validateKey, + status, + storage, + hasKey: (provider) => Boolean(keys[provider]), + }), + [keys, setKey, clearKey, clearAll, validateKey, status, storage], + ) + + return {children} +} diff --git a/packages/ai-byok/src/react/byok-key-manager.tsx b/packages/ai-byok/src/react/byok-key-manager.tsx new file mode 100644 index 000000000..e4101889a --- /dev/null +++ b/packages/ai-byok/src/react/byok-key-manager.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react' +import { BYOK_PROVIDERS, PROVIDER_IDS } from '../shared/providers' +import { useByok } from './use-byok' +import type { CSSProperties } from 'react' +import type { KeyStatus } from './byok-context' +import type { ProviderId } from '../shared/providers' + +export interface ByokKeyManagerProps { + /** Providers to show. Defaults to every registered provider. */ + providers?: Array + className?: string + style?: CSSProperties +} + +/** + * Drop-in settings UI for entering, validating, and clearing provider keys. + * + * Keys are write-only from this component's perspective: once saved, only the + * last 4 characters are ever shown. The full key is never rendered back. + */ +export function ByokKeyManager({ + providers = PROVIDER_IDS, + className, + style, +}: ByokKeyManagerProps) { + const { status, storage } = useByok() + + return ( +
+ {storage.persistent ? ( +

+ Keys are saved in this browser ({storage.label}) and can be read by any + script or extension running on this page. Prefer session-only storage + on shared machines. +

+ ) : null} + + {providers.map((provider) => ( + + ))} +
+ ) +} + +function ProviderRow({ + provider, + status, +}: { + provider: ProviderId + status: KeyStatus +}) { + const { setKey, clearKey, validateKey } = useByok() + const [draft, setDraft] = useState('') + const hasKey = status.state !== 'empty' + + return ( +
+
+ {BYOK_PROVIDERS[provider].label} + +
+ + {hasKey && 'masked' in status ? ( +
+ {status.masked} +
+ + +
+
+ ) : null} + +
{ + event.preventDefault() + if (!draft) return + void setKey(provider, draft) + setDraft('') + }} + > + setDraft(event.target.value)} + style={styles.input} + /> + +
+
+ ) +} + +function StatusBadge({ status }: { status: KeyStatus }) { + const config: Record = { + empty: { label: 'Not set', color: '#9ca3af' }, + set: { label: 'Saved', color: '#6b7280' }, + validating: { label: 'Validating…', color: '#d97706' }, + valid: { label: 'Valid', color: '#059669' }, + invalid: { label: 'Invalid', color: '#dc2626' }, + unsupported: { label: 'Cannot verify', color: '#9ca3af' }, + error: { label: 'Check failed', color: '#dc2626' }, + } + const { label, color } = config[status.state] + const title = status.state === 'error' ? status.message : undefined + return ( + + {label} + + ) +} + +const styles = { + root: { + display: 'flex', + flexDirection: 'column', + gap: 16, + fontFamily: 'system-ui, sans-serif', + fontSize: 14, + maxWidth: 480, + }, + warning: { margin: 0, color: '#b45309', fontSize: 12, lineHeight: 1.4 }, + row: { + display: 'flex', + flexDirection: 'column', + gap: 8, + padding: 12, + border: '1px solid #e5e7eb', + borderRadius: 8, + }, + rowHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + provider: { fontWeight: 600 }, + savedRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + }, + masked: { + fontFamily: 'ui-monospace, monospace', + color: '#374151', + letterSpacing: 1, + }, + actions: { display: 'flex', gap: 6 }, + inputRow: { display: 'flex', gap: 6 }, + input: { + flex: 1, + padding: '6px 8px', + borderRadius: 6, + border: '1px solid #d1d5db', + }, + badge: { fontSize: 12, fontWeight: 600 }, + button: { + padding: '4px 10px', + borderRadius: 6, + border: '1px solid #d1d5db', + background: '#fff', + cursor: 'pointer', + }, + primaryButton: { + padding: '6px 12px', + borderRadius: 6, + border: 'none', + background: '#111827', + color: '#fff', + cursor: 'pointer', + }, +} satisfies Record diff --git a/packages/ai-byok/src/react/use-byok.ts b/packages/ai-byok/src/react/use-byok.ts new file mode 100644 index 000000000..7a6a8cbf6 --- /dev/null +++ b/packages/ai-byok/src/react/use-byok.ts @@ -0,0 +1,25 @@ +import { useContext } from 'react' +import { ByokContext } from './byok-context' +import type { ByokContextValue } from './byok-context' + +/** + * Access the BYOK keyring and controls. Must be called under a + * {@link ByokProvider}. + * + * @example + * ```tsx + * const { keys } = useByok() + * useChat({ + * connection: fetchServerSentEvents('/api/chat', { + * headers: byokHeaders(keys), + * }), + * }) + * ``` + */ +export function useByok(): ByokContextValue { + const context = useContext(ByokContext) + if (!context) { + throw new Error('useByok must be used within a ') + } + return context +} diff --git a/packages/ai-byok/src/server.ts b/packages/ai-byok/src/server.ts new file mode 100644 index 000000000..fc14070cf --- /dev/null +++ b/packages/ai-byok/src/server.ts @@ -0,0 +1,22 @@ +/** + * `@tanstack/ai-byok/server` — stateless server helpers for BYOK relays. + * + * The server piece reads a provider key off the incoming request header, hands + * it to the TanStack AI adapter for that one call, and never persists or logs + * it. It is trivially self-hostable: there is no central endpoint baked in. + */ +export { getByokKey } from './server/get-byok-key' +export { byokMissing, isByokMissingBody } from './server/byok-missing' +export type { ByokMissingBody } from './server/byok-missing' +export { lastFour, maskKey, scrubSecrets } from './server/scrub' + +// Re-export the shared registry so a server can enumerate/validate provider ids +// without a second import path. +export { + BYOK_PROVIDERS, + PROVIDER_IDS, + BYOK_HEADER_PREFIX, + byokHeaderName, + isProviderId, +} from './shared/providers' +export type { ProviderId, ProviderConfig } from './shared/providers' diff --git a/packages/ai-byok/src/server/byok-missing.ts b/packages/ai-byok/src/server/byok-missing.ts new file mode 100644 index 000000000..6fd23091e --- /dev/null +++ b/packages/ai-byok/src/server/byok-missing.ts @@ -0,0 +1,44 @@ +import type { ProviderId } from '../shared/providers' + +/** Discriminated body returned by {@link byokMissing}. */ +export interface ByokMissingBody { + error: { + type: 'byok_missing' + provider: ProviderId + message: string + } +} + +/** + * Builds a typed JSON error response telling the client which provider key is + * missing, so it can render an "add your `` key" prompt. Defaults to + * HTTP 401. Carries no key material. + */ +export function byokMissing( + provider: ProviderId, + init?: ResponseInit, +): Response { + const body: ByokMissingBody = { + error: { + type: 'byok_missing', + provider, + message: `Missing API key for "${provider}". Add your ${provider} key to continue.`, + }, + } + return new Response(JSON.stringify(body), { + status: 401, + ...init, + headers: { + 'content-type': 'application/json', + ...init?.headers, + }, + }) +} + +/** Type guard for a {@link ByokMissingBody} parsed from a response. */ +export function isByokMissingBody(value: unknown): value is ByokMissingBody { + if (typeof value !== 'object' || value === null) return false + const { error } = value as { error?: unknown } + if (typeof error !== 'object' || error === null) return false + return (error as { type?: unknown }).type === 'byok_missing' +} diff --git a/packages/ai-byok/src/server/get-byok-key.ts b/packages/ai-byok/src/server/get-byok-key.ts new file mode 100644 index 000000000..6057b5080 --- /dev/null +++ b/packages/ai-byok/src/server/get-byok-key.ts @@ -0,0 +1,21 @@ +import { byokHeaderName } from '../shared/providers' +import type { ProviderId } from '../shared/providers' + +/** + * Reads a provider's BYOK key off the incoming request header. + * + * Returns the key or `null` if absent. The key is read from the header only — + * never the body — so it stays out of any persisted `messages` array and out + * of the event/observability stream. This function does not log the value and + * must not be wrapped in anything that attaches it to a logger context. + * + * Accepts either a `Request` or any object exposing a `Headers`-like `get`, so + * it works across Fetch-API runtimes (Workers, Deno, Bun, Node/undici). + */ +export function getByokKey( + request: { headers: Pick }, + provider: ProviderId, +): string | null { + const value = request.headers.get(byokHeaderName(provider)) + return value && value.length > 0 ? value : null +} diff --git a/packages/ai-byok/src/server/scrub.ts b/packages/ai-byok/src/server/scrub.ts new file mode 100644 index 000000000..3443f71f1 --- /dev/null +++ b/packages/ai-byok/src/server/scrub.ts @@ -0,0 +1,33 @@ +/** + * Helpers for keeping key material out of logs and error responses. + * + * The server piece is a stateless pass-through: it must never write a key to a + * DB, cache, log, or observability stream, and must never echo a full key back + * to a client. These utilities make the last-4 the only representation that + * ever leaves the process. + */ + +/** Returns the last 4 characters of a key for display, e.g. `"...a1b2"`. */ +export function lastFour(key: string): string { + return key.slice(-4) +} + +/** Masks a key to `"…last4"`, hiding everything but the trailing 4 chars. */ +export function maskKey(key: string): string { + if (key.length <= 4) return '…' + return `…${lastFour(key)}` +} + +/** + * Replaces every occurrence of each secret in `input` with its masked form. + * Use before logging or returning any string that may have interpolated a key + * (URLs, provider SDK error messages, stack traces). + */ +export function scrubSecrets(input: string, secrets: Array): string { + let output = input + for (const secret of secrets) { + if (!secret) continue + output = output.split(secret).join(maskKey(secret)) + } + return output +} diff --git a/packages/ai-byok/src/shared/providers.ts b/packages/ai-byok/src/shared/providers.ts new file mode 100644 index 000000000..f95d27ef3 --- /dev/null +++ b/packages/ai-byok/src/shared/providers.ts @@ -0,0 +1,140 @@ +/** + * Shared provider registry and header-naming convention. + * + * This module is fully isomorphic (no browser or Node APIs) and is the single + * source of truth imported by both the client keyring and the server helpers. + */ + +/** + * Describes how to validate a key against a provider's cheapest authenticated + * endpoint (typically a models list). `headers` builds the request headers + * from the raw key. Only providers that expose a browser-reachable endpoint + * carry a `validate` entry; the rest report `'unsupported'` from + * {@link validateKey}. + */ +export interface ProviderValidateConfig { + /** Endpoint hit with a GET request to confirm the key works. */ + url: string + /** Builds the auth headers for the validation request from the raw key. */ + headers: (key: string) => Record +} + +/** Static metadata for a supported provider. */ +export interface ProviderConfig { + /** Stable id used in the keyring map and the per-provider header name. */ + id: string + /** Human-readable label for UI. */ + label: string + /** Optional validation endpoint metadata. */ + validate?: ProviderValidateConfig +} + +const ANTHROPIC_HEADERS = (key: string): Record => ({ + 'x-api-key': key, + 'anthropic-version': '2023-06-01', + // Required for Anthropic to serve the request from a browser origin. + 'anthropic-dangerous-direct-browser-access': 'true', +}) + +const bearer = (key: string): Record => ({ + Authorization: `Bearer ${key}`, +}) + +/** + * Registry of providers the BYOK toolkit understands. `ProviderId` is derived + * from these keys, so adding a provider here extends the typed union + * everywhere. + */ +export const BYOK_PROVIDERS = { + openai: { + id: 'openai', + label: 'OpenAI', + validate: { url: 'https://api.openai.com/v1/models', headers: bearer }, + }, + anthropic: { + id: 'anthropic', + label: 'Anthropic', + validate: { + url: 'https://api.anthropic.com/v1/models', + headers: ANTHROPIC_HEADERS, + }, + }, + gemini: { + id: 'gemini', + label: 'Google Gemini', + validate: { + // Gemini authenticates the models list via a query param, not a header. + url: 'https://generativelanguage.googleapis.com/v1beta/models', + headers: (key) => ({ 'x-goog-api-key': key }), + }, + }, + openrouter: { + id: 'openrouter', + label: 'OpenRouter', + validate: { url: 'https://openrouter.ai/api/v1/key', headers: bearer }, + }, + groq: { + id: 'groq', + label: 'Groq', + validate: { + url: 'https://api.groq.com/openai/v1/models', + headers: bearer, + }, + }, + grok: { + id: 'grok', + label: 'xAI Grok', + validate: { url: 'https://api.x.ai/v1/models', headers: bearer }, + }, + mistral: { + id: 'mistral', + label: 'Mistral', + validate: { url: 'https://api.mistral.ai/v1/models', headers: bearer }, + }, + elevenlabs: { + id: 'elevenlabs', + label: 'ElevenLabs', + validate: { + url: 'https://api.elevenlabs.io/v1/user', + headers: (key) => ({ 'xi-api-key': key }), + }, + }, + // No browser-reachable validation endpoint (key-scheme auth, no models list). + fal: { id: 'fal', label: 'fal.ai' }, + // Local runtime, no API key involved. + ollama: { id: 'ollama', label: 'Ollama' }, +} as const satisfies Record + +/** Union of every supported provider id. */ +export type ProviderId = keyof typeof BYOK_PROVIDERS + +/** All provider ids as a runtime array. */ +export const PROVIDER_IDS = Object.keys(BYOK_PROVIDERS) as Array + +/** Type guard narrowing an arbitrary string to a known {@link ProviderId}. */ +export function isProviderId(value: string): value is ProviderId { + return value in BYOK_PROVIDERS +} + +/** + * The validation config for a provider, or `undefined` when the provider has no + * browser-reachable validation endpoint. + */ +export function providerValidateConfig( + provider: ProviderId, +): ProviderValidateConfig | undefined { + const config = BYOK_PROVIDERS[provider] + return 'validate' in config ? config.validate : undefined +} + +/** Prefix for every per-provider BYOK header. */ +export const BYOK_HEADER_PREFIX = 'x-tanstack-byok-' + +/** + * The HTTP header name that carries the key for a given provider. Keys always + * travel in this header, never in the request body or message history, so they + * stay out of persisted conversations and the event/observability stream. + */ +export function byokHeaderName(provider: ProviderId): string { + return `${BYOK_HEADER_PREFIX}${provider}` +} diff --git a/packages/ai-byok/tests/byok.test.ts b/packages/ai-byok/tests/byok.test.ts new file mode 100644 index 000000000..165f4b436 --- /dev/null +++ b/packages/ai-byok/tests/byok.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { byokHeaders, byokHeaderName } from '../src/index' +import { + byokMissing, + getByokKey, + isByokMissingBody, + maskKey, + scrubSecrets, +} from '../src/server' +import { localStorageStorage, memoryStorage } from '../src/client/storage' + +describe('byokHeaders', () => { + it('emits one header per present provider and skips empty keys', () => { + const headers = byokHeaders({ openai: 'sk-abc', anthropic: '', gemini: 'g-1' }) + expect(headers).toEqual({ + 'x-tanstack-byok-openai': 'sk-abc', + 'x-tanstack-byok-gemini': 'g-1', + }) + }) +}) + +describe('getByokKey', () => { + it('reads the key from the header, returns null when absent', () => { + const request = new Request('https://x.test', { + headers: { [byokHeaderName('openai')]: 'sk-live' }, + }) + expect(getByokKey(request, 'openai')).toBe('sk-live') + expect(getByokKey(request, 'anthropic')).toBeNull() + }) +}) + +describe('byokMissing', () => { + it('returns a typed 401 the client can detect', async () => { + const response = byokMissing('openai') + expect(response.status).toBe(401) + const body: unknown = await response.json() + expect(isByokMissingBody(body)).toBe(true) + if (isByokMissingBody(body)) { + expect(body.error.provider).toBe('openai') + } + }) +}) + +describe('scrub', () => { + it('masks a key down to the last 4 and redacts occurrences', () => { + expect(maskKey('sk-supersecret1234')).toBe('…1234') + expect(scrubSecrets('failed with sk-supersecret1234!', ['sk-supersecret1234'])).toBe( + 'failed with …1234!', + ) + }) +}) + +describe('storage tiers', () => { + it('memory tier persists nothing', () => { + const store = memoryStorage() + store.save({ openai: 'sk-1' }) + expect(store.load()).toEqual({}) + expect(store.persistent).toBe(false) + }) + + it('localStorage tier round-trips and clears', () => { + const store = localStorageStorage('test-byok') + store.save({ openai: 'sk-1' }) + expect(store.load()).toEqual({ openai: 'sk-1' }) + store.clear() + expect(store.load()).toEqual({}) + }) +}) diff --git a/packages/ai-byok/tests/react.test.tsx b/packages/ai-byok/tests/react.test.tsx new file mode 100644 index 000000000..449946ffb --- /dev/null +++ b/packages/ai-byok/tests/react.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { ByokProvider, useByok } from '../src/react' +import { byokHeaders } from '../src/index' +import { localStorageStorage } from '../src/client/storage' +import type { ReactNode } from 'react' + +describe('useByok', () => { + it('throws outside a provider', () => { + expect(() => renderHook(() => useByok())).toThrow(/ByokProvider/) + }) + + it('sets, exposes for headers, and clears keys', async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useByok(), { wrapper }) + + await act(async () => { + await result.current.setKey('openai', 'sk-live-1234') + }) + expect(byokHeaders(result.current.keys)).toEqual({ + 'x-tanstack-byok-openai': 'sk-live-1234', + }) + // Only the last 4 are exposed as status. + expect(result.current.status.openai).toEqual({ + state: 'set', + masked: '…1234', + }) + + await act(async () => { + await result.current.clearKey('openai') + }) + expect(result.current.keys.openai).toBeUndefined() + }) + + it('hydrates from a persistent tier on mount', async () => { + const store = localStorageStorage('test-hydrate') + store.save({ anthropic: 'sk-ant-9999' }) + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useByok(), { wrapper }) + + await waitFor(() => expect(result.current.keys.anthropic).toBe('sk-ant-9999')) + store.clear() + }) +}) diff --git a/packages/ai-byok/tsconfig.json b/packages/ai-byok/tsconfig.json new file mode 100644 index 000000000..b12da32b4 --- /dev/null +++ b/packages/ai-byok/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-byok/vite.config.ts b/packages/ai-byok/vite.config.ts new file mode 100644 index 000000000..f81606c79 --- /dev/null +++ b/packages/ai-byok/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'jsdom', + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.{ts,tsx}'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/server.ts', './src/react.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5eab53f64..26bd7f50d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,7 +52,7 @@ importers: version: 0.5.0 knip: specifier: ^5.70.2 - version: 5.73.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.3)(typescript@5.9.3) + version: 5.73.4(@types/node@24.10.3)(typescript@5.9.3) markdown-link-extractor: specifier: ^4.0.3 version: 4.0.3 @@ -1470,6 +1470,27 @@ importers: specifier: ^7.3.3 version: 7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/ai-byok: + devDependencies: + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.1.4) + jsdom: + specifier: ^27.2.0 + version: 27.3.0(postcss@8.5.15) + react: + specifier: ^19.2.3 + version: 19.2.3 + vite: + specifier: ^7.3.3 + version: 7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/ai-claude-code: devDependencies: '@tanstack/ai': @@ -5511,12 +5532,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@ngrok/ngrok-android-arm64@1.7.0': resolution: {integrity: sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==} engines: {node: '>= 10'} @@ -8802,9 +8817,6 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -19290,13 +19302,6 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - '@ngrok/ngrok-android-arm64@1.7.0': optional: true @@ -19827,7 +19832,7 @@ snapshots: '@oxc-resolver/binding-wasm32-wasi@11.15.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -20997,7 +21002,6 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53': @@ -23064,11 +23068,6 @@ snapshots: tslib: 2.8.1 optional: true - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -27031,7 +27030,7 @@ snapshots: klona@2.0.6: {} - knip@5.73.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.3)(typescript@5.9.3): + knip@5.73.4(@types/node@24.10.3)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@types/node': 24.10.3 From c9d022678804421d6e016c26fc19a3632af7e321 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:05:54 +0000 Subject: [PATCH 02/12] ci: apply automated fixes --- packages/ai-byok/README.md | 10 ++++++---- packages/ai-byok/src/react/byok-context.tsx | 11 +++++++---- packages/ai-byok/src/react/byok-key-manager.tsx | 6 +++--- packages/ai-byok/tests/byok.test.ts | 12 ++++++++---- packages/ai-byok/tests/react.test.tsx | 4 +++- 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/ai-byok/README.md b/packages/ai-byok/README.md index 8680ac549..1a705cfaf 100644 --- a/packages/ai-byok/README.md +++ b/packages/ai-byok/README.md @@ -22,16 +22,18 @@ event/observability stream. ```tsx import { ByokProvider, ByokKeyManager, useByok } from '@tanstack/ai-byok/react' -import { byokHeaders, memoryStorage, localStorageStorage } from '@tanstack/ai-byok/react' +import { + byokHeaders, + memoryStorage, + localStorageStorage, +} from '@tanstack/ai-byok/react' import { fetchServerSentEvents } from '@tanstack/ai-client' import { useChat } from '@tanstack/ai-react' // Wrap your app. `storage` is chosen once. Defaults to the safest option // (session-only, nothing persisted). Pass `localStorageStorage()` to persist. function App({ children }) { - return ( - {children} - ) + return {children} } // Drop-in settings UI — shows the last 4 chars of a saved key, never the whole key. diff --git a/packages/ai-byok/src/react/byok-context.tsx b/packages/ai-byok/src/react/byok-context.tsx index ff80d808d..8f5b3eb08 100644 --- a/packages/ai-byok/src/react/byok-context.tsx +++ b/packages/ai-byok/src/react/byok-context.tsx @@ -75,9 +75,9 @@ export function ByokProvider({ () => initialStorage ?? memoryStorage(), ) const [keys, setKeys] = useState({}) - const [statuses, setStatuses] = useState>>( - {}, - ) + const [statuses, setStatuses] = useState< + Partial> + >({}) // Keep a ref to the current keys so the persisting callbacks read the latest // without re-creating on every keystroke. @@ -150,7 +150,10 @@ export function ByokProvider({ return EMPTY } const masked = maskKey(target) - setStatuses((prev) => ({ ...prev, [provider]: { state: 'validating', masked } })) + setStatuses((prev) => ({ + ...prev, + [provider]: { state: 'validating', masked }, + })) let result: KeyStatus try { diff --git a/packages/ai-byok/src/react/byok-key-manager.tsx b/packages/ai-byok/src/react/byok-key-manager.tsx index e4101889a..bbb9d8dc9 100644 --- a/packages/ai-byok/src/react/byok-key-manager.tsx +++ b/packages/ai-byok/src/react/byok-key-manager.tsx @@ -29,9 +29,9 @@ export function ByokKeyManager({
{storage.persistent ? (

- Keys are saved in this browser ({storage.label}) and can be read by any - script or extension running on this page. Prefer session-only storage - on shared machines. + Keys are saved in this browser ({storage.label}) and can be read by + any script or extension running on this page. Prefer session-only + storage on shared machines.

) : null} diff --git a/packages/ai-byok/tests/byok.test.ts b/packages/ai-byok/tests/byok.test.ts index 165f4b436..c7c175509 100644 --- a/packages/ai-byok/tests/byok.test.ts +++ b/packages/ai-byok/tests/byok.test.ts @@ -11,7 +11,11 @@ import { localStorageStorage, memoryStorage } from '../src/client/storage' describe('byokHeaders', () => { it('emits one header per present provider and skips empty keys', () => { - const headers = byokHeaders({ openai: 'sk-abc', anthropic: '', gemini: 'g-1' }) + const headers = byokHeaders({ + openai: 'sk-abc', + anthropic: '', + gemini: 'g-1', + }) expect(headers).toEqual({ 'x-tanstack-byok-openai': 'sk-abc', 'x-tanstack-byok-gemini': 'g-1', @@ -44,9 +48,9 @@ describe('byokMissing', () => { describe('scrub', () => { it('masks a key down to the last 4 and redacts occurrences', () => { expect(maskKey('sk-supersecret1234')).toBe('…1234') - expect(scrubSecrets('failed with sk-supersecret1234!', ['sk-supersecret1234'])).toBe( - 'failed with …1234!', - ) + expect( + scrubSecrets('failed with sk-supersecret1234!', ['sk-supersecret1234']), + ).toBe('failed with …1234!') }) }) diff --git a/packages/ai-byok/tests/react.test.tsx b/packages/ai-byok/tests/react.test.tsx index 449946ffb..497ee57ae 100644 --- a/packages/ai-byok/tests/react.test.tsx +++ b/packages/ai-byok/tests/react.test.tsx @@ -43,7 +43,9 @@ describe('useByok', () => { ) const { result } = renderHook(() => useByok(), { wrapper }) - await waitFor(() => expect(result.current.keys.anthropic).toBe('sk-ant-9999')) + await waitFor(() => + expect(result.current.keys.anthropic).toBe('sk-ant-9999'), + ) store.clear() }) }) From f03089b9f67816166f460ed841539d33ab8e3e1d Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:18:15 +1000 Subject: [PATCH 03/12] feat(byok): passkey-encrypted storage; drop plaintext localStorage Replace the plaintext localStorage tier with passkey-encrypted persistence (WebAuthn PRF -> HKDF -> AES-256-GCM ciphertext in IndexedDB), unwrapped on demand with a biometric/PIN tap. Fully client-side; protects at-rest, not live in-page XSS (documented). - passkeyStorage() + isPasskeyStorageSupported() feature detection - KeyringStorage gains optional `unlockable` + `warning` - ByokProvider: `locked`/`unlock` so unlockable storage never prompts on mount; hydrates on explicit unlock or first save - ByokKeyManager: unlock banner + storage-specific warning - memoryStorage() remains the default; no plaintext persistence exists Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/byok-package.md | 4 +- packages/ai-byok/README.md | 45 ++- packages/ai-byok/src/client/passkey.ts | 328 ++++++++++++++++++ packages/ai-byok/src/client/storage.ts | 60 +--- packages/ai-byok/src/index.ts | 6 +- packages/ai-byok/src/react.ts | 3 +- packages/ai-byok/src/react/byok-context.tsx | 75 +++- .../ai-byok/src/react/byok-key-manager.tsx | 45 ++- packages/ai-byok/tests/byok.test.ts | 40 ++- packages/ai-byok/tests/react.test.tsx | 36 +- 10 files changed, 539 insertions(+), 103 deletions(-) create mode 100644 packages/ai-byok/src/client/passkey.ts diff --git a/.changeset/byok-package.md b/.changeset/byok-package.md index ac10bd598..79a2a57c8 100644 --- a/.changeset/byok-package.md +++ b/.changeset/byok-package.md @@ -4,6 +4,6 @@ Add `@tanstack/ai-byok`: a bring-your-own-key toolkit for TanStack AI. Keys live client-side and travel to the relay in a per-provider header (`x-tanstack-byok-`), never the request body or message history. -- **Client** (`@tanstack/ai-byok`): `byokHeaders`, a typed provider registry, pluggable storage (memory by default, opt-in plaintext localStorage), and `validateKey`. -- **React** (`@tanstack/ai-byok/react`): ``, `useByok()`, and a drop-in `` settings UI that only ever shows the last 4 characters of a saved key. +- **Client** (`@tanstack/ai-byok`): `byokHeaders`, a typed provider registry, pluggable storage (session-only memory by default, opt-in passkey-encrypted persistence — WebAuthn PRF → HKDF → AES-256-GCM in IndexedDB, no plaintext option), and `validateKey`. +- **React** (`@tanstack/ai-byok/react`): ``, `useByok()` (with `locked`/`unlock` for encrypted storage), and a drop-in `` settings UI that only ever shows the last 4 characters of a saved key. - **Server** (`@tanstack/ai-byok/server`): `getByokKey` (header-only, never logged), `byokMissing` (typed error response), and `scrubSecrets`/`maskKey` for keeping key material out of logs and errors. Stateless pass-through — no persistence, no central endpoint. diff --git a/packages/ai-byok/README.md b/packages/ai-byok/README.md index 1a705cfaf..7d0bc7f0b 100644 --- a/packages/ai-byok/README.md +++ b/packages/ai-byok/README.md @@ -25,15 +25,20 @@ import { ByokProvider, ByokKeyManager, useByok } from '@tanstack/ai-byok/react' import { byokHeaders, memoryStorage, - localStorageStorage, + passkeyStorage, + isPasskeyStorageSupported, } from '@tanstack/ai-byok/react' import { fetchServerSentEvents } from '@tanstack/ai-client' import { useChat } from '@tanstack/ai-react' // Wrap your app. `storage` is chosen once. Defaults to the safest option -// (session-only, nothing persisted). Pass `localStorageStorage()` to persist. +// (session-only, nothing persisted). Opt into encrypted persistence with +// passkeyStorage() where supported, falling back to memory otherwise. function App({ children }) { - return {children} + const storage = isPasskeyStorageSupported() + ? passkeyStorage() + : memoryStorage() + return {children} } // Drop-in settings UI — shows the last 4 chars of a saved key, never the whole key. @@ -75,19 +80,33 @@ export async function POST(request: Request) { ## Storage -Pass one `storage` to ``. Two are built in: +Pass one `storage` to ``. Two are built in — **there is no +plaintext persistence**: - **`memoryStorage()` (default)** — session / in-memory. Keys vanish on refresh. Never persisted. Zero at-rest liability. -- **`localStorageStorage()` (opt-in)** — persists across refreshes in - `localStorage` as **plaintext. Keys are not encrypted**, so they are readable - by any XSS or extension on the origin. `` shows a warning while - this storage is active. - -`KeyringStorage` is a small interface (`id`, `label`, `persistent`, `load`, -`save`, `clear`), so you can supply your own. A passkey-encrypted -(WebAuthn PRF → AES-256-GCM) storage that encrypts at rest is planned as a -follow-up. +- **`passkeyStorage()` (opt-in)** — encrypted at rest. The keyring is stored in + IndexedDB as AES-256-GCM ciphertext, with the key derived from a passkey's + WebAuthn **PRF** output (via HKDF) and unwrapped on demand with a + biometric/PIN tap. Fully client-side — no server, no custodian. + + Feature-detect with `isPasskeyStorageSupported()` and fall back to + `memoryStorage()` (PRF is solid on Android and Apple platform authenticators; + patchy on Firefox / roaming keys on Safari). + + **Honest scope:** this protects against at-rest theft (stolen device, + storage-dumping extension, backups). It does **not** defeat live in-page XSS — + an attacker running JS in the origin after you unlock can read the decrypted + keys from memory. `` states this while passkey storage is + active. + +`passkeyStorage` is `unlockable`, so `` does not decrypt on mount: +`useByok()` reports `locked: true` until you call `unlock()` (or save a key, +which registers a passkey on first use). `KeyringStorage` is a small interface, +so you can supply your own strategy. + +Recovery is a non-issue: lose the device, re-paste the key from the provider +dashboard. ## Validation diff --git a/packages/ai-byok/src/client/passkey.ts b/packages/ai-byok/src/client/passkey.ts new file mode 100644 index 000000000..878517ed1 --- /dev/null +++ b/packages/ai-byok/src/client/passkey.ts @@ -0,0 +1,328 @@ +import type { Keyring } from './keyring' +import type { KeyringStorage } from './storage' + +/** + * Passkey-encrypted keyring storage (WebAuthn PRF → HKDF → AES-256-GCM). + * + * The keyring is encrypted at rest in IndexedDB with an AES-256-GCM key derived + * from a passkey's PRF output, unwrapped on demand with a biometric/PIN tap. + * Decryption happens entirely client-side with the user present — no server, + * no custodian. + * + * **Honest scope:** this protects against at-rest theft (stolen device, + * storage-dumping extension, backups). It does NOT defeat live in-page XSS — an + * attacker running JS in the origin after the user unlocks can read the + * decrypted keys from memory. + */ + +const STORE_NAME = 'keyring' +const RECORD_ID = 'default' +const HKDF_INFO = 'tanstack-byok:keyring:v1' +const DEFAULT_DB = 'tanstack-byok' + +interface StoredRecord { + id: string + /** The passkey's raw credential id, replayed in the unlock ceremony. */ + credentialId: ArrayBuffer + /** Fixed per-install PRF evaluation input (not secret). */ + salt: ArrayBuffer + /** AES-GCM initialization vector for this ciphertext. */ + iv: ArrayBuffer + /** Encrypted keyring JSON. */ + ciphertext: ArrayBuffer +} + +/** + * Whether the current environment exposes WebAuthn. Note that actual PRF + * support can only be confirmed during registration; `passkeyStorage` throws a + * descriptive error if the chosen authenticator does not support PRF, which the + * caller should catch and fall back to {@link memoryStorage}. + */ +export function isPasskeyStorageSupported(): boolean { + return ( + typeof globalThis !== 'undefined' && + typeof globalThis.PublicKeyCredential !== 'undefined' && + typeof globalThis.navigator !== 'undefined' && + typeof globalThis.navigator.credentials.create === 'function' + ) +} + +// --------------------------------------------------------------------------- +// Crypto (exported for testing; the WebAuthn ceremony below feeds `deriveAesKey`) +// --------------------------------------------------------------------------- + +/** Derive a non-extractable AES-256-GCM key from a 32-byte PRF output. */ +export async function deriveAesKey( + prfOutput: BufferSource, +): Promise { + const base = await crypto.subtle.importKey('raw', prfOutput, 'HKDF', false, [ + 'deriveKey', + ]) + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new TextEncoder().encode(HKDF_INFO), + }, + base, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ) +} + +export async function encryptKeyring( + key: CryptoKey, + keys: Keyring, +): Promise<{ iv: ArrayBuffer; ciphertext: ArrayBuffer }> { + const iv = crypto.getRandomValues(new Uint8Array(12)) + const plaintext = new TextEncoder().encode(JSON.stringify(keys)) + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + plaintext, + ) + return { iv: iv.buffer, ciphertext } +} + +export async function decryptKeyring( + key: CryptoKey, + iv: BufferSource, + ciphertext: BufferSource, +): Promise { + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext, + ) + const parsed: unknown = JSON.parse(new TextDecoder().decode(plaintext)) + if (typeof parsed !== 'object' || parsed === null) return {} + return parsed +} + +// --------------------------------------------------------------------------- +// IndexedDB +// --------------------------------------------------------------------------- + +function openDb(dbName: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1) + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE_NAME, { keyPath: 'id' }) + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + +function idbGet(dbName: string): Promise { + return openDb(dbName).then( + (db) => + new Promise((resolve, reject) => { + const request = db + .transaction(STORE_NAME, 'readonly') + .objectStore(STORE_NAME) + .get(RECORD_ID) + request.onsuccess = () => + resolve((request.result as StoredRecord | undefined) ?? null) + request.onerror = () => reject(request.error) + }), + ) +} + +function idbPut(dbName: string, record: StoredRecord): Promise { + return openDb(dbName).then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + tx.objectStore(STORE_NAME).put(record) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }), + ) +} + +function idbClear(dbName: string): Promise { + return openDb(dbName).then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + tx.objectStore(STORE_NAME).delete(RECORD_ID) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }), + ) +} + +// --------------------------------------------------------------------------- +// WebAuthn ceremonies +// --------------------------------------------------------------------------- + +function requirePublicKeyCredential( + credential: Credential | null, + action: string, +): PublicKeyCredential { + if (!credential) throw new Error(`Passkey ${action} was cancelled`) + if (!(credential instanceof PublicKeyCredential)) { + throw new Error(`Unexpected credential type during ${action}`) + } + return credential +} + +async function registerPasskey( + rpName: string, + userName: string, +): Promise<{ + credentialId: ArrayBuffer + salt: Uint8Array + prf?: BufferSource +}> { + const salt = crypto.getRandomValues(new Uint8Array(32)) + const credential = requirePublicKeyCredential( + await navigator.credentials.create({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: { name: rpName }, + user: { + id: crypto.getRandomValues(new Uint8Array(16)), + name: userName, + displayName: userName, + }, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + authenticatorSelection: { + residentKey: 'required', + userVerification: 'required', + }, + extensions: { prf: { eval: { first: salt } } }, + }, + }), + 'registration', + ) + + const prf = credential.getClientExtensionResults().prf + if (!prf?.enabled) { + throw new Error( + 'This authenticator does not support the WebAuthn PRF extension', + ) + } + return { credentialId: credential.rawId, salt, prf: prf.results?.first } +} + +async function evaluatePrf( + credentialId: BufferSource, + salt: BufferSource, +): Promise { + const credential = requirePublicKeyCredential( + await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [{ type: 'public-key', id: credentialId }], + userVerification: 'required', + extensions: { prf: { eval: { first: salt } } }, + }, + }), + 'unlock', + ) + const result = credential.getClientExtensionResults().prf?.results?.first + if (!result) { + throw new Error('Authenticator did not return a PRF result') + } + return result +} + +// --------------------------------------------------------------------------- +// Storage strategy +// --------------------------------------------------------------------------- + +export interface PasskeyStorageOptions { + /** Relying-party name shown in the passkey prompt. */ + rpName?: string + /** Username label attached to the created passkey. */ + userName?: string + /** IndexedDB database name. Defaults to `tanstack-byok`. */ + dbName?: string +} + +/** + * Passkey-encrypted persistence. `` treats this as `unlockable`, + * so nothing is decrypted until the user calls `unlock()` (or saves a key, + * which registers a passkey on first use). The derived key is cached in memory + * for the session so repeated saves don't re-prompt. + */ +export function passkeyStorage( + options: PasskeyStorageOptions = {}, +): KeyringStorage { + const rpName = options.rpName ?? 'TanStack AI BYOK' + const userName = options.userName ?? 'byok-keyring' + const dbName = options.dbName ?? DEFAULT_DB + + let cachedKey: CryptoKey | null = null + let cachedMeta: { + credentialId: ArrayBuffer + salt: Uint8Array + } | null = null + + // Obtain the AES key, running exactly one WebAuthn ceremony if it isn't + // already cached for this session (unlock if a passkey exists, else register). + async function ensureKey(): Promise<{ + key: CryptoKey + credentialId: ArrayBuffer + salt: Uint8Array + }> { + if (cachedKey && cachedMeta) { + return { key: cachedKey, ...cachedMeta } + } + const existing = await idbGet(dbName) + if (existing) { + const prf = await evaluatePrf(existing.credentialId, existing.salt) + cachedKey = await deriveAesKey(prf) + cachedMeta = { + credentialId: existing.credentialId, + salt: new Uint8Array(existing.salt), + } + } else { + const reg = await registerPasskey(rpName, userName) + const prf = reg.prf ?? (await evaluatePrf(reg.credentialId, reg.salt)) + cachedKey = await deriveAesKey(prf) + cachedMeta = { credentialId: reg.credentialId, salt: reg.salt } + } + return { key: cachedKey, ...cachedMeta } + } + + return { + id: 'passkey', + label: 'Passkey-encrypted (this device)', + persistent: true, + unlockable: true, + warning: + 'Keys are encrypted with your passkey and unlocked with biometrics. ' + + 'This protects saved keys if your device is stolen, but not against code ' + + 'running on this page after you unlock.', + load: async () => { + const existing = await idbGet(dbName) + if (!existing) return {} // nothing stored yet — no ceremony needed + const { key } = await ensureKey() + return decryptKeyring(key, existing.iv, existing.ciphertext) + }, + save: async (keys) => { + const { key, credentialId, salt } = await ensureKey() + const { iv, ciphertext } = await encryptKeyring(key, keys) + await idbPut(dbName, { + id: RECORD_ID, + credentialId, + salt: salt.buffer, + iv, + ciphertext, + }) + }, + clear: async () => { + cachedKey = null + cachedMeta = null + await idbClear(dbName) + }, + } +} diff --git a/packages/ai-byok/src/client/storage.ts b/packages/ai-byok/src/client/storage.ts index 5c8db33bd..5e60d3b19 100644 --- a/packages/ai-byok/src/client/storage.ts +++ b/packages/ai-byok/src/client/storage.ts @@ -2,20 +2,27 @@ import type { Keyring } from './keyring' /** * A client-side persistence strategy for the keyring. Methods may be sync or - * async so a strategy backed by async crypto (e.g. a future passkey/PRF - * strategy) fits the same interface as the synchronous ones. + * async so a strategy backed by async crypto (e.g. the passkey/PRF strategy) + * fits the same interface as the synchronous ones. */ export interface KeyringStorage { /** A stable id for the strategy, surfaced in UI. */ readonly id: string /** Human-readable label. */ readonly label: string + /** Whether keys written here survive a page refresh. `false` for memory. */ + readonly persistent: boolean /** - * Whether keys written here survive a page refresh. `false` for the default - * memory storage; UI uses this to decide whether to show a persistence - * warning. + * Whether this strategy requires an explicit unlock ceremony (e.g. a + * biometric tap) before stored keys can be read. When `true`, `` + * does not auto-load on mount — it waits for `unlock()`. */ - readonly persistent: boolean + readonly unlockable?: boolean + /** + * An honest, storage-specific caveat rendered by `` while this + * strategy is active (e.g. "protects at rest, not against in-page attacks"). + */ + readonly warning?: string readonly load: () => Promise | Keyring readonly save: (keys: Keyring) => Promise | void readonly clear: () => Promise | void @@ -36,44 +43,3 @@ export function memoryStorage(): KeyringStorage { clear: () => {}, } } - -/** - * Opt-in: `localStorage`, **plaintext — keys are NOT encrypted**. Convenient - * across refreshes, but readable by any XSS or browser extension on the origin. - * `` surfaces a warning while this storage is active (it is - * marked `persistent`). Use only when the convenience is worth that exposure; - * an encrypted-at-rest option is planned as a follow-up. - */ -export function localStorageStorage( - storageKey = 'tanstack-byok', -): KeyringStorage { - const store = (): Storage | null => { - // `localStorage` is typed as always-present via the DOM lib, but is absent - // in SSR / Node and can throw in sandboxed frames — guard at runtime. - try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - return globalThis.localStorage ?? null - } catch { - return null - } - } - - return { - id: 'localStorage', - label: 'This browser (localStorage, plaintext)', - persistent: true, - load: () => { - const raw = store()?.getItem(storageKey) - if (!raw) return {} - const parsed: unknown = JSON.parse(raw) - if (typeof parsed !== 'object' || parsed === null) return {} - return parsed - }, - save: (keys) => { - store()?.setItem(storageKey, JSON.stringify(keys)) - }, - clear: () => { - store()?.removeItem(storageKey) - }, - } -} diff --git a/packages/ai-byok/src/index.ts b/packages/ai-byok/src/index.ts index 863cf6941..e9fb8e9b2 100644 --- a/packages/ai-byok/src/index.ts +++ b/packages/ai-byok/src/index.ts @@ -25,9 +25,11 @@ export type { export { byokHeaders } from './client/keyring' export type { Keyring } from './client/keyring' -// Storage tiers -export { memoryStorage, localStorageStorage } from './client/storage' +// Storage +export { memoryStorage } from './client/storage' export type { KeyringStorage } from './client/storage' +export { passkeyStorage, isPasskeyStorageSupported } from './client/passkey' +export type { PasskeyStorageOptions } from './client/passkey' // Validation export { validateKey } from './client/validate' diff --git a/packages/ai-byok/src/react.ts b/packages/ai-byok/src/react.ts index c31966c5d..c80b2f752 100644 --- a/packages/ai-byok/src/react.ts +++ b/packages/ai-byok/src/react.ts @@ -15,7 +15,8 @@ export type { ByokKeyManagerProps } from './react/byok-key-manager' export { byokHeaders, memoryStorage, - localStorageStorage, + passkeyStorage, + isPasskeyStorageSupported, validateKey, BYOK_PROVIDERS, PROVIDER_IDS, diff --git a/packages/ai-byok/src/react/byok-context.tsx b/packages/ai-byok/src/react/byok-context.tsx index 8f5b3eb08..7598026e1 100644 --- a/packages/ai-byok/src/react/byok-context.tsx +++ b/packages/ai-byok/src/react/byok-context.tsx @@ -51,6 +51,19 @@ export interface ByokContextValue { status: Record /** The configured persistence storage. */ storage: KeyringStorage + /** + * `true` when an `unlockable` storage (e.g. passkey) may hold encrypted keys + * that haven't been decrypted this session. Always `false` for storage that + * needs no unlock ceremony (e.g. memory). + */ + locked: boolean + /** + * Decrypt and load keys from an `unlockable` storage, triggering its unlock + * ceremony (e.g. a biometric tap). No-op — and does not prompt — for storage + * that isn't unlockable or when nothing is stored yet. Rejects if the + * ceremony fails or is cancelled. + */ + unlock: () => Promise hasKey: (provider: ProviderId) => boolean } @@ -78,34 +91,48 @@ export function ByokProvider({ const [statuses, setStatuses] = useState< Partial> >({}) + // Unlockable storage (passkey) starts locked; the user must unlock to decrypt. + const [locked, setLocked] = useState(() => Boolean(storage.unlockable)) // Keep a ref to the current keys so the persisting callbacks read the latest // without re-creating on every keystroke. const keysRef = useRef(keys) keysRef.current = keys - // Hydrate from storage on mount. + // Merge loaded keys UNDER any edits the user made during the async load, so an + // early setKey is never clobbered by late hydration. + const applyLoaded = useCallback((loaded: Keyring) => { + const loadedStatuses = Object.fromEntries( + Object.entries(loaded) + .filter(([, key]) => Boolean(key)) + .map(([provider, key]) => [ + provider, + { state: 'set', masked: maskKey(key) } satisfies KeyStatus, + ]), + ) + setKeys((current) => ({ ...loaded, ...current })) + setStatuses((current) => ({ ...loadedStatuses, ...current })) + }, []) + + // Auto-hydrate on mount only for storage that needs no unlock ceremony. + // Unlockable storage waits for an explicit `unlock()` so it never prompts on + // page load. useEffect(() => { + if (storage.unlockable) return let cancelled = false void Promise.resolve(storage.load()).then((loaded) => { - if (cancelled) return - const loadedStatuses = Object.fromEntries( - Object.entries(loaded) - .filter(([, key]) => Boolean(key)) - .map(([provider, key]) => [ - provider, - { state: 'set', masked: maskKey(key) } satisfies KeyStatus, - ]), - ) - // Merge hydrated keys UNDER any edits the user made during the async - // load, so an early setKey is never clobbered by late hydration. - setKeys((current) => ({ ...loaded, ...current })) - setStatuses((current) => ({ ...loadedStatuses, ...current })) + if (!cancelled) applyLoaded(loaded) }) return () => { cancelled = true } - }, [storage]) + }, [storage, applyLoaded]) + + const unlock = useCallback(async () => { + if (!storage.unlockable) return + applyLoaded(await Promise.resolve(storage.load())) + setLocked(false) + }, [storage, applyLoaded]) const persist = useCallback( (next: Keyring) => Promise.resolve(storage.save(next)), @@ -121,6 +148,9 @@ export function ByokProvider({ [provider]: { state: 'set', masked: maskKey(key) }, })) await persist(next) + // A successful save ran any unlock/registration ceremony, so the session + // is now unlocked. + setLocked(false) }, [persist], ) @@ -140,6 +170,7 @@ export function ByokProvider({ setKeys({}) setStatuses({}) await Promise.resolve(storage.clear()) + setLocked(false) }, [storage]) const validateKey = useCallback( @@ -189,9 +220,21 @@ export function ByokProvider({ validateKey, status, storage, + locked, + unlock, hasKey: (provider) => Boolean(keys[provider]), }), - [keys, setKey, clearKey, clearAll, validateKey, status, storage], + [ + keys, + setKey, + clearKey, + clearAll, + validateKey, + status, + storage, + locked, + unlock, + ], ) return {children} diff --git a/packages/ai-byok/src/react/byok-key-manager.tsx b/packages/ai-byok/src/react/byok-key-manager.tsx index bbb9d8dc9..e111dc6a8 100644 --- a/packages/ai-byok/src/react/byok-key-manager.tsx +++ b/packages/ai-byok/src/react/byok-key-manager.tsx @@ -23,17 +23,38 @@ export function ByokKeyManager({ className, style, }: ByokKeyManagerProps) { - const { status, storage } = useByok() + const { status, storage, locked, unlock } = useByok() + const [unlocking, setUnlocking] = useState(false) + const [unlockError, setUnlockError] = useState(null) return (
- {storage.persistent ? ( -

- Keys are saved in this browser ({storage.label}) and can be read by - any script or extension running on this page. Prefer session-only - storage on shared machines. -

+ {locked ? ( +
+ Your saved keys are locked ({storage.label}). + +
) : null} + {unlockError ?

{unlockError}

: null} + + {storage.warning ?

{storage.warning}

: null} {providers.map((provider) => ( { it('emits one header per present provider and skips empty keys', () => { @@ -54,19 +59,36 @@ describe('scrub', () => { }) }) -describe('storage tiers', () => { - it('memory tier persists nothing', () => { +describe('memory storage', () => { + it('persists nothing', () => { const store = memoryStorage() store.save({ openai: 'sk-1' }) expect(store.load()).toEqual({}) expect(store.persistent).toBe(false) }) +}) - it('localStorage tier round-trips and clears', () => { - const store = localStorageStorage('test-byok') - store.save({ openai: 'sk-1' }) - expect(store.load()).toEqual({ openai: 'sk-1' }) - store.clear() - expect(store.load()).toEqual({}) +describe('passkey crypto', () => { + // The WebAuthn ceremony can't run in jsdom, but the PRF → AES-GCM path can: + // derive a key from a fixed 32-byte "PRF output" and round-trip a keyring. + const prf = new Uint8Array(32).fill(7) + + it('round-trips a keyring through AES-256-GCM', async () => { + const key = await deriveAesKey(prf) + const { iv, ciphertext } = await encryptKeyring(key, { + openai: 'sk-secret', + }) + expect(await decryptKeyring(key, iv, ciphertext)).toEqual({ + openai: 'sk-secret', + }) + }) + + it('fails to decrypt with a key derived from a different PRF output', async () => { + const key = await deriveAesKey(prf) + const { iv, ciphertext } = await encryptKeyring(key, { + openai: 'sk-secret', + }) + const wrongKey = await deriveAesKey(new Uint8Array(32).fill(9)) + await expect(decryptKeyring(wrongKey, iv, ciphertext)).rejects.toThrow() }) }) diff --git a/packages/ai-byok/tests/react.test.tsx b/packages/ai-byok/tests/react.test.tsx index 497ee57ae..95e0aaa5b 100644 --- a/packages/ai-byok/tests/react.test.tsx +++ b/packages/ai-byok/tests/react.test.tsx @@ -2,8 +2,27 @@ import { describe, expect, it } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' import { ByokProvider, useByok } from '../src/react' import { byokHeaders } from '../src/index' -import { localStorageStorage } from '../src/client/storage' import type { ReactNode } from 'react' +import type { Keyring } from '../src/index' +import type { KeyringStorage } from '../src/index' + +/** In-memory stand-in for an unlockable (passkey-like) storage, no WebAuthn. */ +function fakeEncryptedStorage(initial: Keyring): KeyringStorage { + let data: Keyring = { ...initial } + return { + id: 'fake', + label: 'Fake encrypted', + persistent: true, + unlockable: true, + load: () => ({ ...data }), + save: (keys) => { + data = { ...keys } + }, + clear: () => { + data = {} + }, + } +} describe('useByok', () => { it('throws outside a provider', () => { @@ -34,18 +53,23 @@ describe('useByok', () => { expect(result.current.keys.openai).toBeUndefined() }) - it('hydrates from a persistent tier on mount', async () => { - const store = localStorageStorage('test-hydrate') - store.save({ anthropic: 'sk-ant-9999' }) - + it('stays locked until unlock() for unlockable storage', async () => { + const store = fakeEncryptedStorage({ anthropic: 'sk-ant-9999' }) const wrapper = ({ children }: { children: ReactNode }) => ( {children} ) const { result } = renderHook(() => useByok(), { wrapper }) + // Not auto-loaded on mount — no ceremony until the user unlocks. + expect(result.current.locked).toBe(true) + expect(result.current.keys.anthropic).toBeUndefined() + + await act(async () => { + await result.current.unlock() + }) + expect(result.current.locked).toBe(false) await waitFor(() => expect(result.current.keys.anthropic).toBe('sk-ant-9999'), ) - store.clear() }) }) From d5fd473ddc09f736427582352ce792c72a9b6e1d Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:29:48 +1000 Subject: [PATCH 04/12] feat(byok): wire BYOK into ts-react-chat example; add passkey rpId option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /byok route: ByokProvider (passkey storage where supported, else memory) + ByokKeyManager + a minimal chat that attaches byokHeaders(keys) - New /api/byok-chat relay: reads the key via getByokKey(request, provider), builds the adapter with create{Openai,Anthropic,Gemini}Chat(model, apiKey), returns byokMissing() when absent — stateless, no persist/log - passkeyStorage gains an rpId option; by default the passkey binds to the current origin (no hardcoded/central domain) Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/ts-react-chat/package.json | 1 + .../ts-react-chat/src/components/Header.tsx | 14 ++ examples/ts-react-chat/src/routeTree.gen.ts | 42 +++++ .../ts-react-chat/src/routes/api.byok-chat.ts | 90 +++++++++ examples/ts-react-chat/src/routes/byok.tsx | 175 ++++++++++++++++++ packages/ai-byok/src/client/passkey.ts | 16 +- pnpm-lock.yaml | 7 +- 7 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 examples/ts-react-chat/src/routes/api.byok-chat.ts create mode 100644 examples/ts-react-chat/src/routes/byok.tsx diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index 67b58ad43..7faef3832 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -18,6 +18,7 @@ "@tanstack/ai": "workspace:*", "@tanstack/ai-anthropic": "workspace:*", "@tanstack/ai-bedrock": "workspace:*", + "@tanstack/ai-byok": "workspace:*", "@tanstack/ai-claude-code": "workspace:*", "@tanstack/ai-client": "workspace:*", "@tanstack/ai-code-mode": "workspace:*", diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index e7fbc6a0a..993f8e39a 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -10,6 +10,7 @@ import { Guitar, Home, Image, + KeyRound, Menu, LayoutGrid, Mic, @@ -76,6 +77,19 @@ export default function Header() { Home + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + Bring your own key + +

diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 69a7764a2..647f6c145 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as ImageToolReproRouteImport } from './routes/image-tool-repro' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as GenerationHooksRouteImport } from './routes/generation-hooks' import { Route as CapabilityDemoRouteImport } from './routes/capability-demo' +import { Route as ByokRouteImport } from './routes/byok' import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription' @@ -47,6 +48,7 @@ import { Route as ApiMcpAppsCallRouteImport } from './routes/api.mcp-apps-call' import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-repro' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' +import { Route as ApiByokChatRouteImport } from './routes/api.byok-chat' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' import { Route as ApiGenerateVideoRouteImport } from './routes/api.generate.video' @@ -109,6 +111,11 @@ const CapabilityDemoRoute = CapabilityDemoRouteImport.update({ path: '/capability-demo', getParentRoute: () => rootRouteImport, } as any) +const ByokRoute = ByokRouteImport.update({ + id: '/byok', + path: '/byok', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -247,6 +254,11 @@ const ApiCapabilityDemoRoute = ApiCapabilityDemoRouteImport.update({ path: '/api/capability-demo', getParentRoute: () => rootRouteImport, } as any) +const ApiByokChatRoute = ApiByokChatRouteImport.update({ + id: '/api/byok-chat', + path: '/api/byok-chat', + getParentRoute: () => rootRouteImport, +} as any) const ExampleGuitarsIndexRoute = ExampleGuitarsIndexRouteImport.update({ id: '/example/guitars/', path: '/example/guitars/', @@ -280,6 +292,7 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/byok': typeof ByokRoute '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute @@ -291,6 +304,7 @@ export interface FileRoutesByFullPath { '/sandboxes': typeof SandboxesRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute + '/api/byok-chat': typeof ApiByokChatRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -326,6 +340,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/byok': typeof ByokRoute '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute @@ -337,6 +352,7 @@ export interface FileRoutesByTo { '/sandboxes': typeof SandboxesRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute + '/api/byok-chat': typeof ApiByokChatRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -373,6 +389,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/byok': typeof ByokRoute '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute @@ -384,6 +401,7 @@ export interface FileRoutesById { '/sandboxes': typeof SandboxesRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute + '/api/byok-chat': typeof ApiByokChatRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -421,6 +439,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/byok' | '/capability-demo' | '/generation-hooks' | '/image-gen' @@ -432,6 +451,7 @@ export interface FileRouteTypes { | '/sandboxes' | '/server-fn-chat' | '/threads' + | '/api/byok-chat' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -467,6 +487,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/byok' | '/capability-demo' | '/generation-hooks' | '/image-gen' @@ -478,6 +499,7 @@ export interface FileRouteTypes { | '/sandboxes' | '/server-fn-chat' | '/threads' + | '/api/byok-chat' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -513,6 +535,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/byok' | '/capability-demo' | '/generation-hooks' | '/image-gen' @@ -524,6 +547,7 @@ export interface FileRouteTypes { | '/sandboxes' | '/server-fn-chat' | '/threads' + | '/api/byok-chat' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -560,6 +584,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ByokRoute: typeof ByokRoute CapabilityDemoRoute: typeof CapabilityDemoRoute GenerationHooksRoute: typeof GenerationHooksRoute ImageGenRoute: typeof ImageGenRoute @@ -571,6 +596,7 @@ export interface RootRouteChildren { SandboxesRoute: typeof SandboxesRoute ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute + ApiByokChatRoute: typeof ApiByokChatRoute ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiImageToolReproRoute: typeof ApiImageToolReproRoute @@ -684,6 +710,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CapabilityDemoRouteImport parentRoute: typeof rootRouteImport } + '/byok': { + id: '/byok' + path: '/byok' + fullPath: '/byok' + preLoaderRoute: typeof ByokRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -873,6 +906,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiCapabilityDemoRouteImport parentRoute: typeof rootRouteImport } + '/api/byok-chat': { + id: '/api/byok-chat' + path: '/api/byok-chat' + fullPath: '/api/byok-chat' + preLoaderRoute: typeof ApiByokChatRouteImport + parentRoute: typeof rootRouteImport + } '/example/guitars/': { id: '/example/guitars/' path: '/example/guitars' @@ -920,6 +960,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ByokRoute: ByokRoute, CapabilityDemoRoute: CapabilityDemoRoute, GenerationHooksRoute: GenerationHooksRoute, ImageGenRoute: ImageGenRoute, @@ -931,6 +972,7 @@ const rootRouteChildren: RootRouteChildren = { SandboxesRoute: SandboxesRoute, ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, + ApiByokChatRoute: ApiByokChatRoute, ApiCapabilityDemoRoute: ApiCapabilityDemoRoute, ApiImageGenRoute: ApiImageGenRoute, ApiImageToolReproRoute: ApiImageToolReproRoute, diff --git a/examples/ts-react-chat/src/routes/api.byok-chat.ts b/examples/ts-react-chat/src/routes/api.byok-chat.ts new file mode 100644 index 000000000..0c4518344 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.byok-chat.ts @@ -0,0 +1,90 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai' +import { createAnthropicChat } from '@tanstack/ai-anthropic' +import { createGeminiChat } from '@tanstack/ai-gemini' +import { byokMissing, getByokKey } from '@tanstack/ai-byok/server' +import type { AnyTextAdapter } from '@tanstack/ai' +import type { ProviderId } from '@tanstack/ai-byok/server' + +/** + * BYOK chat relay — stateless pass-through. + * + * The user's key is read from the per-provider request header (never the body, + * so it never lands in persisted message history) and handed to the adapter for + * this one call. It is never persisted or logged. + */ + +// Build an adapter from a per-request key. Each provider exposes a +// `create*Chat(model, apiKey)` factory that takes an explicit key. +const adapters: Record< + ProviderId & ('openai' | 'anthropic' | 'gemini'), + (model: string, apiKey: string) => AnyTextAdapter +> = { + openai: (model, apiKey) => + createOpenaiChat((model || 'gpt-5.2') as 'gpt-5.2', apiKey), + anthropic: (model, apiKey) => + createAnthropicChat( + (model || 'claude-sonnet-4-6') as 'claude-sonnet-4-6', + apiKey, + ), + gemini: (model, apiKey) => + createGeminiChat( + (model || 'gemini-3.1-pro-preview') as 'gemini-3.1-pro-preview', + apiKey, + ), +} + +function isSupportedProvider(value: string): value is keyof typeof adapters { + return value in adapters +} + +export const Route = createFileRoute('/api/byok-chat')({ + server: { + handlers: { + POST: async ({ request }) => { + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + const requestedProvider = + typeof params.forwardedProps.provider === 'string' + ? params.forwardedProps.provider + : 'openai' + const model = + typeof params.forwardedProps.model === 'string' + ? params.forwardedProps.model + : '' + + if (!isSupportedProvider(requestedProvider)) { + return new Response(`Unsupported provider "${requestedProvider}"`, { + status: 400, + }) + } + + // Read the BYOK key from the header only. No key → typed 401 the client + // renders as "add your key". + const apiKey = getByokKey(request, requestedProvider) + if (!apiKey) return byokMissing(requestedProvider) + + const stream = chat({ + adapter: adapters[requestedProvider](model, apiKey), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + }) + return toServerSentEventsResponse(stream) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/byok.tsx b/examples/ts-react-chat/src/routes/byok.tsx new file mode 100644 index 000000000..693adb2bb --- /dev/null +++ b/examples/ts-react-chat/src/routes/byok.tsx @@ -0,0 +1,175 @@ +import { useMemo, useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { + ByokKeyManager, + ByokProvider, + byokHeaders, + isPasskeyStorageSupported, + memoryStorage, + passkeyStorage, + useByok, +} from '@tanstack/ai-byok/react' +import type { UIMessage } from '@tanstack/ai-react' +import type { ProviderId } from '@tanstack/ai-byok/react' + +export const Route = createFileRoute('/byok')({ + component: ByokPage, +}) + +// Providers this demo relay supports (see src/routes/api.byok-chat.ts). +const MODELS: Array<{ provider: ProviderId; model: string; label: string }> = [ + { provider: 'openai', model: 'gpt-5.2', label: 'OpenAI · GPT-5.2' }, + { provider: 'openai', model: 'gpt-4o', label: 'OpenAI · GPT-4o' }, + { + provider: 'anthropic', + model: 'claude-sonnet-4-6', + label: 'Anthropic · Claude Sonnet 4.6', + }, + { + provider: 'gemini', + model: 'gemini-3.1-pro-preview', + label: 'Gemini · 3.1 Pro', + }, +] + +function ByokPage() { + // Encrypted persistence where the platform supports it (passkey/PRF), else + // session-only. Chosen once — storage is fixed for the provider's life. + const [storage] = useState(() => + isPasskeyStorageSupported() ? passkeyStorage() : memoryStorage(), + ) + return ( + + + + ) +} + +function ByokChat() { + const { keys } = useByok() + // Read the latest keys at request time without recreating the connection. + const keysRef = useRef(keys) + keysRef.current = keys + + const [selected, setSelected] = useState(MODELS[0]) + const [input, setInput] = useState('') + + const connection = useMemo( + () => + fetchServerSentEvents('/api/byok-chat', () => ({ + headers: byokHeaders(keysRef.current), + })), + [], + ) + + const body = useMemo( + () => ({ provider: selected.provider, model: selected.model }), + [selected], + ) + + const { messages, sendMessage, isLoading, error } = useChat({ + connection, + body, + }) + + const missingKey = !keys[selected.provider] + + return ( +

+
+

+ Bring your own key +

+

+ Keys stay in your browser and are sent per-request in a header — never + the message body, never stored on the server. +

+ +
+ +
+ + {missingKey ? ( + + Add your {selected.provider} key above to chat. + + ) : null} +
+ +
+ {messages.length === 0 ? ( +

Send a message to start.

+ ) : ( + messages.map((message) => ( + + )) + )} + {error ?

{error.message}

: null} +
+ +
{ + event.preventDefault() + if (!input.trim()) return + sendMessage(input.trim()) + setInput('') + }} + > + setInput(event.target.value)} + placeholder="Ask something…" + className="flex-1 rounded-md border border-gray-600 bg-gray-800 px-3 py-2 text-white" + /> + +
+
+ ) +} + +function MessageBubble({ message }: { message: UIMessage }) { + const text = message.parts + .filter((part) => part.type === 'text') + .map((part) => (part.type === 'text' ? part.content : '')) + .join('') + if (!text) return null + return ( +
+ + {text} + +
+ ) +} diff --git a/packages/ai-byok/src/client/passkey.ts b/packages/ai-byok/src/client/passkey.ts index 878517ed1..1e9c3471b 100644 --- a/packages/ai-byok/src/client/passkey.ts +++ b/packages/ai-byok/src/client/passkey.ts @@ -173,6 +173,7 @@ function requirePublicKeyCredential( async function registerPasskey( rpName: string, userName: string, + rpId?: string, ): Promise<{ credentialId: ArrayBuffer salt: Uint8Array @@ -183,7 +184,9 @@ async function registerPasskey( await navigator.credentials.create({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), - rp: { name: rpName }, + // Omit `id` to let the browser bind the passkey to the current origin's + // effective domain; set it to scope across subdomains of a self-host. + rp: rpId ? { name: rpName, id: rpId } : { name: rpName }, user: { id: crypto.getRandomValues(new Uint8Array(16)), name: userName, @@ -243,6 +246,14 @@ export interface PasskeyStorageOptions { rpName?: string /** Username label attached to the created passkey. */ userName?: string + /** + * WebAuthn Relying Party ID. Omit to bind the passkey to the current origin's + * effective domain (the default — no central/hardcoded domain). Set it to a + * registrable parent domain to share the credential across subdomains of your + * own deployment. The encrypted keyring is never portable across unrelated + * domains. + */ + rpId?: string /** IndexedDB database name. Defaults to `tanstack-byok`. */ dbName?: string } @@ -258,6 +269,7 @@ export function passkeyStorage( ): KeyringStorage { const rpName = options.rpName ?? 'TanStack AI BYOK' const userName = options.userName ?? 'byok-keyring' + const { rpId } = options const dbName = options.dbName ?? DEFAULT_DB let cachedKey: CryptoKey | null = null @@ -285,7 +297,7 @@ export function passkeyStorage( salt: new Uint8Array(existing.salt), } } else { - const reg = await registerPasskey(rpName, userName) + const reg = await registerPasskey(rpName, userName, rpId) const prf = reg.prf ?? (await evaluatePrf(reg.credentialId, reg.salt)) cachedKey = await deriveAesKey(prf) cachedMeta = { credentialId: reg.credentialId, salt: reg.salt } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26bd7f50d..aae9bd11e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,7 +52,7 @@ importers: version: 0.5.0 knip: specifier: ^5.70.2 - version: 5.73.4(@types/node@24.10.3)(typescript@5.9.3) + version: 5.73.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.3)(typescript@5.9.3) markdown-link-extractor: specifier: ^4.0.3 version: 4.0.3 @@ -622,6 +622,9 @@ importers: '@tanstack/ai-bedrock': specifier: workspace:* version: link:../../packages/ai-bedrock + '@tanstack/ai-byok': + specifier: workspace:* + version: link:../../packages/ai-byok '@tanstack/ai-claude-code': specifier: workspace:* version: link:../../packages/ai-claude-code @@ -27030,7 +27033,7 @@ snapshots: klona@2.0.6: {} - knip@5.73.4(@types/node@24.10.3)(typescript@5.9.3): + knip@5.73.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.3)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@types/node': 24.10.3 From f085322652fdc6c708ffb52608e2eda9f05b1a38 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:01:41 +1000 Subject: [PATCH 05/12] test(byok): add E2E coverage for the BYOK relay flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New testing/e2e /byok route + /api/byok-chat relay + byok.spec.ts: - key rides in the x-tanstack-byok-openai header, is absent from the request body, streams the aimock response, and the manager shows only the last-4 - missing key → byokMissing 401 surfaced as an error, no answer produced Keyring is hydrated via a preloaded storage (the same load() path passkey storage uses), so the flow is deterministic without a live WebAuthn ceremony (passkey crypto + locked/unlock are covered by package unit tests). Adds an apiKeyOverride to the e2e createTextAdapter and a byok-masked test hook to ByokKeyManager. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-byok/src/react/byok-key-manager.tsx | 4 +- pnpm-lock.yaml | 3 + testing/e2e/README.md | 1 + testing/e2e/package.json | 1 + testing/e2e/src/lib/providers.ts | 7 +- testing/e2e/src/routeTree.gen.ts | 42 ++++++++ testing/e2e/src/routes/api.byok-chat.ts | 58 +++++++++++ testing/e2e/src/routes/byok.tsx | 95 +++++++++++++++++++ testing/e2e/tests/byok.spec.ts | 61 ++++++++++++ 9 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 testing/e2e/src/routes/api.byok-chat.ts create mode 100644 testing/e2e/src/routes/byok.tsx create mode 100644 testing/e2e/tests/byok.spec.ts diff --git a/packages/ai-byok/src/react/byok-key-manager.tsx b/packages/ai-byok/src/react/byok-key-manager.tsx index e111dc6a8..5e02431a7 100644 --- a/packages/ai-byok/src/react/byok-key-manager.tsx +++ b/packages/ai-byok/src/react/byok-key-manager.tsx @@ -87,7 +87,9 @@ function ProviderRow({ {hasKey && 'masked' in status ? (
- {status.masked} + + {status.masked} +
+ + {open ? ( +
setOpen(false)} + > +
event.stopPropagation()} + > +
+

API keys

+ +
+

+ Keys stay in your browser and are sent per-request in a header — + never stored on the server. Providers with a server key already + work without one. +

+ +
+ {PROVIDERS.map((provider) => ( + + ))} +
+
+
+ ) : null} + + ) +} + +function ProviderKeyRow({ + provider, + hasEnvKey, +}: { + provider: ProviderId + hasEnvKey: boolean +}) { + const { keys, setKey, clearKey } = useByok() + const [draft, setDraft] = useState('') + const yourKey = keys[provider] + + return ( +
+
+ + {BYOK_PROVIDERS[provider].label} + + {yourKey ? ( + + Your key ··{yourKey.slice(-4)} + + ) : hasEnvKey ? ( + Server key + ) : ( + No key + )} +
+
{ + event.preventDefault() + if (!draft) return + void setKey(provider, draft) + setDraft('') + }} + > + setDraft(event.target.value)} + className="min-w-0 flex-1 rounded-md border border-gray-600 bg-gray-900 px-2 py-1 text-sm text-white focus:outline-none focus:ring-2 focus:ring-orange-500/50" + /> + + {yourKey ? ( + + ) : null} +
+
+ ) +} diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 993f8e39a..e7fbc6a0a 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -10,7 +10,6 @@ import { Guitar, Home, Image, - KeyRound, Menu, LayoutGrid, Mic, @@ -77,19 +76,6 @@ export default function Header() { Home - setIsOpen(false)} - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" - activeProps={{ - className: - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', - }} - > - - Bring your own key - -

diff --git a/examples/ts-react-chat/src/lib/byok-config.ts b/examples/ts-react-chat/src/lib/byok-config.ts new file mode 100644 index 000000000..53c1974af --- /dev/null +++ b/examples/ts-react-chat/src/lib/byok-config.ts @@ -0,0 +1,41 @@ +import { createServerFn } from '@tanstack/react-start' +import type { ProviderId } from '@tanstack/ai-byok' +import type { Provider } from '@/lib/model-selection' + +/** + * Maps the example's providers to a BYOK provider id and the env var(s) the + * adapter reads server-side. Providers not listed (ollama = local, bedrock = + * AWS credentials) don't use a single bring-your-own key. + */ +export const BYOK_PROVIDER_MAP: Partial< + Record }> +> = { + openai: { byokId: 'openai', envVars: ['OPENAI_API_KEY'] }, + anthropic: { byokId: 'anthropic', envVars: ['ANTHROPIC_API_KEY'] }, + gemini: { byokId: 'gemini', envVars: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] }, + 'gemini-interactions': { + byokId: 'gemini', + envVars: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], + }, + grok: { byokId: 'grok', envVars: ['XAI_API_KEY'] }, + groq: { byokId: 'groq', envVars: ['GROQ_API_KEY'] }, + openrouter: { byokId: 'openrouter', envVars: ['OPENROUTER_API_KEY'] }, +} + +/** The BYOK provider id backing an example provider, if any. */ +export function byokIdForProvider(provider: Provider): ProviderId | null { + return BYOK_PROVIDER_MAP[provider]?.byokId ?? null +} + +/** + * Reports which BYOK-supported providers have their key set in the server env, + * so the client can warn before a user picks a model it can't run. Returns only + * booleans — never the key values. + */ +export const getEnvKeyStatus = createServerFn({ method: 'GET' }).handler(() => { + const status: Partial> = {} + for (const { byokId, envVars } of Object.values(BYOK_PROVIDER_MAP)) { + status[byokId] = envVars.some((name) => Boolean(process.env[name])) + } + return status +}) diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 647f6c145..69a7764a2 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -20,7 +20,6 @@ import { Route as ImageToolReproRouteImport } from './routes/image-tool-repro' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as GenerationHooksRouteImport } from './routes/generation-hooks' import { Route as CapabilityDemoRouteImport } from './routes/capability-demo' -import { Route as ByokRouteImport } from './routes/byok' import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription' @@ -48,7 +47,6 @@ import { Route as ApiMcpAppsCallRouteImport } from './routes/api.mcp-apps-call' import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-repro' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' -import { Route as ApiByokChatRouteImport } from './routes/api.byok-chat' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' import { Route as ApiGenerateVideoRouteImport } from './routes/api.generate.video' @@ -111,11 +109,6 @@ const CapabilityDemoRoute = CapabilityDemoRouteImport.update({ path: '/capability-demo', getParentRoute: () => rootRouteImport, } as any) -const ByokRoute = ByokRouteImport.update({ - id: '/byok', - path: '/byok', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -254,11 +247,6 @@ const ApiCapabilityDemoRoute = ApiCapabilityDemoRouteImport.update({ path: '/api/capability-demo', getParentRoute: () => rootRouteImport, } as any) -const ApiByokChatRoute = ApiByokChatRouteImport.update({ - id: '/api/byok-chat', - path: '/api/byok-chat', - getParentRoute: () => rootRouteImport, -} as any) const ExampleGuitarsIndexRoute = ExampleGuitarsIndexRouteImport.update({ id: '/example/guitars/', path: '/example/guitars/', @@ -292,7 +280,6 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/byok': typeof ByokRoute '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute @@ -304,7 +291,6 @@ export interface FileRoutesByFullPath { '/sandboxes': typeof SandboxesRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute - '/api/byok-chat': typeof ApiByokChatRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -340,7 +326,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute - '/byok': typeof ByokRoute '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute @@ -352,7 +337,6 @@ export interface FileRoutesByTo { '/sandboxes': typeof SandboxesRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute - '/api/byok-chat': typeof ApiByokChatRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -389,7 +373,6 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/byok': typeof ByokRoute '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute @@ -401,7 +384,6 @@ export interface FileRoutesById { '/sandboxes': typeof SandboxesRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute - '/api/byok-chat': typeof ApiByokChatRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -439,7 +421,6 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/byok' | '/capability-demo' | '/generation-hooks' | '/image-gen' @@ -451,7 +432,6 @@ export interface FileRouteTypes { | '/sandboxes' | '/server-fn-chat' | '/threads' - | '/api/byok-chat' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -487,7 +467,6 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' - | '/byok' | '/capability-demo' | '/generation-hooks' | '/image-gen' @@ -499,7 +478,6 @@ export interface FileRouteTypes { | '/sandboxes' | '/server-fn-chat' | '/threads' - | '/api/byok-chat' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -535,7 +513,6 @@ export interface FileRouteTypes { id: | '__root__' | '/' - | '/byok' | '/capability-demo' | '/generation-hooks' | '/image-gen' @@ -547,7 +524,6 @@ export interface FileRouteTypes { | '/sandboxes' | '/server-fn-chat' | '/threads' - | '/api/byok-chat' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -584,7 +560,6 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - ByokRoute: typeof ByokRoute CapabilityDemoRoute: typeof CapabilityDemoRoute GenerationHooksRoute: typeof GenerationHooksRoute ImageGenRoute: typeof ImageGenRoute @@ -596,7 +571,6 @@ export interface RootRouteChildren { SandboxesRoute: typeof SandboxesRoute ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute - ApiByokChatRoute: typeof ApiByokChatRoute ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiImageToolReproRoute: typeof ApiImageToolReproRoute @@ -710,13 +684,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CapabilityDemoRouteImport parentRoute: typeof rootRouteImport } - '/byok': { - id: '/byok' - path: '/byok' - fullPath: '/byok' - preLoaderRoute: typeof ByokRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -906,13 +873,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiCapabilityDemoRouteImport parentRoute: typeof rootRouteImport } - '/api/byok-chat': { - id: '/api/byok-chat' - path: '/api/byok-chat' - fullPath: '/api/byok-chat' - preLoaderRoute: typeof ApiByokChatRouteImport - parentRoute: typeof rootRouteImport - } '/example/guitars/': { id: '/example/guitars/' path: '/example/guitars' @@ -960,7 +920,6 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - ByokRoute: ByokRoute, CapabilityDemoRoute: CapabilityDemoRoute, GenerationHooksRoute: GenerationHooksRoute, ImageGenRoute: ImageGenRoute, @@ -972,7 +931,6 @@ const rootRouteChildren: RootRouteChildren = { SandboxesRoute: SandboxesRoute, ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, - ApiByokChatRoute: ApiByokChatRoute, ApiCapabilityDemoRoute: ApiCapabilityDemoRoute, ApiImageGenRoute: ApiImageGenRoute, ApiImageToolReproRoute: ApiImageToolReproRoute, diff --git a/examples/ts-react-chat/src/routes/api.byok-chat.ts b/examples/ts-react-chat/src/routes/api.byok-chat.ts deleted file mode 100644 index 0c4518344..000000000 --- a/examples/ts-react-chat/src/routes/api.byok-chat.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { - chat, - chatParamsFromRequestBody, - toServerSentEventsResponse, -} from '@tanstack/ai' -import { createOpenaiChat } from '@tanstack/ai-openai' -import { createAnthropicChat } from '@tanstack/ai-anthropic' -import { createGeminiChat } from '@tanstack/ai-gemini' -import { byokMissing, getByokKey } from '@tanstack/ai-byok/server' -import type { AnyTextAdapter } from '@tanstack/ai' -import type { ProviderId } from '@tanstack/ai-byok/server' - -/** - * BYOK chat relay — stateless pass-through. - * - * The user's key is read from the per-provider request header (never the body, - * so it never lands in persisted message history) and handed to the adapter for - * this one call. It is never persisted or logged. - */ - -// Build an adapter from a per-request key. Each provider exposes a -// `create*Chat(model, apiKey)` factory that takes an explicit key. -const adapters: Record< - ProviderId & ('openai' | 'anthropic' | 'gemini'), - (model: string, apiKey: string) => AnyTextAdapter -> = { - openai: (model, apiKey) => - createOpenaiChat((model || 'gpt-5.2') as 'gpt-5.2', apiKey), - anthropic: (model, apiKey) => - createAnthropicChat( - (model || 'claude-sonnet-4-6') as 'claude-sonnet-4-6', - apiKey, - ), - gemini: (model, apiKey) => - createGeminiChat( - (model || 'gemini-3.1-pro-preview') as 'gemini-3.1-pro-preview', - apiKey, - ), -} - -function isSupportedProvider(value: string): value is keyof typeof adapters { - return value in adapters -} - -export const Route = createFileRoute('/api/byok-chat')({ - server: { - handlers: { - POST: async ({ request }) => { - let params - try { - params = await chatParamsFromRequestBody(await request.json()) - } catch (error) { - return new Response( - error instanceof Error ? error.message : 'Bad request', - { status: 400 }, - ) - } - - const requestedProvider = - typeof params.forwardedProps.provider === 'string' - ? params.forwardedProps.provider - : 'openai' - const model = - typeof params.forwardedProps.model === 'string' - ? params.forwardedProps.model - : '' - - if (!isSupportedProvider(requestedProvider)) { - return new Response(`Unsupported provider "${requestedProvider}"`, { - status: 400, - }) - } - - // Read the BYOK key from the header only. No key → typed 401 the client - // renders as "add your key". - const apiKey = getByokKey(request, requestedProvider) - if (!apiKey) return byokMissing(requestedProvider) - - const stream = chat({ - adapter: adapters[requestedProvider](model, apiKey), - messages: params.messages, - threadId: params.threadId, - runId: params.runId, - }) - return toServerSentEventsResponse(stream) - }, - }, - }, -}) diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index a815d1e13..11181d247 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -7,15 +7,20 @@ import { mergeAgentTools, toServerSentEventsResponse, } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai' +import { createOpenaiChat, openaiText } from '@tanstack/ai-openai' import { ollamaText } from '@tanstack/ai-ollama' -import { anthropicText } from '@tanstack/ai-anthropic' -import { geminiText } from '@tanstack/ai-gemini' -import { geminiTextInteractions } from '@tanstack/ai-gemini/experimental' -import { openRouterText } from '@tanstack/ai-openrouter' -import { grokText } from '@tanstack/ai-grok' -import { groqText } from '@tanstack/ai-groq' +import { anthropicText, createAnthropicChat } from '@tanstack/ai-anthropic' +import { createGeminiChat, geminiText } from '@tanstack/ai-gemini' +import { + createGeminiTextInteractions, + geminiTextInteractions, +} from '@tanstack/ai-gemini/experimental' +import { createOpenRouterText, openRouterText } from '@tanstack/ai-openrouter' +import { createGrokText, grokText } from '@tanstack/ai-grok' +import { createGroqText, groqText } from '@tanstack/ai-groq' import { bedrockText } from '@tanstack/ai-bedrock' +import { getByokKey } from '@tanstack/ai-byok/server' +import type { ProviderId } from '@tanstack/ai-byok/server' import type { AnyTextAdapter, ChatMiddleware } from '@tanstack/ai' import { addToCartToolDef, @@ -248,6 +253,19 @@ export const Route = createFileRoute('/api/tanchat')({ ? params.forwardedProps.previousInteractionId : undefined + // BYOK: prefer a per-request key from the request header, falling back + // to the env-configured adapter. The key is read from the header only, + // never persisted or logged. + function withByok( + provider: ProviderId, + m: M, + byok: (model: M, apiKey: string) => AnyTextAdapter, + env: (model: M) => AnyTextAdapter, + ): AnyTextAdapter { + const key = getByokKey(request, provider) + return key ? byok(m, key) : env(m) + } + // Pre-define typed adapter configurations with full type inference // Model is passed to the adapter factory function for type-safe autocomplete const adapterConfig: Record< @@ -256,14 +274,20 @@ export const Route = createFileRoute('/api/tanchat')({ > = { anthropic: () => createChatOptions({ - adapter: anthropicText( + adapter: withByok( + 'anthropic', (model || 'claude-sonnet-4-6') as 'claude-sonnet-4-6', + createAnthropicChat, + anthropicText, ), }), openrouter: () => createChatOptions({ - adapter: openRouterText( + adapter: withByok( + 'openrouter', (model || 'openai/gpt-5.1') as 'openai/gpt-5.1', + createOpenRouterText, + openRouterText, ), modelOptions: { reasoning: { @@ -273,8 +297,11 @@ export const Route = createFileRoute('/api/tanchat')({ }), gemini: () => createChatOptions({ - adapter: geminiText( + adapter: withByok( + 'gemini', (model || 'gemini-3.1-pro-preview') as 'gemini-3.1-pro-preview', + createGeminiChat, + geminiText, ), modelOptions: { thinkingConfig: { @@ -285,8 +312,11 @@ export const Route = createFileRoute('/api/tanchat')({ }), 'gemini-interactions': () => createChatOptions({ - adapter: geminiTextInteractions( + adapter: withByok( + 'gemini', (model || 'gemini-3.1-pro-preview') as 'gemini-3.1-pro-preview', + createGeminiTextInteractions, + geminiTextInteractions, ), modelOptions: { previous_interaction_id: previousInteractionId, @@ -295,15 +325,21 @@ export const Route = createFileRoute('/api/tanchat')({ }), grok: () => createChatOptions({ - adapter: grokText( + adapter: withByok( + 'grok', (model || 'grok-build-0.1') as 'grok-build-0.1', + createGrokText, + grokText, ), modelOptions: {}, }), groq: () => createChatOptions({ - adapter: groqText( + adapter: withByok( + 'groq', (model || 'openai/gpt-oss-120b') as 'openai/gpt-oss-120b', + createGroqText, + groqText, ), }), bedrock: () => @@ -330,7 +366,12 @@ export const Route = createFileRoute('/api/tanchat')({ }), openai: () => createChatOptions({ - adapter: openaiText((model || 'gpt-5.2') as 'gpt-5.2'), + adapter: withByok( + 'openai', + (model || 'gpt-5.2') as 'gpt-5.2', + createOpenaiChat, + openaiText, + ), modelOptions: { prompt_cache_key: 'user-session-12345', prompt_cache_retention: '24h', diff --git a/examples/ts-react-chat/src/routes/byok.tsx b/examples/ts-react-chat/src/routes/byok.tsx deleted file mode 100644 index 693adb2bb..000000000 --- a/examples/ts-react-chat/src/routes/byok.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { useMemo, useRef, useState } from 'react' -import { createFileRoute } from '@tanstack/react-router' -import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { - ByokKeyManager, - ByokProvider, - byokHeaders, - isPasskeyStorageSupported, - memoryStorage, - passkeyStorage, - useByok, -} from '@tanstack/ai-byok/react' -import type { UIMessage } from '@tanstack/ai-react' -import type { ProviderId } from '@tanstack/ai-byok/react' - -export const Route = createFileRoute('/byok')({ - component: ByokPage, -}) - -// Providers this demo relay supports (see src/routes/api.byok-chat.ts). -const MODELS: Array<{ provider: ProviderId; model: string; label: string }> = [ - { provider: 'openai', model: 'gpt-5.2', label: 'OpenAI · GPT-5.2' }, - { provider: 'openai', model: 'gpt-4o', label: 'OpenAI · GPT-4o' }, - { - provider: 'anthropic', - model: 'claude-sonnet-4-6', - label: 'Anthropic · Claude Sonnet 4.6', - }, - { - provider: 'gemini', - model: 'gemini-3.1-pro-preview', - label: 'Gemini · 3.1 Pro', - }, -] - -function ByokPage() { - // Encrypted persistence where the platform supports it (passkey/PRF), else - // session-only. Chosen once — storage is fixed for the provider's life. - const [storage] = useState(() => - isPasskeyStorageSupported() ? passkeyStorage() : memoryStorage(), - ) - return ( - - - - ) -} - -function ByokChat() { - const { keys } = useByok() - // Read the latest keys at request time without recreating the connection. - const keysRef = useRef(keys) - keysRef.current = keys - - const [selected, setSelected] = useState(MODELS[0]) - const [input, setInput] = useState('') - - const connection = useMemo( - () => - fetchServerSentEvents('/api/byok-chat', () => ({ - headers: byokHeaders(keysRef.current), - })), - [], - ) - - const body = useMemo( - () => ({ provider: selected.provider, model: selected.model }), - [selected], - ) - - const { messages, sendMessage, isLoading, error } = useChat({ - connection, - body, - }) - - const missingKey = !keys[selected.provider] - - return ( -

-
-

- Bring your own key -

-

- Keys stay in your browser and are sent per-request in a header — never - the message body, never stored on the server. -

- -
- -
- - {missingKey ? ( - - Add your {selected.provider} key above to chat. - - ) : null} -
- -
- {messages.length === 0 ? ( -

Send a message to start.

- ) : ( - messages.map((message) => ( - - )) - )} - {error ?

{error.message}

: null} -
- -
{ - event.preventDefault() - if (!input.trim()) return - sendMessage(input.trim()) - setInput('') - }} - > - setInput(event.target.value)} - placeholder="Ask something…" - className="flex-1 rounded-md border border-gray-600 bg-gray-800 px-3 py-2 text-white" - /> - -
-
- ) -} - -function MessageBubble({ message }: { message: UIMessage }) { - const text = message.parts - .filter((part) => part.type === 'text') - .map((part) => (part.type === 'text' ? part.content : '')) - .join('') - if (!text) return null - return ( -
- - {text} - -
- ) -} diff --git a/examples/ts-react-chat/src/routes/index.tsx b/examples/ts-react-chat/src/routes/index.tsx index fb1ce78f0..373257ec9 100644 --- a/examples/ts-react-chat/src/routes/index.tsx +++ b/examples/ts-react-chat/src/routes/index.tsx @@ -25,7 +25,16 @@ import { useChat, useTranscription, } from '@tanstack/ai-react' +import { + ByokProvider, + byokHeaders, + memoryStorage, + useByok, +} from '@tanstack/ai-byok/react' import { clientTools } from '@tanstack/ai-client' +import type { ProviderId } from '@tanstack/ai-byok/react' +import { ByokKeyDialog } from '@/components/ByokKeyDialog' +import { byokIdForProvider, getEnvKeyStatus } from '@/lib/byok-config' import { ThinkingPart } from '@tanstack/ai-react-ui' import type { UIMessage } from '@tanstack/ai-react' import type { ContentPart, TranscriptionResult } from '@tanstack/ai' @@ -371,6 +380,22 @@ function Messages({ function ChatPage() { const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_OPTION) + + // BYOK: read the browser-held keyring and report which providers have a + // server-side env key, so we can warn before a keyless model is used. + const { keys } = useByok() + const keysRef = useRef(keys) + keysRef.current = keys + const [envKeyStatus, setEnvKeyStatus] = useState< + Partial> + >({}) + useEffect(() => { + void getEnvKeyStatus().then(setEnvKeyStatus) + }, []) + + const activeByokId = byokIdForProvider(selectedModel.provider) + const needsKey = + activeByokId != null && !envKeyStatus[activeByokId] && !keys[activeByokId] const [attachedImages, setAttachedImages] = useState< Array<{ id: string; base64: string; mimeType: string; preview: string }> >([]) @@ -414,7 +439,9 @@ function ChatPage() { addToolApprovalResponse, stop, } = useChat({ - connection: fetchServerSentEvents('/api/tanchat'), + connection: fetchServerSentEvents('/api/tanchat', () => ({ + headers: byokHeaders(keysRef.current), + })), tools, body, onCustomEvent: (eventType, data, context) => { @@ -615,6 +642,10 @@ function ChatPage() { ))}
+
+ {needsKey && activeByokId ? ( +
+ No key for {activeByokId}. Add one with the key icon to use this + model. +
+ ) : null}
+ +
+ ) +} + export const Route = createFileRoute('/')({ - component: ChatPage, + component: ChatRoute, }) From acc46f0c750e9d176cc245245b65fd47f11594e0 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:51:28 +1000 Subject: [PATCH 08/12] feat(byok): surface saved keys as "locked" after refresh (peek); example uses passkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistent storage can now report presence without decrypting, so the UI knows keys exist after a refresh: - KeyringStorage gains optional peek() → { provider: last-4 } - passkeyStorage stores an unencrypted provider→last-4 sidecar next to the ciphertext and reads it via peek() with no unlock ceremony - new KeyStatus 'locked'; ByokProvider peeks on mount to mark saved keys locked (with last-4); unlock() promotes them to 'set' on decrypt Example front page now uses passkey-encrypted storage when a platform authenticator is available (else memory). The key dialog shows locked keys with a lock + last-4 and an "Unlock saved keys" action; the model-bar warning distinguishes "no key" from "saved but locked → Unlock". Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/byok-package.md | 4 +- .../src/components/ByokKeyDialog.tsx | 34 ++++++++-- examples/ts-react-chat/src/routes/index.tsx | 62 ++++++++++++++++--- packages/ai-byok/README.md | 5 +- packages/ai-byok/src/client/passkey.ts | 24 ++++++- packages/ai-byok/src/client/storage.ts | 14 +++++ packages/ai-byok/src/index.ts | 2 +- packages/ai-byok/src/react/byok-context.tsx | 61 +++++++++++++----- .../ai-byok/src/react/byok-key-manager.tsx | 1 + packages/ai-byok/tests/react.test.tsx | 25 ++++++-- 10 files changed, 193 insertions(+), 39 deletions(-) diff --git a/.changeset/byok-package.md b/.changeset/byok-package.md index 79a2a57c8..41d8daadf 100644 --- a/.changeset/byok-package.md +++ b/.changeset/byok-package.md @@ -4,6 +4,6 @@ Add `@tanstack/ai-byok`: a bring-your-own-key toolkit for TanStack AI. Keys live client-side and travel to the relay in a per-provider header (`x-tanstack-byok-`), never the request body or message history. -- **Client** (`@tanstack/ai-byok`): `byokHeaders`, a typed provider registry, pluggable storage (session-only memory by default, opt-in passkey-encrypted persistence — WebAuthn PRF → HKDF → AES-256-GCM in IndexedDB, no plaintext option), and `validateKey`. -- **React** (`@tanstack/ai-byok/react`): ``, `useByok()` (with `locked`/`unlock` for encrypted storage), and a drop-in `` settings UI that only ever shows the last 4 characters of a saved key. +- **Client** (`@tanstack/ai-byok`): `byokHeaders`, a typed provider registry, pluggable storage (session-only memory by default, opt-in passkey-encrypted persistence — WebAuthn PRF → HKDF → AES-256-GCM in IndexedDB, no plaintext option), and `validateKey`. Storage may expose `peek()` to report `provider → last-4` presence without decrypting. +- **React** (`@tanstack/ai-byok/react`): ``, `useByok()` (with `locked`/`unlock` for encrypted storage), and a drop-in `` settings UI that only ever shows the last 4 characters of a saved key. After a refresh, saved keys from encrypted storage surface as a `locked` status (with last-4) before the biometric unlock. - **Server** (`@tanstack/ai-byok/server`): `getByokKey` (header-only, never logged), `byokMissing` (typed error response), and `scrubSecrets`/`maskKey` for keeping key material out of logs and errors. Stateless pass-through — no persistence, no central endpoint. diff --git a/examples/ts-react-chat/src/components/ByokKeyDialog.tsx b/examples/ts-react-chat/src/components/ByokKeyDialog.tsx index ccefa77e2..cc0eb9eba 100644 --- a/examples/ts-react-chat/src/components/ByokKeyDialog.tsx +++ b/examples/ts-react-chat/src/components/ByokKeyDialog.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' -import { KeyRound, X } from 'lucide-react' +import { KeyRound, Lock, X } from 'lucide-react' import { BYOK_PROVIDERS, useByok } from '@tanstack/ai-byok/react' -import type { ProviderId } from '@tanstack/ai-byok/react' +import type { KeyStatus, ProviderId } from '@tanstack/ai-byok/react' // BYOK-supported providers in this example (see byok-config.ts). const PROVIDERS: Array = [ @@ -22,16 +22,19 @@ interface ByokKeyDialogProps { /** * Key-icon button + modal for entering per-provider BYOK keys. Keys stay in the - * browser (session-only here) and are attached per-request by the caller. Only - * the last 4 characters are ever shown back. + * browser (passkey-encrypted where supported, else session-only) and are + * attached per-request by the caller. Only the last 4 characters are ever shown + * back; saved-but-locked keys show a lock until unlocked. */ export function ByokKeyDialog({ envStatus, activeProvider, }: ByokKeyDialogProps) { - const { keys } = useByok() + const { keys, status, locked, unlock } = useByok() const [open, setOpen] = useState(false) + // Attention needed when the selected model's provider isn't usable right now + // (no server key and no decrypted key — whether missing or saved-but-locked). const activeNeedsKey = activeProvider != null && !envStatus[activeProvider] && @@ -78,12 +81,24 @@ export function ByokKeyDialog({ work without one.

+ {locked ? ( + + ) : null} +
{PROVIDERS.map((provider) => ( ))}
@@ -97,13 +112,17 @@ export function ByokKeyDialog({ function ProviderKeyRow({ provider, hasEnvKey, + status, }: { provider: ProviderId hasEnvKey: boolean + status: KeyStatus }) { const { keys, setKey, clearKey } = useByok() const [draft, setDraft] = useState('') const yourKey = keys[provider] + // Saved-but-not-decrypted this session (persistent storage after a refresh). + const lockedLast4 = status.state === 'locked' ? status.masked.slice(-4) : null return (
@@ -115,6 +134,11 @@ function ProviderKeyRow({ Your key ··{yourKey.slice(-4)} + ) : lockedLast4 ? ( + + + ··{lockedLast4} + ) : hasEnvKey ? ( Server key ) : ( diff --git a/examples/ts-react-chat/src/routes/index.tsx b/examples/ts-react-chat/src/routes/index.tsx index 373257ec9..ca96d27f9 100644 --- a/examples/ts-react-chat/src/routes/index.tsx +++ b/examples/ts-react-chat/src/routes/index.tsx @@ -28,7 +28,9 @@ import { import { ByokProvider, byokHeaders, + isPasskeyStorageSupported, memoryStorage, + passkeyStorage, useByok, } from '@tanstack/ai-byok/react' import { clientTools } from '@tanstack/ai-client' @@ -383,7 +385,7 @@ function ChatPage() { // BYOK: read the browser-held keyring and report which providers have a // server-side env key, so we can warn before a keyless model is used. - const { keys } = useByok() + const { keys, status, unlock } = useByok() const keysRef = useRef(keys) keysRef.current = keys const [envKeyStatus, setEnvKeyStatus] = useState< @@ -394,8 +396,13 @@ function ChatPage() { }, []) const activeByokId = byokIdForProvider(selectedModel.provider) - const needsKey = + // The selected model can't run right now if its provider has no server key + // and no decrypted key in the browser. + const notUsable = activeByokId != null && !envKeyStatus[activeByokId] && !keys[activeByokId] + // A saved-but-locked key just needs unlocking; distinguish it from "no key". + const activeLocked = + activeByokId != null && status[activeByokId]?.state === 'locked' const [attachedImages, setAttachedImages] = useState< Array<{ id: string; base64: string; mimeType: string; preview: string }> >([]) @@ -654,11 +661,24 @@ function ChatPage() { Image Gen
- {needsKey && activeByokId ? ( -
- No key for {activeByokId}. Add one with the key icon to use this - model. -
+ {notUsable && activeByokId ? ( + activeLocked ? ( +
+ Your {activeByokId} key is saved but locked. + +
+ ) : ( +
+ No key for {activeByokId}. Add one with the key icon to use this + model. +
+ ) ) : null}
@@ -817,9 +837,33 @@ function ChatPage() { } function ChatRoute() { - // Session-only keyring: keys vanish on refresh, nothing is persisted. + // Passkey-encrypted persistence when a platform authenticator (Touch ID, + // Windows Hello, Android) is actually available — keys then survive refresh + // and are unlocked with biometrics. Otherwise session-only memory. The choice + // needs an async capability probe, so wait for it before mounting the + // provider (storage is fixed for the provider's life). + const [storage, setStorage] = useState | null>(null) + useEffect(() => { + let active = true + async function pick() { + const platformAuth = + isPasskeyStorageSupported() && + (await globalThis.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable().catch( + () => false, + )) + if (active) setStorage(platformAuth ? passkeyStorage() : memoryStorage()) + } + void pick() + return () => { + active = false + } + }, []) + + if (!storage) return null return ( - + ) diff --git a/packages/ai-byok/README.md b/packages/ai-byok/README.md index 7d0bc7f0b..38a1ecccc 100644 --- a/packages/ai-byok/README.md +++ b/packages/ai-byok/README.md @@ -102,7 +102,10 @@ plaintext persistence**: `passkeyStorage` is `unlockable`, so `` does not decrypt on mount: `useByok()` reports `locked: true` until you call `unlock()` (or save a key, -which registers a passkey on first use). `KeyringStorage` is a small interface, +which registers a passkey on first use). It does, however, `peek()` on mount — +reading an unencrypted `provider → last-4` sidecar with **no** unlock ceremony — +so after a refresh the UI can immediately show saved keys as `locked` (with +their last-4) before the biometric tap. `KeyringStorage` is a small interface, so you can supply your own strategy. Recovery is a non-issue: lose the device, re-paste the key from the provider diff --git a/packages/ai-byok/src/client/passkey.ts b/packages/ai-byok/src/client/passkey.ts index 1e9c3471b..3c31428b7 100644 --- a/packages/ai-byok/src/client/passkey.ts +++ b/packages/ai-byok/src/client/passkey.ts @@ -1,5 +1,6 @@ import type { Keyring } from './keyring' -import type { KeyringStorage } from './storage' +import type { KeyPreview, KeyringStorage } from './storage' +import type { ProviderId } from '../shared/providers' /** * Passkey-encrypted keyring storage (WebAuthn PRF → HKDF → AES-256-GCM). @@ -30,6 +31,21 @@ interface StoredRecord { iv: ArrayBuffer /** Encrypted keyring JSON. */ ciphertext: ArrayBuffer + /** + * Unencrypted presence metadata (`provider → last 4`). Non-sensitive, so it + * can be read via {@link KeyringStorage.peek} without an unlock ceremony to + * show saved keys as "locked" after a refresh. + */ + preview: KeyPreview +} + +/** Build the non-sensitive `provider → last 4` preview from a keyring. */ +function previewOf(keys: Keyring): KeyPreview { + const preview: KeyPreview = {} + for (const [provider, key] of Object.entries(keys)) { + if (key) preview[provider as ProviderId] = key.slice(-4) + } + return preview } /** @@ -314,6 +330,11 @@ export function passkeyStorage( 'Keys are encrypted with your passkey and unlocked with biometrics. ' + 'This protects saved keys if your device is stolen, but not against code ' + 'running on this page after you unlock.', + peek: async () => { + // Read the unencrypted preview — no key material, no unlock ceremony. + const existing = await idbGet(dbName) + return existing?.preview ?? {} + }, load: async () => { const existing = await idbGet(dbName) if (!existing) return {} // nothing stored yet — no ceremony needed @@ -329,6 +350,7 @@ export function passkeyStorage( salt: salt.buffer, iv, ciphertext, + preview: previewOf(keys), }) }, clear: async () => { diff --git a/packages/ai-byok/src/client/storage.ts b/packages/ai-byok/src/client/storage.ts index 5e60d3b19..de22d2827 100644 --- a/packages/ai-byok/src/client/storage.ts +++ b/packages/ai-byok/src/client/storage.ts @@ -1,4 +1,11 @@ import type { Keyring } from './keyring' +import type { ProviderId } from '../shared/providers' + +/** + * Non-sensitive presence metadata: `provider → last 4 chars` of a stored key. + * Never contains the full key. + */ +export type KeyPreview = Partial> /** * A client-side persistence strategy for the keyring. Methods may be sync or @@ -23,6 +30,13 @@ export interface KeyringStorage { * strategy is active (e.g. "protects at rest, not against in-page attacks"). */ readonly warning?: string + /** + * Report which providers have a stored key, and each key's last 4 chars, + * WITHOUT decrypting or running an unlock ceremony. Lets the UI surface saved + * keys as "locked" immediately after a refresh, before the user unlocks. Omit + * when not applicable (e.g. memory, which persists nothing). + */ + readonly peek?: () => Promise | KeyPreview readonly load: () => Promise | Keyring readonly save: (keys: Keyring) => Promise | void readonly clear: () => Promise | void diff --git a/packages/ai-byok/src/index.ts b/packages/ai-byok/src/index.ts index e9fb8e9b2..4b62e3ae8 100644 --- a/packages/ai-byok/src/index.ts +++ b/packages/ai-byok/src/index.ts @@ -27,7 +27,7 @@ export type { Keyring } from './client/keyring' // Storage export { memoryStorage } from './client/storage' -export type { KeyringStorage } from './client/storage' +export type { KeyringStorage, KeyPreview } from './client/storage' export { passkeyStorage, isPasskeyStorageSupported } from './client/passkey' export type { PasskeyStorageOptions } from './client/passkey' diff --git a/packages/ai-byok/src/react/byok-context.tsx b/packages/ai-byok/src/react/byok-context.tsx index 7598026e1..11cfd792b 100644 --- a/packages/ai-byok/src/react/byok-context.tsx +++ b/packages/ai-byok/src/react/byok-context.tsx @@ -23,6 +23,9 @@ import type { ValidationStatus } from '../client/validate' export type KeyStatus = | { state: 'empty' } | { state: 'set'; masked: string } + // Stored but not yet decrypted this session (unlockable storage after a + // refresh). The key isn't usable until `unlock()`; only last-4 is known. + | { state: 'locked'; masked: string } | { state: 'validating'; masked: string } | { state: ValidationStatus; masked: string } | { state: 'error'; masked: string; message: string } @@ -99,27 +102,55 @@ export function ByokProvider({ const keysRef = useRef(keys) keysRef.current = keys - // Merge loaded keys UNDER any edits the user made during the async load, so an - // early setKey is never clobbered by late hydration. + // Apply decrypted/loaded keys. Merge keys UNDER any edits the user made during + // the async load so an early setKey is never clobbered; promote a provider's + // status to `set` when it had no status or was a `locked` placeholder, while + // preserving a fresh user edit. const applyLoaded = useCallback((loaded: Keyring) => { - const loadedStatuses = Object.fromEntries( - Object.entries(loaded) - .filter(([, key]) => Boolean(key)) - .map(([provider, key]) => [ - provider, - { state: 'set', masked: maskKey(key) } satisfies KeyStatus, - ]), - ) setKeys((current) => ({ ...loaded, ...current })) - setStatuses((current) => ({ ...loadedStatuses, ...current })) + setStatuses((current) => { + const next = { ...current } + for (const [provider, key] of Object.entries(loaded)) { + if (!key) continue + const existing = current[provider as ProviderId] + if (!existing || existing.state === 'locked') { + next[provider as ProviderId] = { state: 'set', masked: maskKey(key) } + } + } + return next + }) }, []) - // Auto-hydrate on mount only for storage that needs no unlock ceremony. - // Unlockable storage waits for an explicit `unlock()` so it never prompts on - // page load. + // On mount: unlockable storage peeks (no ceremony) to surface saved keys as + // `locked`; other storage auto-hydrates its keys. useEffect(() => { - if (storage.unlockable) return let cancelled = false + if (storage.unlockable) { + if (!storage.peek) return + void Promise.resolve(storage.peek()).then((preview) => { + if (cancelled) return + const present = Object.entries(preview).filter(([, last4]) => + Boolean(last4), + ) + setStatuses((current) => { + const next = { ...current } + for (const [provider, last4] of present) { + if (!next[provider as ProviderId]) { + next[provider as ProviderId] = { + state: 'locked', + masked: `…${last4}`, + } + } + } + return next + }) + // Nothing stored → nothing to unlock. + if (present.length === 0) setLocked(false) + }) + return () => { + cancelled = true + } + } void Promise.resolve(storage.load()).then((loaded) => { if (!cancelled) applyLoaded(loaded) }) diff --git a/packages/ai-byok/src/react/byok-key-manager.tsx b/packages/ai-byok/src/react/byok-key-manager.tsx index 5e02431a7..65e32f6f6 100644 --- a/packages/ai-byok/src/react/byok-key-manager.tsx +++ b/packages/ai-byok/src/react/byok-key-manager.tsx @@ -139,6 +139,7 @@ function StatusBadge({ status }: { status: KeyStatus }) { const config: Record = { empty: { label: 'Not set', color: '#9ca3af' }, set: { label: 'Saved', color: '#6b7280' }, + locked: { label: 'Locked', color: '#d97706' }, validating: { label: 'Validating…', color: '#d97706' }, valid: { label: 'Valid', color: '#059669' }, invalid: { label: 'Invalid', color: '#dc2626' }, diff --git a/packages/ai-byok/tests/react.test.tsx b/packages/ai-byok/tests/react.test.tsx index 95e0aaa5b..68f45e64b 100644 --- a/packages/ai-byok/tests/react.test.tsx +++ b/packages/ai-byok/tests/react.test.tsx @@ -14,6 +14,12 @@ function fakeEncryptedStorage(initial: Keyring): KeyringStorage { label: 'Fake encrypted', persistent: true, unlockable: true, + peek: () => + Object.fromEntries( + Object.entries(data) + .filter(([, key]) => Boolean(key)) + .map(([provider, key]) => [provider, key.slice(-4)]), + ), load: () => ({ ...data }), save: (keys) => { data = { ...keys } @@ -53,14 +59,21 @@ describe('useByok', () => { expect(result.current.keys.openai).toBeUndefined() }) - it('stays locked until unlock() for unlockable storage', async () => { + it('surfaces saved keys as locked after refresh, then unlock reveals them', async () => { const store = fakeEncryptedStorage({ anthropic: 'sk-ant-9999' }) const wrapper = ({ children }: { children: ReactNode }) => ( {children} ) const { result } = renderHook(() => useByok(), { wrapper }) - // Not auto-loaded on mount — no ceremony until the user unlocks. + // Peek (no decryption) surfaces presence + last-4 as a locked status, but + // the key itself isn't available until unlock. + await waitFor(() => + expect(result.current.status.anthropic).toEqual({ + state: 'locked', + masked: '…9999', + }), + ) expect(result.current.locked).toBe(true) expect(result.current.keys.anthropic).toBeUndefined() @@ -68,8 +81,10 @@ describe('useByok', () => { await result.current.unlock() }) expect(result.current.locked).toBe(false) - await waitFor(() => - expect(result.current.keys.anthropic).toBe('sk-ant-9999'), - ) + expect(result.current.keys.anthropic).toBe('sk-ant-9999') + expect(result.current.status.anthropic).toEqual({ + state: 'set', + masked: '…9999', + }) }) }) From cd6251ef9f54095f0f87f22f45e1aa396ecea77b Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:17:59 +1000 Subject: [PATCH 09/12] =?UTF-8?q?feat(byok):=20add=20withByok=20connection?= =?UTF-8?q?=20helper=20=E2=80=94=20prompt=20to=20add/unlock=20key=20on=20b?= =?UTF-8?q?yokMissing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSE adapter throws a generic HTTP error on non-2xx without the body, so useChat's error can't identify the missing provider. withByok wires the provider's fetchClient option to peek at the relay's byokMissing 401 body: - withByok(getKeys, { onMissingKey }) attaches byokHeaders per request and invokes onMissingKey(provider) when the relay returns byokMissing - byokFetch is the lower-level fetch wrapper; both exported from root + /react - root now re-exports isByokMissingBody / ByokMissingBody Example: /api/tanchat returns byokMissing(provider) when it has no server env key and no BYOK header (instead of a generic 500); the front page uses withByok, and onMissingKey opens the (now controllable) key dialog focused on that provider — or, if the key is saved-but-locked, calls unlock() instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/byok-package.md | 2 +- .../src/components/ByokKeyDialog.tsx | 28 +++++-- .../ts-react-chat/src/routes/api.tanchat.ts | 32 +++++--- examples/ts-react-chat/src/routes/index.tsx | 36 +++++++-- packages/ai-byok/README.md | 26 +++++-- packages/ai-byok/src/client/with-byok.ts | 76 +++++++++++++++++++ packages/ai-byok/src/index.ts | 6 ++ packages/ai-byok/src/react.ts | 6 ++ packages/ai-byok/tests/byok.test.ts | 36 ++++++++- 9 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 packages/ai-byok/src/client/with-byok.ts diff --git a/.changeset/byok-package.md b/.changeset/byok-package.md index 41d8daadf..84b955f6f 100644 --- a/.changeset/byok-package.md +++ b/.changeset/byok-package.md @@ -4,6 +4,6 @@ Add `@tanstack/ai-byok`: a bring-your-own-key toolkit for TanStack AI. Keys live client-side and travel to the relay in a per-provider header (`x-tanstack-byok-`), never the request body or message history. -- **Client** (`@tanstack/ai-byok`): `byokHeaders`, a typed provider registry, pluggable storage (session-only memory by default, opt-in passkey-encrypted persistence — WebAuthn PRF → HKDF → AES-256-GCM in IndexedDB, no plaintext option), and `validateKey`. Storage may expose `peek()` to report `provider → last-4` presence without decrypting. +- **Client** (`@tanstack/ai-byok`): `byokHeaders`, `withByok`/`byokFetch` (attach BYOK headers to a connection and detect the relay's `byokMissing` 401 so the UI can prompt for or unlock the missing key), a typed provider registry, pluggable storage (session-only memory by default, opt-in passkey-encrypted persistence — WebAuthn PRF → HKDF → AES-256-GCM in IndexedDB, no plaintext option), and `validateKey`. Storage may expose `peek()` to report `provider → last-4` presence without decrypting. - **React** (`@tanstack/ai-byok/react`): ``, `useByok()` (with `locked`/`unlock` for encrypted storage), and a drop-in `` settings UI that only ever shows the last 4 characters of a saved key. After a refresh, saved keys from encrypted storage surface as a `locked` status (with last-4) before the biometric unlock. - **Server** (`@tanstack/ai-byok/server`): `getByokKey` (header-only, never logged), `byokMissing` (typed error response), and `scrubSecrets`/`maskKey` for keeping key material out of logs and errors. Stateless pass-through — no persistence, no central endpoint. diff --git a/examples/ts-react-chat/src/components/ByokKeyDialog.tsx b/examples/ts-react-chat/src/components/ByokKeyDialog.tsx index cc0eb9eba..6f9f20d1a 100644 --- a/examples/ts-react-chat/src/components/ByokKeyDialog.tsx +++ b/examples/ts-react-chat/src/components/ByokKeyDialog.tsx @@ -16,8 +16,13 @@ const PROVIDERS: Array = [ interface ByokKeyDialogProps { /** Which providers have a key in the server env (never the key itself). */ envStatus: Partial> - /** The provider for the currently selected model, highlighted when keyless. */ + /** The provider for the currently selected model, flagged on the icon when keyless. */ activeProvider: ProviderId | null + /** Controlled open state (the modal can be opened reactively, not just by the icon). */ + open: boolean + onOpenChange: (open: boolean) => void + /** Provider row to highlight when opened reactively (e.g. a missing key). */ + highlightProvider?: ProviderId | null } /** @@ -29,9 +34,11 @@ interface ByokKeyDialogProps { export function ByokKeyDialog({ envStatus, activeProvider, + open, + onOpenChange, + highlightProvider, }: ByokKeyDialogProps) { const { keys, status, locked, unlock } = useByok() - const [open, setOpen] = useState(false) // Attention needed when the selected model's provider isn't usable right now // (no server key and no decrypted key — whether missing or saved-but-locked). @@ -44,7 +51,7 @@ export function ByokKeyDialog({ <>