Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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