Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ npm run dev

Set `REQUIRE_USER_API_KEYS=true`, `ACCESS_CODES=code-one,code-two`, and your server-side provider keys, then hand a code to a friend. They enter it once in **Settings > Access code** and debate on your deployment's keys - no API keys, no account. Codes only gate enforced deployments: without `REQUIRE_USER_API_KEYS=true`, your server keys serve every visitor. Rotate or revoke by editing the env var and redeploying; set spend limits on your provider dashboards.

### Free debate after Google login

With `NEXT_PUBLIC_AUTH_ENABLED=true` and `REQUIRE_USER_API_KEYS=true`, each new account gets **1 free debate** on your server keys (30-minute window for multi-call chat + consensus). BYOK and access codes still take priority and do not burn the grant. Set hard spend limits on provider dashboards before launching publicly.

---

## Tech Stack
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "freeDebatesRemaining" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "User" ADD COLUMN "freeDebateExpiresAt" TIMESTAMP(3);
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ model User {
email String? @unique
emailVerified DateTime?
image String?
// 1 free debate on host keys after Google login; window covers multi-call debates.
freeDebatesRemaining Int @default(1)
freeDebateExpiresAt DateTime?
accounts Account[]
sessions Session[]
threads Thread[]
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/api-key-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ describe("getApiKeyPromptMessage", () => {
)
})

it("invites sign-in for a free debate when auth is on and visitor is signed out", () => {
expect(
getApiKeyPromptMessage("perplexity", true, "en", { authOn: true, signedIn: false })
).toBe("Sign in with Google for 1 free debate, or add an API key in Settings.")
})

it("tells signed-in users the free debate is used", () => {
expect(getApiKeyPromptMessage("perplexity", true, "en", { authOn: true, signedIn: true })).toBe(
"Free debate used. Add an API key in Settings to continue."
)
})

it("ignores the provider when keyless (rotation order should not leak)", () => {
expect(getApiKeyPromptMessage("perplexity", true)).toBe(getApiKeyPromptMessage("gpt", true))
})
Expand Down
64 changes: 64 additions & 0 deletions src/__tests__/byok-route-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Message } from "@/types"

const authMock = vi.hoisted(() => vi.fn())
const getUserProviderApiKeyMock = vi.hoisted(() => vi.fn())
const tryClaimFreeServerAccessMock = vi.hoisted(() => vi.fn())
const streamGPTMock = vi.hoisted(() => vi.fn())
const generateGeminiVerdictWithApiKeyMock = vi.hoisted(() => vi.fn())
const generateGoogleAiContentWithApiKeyMock = vi.hoisted(() => vi.fn())
Expand All @@ -18,6 +19,10 @@ const validVerdict = {

vi.mock("@/lib/auth", () => ({ auth: authMock }))
vi.mock("@/lib/user-api-keys", () => ({ getUserProviderApiKey: getUserProviderApiKeyMock }))
vi.mock("@/lib/free-debates", () => ({
tryClaimFreeServerAccess: tryClaimFreeServerAccessMock,
getFreeDebateStatus: vi.fn(),
}))
const generateClaudeVerdictMock = vi.hoisted(() => vi.fn())
const generateGptVerdictMock = vi.hoisted(() => vi.fn())
const generatePerplexityVerdictMock = vi.hoisted(() => vi.fn())
Expand Down Expand Up @@ -110,6 +115,7 @@ describe("BYOK-required route guards", () => {
process.env.NEXT_PUBLIC_AUTH_ENABLED = "true"
authMock.mockResolvedValue(null)
getUserProviderApiKeyMock.mockResolvedValue(undefined)
tryClaimFreeServerAccessMock.mockResolvedValue(false)
streamGPTMock.mockImplementation(async function* () {
yield "server response"
})
Expand Down Expand Up @@ -611,3 +617,61 @@ describe("access codes", () => {
expect(result.blockedResponse?.status).toBe(402)
})
})

describe("free debate grant", () => {
let previousRequireUserApiKeys: string | undefined
let previousAuthEnabled: string | undefined

beforeEach(() => {
previousRequireUserApiKeys = process.env.REQUIRE_USER_API_KEYS
previousAuthEnabled = process.env.NEXT_PUBLIC_AUTH_ENABLED
process.env.REQUIRE_USER_API_KEYS = "true"
process.env.NEXT_PUBLIC_AUTH_ENABLED = "true"
authMock.mockResolvedValue({ user: { id: "user-1" } })
getUserProviderApiKeyMock.mockResolvedValue(undefined)
tryClaimFreeServerAccessMock.mockResolvedValue(false)
})

afterEach(() => {
if (previousRequireUserApiKeys === undefined) {
delete process.env.REQUIRE_USER_API_KEYS
} else {
process.env.REQUIRE_USER_API_KEYS = previousRequireUserApiKeys
}
if (previousAuthEnabled === undefined) {
delete process.env.NEXT_PUBLIC_AUTH_ENABLED
} else {
process.env.NEXT_PUBLIC_AUTH_ENABLED = previousAuthEnabled
}
vi.clearAllMocks()
})

it("uses server keys when the signed-in user claims a free debate", async () => {
tryClaimFreeServerAccessMock.mockResolvedValue(true)
const result = await resolveUserProviderApiKey("gpt", "test")
expect(result.blockedResponse).toBeUndefined()
expect(result.userApiKey).toBeUndefined()
expect(tryClaimFreeServerAccessMock).toHaveBeenCalledWith("user-1")
})

it("still 402s when free grant is exhausted", async () => {
tryClaimFreeServerAccessMock.mockResolvedValue(false)
const result = await resolveUserProviderApiKey("gpt", "test")
expect(result.blockedResponse?.status).toBe(402)
})

it("prefers body key over free grant", async () => {
tryClaimFreeServerAccessMock.mockResolvedValue(true)
const result = await resolveUserProviderApiKey("gpt", "test", "user-key")
expect(result.userApiKey).toBe("user-key")
expect(tryClaimFreeServerAccessMock).not.toHaveBeenCalled()
})

it("prefers access code over free grant", async () => {
process.env.ACCESS_CODES = "alpha-1234"
tryClaimFreeServerAccessMock.mockResolvedValue(true)
const result = await resolveUserProviderApiKey("gpt", "test", undefined, "alpha-1234")
expect(result.blockedResponse).toBeUndefined()
expect(tryClaimFreeServerAccessMock).not.toHaveBeenCalled()
})
})
121 changes: 121 additions & 0 deletions src/__tests__/free-debates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

const findUniqueMock = vi.hoisted(() => vi.fn())
const updateManyMock = vi.hoisted(() => vi.fn())

vi.mock("@/lib/prisma", () => ({
prisma: {
user: {
findUnique: findUniqueMock,
updateMany: updateManyMock,
},
},
}))

import {
FREE_DEBATE_WINDOW_MS,
getFreeDebateStatus,
tryClaimFreeServerAccess,
} from "@/lib/free-debates"

describe("free debates", () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-07-15T12:00:00.000Z"))
findUniqueMock.mockReset()
updateManyMock.mockReset()
})

