Skip to content

Commit 83da0c2

Browse files
[codex] Put VPN and proxy Freebuff users in limited mode (#726)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent f9f63c8 commit 83da0c2

8 files changed

Lines changed: 42 additions & 46 deletions

File tree

docs/environment-variables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
- Server secrets: validated in `packages/internal/src/env-schema.ts` (used via `@codebuff/internal/env`).
77
- Runtime/OS env: pass typed snapshots instead of reading `process.env` throughout the codebase.
88
- `IPINFO_TOKEN` is required; free-mode country gating uses it to check IPinfo privacy signals for VPN/proxy/Tor/relay/hosting traffic.
9-
- `SPUR_TOKEN` is required; hard VPN/proxy/Tor/residential-proxy free-mode blocks require Spur Context API corroboration. In allowlisted countries, a successful clean Spur result overrides IPinfo privacy signals back to full access, while a Spur lookup failure falls back to limited access.
9+
- `SPUR_TOKEN` is required; VPN/proxy/Tor/residential-proxy privacy signals use Spur Context API corroboration. In allowlisted countries, a successful clean Spur result overrides IPinfo privacy signals back to full access, while suspicious or failed Spur lookups fall back to limited access. Cloudflare Tor country detection remains a hard block.
1010
- `CODEBUFF_FULL_TELEMETRY=true` or `CODEBUFF_FULL_TELEMETRY_IDS=user-id,email@example.com`
1111
disables client analytics sampling for targeted debugging. Use sparingly because it can send full CLI log payloads.
1212

docs/freebuff-waiting-room.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ All endpoints authenticate via the standard `Authorization: Bearer <api-key>` or
181181
- Existing active+unexpired row, **different model** → reject with `model_locked` (HTTP 409); `active_instance_id` is **not** rotated so the other CLI stays valid. Client must DELETE the session before switching.
182182
- Existing active+expired row → reset to queued with fresh `queued_at` and the requested `model` (re-queue at back).
183183

184-
Before any of those state transitions, the handler requires a resolved country and successful IPinfo/Spur privacy checks. Unsupported countries enter limited Freebuff access. In allowlisted countries, IPinfo privacy signals still receive full access when Spur returns clean context, fall back to limited access when Spur lookup fails, and hard-block only when Spur corroborates VPN/proxy/Tor/residential-proxy traffic. IPinfo lookup failures fail closed into limited access.
184+
Before any of those state transitions, the handler requires a resolved country and IPinfo/Spur privacy classification. Unsupported countries enter limited Freebuff access. In allowlisted countries, IPinfo privacy signals still receive full access when Spur returns clean context, and fall back to limited access when Spur reports suspicious context or lookup fails. IPinfo lookup failures fail closed into limited access. Cloudflare Tor country detection remains a hard block.
185185

186186
Response shapes:
187187

web/src/app/api/v1/chat/completions/__tests__/completions.test.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
628628
)
629629

