docs/superpowers/plans/2026-08-21-byok-foundation.md
Rama main · réplica de lectura del repositorio original.
# BYOK Foundation (0.75a) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A browser-only engine that sends a user's own API key and document straight to their chosen AI provider, never through PrivaTools.
**Architecture:** New `frontend/src/lib/byok/` package. Three provider request shapes (Anthropic, OpenAI-compatible, Gemini) behind one `complete()` call. Keys encrypted at rest in the existing `localStore` `secrets` store, with a session-only mode. `client.ts` is the only module that calls `fetch`, so the never-leak rules are enforceable by reading one file. CSP gains an explicit origin per provider — a curated allowlist, never `https:`.
**Tech Stack:** TypeScript, React, Vitest, existing `localStore` (IndexedDB + WebCrypto AES-GCM), FastAPI (CSP header only).
**Read first:** `docs/superpowers/specs/2026-08-21-byok-ai-design.md` — especially "The constraint that shapes everything: CSP".
---
## File Structure
| File | Responsibility |
|---|---|
| `frontend/src/lib/byok/providers.ts` | Provider registry + per-shape request/response adapters. Pure; no I/O. |
| `frontend/src/lib/byok/errors.ts` | Typed failure taxonomy. No dependencies. |
| `frontend/src/lib/byok/redact.ts` | Strip key material from anything user- or console-facing. |
| `frontend/src/lib/byok/keyStore.ts` | Encrypted persistence + session-only mode. |
| `frontend/src/lib/byok/client.ts` | The only `fetch` caller. |
| `frontend/src/lib/byok/index.ts` | Public surface. |
| `backend/app/main.py` | Add provider origins to `connect-src`. |
| `backend/tests/test_byok_csp.py` | CSP ⊇ registry origins, or the feature dies in the browser. |
Order matters: `errors` and `providers` have no dependencies, `redact` depends on nothing, `keyStore` needs `redact`, `client` needs all three. Build in that order so each task's tests can run in isolation.
---
### Task 1: Error taxonomy
**Files:**
- Create: `frontend/src/lib/byok/errors.ts`
- Test: `frontend/src/lib/byok/errors.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { describe, expect, it } from "vitest";
import { ByokError, classifyHttpStatus } from "./errors";
describe("classifyHttpStatus", () => {
it("maps 401/403 to a bad key", () => {
expect(classifyHttpStatus(401).kind).toBe("BadKey");
expect(classifyHttpStatus(403).kind).toBe("BadKey");
});
it("maps 429 to rate limited", () => {
expect(classifyHttpStatus(429).kind).toBe("RateLimited");
});
it("maps 402 to no credit", () => {
expect(classifyHttpStatus(402).kind).toBe("NoCredit");
});
it("maps 5xx to provider down", () => {
expect(classifyHttpStatus(500).kind).toBe("ProviderDown");
expect(classifyHttpStatus(503).kind).toBe("ProviderDown");
});
it("every error carries a user-facing message that names a next step", () => {
for (const s of [401, 402, 429, 500]) {
const e = classifyHttpStatus(s);
expect(e.userMessage.length).toBeGreaterThan(20);
}
});
it("a ByokError is a real Error so it survives throw/catch", () => {
const e = new ByokError("CspBlocked", "blocked", "Explain it.");
expect(e).toBeInstanceOf(Error);
expect(e.kind).toBe("CspBlocked");
});
});
```
- [ ] **Step 2: Run it and watch it fail**
Run: `cd frontend && npx vitest run src/lib/byok/errors.test.ts`
Expected: FAIL — cannot resolve `./errors`.
- [ ] **Step 3: Implement**
```ts
/**
* Typed failures for BYOK calls.
*
* Each kind exists because it needs a DIFFERENT next step from the user, not
* because it has a different status code. `CspBlocked` in particular must not
* be reported as a network error: it means our allowlist is wrong or the
* endpoint is unsupported, and telling someone "check your connection" would
* send them to debug the wrong thing entirely.
*/
export type ByokErrorKind =
| "CspBlocked"
| "BadKey"
| "RateLimited"
| "NoCredit"
| "ProviderDown"
| "Unsupported"
| "Aborted"
| "Unknown";
export class ByokError extends Error {
readonly kind: ByokErrorKind;
/** Safe to show a user. Never contains key material. */
readonly userMessage: string;
constructor(kind: ByokErrorKind, message: string, userMessage: string) {
super(message);
this.name = "ByokError";
this.kind = kind;
this.userMessage = userMessage;
}
}
export function classifyHttpStatus(status: number): ByokError {
if (status === 401 || status === 403) {
return new ByokError(
"BadKey",
`auth rejected (${status})`,
"That key was rejected. Check it is correct, still active, and has access to the model you picked.",
);
}
if (status === 402) {
return new ByokError(
"NoCredit",
"payment required (402)",
"The provider says this account has no credit left. Top it up on their site and try again.",
);
}
if (status === 429) {
return new ByokError(
"RateLimited",
"rate limited (429)",
"The provider is rate-limiting this key. Wait a moment and try again, or use a smaller document.",
);
}
if (status >= 500) {
return new ByokError(
"ProviderDown",
`provider error (${status})`,
"The provider returned an error on their side. This is not something your key or your file caused.",
);
}
return new ByokError(
"Unknown",
`unexpected status ${status}`,
`The provider returned an unexpected response (${status}).`,
);
}
```
- [ ] **Step 4: Run to green**
Run: `cd frontend && npx vitest run src/lib/byok/errors.test.ts`
Expected: PASS, 6 tests.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/byok/errors.ts frontend/src/lib/byok/errors.test.ts && git commit -m "feat(byok): typed error taxonomy"
```
---
### Task 2: Redaction
Do this BEFORE anything touches a key. Every later task depends on it being available, and adding it afterwards means a window where keys can reach logs.
**Files:**
- Create: `frontend/src/lib/byok/redact.ts`
- Test: `frontend/src/lib/byok/redact.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { describe, expect, it } from "vitest";
import { redact, registerSecret, _clearSecretsForTests } from "./redact";
describe("redact", () => {
it("removes a registered secret from a string", () => {
_clearSecretsForTests();
registerSecret("sk-ant-abc123SECRET");
expect(redact("failed with key sk-ant-abc123SECRET")).not.toContain("SECRET");
expect(redact("failed with key sk-ant-abc123SECRET")).toContain("[redacted]");
});
it("redacts inside nested objects and arrays", () => {
_clearSecretsForTests();
registerSecret("TOPSECRET");
const out = redact({ a: ["x", { b: "has TOPSECRET inside" }] });
expect(JSON.stringify(out)).not.toContain("TOPSECRET");
});
it("redacts an Error's message and stack", () => {
_clearSecretsForTests();
registerSecret("LEAKY");
const e = new Error("boom LEAKY");
const out = redact(e) as { message: string };
expect(out.message).not.toContain("LEAKY");
});
it("catches key-shaped strings even when not registered", () => {
_clearSecretsForTests();
// Defence in depth: a key we never saw must still not sail through.
expect(redact("Bearer sk-proj-AbCdEf0123456789AbCdEf0123456789")).toContain("[redacted]");
expect(redact("x-api-key: sk-ant-api03-ZZZZZZZZZZZZZZZZZZZZZZZZ")).toContain("[redacted]");
});
it("leaves innocent text alone", () => {
_clearSecretsForTests();
expect(redact("could not reach the provider")).toBe("could not reach the provider");
});
it("does not choke on circular structures", () => {
_clearSecretsForTests();
const a: Record<string, unknown> = { name: "x" };
a.self = a;
expect(() => redact(a)).not.toThrow();
});
});
```
- [ ] **Step 2: Run it and watch it fail**
Run: `cd frontend && npx vitest run src/lib/byok/redact.test.ts`
Expected: FAIL — cannot resolve `./redact`.
- [ ] **Step 3: Implement**
```ts
/**
* Keep key material out of anything a human or a log can see.
*
* Two layers on purpose. Registered secrets catch the key we are actually
* using. The pattern pass catches key-SHAPED strings we were never told about
* — a provider echoing the request back, a user pasting a key into a prompt,
* a second key from another tab. Belt and braces, because the cost of being
* wrong here is a user's credential in a log.
*/
const secrets = new Set<string>();
/** Register a live secret so it is stripped wherever it appears. */
export function registerSecret(value: string): void {
if (value && value.length >= 8) secrets.add(value);
}
export function forgetSecret(value: string): void {
secrets.delete(value);
}
export function _clearSecretsForTests(): void {
secrets.clear();
}
// Shapes used by the providers we support. Deliberately broad.
const KEY_PATTERNS: RegExp[] = [
/sk-[A-Za-z0-9_-]{16,}/g, // OpenAI, Anthropic, many compatibles
/AIza[A-Za-z0-9_-]{20,}/g, // Google
/gsk_[A-Za-z0-9]{20,}/g, // Groq
/r8_[A-Za-z0-9]{20,}/g, // Replicate
/Bearer\s+[A-Za-z0-9._-]{20,}/g, // any bearer token
];
function scrubString(s: string): string {
let out = s;
for (const secret of secrets) {
if (secret && out.includes(secret)) out = out.split(secret).join("[redacted]");
}
for (const re of KEY_PATTERNS) out = out.replace(re, "[redacted]");
return out;
}
export function redact(value: unknown, seen = new WeakSet<object>()): unknown {
if (typeof value === "string") return scrubString(value);
if (value === null || typeof value !== "object") return value;
if (seen.has(value as object)) return "[circular]";
seen.add(value as object);
if (value instanceof Error) {
return {
name: value.name,
message: scrubString(value.message),
stack: value.stack ? scrubString(value.stack) : undefined,
};
}
if (Array.isArray(value)) return value.map((v) => redact(v, seen));
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = redact(v, seen);
}
return out;
}
```
- [ ] **Step 4: Run to green**
Run: `cd frontend && npx vitest run src/lib/byok/redact.test.ts`
Expected: PASS, 6 tests.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/byok/redact.ts frontend/src/lib/byok/redact.test.ts && git commit -m "feat(byok): redaction, before anything touches a key"
```
---
### Task 3: Provider registry
**Files:**
- Create: `frontend/src/lib/byok/providers.ts`
- Test: `frontend/src/lib/byok/providers.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { describe, expect, it } from "vitest";
import { PROVIDERS, buildRequest, parseResponse, providerById } from "./providers";
describe("provider registry", () => {
it("every provider declares an https origin, or loopback for local models", () => {
for (const p of PROVIDERS) {
expect(p.origin).toMatch(/^(https:\/\/|http:\/\/(localhost|127\.0\.0\.1))/);
}
});
it("ids are unique", () => {
expect(new Set(PROVIDERS.map((p) => p.id)).size).toBe(PROVIDERS.length);
});
it("anthropic sends the browser-access header CORS requires", () => {
const req = buildRequest(providerById("anthropic")!, {
apiKey: "sk-ant-test", model: "claude-sonnet-4-5", messages: [{ role: "user", content: "hi" }],
});
expect(req.headers["x-api-key"]).toBe("sk-ant-test");
expect(req.headers["anthropic-dangerous-direct-browser-access"]).toBe("true");
expect(req.headers["anthropic-version"]).toBeTruthy();
expect(req.url).toContain("/v1/messages");
});
it("openai uses a bearer token", () => {
const req = buildRequest(providerById("openai")!, {
apiKey: "sk-test", model: "gpt-4o", messages: [{ role: "user", content: "hi" }],
});
expect(req.headers.authorization).toBe("Bearer sk-test");
expect(req.url).toContain("/v1/chat/completions");
});
it("gemini puts the key in a header, never the URL", () => {
const req = buildRequest(providerById("gemini")!, {
apiKey: "AIzaTEST", model: "gemini-2.0-flash", messages: [{ role: "user", content: "hi" }],
});
expect(req.headers["x-goog-api-key"]).toBe("AIzaTEST");
// Regression guard: Google's own docs show ?key=..., which would put the
// secret in history, logs and Referer headers.
expect(req.url).not.toContain("AIzaTEST");
});
it("a custom OpenAI-compatible endpoint overrides the base url", () => {
const req = buildRequest(providerById("openai-compatible")!, {
apiKey: "k", model: "llama3", messages: [{ role: "user", content: "hi" }],
baseUrl: "http://localhost:11434",
});
expect(req.url).toBe("http://localhost:11434/v1/chat/completions");
});
it("parses each provider's response shape", () => {
expect(parseResponse(providerById("anthropic")!, { content: [{ type: "text", text: "A" }] })).toBe("A");
expect(parseResponse(providerById("openai")!, { choices: [{ message: { content: "B" } }] })).toBe("B");
expect(parseResponse(providerById("gemini")!, { candidates: [{ content: { parts: [{ text: "C" }] } }] })).toBe("C");
});
});
```
- [ ] **Step 2: Run it and watch it fail**
Run: `cd frontend && npx vitest run src/lib/byok/providers.test.ts`
Expected: FAIL — cannot resolve `./providers`.
- [ ] **Step 3: Implement**
```ts
/**
* Who we can talk to, and how each one wants to be talked to.
*
* Three request shapes, not one abstraction pretending they are the same.
* Flattening them would mean the adapter lies about at least two providers.
*
* `origin` is load-bearing beyond documentation: a backend test asserts every
* origin here appears in the CSP connect-src, because a provider added here
* without the CSP entry is refused by the browser and looks like a network
* fault to the user.
*/
export type ProviderShape = "anthropic" | "openai" | "gemini";
export interface Provider {
id: string;
label: string;
/** Scheme + host, exactly as it must appear in CSP connect-src. */
origin: string;
shape: ProviderShape;
/** Default models; users may type any model id. */
models: string[];
/** True when the user supplies the base URL (local or self-hosted). */
customBaseUrl?: boolean;
keysUrl?: string;
}
export interface Message { role: "system" | "user" | "assistant"; content: string; }
export interface CompleteInput {
apiKey: string;
model: string;
messages: Message[];
baseUrl?: string;
maxTokens?: number;
}
export interface PreparedRequest {
url: string;
headers: Record<string, string>;
body: string;
}
const ANTHROPIC_VERSION = "2023-06-01";
export const PROVIDERS: Provider[] = [
{
id: "anthropic", label: "Anthropic (Claude)", origin: "https://api.anthropic.com",
shape: "anthropic", models: ["claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5"],
keysUrl: "https://console.anthropic.com/settings/keys",
},
{
id: "openai", label: "OpenAI", origin: "https://api.openai.com",
shape: "openai", models: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
keysUrl: "https://platform.openai.com/api-keys",
},
{
id: "gemini", label: "Google Gemini", origin: "https://generativelanguage.googleapis.com",
shape: "gemini", models: ["gemini-2.0-flash", "gemini-2.0-pro"],
keysUrl: "https://aistudio.google.com/apikey",
},
{
id: "openrouter", label: "OpenRouter", origin: "https://openrouter.ai",
shape: "openai", models: ["auto"], keysUrl: "https://openrouter.ai/keys",
},
{
id: "groq", label: "Groq", origin: "https://api.groq.com",
shape: "openai", models: ["llama-3.3-70b-versatile"], keysUrl: "https://console.groq.com/keys",
},
{
id: "together", label: "Together AI", origin: "https://api.together.xyz",
shape: "openai", models: ["meta-llama/Llama-3-70b-chat-hf"],
},
{
id: "mistral", label: "Mistral", origin: "https://api.mistral.ai",
shape: "openai", models: ["mistral-large-latest"],
},
{
id: "deepseek", label: "DeepSeek", origin: "https://api.deepseek.com",
shape: "openai", models: ["deepseek-chat"],
},
{
id: "openai-compatible", label: "Local or self-hosted (OpenAI-compatible)",
origin: "http://localhost", shape: "openai", models: [], customBaseUrl: true,
},
];
export function providerById(id: string): Provider | undefined {
return PROVIDERS.find((p) => p.id === id);
}
function baseFor(p: Provider, input: CompleteInput): string {
if (p.customBaseUrl) {
if (!input.baseUrl) throw new Error(`${p.label} needs a base URL`);
return input.baseUrl.replace(/\/+$/, "");
}
return p.origin;
}
export function buildRequest(p: Provider, input: CompleteInput): PreparedRequest {
const base = baseFor(p, input);
const maxTokens = input.maxTokens ?? 4096;
if (p.shape === "anthropic") {
const system = input.messages.filter((m) => m.role === "system").map((m) => m.content).join("\n");
const rest = input.messages.filter((m) => m.role !== "system");
return {
url: `${base}/v1/messages`,
headers: {
"content-type": "application/json",
"x-api-key": input.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
// Without this the browser request is rejected. See the spec.
"anthropic-dangerous-direct-browser-access": "true",
},
body: JSON.stringify({
model: input.model, max_tokens: maxTokens,
...(system ? { system } : {}),
messages: rest.map((m) => ({ role: m.role, content: m.content })),
}),
};
}
if (p.shape === "gemini") {
return {
// Key goes in a header, NOT ?key= as Google's docs suggest: a URL
// parameter lands in history, proxy logs and Referer headers.
url: `${base}/v1beta/models/${encodeURIComponent(input.model)}:generateContent`,
headers: { "content-type": "application/json", "x-goog-api-key": input.apiKey },
body: JSON.stringify({
contents: input.messages
.filter((m) => m.role !== "system")
.map((m) => ({ role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }] })),
...(input.messages.some((m) => m.role === "system")
? { systemInstruction: { parts: [{ text: input.messages.filter((m) => m.role === "system").map((m) => m.content).join("\n") }] } }
: {}),
generationConfig: { maxOutputTokens: maxTokens },
}),
};
}
return {
url: `${base}/v1/chat/completions`,
headers: { "content-type": "application/json", authorization: `Bearer ${input.apiKey}` },
body: JSON.stringify({ model: input.model, max_tokens: maxTokens, messages: input.messages }),
};
}
export function parseResponse(p: Provider, json: unknown): string {
const j = json as Record<string, any>;
if (p.shape === "anthropic") {
return (j?.content ?? []).filter((b: any) => b?.type === "text").map((b: any) => b.text).join("");
}
if (p.shape === "gemini") {
return (j?.candidates?.[0]?.content?.parts ?? []).map((x: any) => x?.text ?? "").join("");
}
return j?.choices?.[0]?.message?.content ?? "";
}
```
- [ ] **Step 4: Run to green**
Run: `cd frontend && npx vitest run src/lib/byok/providers.test.ts`
Expected: PASS, 7 tests.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/byok/providers.ts frontend/src/lib/byok/providers.test.ts && git commit -m "feat(byok): provider registry with three request shapes"
```
---
### Task 4: CSP allowlist + the test that stops it silently breaking
**Files:**
- Modify: `backend/app/main.py` (the `connect_src` list near line 203)
- Create: `backend/tests/test_byok_csp.py`
- [ ] **Step 1: Write the failing test**
```python
"""CSP must allow every provider origin, or BYOK dies in the browser.
CORS passing is irrelevant while CSP says no: the browser refuses the request
before it is sent. A provider added to the registry without its origin here
looks to the user like a network fault, and to a developer like a CORS problem.
Parsed from providers.ts rather than duplicated, so the two cannot drift — the
same failure that put two 404s into production via the tool registries.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
PROVIDERS_TS = REPO_ROOT / "frontend" / "src" / "lib" / "byok" / "providers.ts"
def _registry_origins() -> set[str]:
text = PROVIDERS_TS.read_text(encoding="utf-8")
return set(re.findall(r'origin:\s*"([^"]+)"', text))
def _connect_src() -> str:
main_py = (REPO_ROOT / "backend" / "app" / "main.py").read_text(encoding="utf-8")
i = main_py.index("connect_src")
return main_py[i : i + 2000]
def test_every_provider_origin_is_in_connect_src():
missing = sorted(o for o in _registry_origins() if o not in _connect_src())
assert not missing, (
"These provider origins are in providers.ts but absent from the CSP "
f"connect-src, so the browser will block every call to them:\n {missing}"
)
def test_connect_src_is_not_a_wildcard():
src = _connect_src()
for bad in ('"https:"', "'https:'", '"*"', "connect_src = [\"*\"]"):
assert bad not in src, (
"connect-src must stay a curated allowlist. A wildcard would let any "
"tool page exfiltrate to any host, which is the exact thing this "
"product promises does not happen."
)
```
- [ ] **Step 2: Run it and watch it fail**
Run: `cd /Users/lakshya/projects/priva-tool && python -m pytest backend/tests/test_byok_csp.py -q`
Expected: FAIL — the provider origins are not yet in `connect_src`.
- [ ] **Step 3: Add the origins**
In `backend/app/main.py`, find the `connect_src` list (near line 203) and extend it. Keep the existing entries untouched:
```python
# BYOK talks straight from the browser to the user's chosen provider, so
# each provider needs an explicit connect-src entry. Curated on purpose:
# `https:` would let any tool page reach any host, which would give away
# the guarantee this product is built on. Adding a provider is deliberately
# a code change — a new egress destination should not be a runtime choice.
#
# Loopback covers Ollama / LM Studio. It is exempt from mixed-content
# blocking (loopback is a potentially trustworthy origin), and can only
# reach a server on the user's own machine.
connect_src += [
"https://api.anthropic.com",
"https://api.openai.com",
"https://generativelanguage.googleapis.com",
"https://openrouter.ai",
"https://api.groq.com",
"https://api.together.xyz",
"https://api.mistral.ai",
"https://api.deepseek.com",
"http://localhost:*",
"http://127.0.0.1:*",
]
```
- [ ] **Step 4: Run to green**
Run: `cd /Users/lakshya/projects/priva-tool && python -m pytest backend/tests/test_byok_csp.py -q`
Expected: PASS, 2 tests.
- [ ] **Step 5: Confirm the header actually changed**
Run: `cd /Users/lakshya/projects/priva-tool && python -m pytest backend/tests/test_security.py -q`
Expected: PASS — the existing CSP tests must not regress.
- [ ] **Step 6: Commit**
```bash
git add backend/app/main.py backend/tests/test_byok_csp.py && git commit -m "feat(byok): allow provider origins in CSP connect-src"
```
---
### Task 5: Key store
**Files:**
- Create: `frontend/src/lib/byok/keyStore.ts`
- Test: `frontend/src/lib/byok/keyStore.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { beforeEach, describe, expect, it } from "vitest";
import * as db from "@/lib/localStore/db";
import { _resetForTests } from "@/lib/localStore/crypto";
import { eraseEverything } from "@/lib/localStore/inventory";
import { clearKey, getKey, listConfigured, saveKey, setSessionOnly } from "./keyStore";
beforeEach(async () => {
_resetForTests();
await db.clear("secrets");
await setSessionOnly(false);
});
describe("keyStore", () => {
it("round-trips a key", async () => {
await saveKey("anthropic", "sk-ant-secret");
expect(await getKey("anthropic")).toBe("sk-ant-secret");
});
it("never writes the plaintext key to storage", async () => {
await saveKey("openai", "sk-plaintext-must-not-appear");
const raw = JSON.stringify(await db.values("secrets"));
expect(raw).not.toContain("sk-plaintext-must-not-appear");
});
it("session-only mode keeps the key out of IndexedDB entirely", async () => {
await setSessionOnly(true);
await saveKey("openai", "sk-session");
expect(await getKey("openai")).toBe("sk-session");
expect(await db.keys("secrets")).toHaveLength(0);
});
it("clearKey removes it", async () => {
await saveKey("groq", "gsk_x");
await clearKey("groq");
expect(await getKey("groq")).toBeUndefined();
});
it("listConfigured reports which providers have a key, never the key", async () => {
await saveKey("anthropic", "sk-ant-abc");
const list = await listConfigured();
expect(list).toContain("anthropic");
expect(JSON.stringify(list)).not.toContain("sk-ant-abc");
});
it("eraseEverything() removes stored keys", async () => {
await saveKey("openai", "sk-doomed");
await eraseEverything();
expect(await getKey("openai")).toBeUndefined();
});
});
```
- [ ] **Step 2: Run it and watch it fail**
Run: `cd frontend && npx vitest run src/lib/byok/keyStore.test.ts`
Expected: FAIL — cannot resolve `./keyStore`.
- [ ] **Step 3: Implement**
```ts
/**
* Where the user's API key lives.
*
* Encrypted at rest with the same non-extractable AES-GCM wrapping key the
* password vault uses. The honest limit, which the UI must repeat: the key
* itself CANNOT be non-extractable, because it has to be readable to go into
* an auth header. Encrypted on disk; plaintext in memory during a call.
*
* Session-only mode never touches IndexedDB at all — for borrowed machines.
* It is not the default because forcing a re-paste every reload makes the
* feature annoying enough to go unused, and an unused control protects nobody.
*/
import * as db from "@/lib/localStore/db";
import { decryptString, encryptString, type Encrypted } from "@/lib/localStore/crypto";
import { registerSecret, forgetSecret } from "./redact";
const PREFIX = "byok:";
let sessionOnly = false;
const sessionKeys = new Map<string, string>();
export async function setSessionOnly(on: boolean): Promise<void> {
sessionOnly = on;
if (on) {
// Awaited, not fire-and-forget: switching to session-only must have
// finished removing the persisted key before anything reports that
// nothing is stored. A `void db.clear()` here races the very assertion
// the mode exists to make true.
await db.clear("secrets");
} else {
sessionKeys.clear();
}
}
export function isSessionOnly(): boolean {
return sessionOnly;
}
export async function saveKey(providerId: string, apiKey: string): Promise<void> {
registerSecret(apiKey);
if (sessionOnly) {
sessionKeys.set(providerId, apiKey);
return;
}
const enc = await encryptString(apiKey);
await db.put("secrets", PREFIX + providerId, enc);
}
export async function getKey(providerId: string): Promise<string | undefined> {
if (sessionOnly) return sessionKeys.get(providerId);
const enc = await db.get<Encrypted>("secrets", PREFIX + providerId);
if (!enc) return undefined;
try {
const plain = await decryptString(enc);
registerSecret(plain);
return plain;
} catch {
// Wrapping key gone (cleared site data, different browser profile) —
// the ciphertext is unreadable, so treat it as absent rather than error.
return undefined;
}
}
export async function clearKey(providerId: string): Promise<void> {
const existing = sessionKeys.get(providerId);
if (existing) forgetSecret(existing);
sessionKeys.delete(providerId);
await db.del("secrets", PREFIX + providerId);
}
/** Provider ids that have a key. Never returns key material. */
export async function listConfigured(): Promise<string[]> {
if (sessionOnly) return [...sessionKeys.keys()];
const keys = await db.keys("secrets");
return keys.filter((k) => k.startsWith(PREFIX)).map((k) => k.slice(PREFIX.length));
}
```
- [ ] **Step 4: Run to green**
Run: `cd frontend && npx vitest run src/lib/byok/keyStore.test.ts`
Expected: PASS, 6 tests.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/byok/keyStore.ts frontend/src/lib/byok/keyStore.test.ts && git commit -m "feat(byok): encrypted key store with session-only mode"
```
---
### Task 6: The client
**Files:**
- Create: `frontend/src/lib/byok/client.ts`
- Test: `frontend/src/lib/byok/client.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { complete } from "./client";
import { ByokError } from "./errors";
afterEach(() => vi.restoreAllMocks());
function mockFetch(status: number, body: unknown) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response);
}
describe("complete", () => {
it("returns the text on success", async () => {
mockFetch(200, { content: [{ type: "text", text: "hello" }] });
const out = await complete({ providerId: "anthropic", apiKey: "sk-ant-x", model: "claude-sonnet-4-5", messages: [{ role: "user", content: "hi" }] });
expect(out).toBe("hello");
});
it("sends the key in a header and never in the URL", async () => {
const f = mockFetch(200, { content: [{ type: "text", text: "ok" }] });
await complete({ providerId: "anthropic", apiKey: "sk-ant-SECRET", model: "m", messages: [{ role: "user", content: "hi" }] });
const [url, init] = f.mock.calls[0];
expect(String(url)).not.toContain("sk-ant-SECRET");
expect((init as RequestInit).headers).toBeDefined();
});
it("maps a 401 to BadKey", async () => {
mockFetch(401, { error: "nope" });
await expect(complete({ providerId: "openai", apiKey: "bad", model: "m", messages: [] }))
.rejects.toMatchObject({ kind: "BadKey" });
});
it("a CSP/network refusal is reported as CspBlocked, not a network error", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
await expect(complete({ providerId: "openai", apiKey: "k", model: "m", messages: [] }))
.rejects.toMatchObject({ kind: "CspBlocked" });
});
it("never lets the key reach the thrown error", async () => {
mockFetch(500, { error: "upstream said sk-ant-LEAKED" });
try {
await complete({ providerId: "anthropic", apiKey: "sk-ant-LEAKED", model: "m", messages: [] });
throw new Error("should have thrown");
} catch (e) {
const s = JSON.stringify({ m: (e as Error).message, u: (e as ByokError).userMessage });
expect(s).not.toContain("sk-ant-LEAKED");
}
});
it("rejects an unknown provider rather than guessing", async () => {
await expect(complete({ providerId: "nope", apiKey: "k", model: "m", messages: [] }))
.rejects.toMatchObject({ kind: "Unsupported" });
});
});
```
- [ ] **Step 2: Run it and watch it fail**
Run: `cd frontend && npx vitest run src/lib/byok/client.test.ts`
Expected: FAIL — cannot resolve `./client`.
- [ ] **Step 3: Implement**
```ts
/**
* The ONLY module in this package that performs a network call.
*
* Kept that way deliberately: the promises this feature makes — the key goes
* nowhere but the provider, never into a URL, never into an error — are only
* auditable if there is exactly one place to check.
*/
import { ByokError, classifyHttpStatus } from "./errors";
import { buildRequest, parseResponse, providerById, type Message } from "./providers";
import { redact, registerSecret } from "./redact";
export interface CompleteArgs {
providerId: string;
apiKey: string;
model: string;
messages: Message[];
baseUrl?: string;
maxTokens?: number;
signal?: AbortSignal;
}
export async function complete(args: CompleteArgs): Promise<string> {
const provider = providerById(args.providerId);
if (!provider) {
throw new ByokError(
"Unsupported",
`unknown provider ${args.providerId}`,
"That provider is not supported. Pick one from the list.",
);
}
registerSecret(args.apiKey);
const req = buildRequest(provider, args);
let res: Response;
try {
res = await fetch(req.url, {
method: "POST", headers: req.headers, body: req.body, signal: args.signal,
});
} catch (err) {
if ((err as Error)?.name === "AbortError") {
throw new ByokError("Aborted", "aborted", "Cancelled.");
}
// A CSP refusal and an offline network both surface as TypeError here;
// the browser deliberately does not say which. Naming CSP first is the
// more useful guess: "check your connection" sends someone to debug the
// wrong thing, and this path is reachable only for a configured provider.
throw new ByokError(
"CspBlocked",
`fetch failed: ${String(redact((err as Error).message))}`,
`The browser blocked the request to ${provider.label}. If you are using a custom endpoint it is probably not on the allowed list; otherwise check that nothing on your network is intercepting it. PrivaTools will not route your key or your file through its own server as a workaround.`,
);
}
if (!res.ok) throw classifyHttpStatus(res.status);
const json = await res.json().catch(() => ({}));
return parseResponse(provider, json);
}
```
- [ ] **Step 4: Run to green**
Run: `cd frontend && npx vitest run src/lib/byok/client.test.ts`
Expected: PASS, 6 tests.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/byok/client.ts frontend/src/lib/byok/client.test.ts && git commit -m "feat(byok): the single fetch call site"
```
---
### Task 7: Public surface + leak sweep
**Files:**
- Create: `frontend/src/lib/byok/index.ts`
- Create: `frontend/src/lib/byok/leak.test.ts`
- [ ] **Step 1: Write the leak sweep**
```ts
/**
* The tests that matter most: a key must not escape through ANY surface.
*
* Written as a sweep rather than one assertion per path, so a new error branch
* is covered by default instead of by someone remembering to add a test.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { complete } from "./client";
import { redact } from "./redact";
const KEY = "sk-ant-api03-DO-NOT-LEAK-ME-0123456789";
afterEach(() => vi.restoreAllMocks());
const FAILURES: Array<[string, () => void]> = [
["401", () => { vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: `bad key ${KEY}` }) } as unknown as Response); }],
["429", () => { vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 429, json: async () => ({ error: KEY }) } as unknown as Response); }],
["500 echoing the key", () => { vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: KEY }) } as unknown as Response); }],
["network/CSP refusal", () => { vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError(`Failed to fetch ${KEY}`)); }],
];
describe("no surface leaks the key", () => {
it.each(FAILURES)("%s", async (_name, arrange) => {
arrange();
let thrown: unknown;
try {
await complete({ providerId: "anthropic", apiKey: KEY, model: "m", messages: [{ role: "user", content: "hi" }] });
} catch (e) { thrown = e; }
const serialised = JSON.stringify(redact(thrown));
expect(serialised).not.toContain("DO-NOT-LEAK-ME");
});
it("the key never appears in the request URL", async () => {
const f = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 200, json: async () => ({ content: [{ type: "text", text: "x" }] }) } as unknown as Response);
await complete({ providerId: "gemini", apiKey: "AIzaDO-NOT-LEAK-ME-123456789012", model: "gemini-2.0-flash", messages: [{ role: "user", content: "hi" }] });
expect(String(f.mock.calls[0][0])).not.toContain("DO-NOT-LEAK-ME");
});
});
```
- [ ] **Step 2: Write the index**
```ts
/**
* Bring your own AI key.
*
* The key and the document go from the browser straight to the provider the
* user chose. Nothing passes through PrivaTools — see
* docs/superpowers/specs/2026-08-21-byok-ai-design.md.
*/
export { complete, type CompleteArgs } from "./client";
export { PROVIDERS, providerById, type Provider, type Message } from "./providers";
export { saveKey, getKey, clearKey, listConfigured, setSessionOnly, isSessionOnly } from "./keyStore";
export { ByokError, type ByokErrorKind } from "./errors";
export { redact } from "./redact";
```
- [ ] **Step 3: Run the whole package**
Run: `cd frontend && npx vitest run src/lib/byok/`
Expected: PASS — all files, 30+ tests.
- [ ] **Step 4: Typecheck (the bare `tsc --noEmit` is a no-op here)**
Run: `cd frontend && npx tsc --noEmit -p tsconfig.app.json`
Expected: exit 0, no output.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/byok/index.ts frontend/src/lib/byok/leak.test.ts && git commit -m "feat(byok): public surface + leak sweep"
```
---
### Task 8: Full verification
- [ ] **Step 1: Whole frontend suite**
Run: `cd frontend && npx vitest run`
Expected: all previous tests still pass, plus the new ones.
- [ ] **Step 2: Backend suite**
Run: `cd /Users/lakshya/projects/priva-tool && python -m pytest backend/tests -q`
Expected: the CSP tests pass; nothing else regresses.
- [ ] **Step 3: Confirm the CSP is actually served**
After deploying, run:
```bash
curl -sI https://privatools.me/ | tr ';' '\n' | grep -i connect-src
```
Expected: the provider origins appear. If they do not, re-read the spec's
"Where the CSP change actually goes" — nginx overrides CSP on static-asset
locations only, so a wrong-location edit shows up here and nowhere else.
- [ ] **Step 4: Commit and open a PR**
```bash
git push -u origin feat/byok-foundation
```
---
## What is deliberately NOT here
- **No UI.** This is the engine. Key entry, provider picker and the honest
storage copy belong with 0.75b, where they have a screen to live on.
- **No streaming.** Non-streaming first, so the leak tests cover one clear
path. Streaming adds a second response shape per provider and should land
once the engine is proven.
- **No token counting or cost estimates.** Wanted, but each provider needs its
own tokenizer; it belongs with the UI that would display it.
- **No local-model fallback.** By decision: degrading silently from Claude to
distilbart hands someone a much worse answer while implying it came from the
model they picked.