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
2 changes: 1 addition & 1 deletion docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
- 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.
- `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.
- `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.
- `CODEBUFF_FULL_TELEMETRY=true` or `CODEBUFF_FULL_TELEMETRY_IDS=user-id,email@example.com`
disables client analytics sampling for targeted debugging. Use sparingly because it can send full CLI log payloads.

Expand Down
2 changes: 1 addition & 1 deletion docs/freebuff-waiting-room.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ All endpoints authenticate via the standard `Authorization: Bearer <api-key>` or
- 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.
- Existing active+expired row → reset to queued with fresh `queued_at` and the requested `model` (re-queue at back).

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.
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.

Response shapes:

Expand Down
34 changes: 13 additions & 21 deletions web/src/app/api/v1/chat/completions/__tests__/completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
)

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

const endFreebuffSession = mock(async () => {})
const checkSessionAdmissible = mock(async (params) => {
expect(params.accessTier).toBe('limited')
return { ok: true, reason: 'active', remainingMs: 60_000 } as const
})
const response = await postChatCompletionsForTest({
req,
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
Expand All @@ -659,9 +663,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
fetch: mockFetch,
insertMessageBigquery: mockInsertMessageBigquery,
loggerWithContext: mockLoggerWithContext,
checkSessionAdmissible: mock(() => {
throw new Error('session gate should not be reached')
}),
checkSessionAdmissible,
endFreebuffSession,
resolveFreeModeCountryAccess: async () => ({
allowed: false,
Expand All @@ -676,39 +678,29 @@ describe('/api/v1/chat/completions POST endpoint', () => {
clientIpHash: 'test-ip-hash',
}),
})
expect(endFreebuffSession).toHaveBeenCalledWith({
userId: 'user-new-free',
userEmail: null,
})

expect(response.status).toBe(403)
const body = await response.json()
expect(body).toMatchObject({
error: 'free_mode_unavailable',
countryCode: 'US',
countryBlockReason: 'anonymous_network',
ipPrivacySignals: ['vpn', 'hosting'],
})
expect(body.message).toContain('VPN')
expect(response.status).toBe(200)
expect(endFreebuffSession).not.toHaveBeenCalled()
expect(checkSessionAdmissible).toHaveBeenCalledTimes(1)
const validationEvent = (
mockTrackEvent as ReturnType<typeof mock>
).mock.calls
.map(([params]) => params as Parameters<TrackEventFn>[0])
.find(
({ event, properties }) =>
event === AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR &&
properties?.error === 'free_mode_unavailable',
properties?.error === 'free_mode_not_available_in_country',
)
expect(validationEvent?.properties).toMatchObject({
accessStatus: 'blocked',
accessTier: 'limited',
accessStatus: 'limited',
countryCode: 'US',
ipPrivacySignals: ['vpn', 'hosting'],
spurStatus: 'suspicious',
privacyDecision: 'corroborated_block',
privacyProviderDecision: 'corroborated_hard',
privacyHardBlocked: true,
privacyHardBlocked: false,
})
expect(validationEvent?.properties).not.toHaveProperty('accessTier')
},
FETCH_PATH_TEST_TIMEOUT_MS,
)
Expand Down
7 changes: 4 additions & 3 deletions web/src/app/api/v1/chat/completions/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,10 @@ export async function postChatCompletions(params: {
)
}

// For free mode requests, classify the request into full, limited, or
// hard-blocked access. Most non-allowlist/privacy cases are limited to the
// cheap DeepSeek Flash path, but VPN/proxy/Tor traffic is rejected outright.
// For free mode requests, classify the request into full or limited
// access. Most non-allowlist/privacy cases, including VPN/proxy traffic,
// are limited to the cheap DeepSeek Flash path; Cloudflare Tor remains a
// hard block.
if (isFreeModeRequest) {
const countryAccess = await resolveCountryAccess(userId, req, {
fetch,
Expand Down
19 changes: 10 additions & 9 deletions web/src/app/api/v1/freebuff/session/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ describe('POST /api/v1/freebuff/session', () => {
expect(body.status).toBe('queued')
})

test('blocks VPN/proxy privacy signals before joining the queue', async () => {
test('puts VPN/proxy privacy signals in limited mode before joining the queue', async () => {
const sessionDeps = makeSessionDeps()
sessionDeps.rows.set('u1', {
user_id: 'u1',
Expand Down Expand Up @@ -329,13 +329,14 @@ describe('POST /api/v1/freebuff/session', () => {
}),
}),
)
expect(resp.status).toBe(403)
expect(resp.status).toBe(200)
const body = await resp.json()
expect(body.status).toBe('country_blocked')
expect(body.message).toContain('VPN')
expect(body.status).toBe('queued')
expect(body.accessTier).toBe('limited')
expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID)
expect(body.countryBlockReason).toBe('anonymous_network')
expect(body.ipPrivacySignals).toEqual(['vpn', 'hosting'])
expect(sessionDeps.rows.size).toBe(0)
expect(sessionDeps.rows.size).toBe(1)
})

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

test('returns country_blocked on GET for VPN/proxy privacy signals', async () => {
test('returns limited mode on GET for VPN/proxy privacy signals', async () => {
const sessionDeps = makeSessionDeps()
sessionDeps.rows.set('u1', {
user_id: 'u1',
Expand Down Expand Up @@ -494,10 +495,10 @@ describe('GET /api/v1/freebuff/session', () => {
}),
}),
)
expect(resp.status).toBe(403)
expect(resp.status).toBe(200)
const body = await resp.json()
expect(body.status).toBe('country_blocked')
expect(body.message).toContain('proxy')
expect(body.status).toBe('none')
expect(body.accessTier).toBe('limited')
expect(body.countryBlockReason).toBe('anonymous_network')
expect(body.ipPrivacySignals).toEqual(['res_proxy'])
expect(sessionDeps.rows.size).toBe(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe('free mode country access cache', () => {
expect(fetch).toHaveBeenCalledTimes(1)
})

test('does not persist corroborated hard privacy blocks', async () => {
test('stores corroborated VPN/proxy limited decisions', async () => {
const cacheStore: FreeModeCountryAccessCacheStore = {
get: mock(async () => null),
set: mock(async () => {}),
Expand All @@ -141,7 +141,14 @@ describe('free mode country access cache', () => {
expect(access.allowed).toBe(false)
expect(access.spurIpPrivacy?.signals).toEqual(['vpn'])
expect(access.spurStatus).toBe('suspicious')
expect(cacheStore.set).not.toHaveBeenCalled()
expect(cacheStore.set).toHaveBeenCalledWith({
userId,
access,
now,
})
expect(expiresAtForCountryAccess(access, now).getTime() - now.getTime()).toBe(
FREE_MODE_COUNTRY_CACHE_ANONYMOUS_NETWORK_TTL_MS,
)
})

test('stores transient limited decisions when Spur fails after hard IPinfo signals', async () => {
Expand Down
4 changes: 2 additions & 2 deletions web/src/server/__tests__/free-mode-country.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ describe('free mode country access', () => {
expect(shouldHardBlockFreeModeAccess(access)).toBe(false)
})

test('hard-blocks only VPN, proxy, Tor, or residential proxy signals', async () => {
test('keeps corroborated VPN/proxy privacy signals in limited mode', async () => {
const vpnAccess = await getFreeModeCountryAccess(
makeReq({
'cf-ipcountry': 'US',
Expand All @@ -241,7 +241,7 @@ describe('free mode country access', () => {
)
expect(vpnAccess.allowed).toBe(false)
expect(vpnAccess.spurStatus).toBe('suspicious')
expect(shouldHardBlockFreeModeAccess(vpnAccess)).toBe(true)
expect(shouldHardBlockFreeModeAccess(vpnAccess)).toBe(false)
expect(getFreeModePrivacyDecision(vpnAccess)).toBe('corroborated_block')
expect(getFreeModePrivacyProviderDecision(vpnAccess)).toBe(
'corroborated_hard',
Expand Down
9 changes: 2 additions & 7 deletions web/src/server/free-mode-country.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,10 @@ export function hasHardBlockedPrivacySignal(
export function shouldHardBlockFreeModeAccess(
countryAccess: Pick<
FreeModeCountryAccess,
'blockReason' | 'cfCountry' | 'ipPrivacy' | 'spurIpPrivacy'
'cfCountry'
>,
): boolean {
return (
countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY ||
(countryAccess.blockReason === 'anonymous_network' &&
hasHardBlockedPrivacySignal(countryAccess.ipPrivacy) &&
hasHardBlockedPrivacySignal(countryAccess.spurIpPrivacy))
)
return countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY
}

export function getFreeModePrivacyDecision(
Expand Down
Loading