Skip to content

Commit 8233a78

Browse files
committed
Address Freebuff gating review comments
1 parent 292a670 commit 8233a78

3 files changed

Lines changed: 102 additions & 40 deletions

File tree

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,15 @@ const LOGGER = {
163163
function makeDeps(
164164
sessionDeps: SessionDeps,
165165
userId: string | null,
166-
opts: { banned?: boolean } = {},
166+
opts: {
167+
banned?: boolean
168+
getCountryAccess?: FreebuffSessionDeps['getCountryAccess']
169+
} = {},
167170
): FreebuffSessionDeps {
168171
return {
169172
logger: LOGGER as unknown as FreebuffSessionDeps['logger'],
170-
getCountryAccess: async (req) => testCountryAccess(req),
173+
getCountryAccess:
174+
opts.getCountryAccess ?? (async (req) => testCountryAccess(req)),
171175
getUserInfoFromApiKey: (async () =>
172176
userId
173177
? { id: userId, banned: opts.banned ?? false }
@@ -332,6 +336,42 @@ describe('GET /api/v1/freebuff/session', () => {
332336
expect(body.countryBlockReason).toBe('country_not_allowed')
333337
})
334338

339+
test('skips country recheck on GET when the stored check is recent', async () => {
340+
const sessionDeps = makeSessionDeps()
341+
sessionDeps.rows.set('u1', {
342+
user_id: 'u1',
343+
status: 'queued',
344+
active_instance_id: 'inst-1',
345+
model: DEFAULT_MODEL,
346+
country_code: 'US',
347+
cf_country: 'US',
348+
geoip_country: null,
349+
country_block_reason: null,
350+
ip_privacy_signals: [],
351+
client_ip_hash: 'test-ip-hash',
352+
country_checked_at: new Date('2026-04-17T11:45:00Z'),
353+
queued_at: new Date('2026-04-17T11:45:00Z'),
354+
admitted_at: null,
355+
expires_at: null,
356+
created_at: new Date('2026-04-17T11:45:00Z'),
357+
updated_at: new Date('2026-04-17T11:45:00Z'),
358+
})
359+
let countryChecks = 0
360+
const resp = await getFreebuffSession(
361+
makeReq('ok', { cfCountry: 'FR' }),
362+
makeDeps(sessionDeps, 'u1', {
363+
getCountryAccess: async (req) => {
364+
countryChecks++
365+
return testCountryAccess(req)
366+
},
367+
}),
368+
)
369+
const body = await resp.json()
370+
expect(resp.status).toBe(200)
371+
expect(body.status).toBe('queued')
372+
expect(countryChecks).toBe(0)
373+
})
374+
335375
test('returns banned 403 on GET for banned user', async () => {
336376
const sessionDeps = makeSessionDeps()
337377
const resp = await getFreebuffSession(

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

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ import {
66
getSessionState,
77
requestSession,
88
} from '@/server/free-session/public-api'
9-
import { getFreeModeCountryAccess } from '@/server/free-mode-country'
9+
import { getSessionRow as getStoredSessionRow } from '@/server/free-session/store'
10+
import {
11+
FREE_MODE_ALLOWED_COUNTRIES,
12+
getFreeModeCountryAccess,
13+
IPINFO_PRIVACY_CACHE_TTL_MS,
14+
} from '@/server/free-mode-country'
1015
import { extractApiKeyFromHeader } from '@/util/auth'
1116

1217
import type { FreeModeCountryAccess } from '@/server/free-mode-country'
13-
import type { FreeSessionCountryAccessMetadata } from '@/server/free-session/types'
18+
import type {
19+
FreeSessionCountryAccessMetadata,
20+
InternalSessionRow,
21+
} from '@/server/free-session/types'
1422
import type { SessionDeps } from '@/server/free-session/public-api'
1523
import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database'
1624
import type { Logger } from '@codebuff/common/types/contracts/logger'
@@ -57,10 +65,10 @@ function toSessionCountryAccess(
5765
async function countryBlockedResponse(
5866
req: NextRequest,
5967
deps: FreebuffSessionDeps,
60-
): Promise<
61-
| { response: NextResponse; countryAccess: FreeModeCountryAccess }
62-
| { response: null; countryAccess: FreeModeCountryAccess }
63-
> {
68+
): Promise<{
69+
response: NextResponse | null
70+
countryAccess: FreeModeCountryAccess
71+
}> {
6472
const countryAccess = await getCountryAccess(req, deps)
6573
if (countryAccess.allowed) {
6674
return { response: null, countryAccess }
@@ -79,6 +87,32 @@ async function countryBlockedResponse(
7987
}
8088
}
8189

90+
function hasRecentAllowedCountryCheck(
91+
row: InternalSessionRow | null,
92+
now: Date,
93+
): boolean {
94+
if (!row?.country_checked_at || row.country_block_reason !== null) {
95+
return false
96+
}
97+
if (!row.country_code || !FREE_MODE_ALLOWED_COUNTRIES.has(row.country_code)) {
98+
return false
99+
}
100+
return (
101+
now.getTime() - row.country_checked_at.getTime() <
102+
IPINFO_PRIVACY_CACHE_TTL_MS
103+
)
104+
}
105+
106+
async function shouldSkipGetCountryCheck(
107+
userId: string,
108+
deps: FreebuffSessionDeps,
109+
): Promise<boolean> {
110+
const getSessionRow = deps.sessionDeps?.getSessionRow ?? getStoredSessionRow
111+
const row = await getSessionRow(userId)
112+
const now = deps.sessionDeps?.now?.() ?? new Date()
113+
return hasRecentAllowedCountryCheck(row, now)
114+
}
115+
82116
/** Header the CLI uses to identify which instance is polling. Used by GET to
83117
* detect when another CLI on the same account has rotated the id. */
84118
export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id'
@@ -220,10 +254,12 @@ export async function getFreebuffSession(
220254
const auth = await resolveUser(req, deps)
221255
if ('error' in auth) return auth.error
222256

223-
const { response: blocked } = await countryBlockedResponse(req, deps)
224-
if (blocked) return blocked
225-
226257
try {
258+
if (!(await shouldSkipGetCountryCheck(auth.userId, deps))) {
259+
const { response: blocked } = await countryBlockedResponse(req, deps)
260+
if (blocked) return blocked
261+
}
262+
227263
const claimedInstanceId =
228264
req.headers.get(FREEBUFF_INSTANCE_HEADER) ?? undefined
229265
const state = await getSessionState({

web/src/server/free-mode-country.ts

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ type ResolvedCountryAccess = Omit<
6565
countryCode: string
6666
}
6767

68-
const IPINFO_PRIVACY_CACHE_TTL_MS = 30 * 60 * 1000
68+
export const IPINFO_PRIVACY_CACHE_TTL_MS = 30 * 60 * 1000
6969
const IPINFO_PRIVACY_CACHE_MAX_ENTRIES = 5000
7070
const ipinfoPrivacyCache = new Map<
7171
string,
@@ -109,21 +109,14 @@ function setIpinfoPrivacyCache(
109109
ip: string,
110110
privacy: FreeModeIpPrivacy | null,
111111
): void {
112-
const now = Date.now()
113-
for (const [cachedIp, cached] of ipinfoPrivacyCache) {
114-
if (cached.expiresAt <= now) {
115-
ipinfoPrivacyCache.delete(cachedIp)
116-
}
117-
}
118-
119112
while (ipinfoPrivacyCache.size >= IPINFO_PRIVACY_CACHE_MAX_ENTRIES) {
120113
const oldestIp = ipinfoPrivacyCache.keys().next().value
121114
if (!oldestIp) break
122115
ipinfoPrivacyCache.delete(oldestIp)
123116
}
124117

125118
ipinfoPrivacyCache.set(ip, {
126-
expiresAt: now + IPINFO_PRIVACY_CACHE_TTL_MS,
119+
expiresAt: Date.now() + IPINFO_PRIVACY_CACHE_TTL_MS,
127120
privacy,
128121
})
129122
}
@@ -182,25 +175,6 @@ export async function lookupIpinfoPrivacy(params: {
182175
return privacy
183176
}
184177

185-
async function getIpPrivacy(
186-
clientIp: string | undefined,
187-
options: FreeModeCountryAccessOptions,
188-
): Promise<FreeModeIpPrivacy | null> {
189-
if (!clientIp) return null
190-
try {
191-
if (options.lookupIpPrivacy) {
192-
return await options.lookupIpPrivacy(clientIp)
193-
}
194-
return await lookupIpinfoPrivacy({
195-
ip: clientIp,
196-
token: options.ipinfoToken,
197-
fetch: options.fetch ?? globalThis.fetch,
198-
})
199-
} catch {
200-
return null
201-
}
202-
}
203-
204178
export async function getFreeModeCountryAccess(
205179
req: NextRequest,
206180
options: FreeModeCountryAccessOptions,
@@ -290,7 +264,19 @@ export async function getFreeModeCountryAccess(
290264
}
291265
}
292266

293-
const ipPrivacy = await getIpPrivacy(clientIp, options)
267+
let ipPrivacy: FreeModeIpPrivacy | null
268+
try {
269+
ipPrivacy = options.lookupIpPrivacy
270+
? await options.lookupIpPrivacy(clientIp)
271+
: await lookupIpinfoPrivacy({
272+
ip: clientIp,
273+
token: options.ipinfoToken,
274+
fetch: options.fetch ?? globalThis.fetch,
275+
})
276+
} catch {
277+
ipPrivacy = null
278+
}
279+
294280
if (!ipPrivacy) {
295281
return {
296282
...baseAccess,

0 commit comments

Comments
 (0)