Skip to content

Commit 585bc1e

Browse files
authored
feat: anonymous BYOK with optional sign-in
Paste your own provider key and use Quorum with no sign-in - the key travels in the POST body and short-circuits before any auth import, DB read, or 402. Keyless requests still return 402 no_key on chat, consensus, and OCR. NEXT_PUBLIC_AUTH_ENABLED gates the sign-in UI and the server-side session lookup, so a zero-backend clone runs as pure BYOK. Keys live only in the browser, never stored or logged server-side. README rewritten BYOK-first.
1 parent 0263dd5 commit 585bc1e

21 files changed

Lines changed: 723 additions & 154 deletions

.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ KEY_ENCRYPTION_SECRET=generate_with_openssl_rand_-base64_32
2424
# Optional Gemini API key path (Google AI Studio)
2525
GEMINI_API_KEY=your_gemini_api_key
2626

27-
# Public deployments: set to true so visitors must use saved BYOK keys.
27+
# Public deployments: set to true so visitors must supply their own key (pasted BYOK or saved).
2828
# Leave unset or false for local dev with server-side .env keys.
2929
REQUIRE_USER_API_KEYS=false
30+
31+
# Accounts: enable the sign-in UI and the server-side session key lookup.
32+
# Build-time inlined (NEXT_PUBLIC_) - rebuild after changing. Leave false for pure BYOK, no accounts.
33+
NEXT_PUBLIC_AUTH_ENABLED=false

README.md

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -83,36 +83,38 @@ Switch instantly from the header or settings.
8383

8484
## Quick Start
8585

86+
Quorum is **BYOK-first (bring your own key)**: paste your own provider API keys and go. No account, no database, no sign-in. Keys are stored only in your browser.
87+
8688
```bash
8789
git clone https://github.com/aiedwardyi/quorum.git
8890
cd quorum
8991
npm install
92+
npm run dev
9093
```
9194

