Skip to content

Commit b6a8d1b

Browse files
authored
Reject banned users at freebuff session endpoints so queueDepth stops flickering (#533)
1 parent 593b8d1 commit b6a8d1b

9 files changed

Lines changed: 144 additions & 18 deletions

File tree

cli/src/app.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ const AuthedSurface = ({
375375
// 'none' → no seat yet; show model-picker landing
376376
// 'queued' → waiting our turn
377377
// 'country_blocked' → terminal region-gate message
378+
// 'banned' → terminal account-banned message
378379
//
379380
// 'ended' deliberately falls through to <Chat>: the agent may still be
380381
// finishing work under the server-side grace period, and the chat surface
@@ -384,7 +385,8 @@ const AuthedSurface = ({
384385
(session === null ||
385386
session.status === 'queued' ||
386387
session.status === 'none' ||
387-
session.status === 'country_blocked')
388+
session.status === 'country_blocked' ||
389+
session.status === 'banned')
388390
) {
389391
return <WaitingRoomScreen session={session} error={sessionError} />
390392
}

cli/src/components/waiting-room-screen.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,21 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
242242
</text>
243243
</>
244244
)}
245+
246+
{/* Account banned. Terminal — polling has stopped. Blocking here
247+
stops banned bots from re-entering the queue every few seconds
248+
and inflating queueDepth between admission-tick sweeps. */}
249+
{session?.status === 'banned' && (
250+
<>
251+
<text style={{ fg: theme.secondary, marginBottom: 1 }}>
252+
⚠ Account unavailable
253+
</text>
254+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
255+
This account can't use freebuff. If you think this is a
256+
mistake, contact support@codebuff.com. Press Ctrl+C to exit.
257+
</text>
258+
</>
259+
)}
245260
</box>
246261
</box>
247262

cli/src/hooks/use-freebuff-session.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,18 @@ async function callSession(
6060
if (resp.status === 404) {
6161
return { status: 'disabled' }
6262
}
63-
// 403 with a country_blocked body is a terminal signal, not an error — the
64-
// server rejects non-allowlist countries up front (see session _handlers.ts)
65-
// so users don't wait through the queue only to be rejected at chat time.
66-
// The 403 status (rather than 200) is deliberate: older CLIs that don't
67-
// know this status treat it as a generic error and back off on the 10s
68-
// error-retry cadence instead of tight-polling an unrecognized 200 body.
63+
// 403 with a country_blocked or banned body is a terminal signal, not an
64+
// error — the server rejects non-allowlist countries and banned accounts up
65+
// front (see session _handlers.ts) so they don't wait through the queue only
66+
// to be rejected at chat time. The 403 status (rather than 200) is
67+
// deliberate: older CLIs that don't know these statuses treat them as a
68+
// generic error and back off on the 10s error-retry cadence instead of
69+
// tight-polling an unrecognized 200 body.
6970
if (resp.status === 403) {
7071
const body = (await resp.json().catch(() => null)) as
7172
| FreebuffSessionResponse
7273
| null
73-
if (body && body.status === 'country_blocked') {
74+
if (body && (body.status === 'country_blocked' || body.status === 'banned')) {
7475
return body
7576
}
7677
}
@@ -116,6 +117,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
116117
case 'disabled':
117118
case 'superseded':
118119
case 'country_blocked':
120+
case 'banned':
119121
case 'model_locked':
120122
return null
121123
}

common/src/types/freebuff-session.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,10 @@ export type FreebuffSessionServerResponse =
9292
currentModel: string
9393
requestedModel: string
9494
}
95+
| {
96+
/** Account is banned. Returned from every endpoint so banned bots can't
97+
* join the queue at all (otherwise they inflate `queueDepth` until the
98+
* 15s admission tick's `evictBanned` sweeps them). Terminal — CLI
99+
* stops polling and shows a banned message. */
100+
status: 'banned'
101+
}

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,17 @@ const LOGGER = {
8484
debug: () => {},
8585
}
8686

87-
function makeDeps(sessionDeps: SessionDeps, userId: string | null): FreebuffSessionDeps {
87+
function makeDeps(
88+
sessionDeps: SessionDeps,
89+
userId: string | null,
90+
opts: { banned?: boolean } = {},
91+
): FreebuffSessionDeps {
8892
return {
8993
logger: LOGGER as unknown as FreebuffSessionDeps['logger'],
90-
getUserInfoFromApiKey: (async () => (userId ? { id: userId } : undefined)) as unknown as FreebuffSessionDeps['getUserInfoFromApiKey'],
94+
getUserInfoFromApiKey: (async () =>
95+
userId
96+
? { id: userId, banned: opts.banned ?? false }
97+
: undefined) as unknown as FreebuffSessionDeps['getUserInfoFromApiKey'],
9198
sessionDeps,
9299
}
93100
}
@@ -145,6 +152,22 @@ describe('POST /api/v1/freebuff/session', () => {
145152
const body = await resp.json()
146153
expect(body.status).toBe('queued')
147154
})
155+
156+
// Banned bots with valid API keys were POSTing every few seconds and
157+
// inflating queueDepth between the 15s admission-tick sweeps. Rejecting at
158+
// the HTTP layer with 403 (terminal, like country_blocked) keeps them out
159+
// entirely. Also verifies no queue row is created as a side effect.
160+
test('returns banned 403 without joining the queue for banned user', async () => {
161+
const sessionDeps = makeSessionDeps()
162+
const resp = await postFreebuffSession(
163+
makeReq('ok'),
164+
makeDeps(sessionDeps, 'u1', { banned: true }),
165+
)
166+
expect(resp.status).toBe(403)
167+
const body = await resp.json()
168+
expect(body.status).toBe('banned')
169+
expect(sessionDeps.rows.size).toBe(0)
170+
})
148171
})
149172

150173
describe('GET /api/v1/freebuff/session', () => {
@@ -168,6 +191,17 @@ describe('GET /api/v1/freebuff/session', () => {
168191
expect(body.countryCode).toBe('FR')
169192
})
170193

194+
test('returns banned 403 on GET for banned user', async () => {
195+
const sessionDeps = makeSessionDeps()
196+
const resp = await getFreebuffSession(
197+
makeReq('ok'),
198+
makeDeps(sessionDeps, 'u1', { banned: true }),
199+
)
200+
expect(resp.status).toBe(403)
201+
const body = await resp.json()
202+
expect(body.status).toBe('banned')
203+
})
204+
171205
test('returns superseded when active row exists with mismatched instance id', async () => {
172206
const sessionDeps = makeSessionDeps()
173207
sessionDeps.rows.set('u1', {

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export interface FreebuffSessionDeps {
5050

5151
type AuthResult =
5252
| { error: NextResponse }
53-
| { userId: string; userEmail: string | null }
53+
| { userId: string; userEmail: string | null; userBanned: boolean }
5454

5555
async function resolveUser(req: NextRequest, deps: FreebuffSessionDeps): Promise<AuthResult> {
5656
const apiKey = extractApiKeyFromHeader(req)
@@ -67,7 +67,7 @@ async function resolveUser(req: NextRequest, deps: FreebuffSessionDeps): Promise
6767
}
6868
const userInfo = await deps.getUserInfoFromApiKey({
6969
apiKey,
70-
fields: ['id', 'email'],
70+
fields: ['id', 'email', 'banned'],
7171
logger: deps.logger,
7272
})
7373
if (!userInfo?.id) {
@@ -78,7 +78,11 @@ async function resolveUser(req: NextRequest, deps: FreebuffSessionDeps): Promise
7878
),
7979
}
8080
}
81-
return { userId: String(userInfo.id), userEmail: userInfo.email ?? null }
81+
return {
82+
userId: String(userInfo.id),
83+
userEmail: userInfo.email ?? null,
84+
userBanned: Boolean(userInfo.banned),
85+
}
8286
}
8387

8488
function serverError(
@@ -130,13 +134,16 @@ export async function postFreebuffSession(
130134
const state = await requestSession({
131135
userId: auth.userId,
132136
userEmail: auth.userEmail,
137+
userBanned: auth.userBanned,
133138
model: requestedModel,
134139
deps: deps.sessionDeps,
135140
})
136141
// model_locked is a 409 so it's distinguishable from a normal queued/active
137-
// response on the client. The CLI translates it into a "switch model?"
138-
// confirmation prompt.
139-
const status = state.status === 'model_locked' ? 409 : 200
142+
// response on the client. banned is a 403 (terminal, mirrors country_blocked)
143+
// so older CLIs that don't know the status fall into their `!resp.ok` error
144+
// path and back off instead of tight-polling on the unrecognized 200 body.
145+
const status =
146+
state.status === 'model_locked' ? 409 : state.status === 'banned' ? 403 : 200
140147
return NextResponse.json(state, { status })
141148
} catch (error) {
142149
return serverError(deps, 'POST', auth.userId, error)
@@ -161,6 +168,7 @@ export async function getFreebuffSession(
161168
const state = await getSessionState({
162169
userId: auth.userId,
163170
userEmail: auth.userEmail,
171+
userBanned: auth.userBanned,
164172
claimedInstanceId,
165173
deps: deps.sessionDeps,
166174
})
@@ -174,7 +182,10 @@ export async function getFreebuffSession(
174182
{ status: 200 },
175183
)
176184
}
177-
return NextResponse.json(state, { status: 200 })
185+
// banned is terminal; 403 for the same reason as country_blocked — older
186+
// CLIs that don't know this status treat it as a generic error.
187+
const status = state.status === 'banned' ? 403 : 200
188+
return NextResponse.json(state, { status })
178189
} catch (error) {
179190
return serverError(deps, 'GET', auth.userId, error)
180191
}

web/src/server/free-session/__tests__/public-api.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,19 @@ describe('requestSession', () => {
155155
expect(offDeps.rows.size).toBe(0)
156156
})
157157

158+
test('banned user is rejected before joinOrTakeOver runs', async () => {
159+
const state = await requestSession({
160+
userId: 'u1',
161+
model: DEFAULT_MODEL,
162+
userBanned: true,
163+
deps,
164+
})
165+
expect(state).toEqual({ status: 'banned' })
166+
// No row should be created — the point is to keep banned bots out of
167+
// queueDepthsByModel entirely, not just until the next evictBanned tick.
168+
expect(deps.rows.size).toBe(0)
169+
})
170+
158171
test('first call puts user in queue at position 1', async () => {
159172
const state = await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps })
160173
expect(state.status).toBe('queued')
@@ -284,6 +297,15 @@ describe('getSessionState', () => {
284297
expect(state).toEqual({ status: 'disabled' })
285298
})
286299

300+
test('banned user returns banned without hitting the DB', async () => {
301+
const state = await getSessionState({
302+
userId: 'u1',
303+
userBanned: true,
304+
deps,
305+
})
306+
expect(state).toEqual({ status: 'banned' })
307+
})
308+
287309
test('no row returns none with empty queue-depth snapshot', async () => {
288310
const state = await getSessionState({ userId: 'u1', deps })
289311
expect(state).toEqual({ status: 'none', queueDepthByModel: {} })

web/src/server/free-session/public-api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,17 @@ export async function requestSession(params: {
144144
userId: string
145145
model: string
146146
userEmail?: string | null | undefined
147+
/** True if the account is banned. Short-circuited here so banned bots never
148+
* create a queued row — otherwise they inflate `queueDepth` between the
149+
* 15s admission ticks that run `evictBanned`. */
150+
userBanned?: boolean
147151
deps?: SessionDeps
148152
}): Promise<RequestSessionResult> {
149153
const deps = params.deps ?? defaultDeps
150154
const model = resolveFreebuffModel(params.model)
155+
if (params.userBanned) {
156+
return { status: 'banned' }
157+
}
151158
if (
152159
!deps.isWaitingRoomEnabled() ||
153160
isWaitingRoomBypassedForEmail(params.userEmail)
@@ -224,10 +231,14 @@ export async function requestSession(params: {
224231
export async function getSessionState(params: {
225232
userId: string
226233
userEmail?: string | null | undefined
234+
userBanned?: boolean
227235
claimedInstanceId?: string | null | undefined
228236
deps?: SessionDeps
229237
}): Promise<FreebuffSessionServerResponse> {
230238
const deps = params.deps ?? defaultDeps
239+
if (params.userBanned) {
240+
return { status: 'banned' }
241+
}
231242
if (
232243
!deps.isWaitingRoomEnabled() ||
233244
isWaitingRoomBypassedForEmail(params.userEmail)

web/src/server/free-session/store.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,26 @@ export async function queueDepth(params: { model: string }): Promise<number> {
164164
* covers every model's queue depth, so the UI stays cheap to refresh.
165165
* Models with no queued rows are absent from the map; callers should default
166166
* missing keys to 0.
167+
*
168+
* Excludes rows whose user is banned: `evictBanned` only runs on the 15s
169+
* admission tick, so between ticks a flood of banned bots would inflate
170+
* queueDepth by their count and then snap back down. Filtering here keeps
171+
* the user-facing counter stable.
167172
*/
168173
export async function queueDepthsByModel(): Promise<Record<string, number>> {
169174
const rows = await db
170175
.select({ model: schema.freeSession.model, n: count() })
171176
.from(schema.freeSession)
172-
.where(eq(schema.freeSession.status, 'queued'))
177+
.where(
178+
and(
179+
eq(schema.freeSession.status, 'queued'),
180+
sql`NOT EXISTS (
181+
SELECT 1 FROM ${schema.user}
182+
WHERE ${schema.user.id} = ${schema.freeSession.user_id}
183+
AND ${schema.user.banned} = true
184+
)`,
185+
),
186+
)
173187
.groupBy(schema.freeSession.model)
174188
const out: Record<string, number> = {}
175189
for (const row of rows) out[row.model] = Number(row.n)
@@ -224,6 +238,14 @@ export async function queuePositionFor(params: {
224238
eq(schema.freeSession.status, 'queued'),
225239
eq(schema.freeSession.model, params.model),
226240
sql`(${schema.freeSession.queued_at}, ${schema.freeSession.user_id}) <= (${params.queuedAt.toISOString()}::timestamptz, ${params.userId})`,
241+
// Exclude banned users ahead of us — matches queueDepthsByModel so the
242+
// "Position N / M" counter doesn't briefly jump when banned rows are
243+
// swept by the admission tick.
244+
sql`NOT EXISTS (
245+
SELECT 1 FROM ${schema.user}
246+
WHERE ${schema.user.id} = ${schema.freeSession.user_id}
247+
AND ${schema.user.banned} = true
248+
)`,
227249
),
228250
)
229251
return Number(rows[0]?.n ?? 0)

0 commit comments

Comments
 (0)