afterEach(() => {
vi.useRealTimers()
})

it("reports remaining and inactive when no window is open", async () => {
findUniqueMock.mockResolvedValue({
freeDebatesRemaining: 1,
freeDebateExpiresAt: null,
})

await expect(getFreeDebateStatus("u1")).resolves.toEqual({
remaining: 1,
active: false,
expiresAt: null,
})
})

it("reports active while the free window is open", async () => {
const expiresAt = new Date("2026-07-15T12:20:00.000Z")
findUniqueMock.mockResolvedValue({
freeDebatesRemaining: 0,
freeDebateExpiresAt: expiresAt,
})

await expect(getFreeDebateStatus("u1")).resolves.toEqual({
remaining: 0,
active: true,
expiresAt: expiresAt.toISOString(),
})
})

it("reuses an open free window without decrementing again", async () => {
findUniqueMock.mockResolvedValue({
freeDebatesRemaining: 0,
freeDebateExpiresAt: new Date("2026-07-15T12:20:00.000Z"),
})

await expect(tryClaimFreeServerAccess("u1")).resolves.toBe(true)
expect(updateManyMock).not.toHaveBeenCalled()
})

it("claims one remaining grant and opens a window", async () => {
findUniqueMock.mockResolvedValue({
freeDebatesRemaining: 1,
freeDebateExpiresAt: null,
})
updateManyMock.mockResolvedValue({ count: 1 })

await expect(tryClaimFreeServerAccess("u1")).resolves.toBe(true)
expect(updateManyMock).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: "u1",
freeDebatesRemaining: { gt: 0 },
}),
data: expect.objectContaining({
freeDebatesRemaining: { decrement: 1 },
freeDebateExpiresAt: new Date(Date.now() + FREE_DEBATE_WINDOW_MS),
}),
})
)
})

it("returns false when remaining is zero and the window is expired", async () => {
findUniqueMock
.mockResolvedValueOnce({
freeDebatesRemaining: 0,
freeDebateExpiresAt: new Date("2026-07-15T11:00:00.000Z"),
})
.mockResolvedValueOnce({
freeDebatesRemaining: 0,
freeDebateExpiresAt: new Date("2026-07-15T11:00:00.000Z"),
})
updateManyMock.mockResolvedValue({ count: 0 })

await expect(tryClaimFreeServerAccess("u1")).resolves.toBe(false)
})

