diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 980272b6d..8396b7ce7 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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. diff --git a/docs/freebuff-waiting-room.md b/docs/freebuff-waiting-room.md index c0e38b3bf..76af547f3 100644 --- a/docs/freebuff-waiting-room.md +++ b/docs/freebuff-waiting-room.md @@ -181,7 +181,7 @@ All endpoints authenticate via the standard `Authorization: Bearer ` 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: diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 7b97b4aad..c8fdaa232 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -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', @@ -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, @@ -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, @@ -676,20 +678,10 @@ 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 ).mock.calls @@ -697,18 +689,18 @@ describe('/api/v1/chat/completions POST endpoint', () => { .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, ) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index d40c30c57..b23e5fe1b 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -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, diff --git a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts index b55a64add..54dc6c90d 100644 --- a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts +++ b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts @@ -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', @@ -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 () => { @@ -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', @@ -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) diff --git a/web/src/server/__tests__/free-mode-country-access-cache.test.ts b/web/src/server/__tests__/free-mode-country-access-cache.test.ts index c0c81cfe4..005240d2f 100644 --- a/web/src/server/__tests__/free-mode-country-access-cache.test.ts +++ b/web/src/server/__tests__/free-mode-country-access-cache.test.ts @@ -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 () => {}), @@ -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 () => { diff --git a/web/src/server/__tests__/free-mode-country.test.ts b/web/src/server/__tests__/free-mode-country.test.ts index 14ad4c0ff..b29b59536 100644 --- a/web/src/server/__tests__/free-mode-country.test.ts +++ b/web/src/server/__tests__/free-mode-country.test.ts @@ -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', @@ -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', diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index e30f2700a..1b0845e80 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -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(