Skip to content

Commit 0263dd5

Browse files
authored
feat: require user api keys in public deploy
Require saved user provider keys in public deployments and keep local server-key fallback behind REQUIRE_USER_API_KEYS=false.
1 parent 2f51a9b commit 0263dd5

16 files changed

Lines changed: 575 additions & 32 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ KEY_ENCRYPTION_SECRET=generate_with_openssl_rand_-base64_32
2323

2424
# Optional Gemini API key path (Google AI Studio)
2525
GEMINI_API_KEY=your_gemini_api_key
26+
27+
# Public deployments: set to true so visitors must use saved BYOK keys.
28+
# Leave unset or false for local dev with server-side .env keys.
29+
REQUIRE_USER_API_KEYS=false

scripts/write-env.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const keys = [
88
"GOOGLE_CLIENT_SECRET",
99
"KEY_ENCRYPTION_SECRET",
1010
"NEXTAUTH_URL",
11+
"REQUIRE_USER_API_KEYS",
1112
"GEMINI_API_KEY",
1213
"VERTEX_PROJECT_ID",
1314
"VERTEX_LOCATION",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from "vitest"
2+
import {
3+
getMissingApiKeyMessage,
4+
parseNoKeyProvider,
5+
parseNoKeyProviderFromResponse,
6+
} from "@/lib/api-key-errors"
7+
8+
describe("api key error helpers", () => {
9+
it("recognizes no_key responses with a known provider", () => {
10+
expect(parseNoKeyProvider({ error: "no_key", provider: "gemini" })).toBe("gemini")
11+
expect(parseNoKeyProvider({ error: "no_key", provider: "bogus" })).toBeNull()
12+
expect(parseNoKeyProvider({ error: "other", provider: "gemini" })).toBeNull()
13+
})
14+
15+
it("returns null for malformed no_key payloads", () => {
16+
expect(parseNoKeyProvider(null)).toBeNull()
17+
expect(parseNoKeyProvider(undefined)).toBeNull()
18+
expect(parseNoKeyProvider("no_key")).toBeNull()
19+
expect(parseNoKeyProvider({ error: "no_key" })).toBeNull()
20+
expect(parseNoKeyProvider({ error: "no_key", provider: 123 })).toBeNull()
21+
})
22+
23+
it("parses no_key provider from a response body", async () => {
24+
const response = Response.json({ error: "no_key", provider: "claude" }, { status: 402 })
25+
26+
await expect(parseNoKeyProviderFromResponse(response)).resolves.toBe("claude")
27+
})
28+
29+
it("returns null when response JSON parsing fails", async () => {
30+
const response = new Response("{", {
31+
status: 402,
32+
headers: { "Content-Type": "application/json" },
33+
})
34+
35+
await expect(parseNoKeyProviderFromResponse(response)).resolves.toBeNull()
36+
})
37+
38+
it("formats the user-facing Settings message", () => {
39+
expect(getMissingApiKeyMessage("gpt")).toBe(
40+
"Add your GPT API key in Settings to start debating."
41+
)
42+
})
43+
44+
it("formats the Settings message in Korean", () => {
45+
expect(getMissingApiKeyMessage("gemini", "ko")).toBe(
46+
"Settings에서 Gemini API 키를 추가해 토론을 시작하세요."
47+
)
48+
})
49+
})
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2+
import type { Message } from "@/types"
3+
4+
const authMock = vi.hoisted(() => vi.fn())
5+
const getUserProviderApiKeyMock = vi.hoisted(() => vi.fn())
6+
const streamGPTMock = vi.hoisted(() => vi.fn())
7+
const generateGeminiVerdictWithApiKeyMock = vi.hoisted(() => vi.fn())
8+
const generateGoogleAiContentWithApiKeyMock = vi.hoisted(() => vi.fn())
9+
10+
const validVerdict = {
11+
recommendedAnswer: "Choose Option A.",
12+
voteSplit: "2/2 unanimous",
13+
confidence: 88,
14+
reasons: ["Reason one", "Reason two"],
15+
minorityView: "Option B could work with different constraints.",
16+
oppositeCase: "Choose B if latency matters most.",
17+
}
18+
19+
vi.mock("@/lib/auth", () => ({ auth: authMock }))
20+
vi.mock("@/lib/user-api-keys", () => ({ getUserProviderApiKey: getUserProviderApiKeyMock }))
21+
vi.mock("@/lib/providers/gpt", () => ({ streamGPT: streamGPTMock }))
22+
vi.mock("@/lib/providers/claude", () => ({ streamClaude: vi.fn() }))
23+
vi.mock("@/lib/providers/perplexity", () => ({ streamPerplexity: vi.fn() }))
24+
vi.mock("@/lib/providers/gemini", () => ({
25+
getConfiguredGeminiApiKey: vi.fn(() => "server-gemini-key"),
26+
generateGeminiVerdictWithApiKey: generateGeminiVerdictWithApiKeyMock,
27+
generateGoogleAiContentWithApiKey: generateGoogleAiContentWithApiKeyMock,
28+
streamGemini: vi.fn(),
29+
}))
30+
vi.mock("@/lib/vertex-config", () => ({
31+
getVertexConfig: () => ({ projectId: "project", location: "us-central1" }),
32+
}))
33+
vi.mock("@google-cloud/vertexai", () => ({
34+
VertexAI: vi.fn(() => ({
35+
getGenerativeModel: vi.fn(() => ({
36+
generateContent: vi.fn(),
37+
})),
38+
})),
39+
HarmCategory: {
40+
HARM_CATEGORY_HARASSMENT: "harassment",
41+
HARM_CATEGORY_DANGEROUS_CONTENT: "dangerous",
42+
},
43+
HarmBlockThreshold: { BLOCK_ONLY_HIGH: "high" },
44+
SchemaType: {
45+
OBJECT: "object",
46+
STRING: "string",
47+
NUMBER: "number",
48+
ARRAY: "array",
49+
},
50+
}))
51+
52+
import { POST as chatPOST } from "@/app/api/chat/route"
53+
import { POST as consensusPOST } from "@/app/api/consensus/route"
54+
import { POST as ocrPOST } from "@/app/api/ocr/route"
55+
56+
const messages: Message[] = [
57+
{
58+
id: "u1",
59+
sender: "user",
60+
displayName: "You",
61+
content: "Which option?",
62+
timestamp: new Date(),
63+
},
64+
{
65+
id: "g1",
66+
sender: "gemini",
67+
displayName: "Gemini",
68+
content: "Option A.",
69+
timestamp: new Date(),
70+
},
71+
{
72+
id: "c1",
73+
sender: "claude",
74+
displayName: "Claude",
75+
content: "Option B.",
76+
timestamp: new Date(),
77+
},
78+
]
79+
80+
function jsonRequest(path: string, body: unknown): Request {
81+
return new Request(`http://localhost${path}`, {
82+
method: "POST",
83+
headers: { "Content-Type": "application/json" },
84+
body: JSON.stringify(body),
85+
})
86+
}
87+
88+
describe("BYOK-required route guards", () => {
89+
let previousRequireUserApiKeys: string | undefined
90+
91+
beforeEach(() => {
92+
previousRequireUserApiKeys = process.env.REQUIRE_USER_API_KEYS
93+
process.env.REQUIRE_USER_API_KEYS = "true"
94+
authMock.mockResolvedValue(null)
95+
getUserProviderApiKeyMock.mockResolvedValue(undefined)
96+
streamGPTMock.mockImplementation(async function* () {
97+
yield "server response"
98+
})
99+
generateGeminiVerdictWithApiKeyMock.mockResolvedValue(JSON.stringify(validVerdict))
100+
generateGoogleAiContentWithApiKeyMock.mockResolvedValue("ocr text")
101+
})
102+
103+
afterEach(() => {
104+
if (previousRequireUserApiKeys === undefined) {
105+
delete process.env.REQUIRE_USER_API_KEYS
106+
} else {
107+
process.env.REQUIRE_USER_API_KEYS = previousRequireUserApiKeys
108+
}
109+
vi.clearAllMocks()
110+
})
111+
112+
it("chat returns no_key before calling the provider when no user key exists", async () => {
113+
const response = await chatPOST(
114+
jsonRequest("/api/chat", {
115+
messages,
116+
provider: "gpt",
117+
locale: "en",
118+
responseLength: "medium",
119+
})
120+
)
121+
122+
expect(response.status).toBe(402)
123+
await expect(response.json()).resolves.toEqual({ error: "no_key", provider: "gpt" })
124+
expect(streamGPTMock).not.toHaveBeenCalled()
125+
})
126+
127+
it("chat returns key_lookup_failed when the saved key lookup fails", async () => {
128+
authMock.mockResolvedValue({ user: { id: "user-1" } })
129+
getUserProviderApiKeyMock.mockRejectedValueOnce(new Error("database unavailable"))
130+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)
131+
132+
try {
133+
const response = await chatPOST(
134+
jsonRequest("/api/chat", {
135+
messages,
136+
provider: "gpt",
137+
locale: "en",
138+
responseLength: "medium",
139+
})
140+
)
141+
142+
expect(response.status).toBe(500)
143+
await expect(response.json()).resolves.toEqual({ error: "key_lookup_failed" })
144+
expect(streamGPTMock).not.toHaveBeenCalled()
145+
} finally {
146+
errorSpy.mockRestore()
147+
}
148+
})
149+
150+
it("consensus returns no_key before using Gemini server credentials", async () => {
151+
const response = await consensusPOST(
152+
jsonRequest("/api/consensus", {
153+
messages,
154+
locale: "en",
155+
responseLength: "medium",
156+
}) as never
157+
)
158+
159+
expect(response.status).toBe(402)
160+
await expect(response.json()).resolves.toEqual({ error: "no_key", provider: "gemini" })
161+
expect(generateGeminiVerdictWithApiKeyMock).not.toHaveBeenCalled()
162+
})
163+
164+
it("ocr returns no_key before using Gemini server credentials", async () => {
165+
const response = await ocrPOST(jsonRequest("/api/ocr", { images: ["abc"] }) as never)
166+
167+
expect(response.status).toBe(402)
168+
await expect(response.json()).resolves.toEqual({ error: "no_key", provider: "gemini" })
169+
expect(generateGoogleAiContentWithApiKeyMock).not.toHaveBeenCalled()
170+
})
171+
172+
it("chat proceeds with the saved user key when BYOK is required", async () => {
173+
authMock.mockResolvedValue({ user: { id: "user-1" } })
174+
getUserProviderApiKeyMock.mockResolvedValue("user-gpt-key")
175+
176+
const response = await chatPOST(
177+
jsonRequest("/api/chat", {
178+
messages,
179+
provider: "gpt",
180+
locale: "en",
181+
responseLength: "medium",
182+
})
183+
)
184+
185+
expect(response.status).toBe(200)
186+
await response.text()
187+
expect(streamGPTMock).toHaveBeenCalled()
188+
expect(streamGPTMock.mock.calls[0]?.[4]).toBe("user-gpt-key")
189+
})
190+
191+
it("chat proceeds without a user key when BYOK is disabled", async () => {
192+
process.env.REQUIRE_USER_API_KEYS = "false"
193+
194+
const response = await chatPOST(
195+
jsonRequest("/api/chat", {
196+
messages,
197+
provider: "gpt",
198+
locale: "en",
199+
responseLength: "medium",
200+
})
201+
)
202+
203+
expect(response.status).toBe(200)
204+
await response.text()
205+
expect(streamGPTMock).toHaveBeenCalled()
206+
expect(streamGPTMock.mock.calls[0]?.[4]).toBeUndefined()
207+
})
208+
209+
it("consensus proceeds with the saved Gemini key when BYOK is required", async () => {
210+
authMock.mockResolvedValue({ user: { id: "user-1" } })
211+
getUserProviderApiKeyMock.mockResolvedValue("user-gemini-key")
212+
213+
const response = await consensusPOST(
214+
jsonRequest("/api/consensus", {
215+
messages,
216+
locale: "en",
217+
responseLength: "medium",
218+
}) as never
219+
)
220+
221+
expect(response.status).toBe(200)
222+
await expect(response.json()).resolves.toMatchObject(validVerdict)
223+
expect(generateGeminiVerdictWithApiKeyMock).toHaveBeenCalledWith(
224+
expect.objectContaining({ apiKey: "user-gemini-key" })
225+
)
226+
})
227+
228+
it("consensus falls back to the configured Gemini key when BYOK is unset", async () => {
229+
delete process.env.REQUIRE_USER_API_KEYS
230+
231+
const response = await consensusPOST(
232+
jsonRequest("/api/consensus", {
233+
messages,
234+
locale: "en",
235+
responseLength: "medium",
236+
}) as never
237+
)
238+
239+
expect(response.status).toBe(200)
240+
await expect(response.json()).resolves.toMatchObject(validVerdict)
241+
expect(generateGeminiVerdictWithApiKeyMock).toHaveBeenCalledWith(
242+
expect.objectContaining({ apiKey: "server-gemini-key" })
243+
)
244+
})
245+
246+
it("ocr proceeds with the saved Gemini key when BYOK is required", async () => {
247+
authMock.mockResolvedValue({ user: { id: "user-1" } })
248+
getUserProviderApiKeyMock.mockResolvedValue("user-gemini-key")
249+
250+
const response = await ocrPOST(jsonRequest("/api/ocr", { images: ["abc"] }) as never)
251+
252+
expect(response.status).toBe(200)
253+
await expect(response.json()).resolves.toEqual({ text: "ocr text" })
254+
expect(generateGoogleAiContentWithApiKeyMock).toHaveBeenCalledWith(
255+
expect.objectContaining({ apiKey: "user-gemini-key" })
256+
)
257+
})
258+
259+
it("ocr falls back to the configured Gemini key when BYOK is disabled", async () => {
260+
process.env.REQUIRE_USER_API_KEYS = "false"
261+
262+
const response = await ocrPOST(jsonRequest("/api/ocr", { images: ["abc"] }) as never)
263+
264+
expect(response.status).toBe(200)
265+
await expect(response.json()).resolves.toEqual({ text: "ocr text" })
266+
expect(generateGoogleAiContentWithApiKeyMock).toHaveBeenCalledWith(
267+
expect.objectContaining({ apiKey: "server-gemini-key" })
268+
)
269+
})
270+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest"
2+
import { requireUserKeys } from "@/lib/deploy-config"
3+
4+
describe("requireUserKeys", () => {
5+
it("is enabled only when REQUIRE_USER_API_KEYS is exactly true", () => {
6+
const previous = process.env.REQUIRE_USER_API_KEYS
7+
try {
8+
process.env.REQUIRE_USER_API_KEYS = "true"
9+
expect(requireUserKeys()).toBe(true)
10+
11+
process.env.REQUIRE_USER_API_KEYS = "TRUE"
12+
expect(requireUserKeys()).toBe(false)
13+
14+
delete process.env.REQUIRE_USER_API_KEYS
15+
expect(requireUserKeys()).toBe(false)
16+
} finally {
17+
if (previous === undefined) {
18+
delete process.env.REQUIRE_USER_API_KEYS
19+
} else {
20+
process.env.REQUIRE_USER_API_KEYS = previous
21+
}
22+
}
23+
})
24+
})

src/__tests__/write-env.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { execFileSync } from "node:child_process"
2+
import { mkdtempSync, readFileSync, rmSync } from "node:fs"
3+
import { tmpdir } from "node:os"
4+
import { join } from "node:path"
5+
import { describe, expect, it } from "vitest"
6+
7+
describe("write-env", () => {
8+
it("writes REQUIRE_USER_API_KEYS when present in the build environment", () => {
9+
const tempDir = mkdtempSync(join(tmpdir(), "quorum-write-env-"))
10+
const scriptPath = join(process.cwd(), "scripts", "write-env.js")
11+
12+
try {
13+
execFileSync(process.execPath, [scriptPath], {
14+
cwd: tempDir,
15+
env: {
16+
...process.env,
17+
REQUIRE_USER_API_KEYS: "true",
18+
},
19+
})
20+
21+
expect(readFileSync(join(tempDir, ".env"), "utf8")).toContain("REQUIRE_USER_API_KEYS='true'")
22+
} finally {
23+
rmSync(tempDir, { recursive: true, force: true })
24+
}
25+
})
26+
})

0 commit comments

Comments
 (0)