Skip to content

Block freebuff waiting room for disallowed countries#522

Merged
jahooma merged 2 commits intomainfrom
jahooma/freebuff-country-warning
Apr 20, 2026
Merged

Block freebuff waiting room for disallowed countries#522
jahooma merged 2 commits intomainfrom
jahooma/freebuff-country-warning

Conversation

@jahooma
Copy link
Copy Markdown
Contributor

@jahooma jahooma commented Apr 20, 2026

Summary

Users from countries outside the free-mode allowlist were waiting through the entire freebuff queue only to be rejected at their first chat request. This PR short-circuits POST/GET /api/v1/freebuff/session with a new terminal country_blocked status so the CLI can skip the queue and render a clear "not available in your region" screen up front.

The country-check logic (FREE_MODE_ALLOWED_COUNTRIES, getCountryCode, extractClientIp) was lifted out of chat/completions/_post.ts into a shared web/src/server/free-mode-country.ts so both endpoints use the same detection (Cloudflare cf-ipcountry header, falling back to geoip-lite). Null country (VPN/localhost) still fails open. Added handler tests covering the new block for both POST and GET.

Test plan

  • bun test web/src/app/api/v1/freebuff/session/__tests__/session.test.ts — 10 pass
  • bun test web/src/app/api/v1/chat/completions/__tests__/completions.test.ts — 24 pass, unchanged
  • tsc --noEmit passes for web, cli, and common
  • Manual smoke: POST from a non-allowed region returns country_blocked without creating a session row, and the CLI shows the warning screen

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 20, 2026

Greptile Summary

This PR introduces an early country-block gate for the freebuff waiting-room endpoints (POST/GET /api/v1/freebuff/session), short-circuiting with a new terminal country_blocked status before a user joins the queue. It also extracts the shared country-detection logic (FREE_MODE_ALLOWED_COUNTRIES, getCountryCode, extractClientIp) from chat/completions/_post.ts into a new web/src/server/free-mode-country.ts module so both code paths use identical detection (Cloudflare cf-ipcountry header, falling back to geoip-lite).

Key changes:

  • common/src/types/freebuff-session.ts: New country_blocked discriminant added to the shared server-response union (with countryCode: string for display).
  • web/src/server/free-mode-country.ts: New shared module housing getCountryCode, extractClientIp, FREE_MODE_ALLOWED_COUNTRIES, and isCountryAllowedForFreeMode.
  • web/src/app/api/v1/freebuff/session/_handlers.ts: countryBlockedResponse helper inserted after auth (so unauthenticated requests still get 401); DELETE is intentionally not gated.
  • cli/src/hooks/use-freebuff-session.ts: country_blocked returns null from nextDelayMs, correctly treating it as a terminal state (no further polling, no DELETE issued since no slot was ever held).
  • cli/src/app.tsx + waiting-room-screen.tsx: Routes country_blocked to WaitingRoomScreen with a clear "not available in your region" message displaying the resolved country code.
  • Tests: 10 handler tests pass, including new coverage for blocked and allowed country scenarios on both POST and GET.

Confidence Score: 4/5

Safe to merge; no runtime bugs found and the core flow is correctly implemented end-to-end.

The feature is well-designed with correct terminal-state handling in the CLI, proper auth-before-country ordering on the server, and good test coverage of the new code paths. The two P2 findings (redundant geoip.lookup in _post.ts and the unused isCountryAllowedForFreeMode export) are cleanup opportunities, not blockers. A missing null-country test is the only gap worth addressing before the next iteration.

web/src/app/api/v1/chat/completions/_post.ts (redundant geoip import) and web/src/server/free-mode-country.ts (unused export).

Important Files Changed

Filename Overview
web/src/server/free-mode-country.ts New shared module extracting country detection logic; isCountryAllowedForFreeMode is exported but never consumed by any caller
web/src/app/api/v1/freebuff/session/_handlers.ts Adds countryBlockedResponse guard to POST and GET handlers; auth check correctly precedes the country gate; DELETE is intentionally not gated
common/src/types/freebuff-session.ts Well-typed country_blocked discriminant variant added to the server response union; countryCode field always present for display
cli/src/hooks/use-freebuff-session.ts country_blocked correctly returns null from nextDelayMs (terminal), stopping the poll loop; DELETE cleanup is correctly skipped since no slot was ever held
cli/src/components/waiting-room-screen.tsx Adds a country_blocked branch with clear messaging and the resolved 2-letter country code; integrates cleanly into existing screen layout
cli/src/app.tsx country_blocked added to the WaitingRoomScreen routing guard alongside queued and none; prevents blocked users from ever reaching the Chat component
web/src/app/api/v1/chat/completions/_post.ts Now imports from shared free-mode-country module, but retains a direct geoip import for a logging line, causing a redundant double geoip.lookup for non-Cloudflare requests
web/src/app/api/v1/freebuff/session/tests/session.test.ts Good coverage of the new country gate (blocked + allowed); missing a test for the null-country (VPN/localhost) fail-open path

