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
1 change: 1 addition & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Public client env: `NEXT_PUBLIC_*` only, validated in `common/src/env-schema.ts` (used via `@codebuff/common/env`).
- Server secrets: validated in `packages/internal/src/env-schema.ts` (used via `@codebuff/internal/env`).
- Runtime/OS env: pass typed snapshots instead of reading `process.env` throughout the codebase.
- `IPINFO_TOKEN` is required; free-mode country gating uses it to check IPinfo privacy signals for VPN/proxy/Tor/relay/hosting traffic.

## Env DI Helpers

Expand Down
23 changes: 18 additions & 5 deletions packages/internal/src/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const serverEnvSchema = clientEnvSchema.extend({
LINKUP_API_KEY: z.string().min(1),
CONTEXT7_API_KEY: z.string().optional(),
GRAVITY_API_KEY: z.string().min(1),
IPINFO_TOKEN: z.string().min(1),
// BuySellAds (Carbon) zone key used for the Freebuff waiting-room ad.
// Optional: when unset the Carbon provider returns no ad and callers fall
// back to their cached ads / fallback content. `CVADC53U` is the public
Expand Down Expand Up @@ -58,8 +59,16 @@ export const serverEnvSchema = clientEnvSchema.extend({
.enum(['true', 'false'])
.default('false')
.transform((v) => v === 'true'),
FREEBUFF_SESSION_LENGTH_MS: z.coerce.number().int().positive().default(60 * 60 * 1000),
FREEBUFF_SESSION_GRACE_MS: z.coerce.number().int().nonnegative().default(30 * 60 * 1000),
FREEBUFF_SESSION_LENGTH_MS: z.coerce
.number()
.int()
.positive()
.default(60 * 60 * 1000),
FREEBUFF_SESSION_GRACE_MS: z.coerce
.number()
.int()
.nonnegative()
.default(30 * 60 * 1000),
})
export const serverEnvVars = serverEnvSchema.keyof().options
export type ServerEnvVar = (typeof serverEnvVars)[number]
Expand Down Expand Up @@ -87,6 +96,7 @@ export const serverProcessEnv: ServerInput = {
LINKUP_API_KEY: process.env.LINKUP_API_KEY,
CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY,
GRAVITY_API_KEY: process.env.GRAVITY_API_KEY,
IPINFO_TOKEN: process.env.IPINFO_TOKEN,
CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY,
PORT: process.env.PORT,

Expand All @@ -101,9 +111,12 @@ export const serverProcessEnv: ServerInput = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET_KEY: process.env.STRIPE_WEBHOOK_SECRET_KEY,
STRIPE_TEAM_FEE_PRICE_ID: process.env.STRIPE_TEAM_FEE_PRICE_ID,
STRIPE_SUBSCRIPTION_100_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_100_PRICE_ID,
STRIPE_SUBSCRIPTION_200_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_200_PRICE_ID,
STRIPE_SUBSCRIPTION_500_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_500_PRICE_ID,
STRIPE_SUBSCRIPTION_100_PRICE_ID:
process.env.STRIPE_SUBSCRIPTION_100_PRICE_ID,
STRIPE_SUBSCRIPTION_200_PRICE_ID:
process.env.STRIPE_SUBSCRIPTION_200_PRICE_ID,
STRIPE_SUBSCRIPTION_500_PRICE_ID:
process.env.STRIPE_SUBSCRIPTION_500_PRICE_ID,
LOOPS_API_KEY: process.env.LOOPS_API_KEY,
DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY,
DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN,
Expand Down
9 changes: 8 additions & 1 deletion packages/internal/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@ import { serverEnvSchema, serverProcessEnv } from './env-schema'
// Only provide safe defaults in CI to avoid schema failures during tests
// In local dev, missing env vars should fail fast so devs know to configure them
const isCI = process.env.CI === 'true' || process.env.CI === '1'
const envInput = { ...serverProcessEnv }

if (isCI) {
const ensureEnvDefault = (key: string, value: string) => {
if (!process.env[key]) {
process.env[key] = value
}
envInput[key as keyof typeof envInput] = process.env[key]
}

ensureEnvDefault('OPEN_ROUTER_API_KEY', 'test')
ensureEnvDefault('OPENAI_API_KEY', 'test')
ensureEnvDefault('ANTHROPIC_API_KEY', 'test')
ensureEnvDefault('FIREWORKS_API_KEY', 'test')
ensureEnvDefault('LINKUP_API_KEY', 'test')
ensureEnvDefault('GRAVITY_API_KEY', 'test')
ensureEnvDefault('IPINFO_TOKEN', 'test')
ensureEnvDefault('PORT', '4242')
ensureEnvDefault('DATABASE_URL', 'postgres://user:pass@localhost:5432/db')
ensureEnvDefault('CODEBUFF_GITHUB_ID', 'test-id')
Expand All @@ -26,6 +30,9 @@ if (isCI) {
ensureEnvDefault('STRIPE_SECRET_KEY', 'sk_test_dummy')
ensureEnvDefault('STRIPE_WEBHOOK_SECRET_KEY', 'whsec_dummy')
ensureEnvDefault('STRIPE_TEAM_FEE_PRICE_ID', 'price_test')
ensureEnvDefault('STRIPE_SUBSCRIPTION_100_PRICE_ID', 'price_test_100')
ensureEnvDefault('STRIPE_SUBSCRIPTION_200_PRICE_ID', 'price_test_200')
ensureEnvDefault('STRIPE_SUBSCRIPTION_500_PRICE_ID', 'price_test_500')
ensureEnvDefault('LOOPS_API_KEY', 'test')
ensureEnvDefault('DISCORD_PUBLIC_KEY', 'test')
ensureEnvDefault('DISCORD_BOT_TOKEN', 'test')
Expand All @@ -46,4 +53,4 @@ if (process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod') {
}
}

export const env = serverEnvSchema.parse(serverProcessEnv)
export const env = serverEnvSchema.parse(envInput)
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
const allowedFreeModeHeaders = (apiKey: string) => ({
Authorization: `Bearer ${apiKey}`,
'cf-ipcountry': 'US',
'cf-connecting-ip': '203.0.113.10',
})

beforeEach(() => {
Expand Down
7 changes: 6 additions & 1 deletion web/src/app/api/v1/chat/completions/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,14 +256,18 @@ export async function postChatCompletions(params: {

// For free mode requests, require a resolved allowlisted country.
if (isFreeModeRequest) {
const countryAccess = getFreeModeCountryAccess(req)
const countryAccess = await getFreeModeCountryAccess(req, {
fetch,
ipinfoToken: env.IPINFO_TOKEN,
})

logger.info(
{
cfHeader: countryAccess.cfCountry,
geoipResult: countryAccess.geoipCountry,
resolvedCountry: countryAccess.countryCode,
countryBlockReason: countryAccess.blockReason,
ipPrivacySignals: countryAccess.ipPrivacy?.signals,
clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined,
},
'Free mode country detection',
Expand All @@ -277,6 +281,7 @@ export async function postChatCompletions(params: {
error: 'free_mode_not_available_in_country',
countryCode: countryAccess.countryCode,
countryBlockReason: countryAccess.blockReason,
ipPrivacySignals: countryAccess.ipPrivacy?.signals,
clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined,
},
logger,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ function makeReq(
if (apiKey) headers.set('Authorization', `Bearer ${apiKey}`)
if (opts.instanceId) headers.set(FREEBUFF_INSTANCE_HEADER, opts.instanceId)
const cfCountry = opts.cfCountry === null ? null : (opts.cfCountry ?? 'US')
if (cfCountry) headers.set('cf-ipcountry', cfCountry)
if (cfCountry) {
headers.set('cf-ipcountry', cfCountry)
headers.set('cf-connecting-ip', '203.0.113.10')
}
if (opts.model) headers.set(FREEBUFF_MODEL_HEADER, opts.model)
return {
headers,
Expand Down
13 changes: 9 additions & 4 deletions web/src/app/api/v1/freebuff/session/_handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { env } from '@codebuff/internal/env'

import {
endUserSession,
Expand All @@ -22,8 +23,12 @@ import type { NextRequest } from 'next/server'
* `country_blocked` status and would tight-poll on an unrecognized 200
* body — fall into their existing `!resp.ok` error path and back off on
* the 10s error retry cadence. The new CLI parses the 403 body directly. */
function countryBlockedResponse(req: NextRequest): NextResponse | null {
const countryAccess = getFreeModeCountryAccess(req)
async function countryBlockedResponse(
req: NextRequest,
): Promise<NextResponse | null> {
const countryAccess = await getFreeModeCountryAccess(req, {
ipinfoToken: env.IPINFO_TOKEN,
})
if (countryAccess.allowed) return null
return NextResponse.json(
{
Expand Down Expand Up @@ -126,7 +131,7 @@ export async function postFreebuffSession(
const auth = await resolveUser(req, deps)
if ('error' in auth) return auth.error

const blocked = countryBlockedResponse(req)
const blocked = await countryBlockedResponse(req)
if (blocked) return blocked

const requestedModel = req.headers.get(FREEBUFF_MODEL_HEADER) ?? ''
Expand Down Expand Up @@ -170,7 +175,7 @@ export async function getFreebuffSession(
const auth = await resolveUser(req, deps)
if ('error' in auth) return auth.error

const blocked = countryBlockedResponse(req)
const blocked = await countryBlockedResponse(req)
if (blocked) return blocked

try {
Expand Down
9 changes: 7 additions & 2 deletions web/src/app/api/v1/freebuff/session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ import { logger } from '@/util/logger'

import type { NextRequest } from 'next/server'

const freebuffSessionDeps = {
getUserInfoFromApiKey,
logger,
}

export async function GET(req: NextRequest) {
return getFreebuffSession(req, { getUserInfoFromApiKey, logger })
return getFreebuffSession(req, freebuffSessionDeps)
}

export async function POST(req: NextRequest) {
return postFreebuffSession(req, { getUserInfoFromApiKey, logger })
return postFreebuffSession(req, freebuffSessionDeps)
}

export async function DELETE(req: NextRequest) {
Expand Down
Loading
Loading