it("wins a parallel claim race by reading the winner window", async () => {
findUniqueMock
.mockResolvedValueOnce({
freeDebatesRemaining: 1,
freeDebateExpiresAt: null,
})
.mockResolvedValueOnce({
freeDebatesRemaining: 0,
freeDebateExpiresAt: new Date("2026-07-15T12:30:00.000Z"),
})
updateManyMock.mockResolvedValue({ count: 0 })

await expect(tryClaimFreeServerAccess("u1")).resolves.toBe(true)
})
})
24 changes: 24 additions & 0 deletions src/app/api/free-debate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** GET free-debate grant status for the signed-in user. */
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { authEnabled } from "@/lib/deploy-config"
import { getFreeDebateStatus } from "@/lib/free-debates"

export async function GET() {
if (!authEnabled()) {
return NextResponse.json({ remaining: 0, active: false, expiresAt: null })
}

const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 })
}

try {
const status = await getFreeDebateStatus(session.user.id)
return NextResponse.json(status)
} catch (error) {
console.error("[free-debate] status failed:", error)
return NextResponse.json({ error: "status_failed" }, { status: 500 })
}
}
5 changes: 4 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,10 @@ function ChatPageContent() {
>
<KeyRound className="h-4 w-4 shrink-0 text-warning" />
<span className="min-w-0 flex-1">
{getApiKeyPromptMessage(apiKeyToastProvider, isFirstRunKeyless(isAnonymous), locale)}
{getApiKeyPromptMessage(apiKeyToastProvider, isFirstRunKeyless(isAnonymous), locale, {
signedIn: !isAnonymous,
authOn: authEnabled(),
})}
</span>
<button
type="button"
Expand Down
38 changes: 38 additions & 0 deletions src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ const translations = {
accessCode: "Access code",
accessCodeDesc:
"Have a code from the host? It unlocks this deployment's built-in keys - no API keys needed.",
freeDebate: "Free debate",
freeDebateAvailable: "1 free debate on host keys included with your account.",
freeDebateActive: "Free debate in progress - host keys unlocked for a bit.",
freeDebateUsed: "Free debate used. Paste your own API keys to keep going.",
},
ko: {
settings: "설정",
Expand Down Expand Up @@ -339,6 +343,10 @@ const translations = {
accessCode: "액세스 코드",
accessCodeDesc:
"호스트에게 받은 코드가 있나요? 이 배포에 내장된 키를 사용할 수 있습니다 - API 키가 필요 없습니다.",
freeDebate: "무료 토론",
freeDebateAvailable: "계정에 호스트 키로 무료 토론 1회가 포함되어 있어요.",
freeDebateActive: "무료 토론 진행 중 - 잠시 호스트 키를 쓸 수 있어요.",
freeDebateUsed: "무료 토론을 다 썼어요. API 키를 넣으면 계속할 수 있어요.",
},
}

Expand Down Expand Up @@ -382,6 +390,7 @@ export default function SettingsModal({
const [saved, setSaved] = useState(false)
const [accessCodeValue, setAccessCodeValue] = useState("")
const [accessCodeVisible, setAccessCodeVisible] = useState(false)
const [freeDebate, setFreeDebate] = useState<{ remaining: number; active: boolean } | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const t = translations[locale]

Expand All @@ -396,8 +405,27 @@ export default function SettingsModal({
setKeys(createEmptyKeys())
setTouchedKeys(createEmptyKeyStatus())
setAccessCodeValue(getAccessCode())
setFreeDebate(null)
}, [isOpen])

// Load free-debate grant when signed-in account tab is open.
useEffect(() => {
if (!isOpen || anonymous || !authEnabled()) return
let cancelled = false
fetch("/api/free-debate")
.then(async (res) => {
if (!res.ok) return null
return res.json() as Promise<{ remaining: number; active: boolean }>
})
.then((data) => {
if (!cancelled && data) setFreeDebate({ remaining: data.remaining, active: data.active })
})
.catch(() => {})
return () => {
cancelled = true
}
}, [isOpen, anonymous])

// Load which providers already have a saved key, re-running once auth settles.
useEffect(() => {
if (!isOpen) return
Expand Down Expand Up @@ -610,6 +638,16 @@ export default function SettingsModal({
<p className="text-[11px] text-muted-foreground leading-relaxed">
{anonymous ? t.keysDescAnon : t.keysDesc}
</p>
{!anonymous && freeDebate && (
<p className="text-[11px] text-muted-foreground leading-relaxed">
<span className="font-medium text-foreground/80">{t.freeDebate}: </span>
{freeDebate.active
? t.freeDebateActive
: freeDebate.remaining > 0
? t.freeDebateAvailable
: t.freeDebateUsed}
</p>
)}
<p className="text-[10px] text-muted-foreground/70 leading-relaxed">
{t.keysGeminiNote}
</p>
Expand Down
Loading
Loading