630630
it(
631-
'blocks hard VPN/proxy privacy signals before the session gate',
631+
'puts VPN/proxy privacy signals in limited mode before the session gate',
632632
async () => {
633633
const req = new NextRequest(
634634
'http://localhost:3000/api/v1/chat/completions',
@@ -649,6 +649,10 @@ describe('/api/v1/chat/completions POST endpoint', () => {
649649
)
650650

651651
const endFreebuffSession = mock(async () => {})
652+
const checkSessionAdmissible = mock(async (params) => {
653+
expect(params.accessTier).toBe('limited')
654+
return { ok: true, reason: 'active', remainingMs: 60_000 } as const
655+
})
652656
const response = await postChatCompletionsForTest({
653657
req,
654658
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
@@ -659,9 +663,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
659663
fetch: mockFetch,
660664
insertMessageBigquery: mockInsertMessageBigquery,
661665
loggerWithContext: mockLoggerWithContext,
662-
checkSessionAdmissible: mock(() => {
663-
throw new Error('session gate should not be reached')
664-
}),
666+
checkSessionAdmissible,
665667
endFreebuffSession,
666668
resolveFreeModeCountryAccess: async () => ({
667669
allowed: false,
@@ -676,39 +678,29 @@ describe('/api/v1/chat/completions POST endpoint', () => {
676678
clientIpHash: 'test-ip-hash',
677679
}),
678680
})
679-
expect(endFreebuffSession).toHaveBeenCalledWith({
680-
userId: 'user-new-free',
681-
userEmail: null,
682-
})
683681

684-
expect(response.status).toBe(403)
685-
const body = await response.json()
686-
expect(body).toMatchObject({
687-
error: 'free_mode_unavailable',
688-
countryCode: 'US',
689-
countryBlockReason: 'anonymous_network',
690-
ipPrivacySignals: ['vpn', 'hosting'],
691-
})
692-
expect(body.message).toContain('VPN')
682+
expect(response.status).toBe(200)
683+
expect(endFreebuffSession).not.toHaveBeenCalled()
684+
expect(checkSessionAdmissible).toHaveBeenCalledTimes(1)
693685
const validationEvent = (
694686
mockTrackEvent as ReturnType<typeof mock>
695687
).mock.calls
696688
.map(([params]) => params as Parameters<TrackEventFn>[0])
697689
.find(
698690
({ event, properties }) =>
699691
event === AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR &&
700-
properties?.error === 'free_mode_unavailable',
692+
properties?.error === 'free_mode_not_available_in_country',
701693
)
702694
expect(validationEvent?.properties).toMatchObject({
703-
accessStatus: 'blocked',
695+
accessTier: 'limited',
696+
accessStatus: 'limited',
704697
countryCode: 'US',
705698
ipPrivacySignals: ['vpn', 'hosting'],
706699
spurStatus: 'suspicious',
707700
privacyDecision: 'corroborated_block',
708701
privacyProviderDecision: 'corroborated_hard',
709-
privacyHardBlocked: true,
702+
privacyHardBlocked: false,
710703
})
711-
expect(validationEvent?.properties).not.toHaveProperty('accessTier')
712704
},
713705
FETCH_PATH_TEST_TIMEOUT_MS,
714706
)

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,9 +337,10 @@ export async function postChatCompletions(params: {
337337
)
338338
}
339339

340-
// For free mode requests, classify the request into full, limited, or
341-
// hard-blocked access. Most non-allowlist/privacy cases are limited to the
342-
// cheap DeepSeek Flash path, but VPN/proxy/Tor traffic is rejected outright.
340+
// For free mode requests, classify the request into full or limited
341+
// access. Most non-allowlist/privacy cases, including VPN/proxy traffic,
342+
// are limited to the cheap DeepSeek Flash path; Cloudflare Tor remains a
343+
// hard block.
343344
if (isFreeModeRequest) {
344345
const countryAccess = await resolveCountryAccess(userId, req, {
345346
fetch,

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

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ describe('POST /api/v1/freebuff/session', () => {
299299
expect(body.status).toBe('queued')
300300
})
301301

302-
test('blocks VPN/proxy privacy signals before joining the queue', async () => {
302+
test('puts VPN/proxy privacy signals in limited mode before joining the queue', async () => {
303303
const sessionDeps = makeSessionDeps()
304304
sessionDeps.rows.set('u1', {
305305
user_id: 'u1',
@@ -329,13 +329,14 @@ describe('POST /api/v1/freebuff/session', () => {
329329
}),
330330
}),
331331
)
332-
expect(resp.status).toBe(403)
332+
expect(resp.status).toBe(200)
333333
const body = await resp.json()
334-
expect(body.status).toBe('country_blocked')
335-
expect(body.message).toContain('VPN')
334+
expect(body.status).toBe('queued')
335+
expect(body.accessTier).toBe('limited')
336+
expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID)
336337
expect(body.countryBlockReason).toBe('anonymous_network')
337338
expect(body.ipPrivacySignals).toEqual(['vpn', 'hosting'])
338-
expect(sessionDeps.rows.size).toBe(0)
339+
expect(sessionDeps.rows.size).toBe(1)
339340
})
340341

341342
test('blocks Cloudflare Tor before joining the queue', async () => {
@@ -464,7 +465,7 @@ describe('GET /api/v1/freebuff/session', () => {
464465
expect(body.ipPrivacySignals).toBeUndefined()
465466
})
466467

467-
test('returns country_blocked on GET for VPN/proxy privacy signals', async () => {
468+
test('returns limited mode on GET for VPN/proxy privacy signals', async () => {
468469
const sessionDeps = makeSessionDeps()
469470
sessionDeps.rows.set('u1', {
470471
user_id: 'u1',
@@ -494,10 +495,10 @@ describe('GET /api/v1/freebuff/session', () => {
494495
}),
495496
}),
496497
)
497-
expect(resp.status).toBe(403)
498+
expect(resp.status).toBe(200)
498499
const body = await resp.json()
499-
expect(body.status).toBe('country_blocked')
500-
expect(body.message).toContain('proxy')
500+
expect(body.status).toBe('none')
501+
expect(body.accessTier).toBe('limited')
501502
expect(body.countryBlockReason).toBe('anonymous_network')
502503
expect(body.ipPrivacySignals).toEqual(['res_proxy'])
503504
expect(sessionDeps.rows.size).toBe(0)

web/src/server/__tests__/free-mode-country-access-cache.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ describe('free mode country access cache', () => {
115115
expect(fetch).toHaveBeenCalledTimes(1)
116116
})
117117

118-
test('does not persist corroborated hard privacy blocks', async () => {
118+
test('stores corroborated VPN/proxy limited decisions', async () => {
119119
const cacheStore: FreeModeCountryAccessCacheStore = {
120120
get: mock(async () => null),
121121
set: mock(async () => {}),
@@ -141,7 +141,14 @@ describe('free mode country access cache', () => {
141141
expect(access.allowed).toBe(false)
142142
expect(access.spurIpPrivacy?.signals).toEqual(['vpn'])
143143
expect(access.spurStatus).toBe('suspicious')
144-
expect(cacheStore.set).not.toHaveBeenCalled()
144+
expect(cacheStore.set).toHaveBeenCalledWith({
145+
userId,
146+
access,
147+
now,
148+
})
149+
expect(expiresAtForCountryAccess(access, now).getTime() - now.getTime()).toBe(
150+
FREE_MODE_COUNTRY_CACHE_ANONYMOUS_NETWORK_TTL_MS,
151+
)
145152
})
146153

147154
test('stores transient limited decisions when Spur fails after hard IPinfo signals', async () => {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ describe('free mode country access', () => {
222222
expect(shouldHardBlockFreeModeAccess(access)).toBe(false)
223223
})
224224

225-
test('hard-blocks only VPN, proxy, Tor, or residential proxy signals', async () => {
225+
test('keeps corroborated VPN/proxy privacy signals in limited mode', async () => {
226226
const vpnAccess = await getFreeModeCountryAccess(
227227
makeReq({
228228
'cf-ipcountry': 'US',
@@ -241,7 +241,7 @@ describe('free mode country access', () => {
241241
)
242242
expect(vpnAccess.allowed).toBe(false)
243243
expect(vpnAccess.spurStatus).toBe('suspicious')
244-
expect(shouldHardBlockFreeModeAccess(vpnAccess)).toBe(true)
244+
expect(shouldHardBlockFreeModeAccess(vpnAccess)).toBe(false)
245245
expect(getFreeModePrivacyDecision(vpnAccess)).toBe('corroborated_block')
246246
expect(getFreeModePrivacyProviderDecision(vpnAccess)).toBe(
247247
'corroborated_hard',

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,10 @@ export function hasHardBlockedPrivacySignal(
141141
export function shouldHardBlockFreeModeAccess(
142142
countryAccess: Pick<
143143
FreeModeCountryAccess,
144-
'blockReason' | 'cfCountry' | 'ipPrivacy' | 'spurIpPrivacy'
144+
'cfCountry'
145145
>,
146146
): boolean {
147-
return (
148-
countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY ||
149-
(countryAccess.blockReason === 'anonymous_network' &&
150-
hasHardBlockedPrivacySignal(countryAccess.ipPrivacy) &&
151-
hasHardBlockedPrivacySignal(countryAccess.spurIpPrivacy))
152-
)
147+
return countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY
153148
}
154149

155150
export function getFreeModePrivacyDecision(

0 commit comments

Comments
 (0)