92-
Create a `.env.local` file from [`.env.example`](./.env.example):
95+
Open [localhost:3000](http://localhost:3000), open **Settings**, and paste an API key for any provider you want on the panel. That is the whole setup.
9396

94-
```bash
95-
cp .env.example .env.local
96-
```
97+
> The consensus verdict and document OCR always run on **Gemini**, so add a Gemini key even if your panel is Claude/GPT/Perplexity only.
9798
98-
```powershell
99-
Copy-Item .env.example .env.local
100-
```
99+
Set `REQUIRE_USER_API_KEYS=true` (recommended for any BYOK or public deploy) so a keyless request returns a clear "add your key" prompt instead of a generic provider error. Anonymous BYOK gives you an in-session thread; sign in (below) to save history across visits.
101100

102-
Fill in your credentials (see `.env.example` for all placeholders). Quorum is BYOK-first: you can run it with server-side provider keys in `.env.local`, and signed-in users can save their own encrypted provider keys from Settings.
101+
---
103102

104-
```env
105-
# Gemini option A: Google AI Studio API key
106-
GEMINI_API_KEY=your_gemini_api_key
103+
## Self-Hosting with Accounts (optional)
107104

108-
# Gemini option B: Vertex AI with Application Default Credentials
109-
VERTEX_PROJECT_ID=your_google_cloud_project_id
110-
VERTEX_LOCATION=us-central1
105+
Sign-in is an optional upgrade: saved, encrypted provider keys and thread history synced to a database. Skip this entire section for pure BYOK.
111106

112-
# Model Providers
113-
PERPLEXITY_API_KEY=your_perplexity_api_key
114-
ANTHROPIC_API_KEY=your_anthropic_api_key
115-
OPENAI_API_KEY=your_openai_api_key
107+
Create a `.env.local` from [`.env.example`](./.env.example):
108+
109+
```bash
110+
cp .env.example .env.local # PowerShell: Copy-Item .env.example .env.local
111+
```
112+
113+
```env
114+
# Enable accounts: sign-in UI + the server-side session key lookup.
115+
# Build-time inlined - rebuild after changing.
116+
NEXT_PUBLIC_AUTH_ENABLED=true
117+
REQUIRE_USER_API_KEYS=true
116118
117119
# Database (Neon PostgreSQL)
118120
DATABASE_URL=postgresql://user:password@host/neondb?sslmode=require
@@ -122,35 +124,43 @@ AUTH_SECRET=generate_with_openssl_rand_-base64_32
122124
GOOGLE_CLIENT_ID=your_google_oauth_client_id
123125
GOOGLE_CLIENT_SECRET=your_google_oauth_client_secret
124126
125-
# Encryption for user-supplied API keys
127+
# Encryption for accounts' saved provider keys
126128
KEY_ENCRYPTION_SECRET=generate_with_openssl_rand_-base64_32
129+
130+
# Optional server-side provider keys (used only when REQUIRE_USER_API_KEYS is false)
131+
GEMINI_API_KEY=your_gemini_api_key
132+
PERPLEXITY_API_KEY=your_perplexity_api_key
133+
ANTHROPIC_API_KEY=your_anthropic_api_key
134+
OPENAI_API_KEY=your_openai_api_key
127135
```
128136

129137
> **Important:** Prefer `.env.local` for local secrets. It takes precedence over `.env` and is gitignored by default.
130138
131-
Gemini supports two server-side auth paths:
132-
133-
- Google AI Studio/API key: set `GEMINI_API_KEY`.
134-
- Vertex AI ADC: leave `GEMINI_API_KEY` empty, set `VERTEX_PROJECT_ID` and `VERTEX_LOCATION`, then authenticate locally:
135-
136-
```bash
137-
gcloud auth application-default login
138-
```
139-
140-
For local Google login, create a Google OAuth client and add this authorized redirect URI:
139+
For Google login, create a Google OAuth client and add this authorized redirect URI:
141140

142141
```text
143142
http://localhost:3000/api/auth/callback/google
144143
```
145144

146-
Then initialize the database and run the app:
145+
Gemini has two server-side auth paths (only relevant when serving your own keys):
146+
147+
- Google AI Studio API key: set `GEMINI_API_KEY`.
148+
- Vertex AI ADC: leave `GEMINI_API_KEY` empty, set `VERTEX_PROJECT_ID` and `VERTEX_LOCATION`, then `gcloud auth application-default login`.
149+
150+
Then migrate the database and run:
147151

148152
```bash
149153
npx prisma migrate dev
150154
npm run dev
151155
```
152156

153-
Open [localhost:3000](http://localhost:3000) and start debating.
157+
---
158+
159+
## Privacy & Security
160+
161+
- Your keys live only in your browser's `localStorage` and are sent to the server solely to call the model you picked. They are never persisted or logged server-side.
162+
- Signed-in users' saved keys are encrypted at rest, scoped per account, and never travel back to the client.
163+
- Browser BYOK is inherently exposed to XSS: any script running on the page can read `localStorage`. Use your own revocable keys, and self-hosters should serve a strict Content-Security-Policy.
154164

155165
---
156166

scripts/write-env.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const keys = [
99
"KEY_ENCRYPTION_SECRET",
1010
"NEXTAUTH_URL",
1111
"REQUIRE_USER_API_KEYS",
12+
"NEXT_PUBLIC_AUTH_ENABLED",
1213
"GEMINI_API_KEY",
1314
"VERTEX_PROJECT_ID",
1415
"VERTEX_LOCATION",

src/__tests__/byok-route-guards.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,13 @@ function jsonRequest(path: string, body: unknown): Request {
8787

8888
describe("BYOK-required route guards", () => {
8989
let previousRequireUserApiKeys: string | undefined
90+
let previousAuthEnabled: string | undefined
9091

9192
beforeEach(() => {
9293
previousRequireUserApiKeys = process.env.REQUIRE_USER_API_KEYS
9394
process.env.REQUIRE_USER_API_KEYS = "true"
95+
previousAuthEnabled = process.env.NEXT_PUBLIC_AUTH_ENABLED
96+
process.env.NEXT_PUBLIC_AUTH_ENABLED = "true"
9497
authMock.mockResolvedValue(null)
9598
getUserProviderApiKeyMock.mockResolvedValue(undefined)
9699
streamGPTMock.mockImplementation(async function* () {
@@ -106,6 +109,11 @@ describe("BYOK-required route guards", () => {
106109
} else {
107110
process.env.REQUIRE_USER_API_KEYS = previousRequireUserApiKeys
108111
}
112+
if (previousAuthEnabled === undefined) {
113+
delete process.env.NEXT_PUBLIC_AUTH_ENABLED
114+
} else {
115+
process.env.NEXT_PUBLIC_AUTH_ENABLED = previousAuthEnabled
116+
}
109117
vi.clearAllMocks()
110118
})
111119

@@ -267,4 +275,179 @@ describe("BYOK-required route guards", () => {
267275
expect.objectContaining({ apiKey: "server-gemini-key" })
268276
)
269277
})
278+
279+
// ---- Anonymous body-key BYOK (Phase 1) ----
280+
281+
it("chat uses a request-body key without touching auth or the DB", async () => {
282+
const response = await chatPOST(
283+
jsonRequest("/api/chat", {
284+
messages,
285+
provider: "gpt",
286+
locale: "en",
287+
responseLength: "medium",
288+
userApiKey: "body-gpt-key",
289+
})
290+
)
291+
292+
expect(response.status).toBe(200)
293+
await response.text()
294+
expect(streamGPTMock.mock.calls[0]?.[4]).toBe("body-gpt-key")
295+
expect(authMock).not.toHaveBeenCalled()
296+
expect(getUserProviderApiKeyMock).not.toHaveBeenCalled()
297+
})
298+
299+
it("consensus uses a request-body gemini key without touching auth", async () => {
300+
const response = await consensusPOST(
301+
jsonRequest("/api/consensus", {
302+
messages,
303+
locale: "en",
304+
responseLength: "medium",
305+
userApiKey: "body-gemini-key",
306+
}) as never
307+
)
308+
309+
expect(response.status).toBe(200)
310+
await expect(response.json()).resolves.toMatchObject(validVerdict)
311+
expect(generateGeminiVerdictWithApiKeyMock).toHaveBeenCalledWith(
312+
expect.objectContaining({ apiKey: "body-gemini-key" })
313+
)
314+
expect(authMock).not.toHaveBeenCalled()
315+
})
316+
317+
it("ocr uses a request-body gemini key without touching auth", async () => {
318+
const response = await ocrPOST(
319+
jsonRequest("/api/ocr", { images: ["abc"], userApiKey: "body-gemini-key" }) as never
320+
)
321+
322+
expect(response.status).toBe(200)
323+
await expect(response.json()).resolves.toEqual({ text: "ocr text" })
324+
expect(generateGoogleAiContentWithApiKeyMock).toHaveBeenCalledWith(
325+
expect.objectContaining({ apiKey: "body-gemini-key" })
326+
)
327+
expect(authMock).not.toHaveBeenCalled()
328+
})
329+
330+
it("chat body key takes precedence over a saved session key", async () => {
331+
authMock.mockResolvedValue({ user: { id: "user-1" } })
332+
getUserProviderApiKeyMock.mockResolvedValue("session-gpt-key")
333+
334+
const response = await chatPOST(
335+
jsonRequest("/api/chat", {
336+
messages,
337+
provider: "gpt",
338+
locale: "en",
339+
responseLength: "medium",
340+
userApiKey: "body-gpt-key",
341+
})
342+
)
343+
344+
expect(response.status).toBe(200)
345+
await response.text()
346+
expect(streamGPTMock.mock.calls[0]?.[4]).toBe("body-gpt-key")
347+
expect(authMock).not.toHaveBeenCalled()
348+
expect(getUserProviderApiKeyMock).not.toHaveBeenCalled()
349+
})
350+
351+
// ---- Auth gate (Phase 2): flag off never touches the session ----
352+
353+
it("skips the session lookup entirely when auth is disabled, still 402 without a key", async () => {
354+
process.env.NEXT_PUBLIC_AUTH_ENABLED = "false"
355+
authMock.mockResolvedValue({ user: { id: "user-1" } })
356+
getUserProviderApiKeyMock.mockResolvedValue("session-gpt-key")
357+
358+
const response = await chatPOST(
359+
jsonRequest("/api/chat", {
360+
messages,
361+
provider: "gpt",
362+
locale: "en",
363+
responseLength: "medium",
364+
})
365+
)
366+
367+
expect(response.status).toBe(402)
368+
await expect(response.json()).resolves.toEqual({ error: "no_key", provider: "gpt" })
369+
expect(authMock).not.toHaveBeenCalled()
370+
expect(streamGPTMock).not.toHaveBeenCalled()
371+
})
372+
373+
it("does not log the request-body key when the provider errors", async () => {
374+
const leakyKey = "sk-body-leak-1234567890abcdefghij"
375+
streamGPTMock.mockImplementation(async function* () {
376+
yield "partial"
377+
throw new Error(`Provider auth failed for key ${leakyKey}`)
378+
})
379+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)
380+
381+
try {
382+
const response = await chatPOST(
383+
jsonRequest("/api/chat", {
384+
messages,
385+
provider: "gpt",
386+
locale: "en",
387+
responseLength: "medium",
388+
userApiKey: leakyKey,
389+
})
390+
)
391+
const streamed = await response.text()
392+
393+
expect(errorSpy).toHaveBeenCalled()
394+
for (const call of errorSpy.mock.calls) {
395+
expect(call.map(String).join(" ")).not.toContain(leakyKey)
396+
}
397+
expect(streamed).not.toContain(leakyKey)
398+
} finally {
399+
errorSpy.mockRestore()
400+
}
401+
})
402+
403+
it("consensus does not log the request-body key when the provider errors", async () => {
404+
const leakyKey = "AIzaBodyLeakConsensus1234567890abcd"
405+
generateGeminiVerdictWithApiKeyMock.mockRejectedValue(
406+
new Error(`Vertex auth failed for key ${leakyKey}`)
407+
)
408+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)
409+
410+
try {
411+
const response = await consensusPOST(
412+
jsonRequest("/api/consensus", {
413+
messages,
414+
locale: "en",
415+
responseLength: "medium",
416+
userApiKey: leakyKey,
417+
}) as never
418+
)
419+
const bodyText = await response.text()
420+
421+
expect(errorSpy).toHaveBeenCalled()
422+
for (const call of errorSpy.mock.calls) {
423+
expect(call.map(String).join(" ")).not.toContain(leakyKey)
424+
}
425+
expect(bodyText).not.toContain(leakyKey)
426+
} finally {
427+
errorSpy.mockRestore()
428+
}
429+
})
430+
431+
it("ocr does not log the request-body key when the provider errors", async () => {
432+
const leakyKey = "AIzaBodyLeakOcr1234567890abcdefghij"
433+
generateGoogleAiContentWithApiKeyMock.mockRejectedValue(
434+
new Error(`Vertex auth failed for key ${leakyKey}`)
435+
)
436+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)
437+
438+
try {
439+
const response = await ocrPOST(
440+
jsonRequest("/api/ocr", { images: ["abc"], userApiKey: leakyKey }) as never
441+
)
442+
const bodyText = await response.text()
443+
444+
expect(errorSpy).toHaveBeenCalled()
445+
for (const call of errorSpy.mock.calls) {
446+
expect(call.map(String).join(" ")).not.toContain(leakyKey)
447+
}
448+
expect(bodyText).not.toContain(leakyKey)
449+
} finally {
450+
errorSpy.mockRestore()
451+
}
452+
})
270453
})

0 commit comments

Comments
 (0)