Skip to content

Commit 52b8c7f

Browse files
committed
Block freebuff waiting room for disallowed countries
1 parent 59f1aea commit 52b8c7f

8 files changed

Lines changed: 139 additions & 30 deletions

File tree

cli/src/app.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,8 @@ const AuthedSurface = ({
384384
IS_FREEBUFF &&
385385
(session === null ||
386386
session.status === 'queued' ||
387-
session.status === 'none')
387+
session.status === 'none' ||
388+
session.status === 'country_blocked')
388389
) {
389390
return <WaitingRoomScreen session={session} error={sessionError} />
390391
}

cli/src/components/waiting-room-screen.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,23 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
213213
{session?.status === 'disabled' && (
214214
<text style={{ fg: theme.muted }}>Waiting room disabled.</text>
215215
)}
216+
217+
{/* Country outside the free-mode allowlist. Terminal — polling has
218+
stopped. Tell the user up front rather than letting them wait in
219+
the queue only to be rejected at the chat/completions gate. */}
220+
{session?.status === 'country_blocked' && (
221+
<>
222+
<text style={{ fg: theme.secondary, marginBottom: 1 }}>
223+
⚠ Free mode isn't available in your region
224+
</text>
225+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
226+
We detected your location as{' '}
227+
<span fg={theme.foreground}>{session.countryCode}</span>,
228+
which is outside the countries where freebuff is currently
229+
offered. Press Ctrl+C to exit.
230+
</text>
231+
</>
232+
)}
216233
</box>
217234
</box>
218235

cli/src/hooks/use-freebuff-session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
8080
case 'none':
8181
case 'disabled':
8282
case 'superseded':
83+
case 'country_blocked':
8384
return null
8485
}
8586
}

common/src/types/freebuff-session.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,12 @@ export type FreebuffSessionServerResponse =
5959
* surfaces it as a 409 for fast in-flight feedback. */
6060
status: 'superseded'
6161
}
62+
| {
63+
/** Request originated from a country outside the free-mode allowlist.
64+
* Returned before queue admission so users don't wait through the
65+
* room only to be rejected on their first chat request. Terminal —
66+
* CLI stops polling and shows a "not available in your country"
67+
* screen. `countryCode` is the resolved country for display. */
68+
status: 'country_blocked'
69+
countryCode: string
70+
}

web/src/app/api/v1/chat/completions/_post.ts

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -68,40 +68,17 @@ import {
6868
OpenRouterError,
6969
} from '@/llm-api/openrouter'
7070
import { checkSessionAdmissible } from '@/server/free-session/public-api'
71+
import {
72+
FREE_MODE_ALLOWED_COUNTRIES,
73+
extractClientIp,
74+
getCountryCode,
75+
} from '@/server/free-mode-country'
7176

7277
import type { SessionGateResult } from '@/server/free-session/public-api'
7378
import { extractApiKeyFromHeader } from '@/util/auth'
7479
import { withDefaultProperties } from '@codebuff/common/analytics'
7580
import { checkFreeModeRateLimit } from './free-mode-rate-limiter'
7681

77-
const FREE_MODE_ALLOWED_COUNTRIES = new Set([
78-
'US', 'CA',
79-
'GB', 'AU', 'NZ',
80-
'NO', 'SE', 'NL', 'DK', 'DE', 'FI', 'BE', 'LU', 'CH', 'IE', 'IS',
81-
])
82-
83-
function extractClientIp(req: NextRequest): string | undefined {
84-
const forwardedFor = req.headers.get('x-forwarded-for')
85-
if (forwardedFor) {
86-
return forwardedFor.split(',')[0].trim()
87-
}
88-
return req.headers.get('x-real-ip') ?? undefined
89-
}
90-
91-
function getCountryCode(req: NextRequest): string | null {
92-
const cfCountry = req.headers.get('cf-ipcountry')
93-
if (cfCountry && cfCountry !== 'XX' && cfCountry !== 'T1') {
94-
return cfCountry.toUpperCase()
95-
}
96-
97-
const clientIp = extractClientIp(req)
98-
if (!clientIp) {
99-
return null
100-
}
101-
const geo = geoip.lookup(clientIp)
102-
return geo?.country ?? null
103-
}
104-
10582
export const formatQuotaResetCountdown = (
10683
nextQuotaReset: string | null | undefined,
10784
): string => {

web/src/app/api/v1/freebuff/session/__tests__/session.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ import type { NextRequest } from 'next/server'
1414

1515
function makeReq(
1616
apiKey: string | null,
17-
opts: { instanceId?: string } = {},
17+
opts: { instanceId?: string; cfCountry?: string } = {},
1818
): NextRequest {
1919
const headers = new Headers()
2020
if (apiKey) headers.set('Authorization', `Bearer ${apiKey}`)
2121
if (opts.instanceId) headers.set(FREEBUFF_INSTANCE_HEADER, opts.instanceId)
22+
if (opts.cfCountry) headers.set('cf-ipcountry', opts.cfCountry)
2223
return {
2324
headers,
2425
} as unknown as NextRequest
@@ -102,6 +103,29 @@ describe('POST /api/v1/freebuff/session', () => {
102103
const body = await resp.json()
103104
expect(body.status).toBe('disabled')
104105
})
106+
107+
test('returns country_blocked without joining the queue for disallowed country', async () => {
108+
const sessionDeps = makeSessionDeps()
109+
const resp = await postFreebuffSession(
110+
makeReq('ok', { cfCountry: 'FR' }),
111+
makeDeps(sessionDeps, 'u1'),
112+
)
113+
expect(resp.status).toBe(200)
114+
const body = await resp.json()
115+
expect(body.status).toBe('country_blocked')
116+
expect(body.countryCode).toBe('FR')
117+
expect(sessionDeps.rows.size).toBe(0)
118+
})
119+
120+
test('allows queue entry for allowed country', async () => {
121+
const sessionDeps = makeSessionDeps()
122+
const resp = await postFreebuffSession(
123+
makeReq('ok', { cfCountry: 'US' }),
124+
makeDeps(sessionDeps, 'u1'),
125+
)
126+
const body = await resp.json()
127+
expect(body.status).toBe('queued')
128+
})
105129
})
106130

107131
describe('GET /api/v1/freebuff/session', () => {
@@ -113,6 +137,18 @@ describe('GET /api/v1/freebuff/session', () => {
113137
expect(body.status).toBe('none')
114138
})
115139

140+
test('returns country_blocked for disallowed country on GET', async () => {
141+
const sessionDeps = makeSessionDeps()
142+
const resp = await getFreebuffSession(
143+
makeReq('ok', { cfCountry: 'FR' }),
144+
makeDeps(sessionDeps, 'u1'),
145+
)
146+
expect(resp.status).toBe(200)
147+
const body = await resp.json()
148+
expect(body.status).toBe('country_blocked')
149+
expect(body.countryCode).toBe('FR')
150+
})
151+
116152
test('returns superseded when active row exists with mismatched instance id', async () => {
117153
const sessionDeps = makeSessionDeps()
118154
sessionDeps.rows.set('u1', {

web/src/app/api/v1/freebuff/session/_handlers.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,32 @@ import {
55
getSessionState,
66
requestSession,
77
} from '@/server/free-session/public-api'
8+
import {
9+
FREE_MODE_ALLOWED_COUNTRIES,
10+
getCountryCode,
11+
} from '@/server/free-mode-country'
812
import { extractApiKeyFromHeader } from '@/util/auth'
913

1014
import type { SessionDeps } from '@/server/free-session/public-api'
1115
import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database'
1216
import type { Logger } from '@codebuff/common/types/contracts/logger'
1317
import type { NextRequest } from 'next/server'
1418

19+
/** Early country gate. Mirrors the chat/completions check: if we can resolve
20+
* the caller's country and it's not on the allowlist, short-circuit with a
21+
* terminal `country_blocked` response so the CLI can show the warning
22+
* screen without ever joining the queue. Null country (VPN / localhost)
23+
* fails open — chat/completions will catch it later if it matters. */
24+
function countryBlockedResponse(req: NextRequest): NextResponse | null {
25+
const countryCode = getCountryCode(req)
26+
if (!countryCode) return null
27+
if (FREE_MODE_ALLOWED_COUNTRIES.has(countryCode)) return null
28+
return NextResponse.json(
29+
{ status: 'country_blocked', countryCode },
30+
{ status: 200 },
31+
)
32+
}
33+
1534
/** Header the CLI uses to identify which instance is polling. Used by GET to
1635
* detect when another CLI on the same account has rotated the id. */
1736
export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id'
@@ -95,6 +114,9 @@ export async function postFreebuffSession(
95114
const auth = await resolveUser(req, deps)
96115
if ('error' in auth) return auth.error
97116

117+
const blocked = countryBlockedResponse(req)
118+
if (blocked) return blocked
119+
98120
try {
99121
const state = await requestSession({
100122
userId: auth.userId,
@@ -117,6 +139,9 @@ export async function getFreebuffSession(
117139
const auth = await resolveUser(req, deps)
118140
if ('error' in auth) return auth.error
119141

142+
const blocked = countryBlockedResponse(req)
143+
if (blocked) return blocked
144+
120145
try {
121146
const claimedInstanceId = req.headers.get(FREEBUFF_INSTANCE_HEADER) ?? undefined
122147
const state = await getSessionState({
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import geoip from 'geoip-lite'
2+
3+
import type { NextRequest } from 'next/server'
4+
5+
export const FREE_MODE_ALLOWED_COUNTRIES = new Set([
6+
'US', 'CA',
7+
'GB', 'AU', 'NZ',
8+
'NO', 'SE', 'NL', 'DK', 'DE', 'FI', 'BE', 'LU', 'CH', 'IE', 'IS',
9+
])
10+
11+
export function extractClientIp(req: NextRequest): string | undefined {
12+
const forwardedFor = req.headers.get('x-forwarded-for')
13+
if (forwardedFor) {
14+
return forwardedFor.split(',')[0].trim()
15+
}
16+
return req.headers.get('x-real-ip') ?? undefined
17+
}
18+
19+
export function getCountryCode(req: NextRequest): string | null {
20+
const cfCountry = req.headers.get('cf-ipcountry')
21+
if (cfCountry && cfCountry !== 'XX' && cfCountry !== 'T1') {
22+
return cfCountry.toUpperCase()
23+
}
24+
25+
const clientIp = extractClientIp(req)
26+
if (!clientIp) {
27+
return null
28+
}
29+
const geo = geoip.lookup(clientIp)
30+
return geo?.country ?? null
31+
}
32+
33+
/**
34+
* Returns true if the request's resolved country is allowed to use free
35+
* mode, false if it's explicitly disallowed. Returns null when country can't
36+
* be determined (VPN / localhost / corporate proxy) — callers should fail
37+
* open in that case to match the chat-completions gate.
38+
*/
39+
export function isCountryAllowedForFreeMode(req: NextRequest): boolean | null {
40+
const countryCode = getCountryCode(req)
41+
if (!countryCode) return null
42+
return FREE_MODE_ALLOWED_COUNTRIES.has(countryCode)
43+
}

0 commit comments

Comments
 (0)