Skip to content

Commit cc67463

Browse files
authored
Block freebuff waiting room for disallowed countries (#522)
1 parent 59f1aea commit cc67463

File tree

8 files changed

+160
-30
lines changed

8 files changed

+160
-30
lines changed

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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ async function callSession(
5050
if (resp.status === 404) {
5151
return { status: 'disabled' }
5252
}
53+
// 403 with a country_blocked body is a terminal signal, not an error — the
54+
// server rejects non-allowlist countries up front (see session _handlers.ts)
55+
// so users don't wait through the queue only to be rejected at chat time.
56+
// The 403 status (rather than 200) is deliberate: older CLIs that don't
57+
// know this status treat it as a generic error and back off on the 10s
58+
// error-retry cadence instead of tight-polling an unrecognized 200 body.
59+
if (resp.status === 403) {
60+
const body = (await resp.json().catch(() => null)) as
61+
| FreebuffSessionResponse
62+
| null
63+
if (body && body.status === 'country_blocked') {
64+
return body
65+
}
66+
}
5367
if (!resp.ok) {
5468
const text = await resp.text().catch(() => '')
5569
throw new Error(
@@ -80,6 +94,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
8094
case 'none':
8195
case 'disabled':
8296
case 'superseded':
97+
case 'country_blocked':
8398
return null
8499
}
85100
}

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: 39 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,31 @@ 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+
// 403 (not 200) so older CLIs that don't know `country_blocked` fall into
114+
// their error-retry backoff instead of tight-polling.
115+
expect(resp.status).toBe(403)
116+
const body = await resp.json()
117+
expect(body.status).toBe('country_blocked')
118+
expect(body.countryCode).toBe('FR')
119+
expect(sessionDeps.rows.size).toBe(0)
120+
})
121+
122+
test('allows queue entry for allowed country', async () => {
123+
const sessionDeps = makeSessionDeps()
124+
const resp = await postFreebuffSession(
125+
makeReq('ok', { cfCountry: 'US' }),
126+
makeDeps(sessionDeps, 'u1'),
127+
)
128+
const body = await resp.json()
129+
expect(body.status).toBe('queued')
130+
})
105131
})
106132

107133
describe('GET /api/v1/freebuff/session', () => {
@@ -113,6 +139,18 @@ describe('GET /api/v1/freebuff/session', () => {
113139
expect(body.status).toBe('none')
114140
})
115141

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

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,37 @@ 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+
*
25+
* Returns HTTP 403 (not 200) so older CLIs — which don't know the
26+
* `country_blocked` status and would tight-poll on an unrecognized 200
27+
* body — fall into their existing `!resp.ok` error path and back off on
28+
* the 10s error retry cadence. The new CLI parses the 403 body directly. */
29+
function countryBlockedResponse(req: NextRequest): NextResponse | null {
30+
const countryCode = getCountryCode(req)
31+
if (!countryCode) return null
32+
if (FREE_MODE_ALLOWED_COUNTRIES.has(countryCode)) return null
33+
return NextResponse.json(
34+
{ status: 'country_blocked', countryCode },
35+
{ status: 403 },
36+
)
37+
}
38+
1539
/** Header the CLI uses to identify which instance is polling. Used by GET to
1640
* detect when another CLI on the same account has rotated the id. */
1741
export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id'
@@ -95,6 +119,9 @@ export async function postFreebuffSession(
95119
const auth = await resolveUser(req, deps)
96120
if ('error' in auth) return auth.error
97121

122+
const blocked = countryBlockedResponse(req)
123+
if (blocked) return blocked
124+
98125
try {
99126
const state = await requestSession({
100127
userId: auth.userId,
@@ -117,6 +144,9 @@ export async function getFreebuffSession(
117144
const auth = await resolveUser(req, deps)
118145
if ('error' in auth) return auth.error
119146

147+
const blocked = countryBlockedResponse(req)
148+
if (blocked) return blocked
149+
120150
try {
121151
const claimedInstanceId = req.headers.get(FREEBUFF_INSTANCE_HEADER) ?? undefined
122152
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)