Sequence Diagram

sequenceDiagram
    participant CLI
    participant Session as POST /api/v1/freebuff/session
    participant Country as free-mode-country.ts
    participant Queue as Free Session Queue

    CLI->>Session: POST (Bearer token)
    Session->>Session: resolveUser() — auth check
    alt Auth fails
        Session-->>CLI: 401 Unauthorized
    else Auth ok
        Session->>Country: getCountryCode(req)
        Country->>Country: cf-ipcountry header (or geoip fallback)
        Country-->>Session: countryCode | null
        alt Country resolved and not in allowlist
            Session-->>CLI: 200 { status: country_blocked, countryCode }
            CLI->>CLI: nextDelayMs returns null (terminal, stop polling)
            CLI->>CLI: Render WaitingRoomScreen not available in your region
        else Country null VPN/localhost or allowed
            Session->>Queue: requestSession(userId)
            Queue-->>Session: queued | active | disabled
            Session-->>CLI: 200 { status: queued | active | disabled }
            CLI->>CLI: Schedule GET poll
        end
    end
Loading

Comments Outside Diff (1)

  1. web/src/app/api/v1/chat/completions/_post.ts, line 264 (link)

    P2 Double geoip.lookup call after shared-module extraction

    geoip.lookup(clientIp) is called here directly for the geoipResult log field, but getCountryCode (called two lines above) already runs geoip.lookup(clientIp) internally for non-Cloudflare requests. This leaves two synchronous geoip lookups per free-mode request, which is the exact redundancy the refactoring aimed to remove.

    Consider having getCountryCode return a diagnostic struct (e.g., { country, source, rawGeoip }) so the log can still show both values without a second lookup, or simply omit geoipResult from the log since cfHeader + resolvedCountry already tell the full story. At minimum, the top-level import geoip from 'geoip-lite' can then be removed from this file.

Reviews (1): Last reviewed commit: "Block freebuff waiting room for disallow..." | Re-trigger Greptile

Comment on lines +39 to +43
export function isCountryAllowedForFreeMode(req: NextRequest): boolean | null {
const countryCode = getCountryCode(req)
if (!countryCode) return null
return FREE_MODE_ALLOWED_COUNTRIES.has(countryCode)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 isCountryAllowedForFreeMode is exported but never imported

This helper was added to the shared module but nothing in the codebase actually imports it — both _handlers.ts and _post.ts call getCountryCode + FREE_MODE_ALLOWED_COUNTRIES.has() directly. Either wire up the callers to use this function (which would simplify countryBlockedResponse in _handlers.ts) or remove it to keep the public surface minimal.

Comment on lines +107 to 129
test('returns country_blocked without joining the queue for disallowed country', async () => {
const sessionDeps = makeSessionDeps()
const resp = await postFreebuffSession(
makeReq('ok', { cfCountry: 'FR' }),
makeDeps(sessionDeps, 'u1'),
)
expect(resp.status).toBe(200)
const body = await resp.json()
expect(body.status).toBe('country_blocked')
expect(body.countryCode).toBe('FR')
expect(sessionDeps.rows.size).toBe(0)
})

test('allows queue entry for allowed country', async () => {
const sessionDeps = makeSessionDeps()
const resp = await postFreebuffSession(
makeReq('ok', { cfCountry: 'US' }),
makeDeps(sessionDeps, 'u1'),
)
const body = await resp.json()
expect(body.status).toBe('queued')
})
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test for the null-country fail-open path

The new tests cover blocked (FR) and allowed (US) countries, but the shared module's stated contract — "null country (VPN/localhost) fails open" — is not exercised. A request without cf-ipcountry and no resolvable IP should pass through to queue admission. Given this is the main escape hatch for VPN/localhost users, a test that verifies postFreebuffSession creates a queued session when no country headers are present would round out the coverage.

test('allows queue entry when country cannot be determined (null/VPN)', async () => {
  const sessionDeps = makeSessionDeps()
  // No cfCountry → geoip returns null → fail open
  const resp = await postFreebuffSession(makeReq('ok'), makeDeps(sessionDeps, 'u1'))
  const body = await resp.json()
  expect(body.status).toBe('queued')
})

@jahooma jahooma merged commit cc67463 into main Apr 20, 2026
34 checks passed
@jahooma jahooma deleted the jahooma/freebuff-country-warning branch April 20, 2026 06:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant