Skip to content

Commit f25213a

Browse files
committed
Block codebuff usage
1 parent 23c304f commit f25213a

9 files changed

Lines changed: 159 additions & 7 deletions

File tree

common/src/types/contracts/billing.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type GetUserUsageDataFn = (params: {
1212
totalDebt: number
1313
netBalance: number
1414
breakdown: Record<string, number>
15+
principals: Record<string, number>
1516
}
1617
nextQuotaReset: string
1718
autoTopupTriggered?: boolean

common/src/types/contracts/database.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type User = {
88
referral_code: string | null
99
stripe_customer_id: string | null
1010
banned: boolean
11+
created_at: Date
1112
}
1213
export const userColumns = [
1314
'id',
@@ -16,6 +17,7 @@ export const userColumns = [
1617
'referral_code',
1718
'stripe_customer_id',
1819
'banned',
20+
'created_at',
1921
] as const
2022
export type UserColumn = keyof User
2123
export type GetUserInfoFromApiKeyInput<T extends UserColumn> = {

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

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,32 @@ import type { BlockGrantResult } from '@codebuff/billing/subscription'
1818
import type { GetUserPreferencesFn } from '../_post'
1919

2020
describe('/api/v1/chat/completions POST endpoint', () => {
21+
// Old enough to clear the account-age gate in _post.ts
22+
const AGED_ACCOUNT_CREATED_AT = new Date('2024-01-01T00:00:00Z')
23+
2124
const mockUserData: Record<
2225
string,
23-
{ id: string; banned: boolean }
26+
{ id: string; banned: boolean; created_at: Date }
2427
> = {
2528
'test-api-key-123': {
2629
id: 'user-123',
2730
banned: false,
31+
created_at: AGED_ACCOUNT_CREATED_AT,
2832
},
2933
'test-api-key-no-credits': {
3034
id: 'user-no-credits',
3135
banned: false,
36+
created_at: AGED_ACCOUNT_CREATED_AT,
3237
},
3338
'test-api-key-blocked': {
3439
id: 'banned-user-id',
3540
banned: true,
41+
created_at: AGED_ACCOUNT_CREATED_AT,
42+
},
43+
'test-api-key-new-free': {
44+
id: 'user-new-free',
45+
banned: false,
46+
created_at: new Date(),
3647
},
3748
}
3849

@@ -43,7 +54,11 @@ describe('/api/v1/chat/completions POST endpoint', () => {
4354
if (!userData) {
4455
return null
4556
}
46-
return { id: userData.id, banned: userData.banned } as Awaited<ReturnType<GetUserInfoFromApiKeyFn>>
57+
return {
58+
id: userData.id,
59+
banned: userData.banned,
60+
created_at: userData.created_at,
61+
} as Awaited<ReturnType<GetUserInfoFromApiKeyFn>>
4762
}
4863

4964
let mockLogger: Logger
@@ -80,6 +95,22 @@ describe('/api/v1/chat/completions POST endpoint', () => {
8095
totalDebt: 0,
8196
netBalance: 0,
8297
breakdown: {},
98+
// Has purchased credits historically (principals > 0) but 0 remaining
99+
// so the paid-plan gate passes and the credit check is what enforces 402.
100+
principals: { purchase: 100 },
101+
},
102+
nextQuotaReset,
103+
}
104+
}
105+
if (userId === 'user-new-free') {
106+
return {
107+
usageThisCycle: 0,
108+
balance: {
109+
totalRemaining: 100,
110+
totalDebt: 0,
111+
netBalance: 100,
112+
breakdown: {},
113+
principals: {},
83114
},
84115
nextQuotaReset,
85116
}
@@ -91,6 +122,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
91122
totalDebt: 0,
92123
netBalance: 100,
93124
breakdown: {},
125+
principals: { purchase: 100 },
94126
},
95127
nextQuotaReset,
96128
}
@@ -421,6 +453,75 @@ describe('/api/v1/chat/completions POST endpoint', () => {
421453
expect(body.message).not.toContain(nextQuotaReset)
422454
})
423455

456+
it('returns 403 for a free-tier user with no paid relationship', async () => {
457+
const req = new NextRequest(
458+
'http://localhost:3000/api/v1/chat/completions',
459+
{
460+
method: 'POST',
461+
headers: { Authorization: 'Bearer test-api-key-new-free' },
462+
body: JSON.stringify({
463+
model: 'test/test-model',
464+
stream: false,
465+
codebuff_metadata: {
466+
run_id: 'run-123',
467+
client_id: 'test-client-id-123',
468+
},
469+
}),
470+
},
471+
)
472+
473+
const response = await postChatCompletions({
474+
req,
475+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
476+
logger: mockLogger,
477+
trackEvent: mockTrackEvent,
478+
getUserUsageData: mockGetUserUsageData,
479+
getAgentRunFromId: mockGetAgentRunFromId,
480+
fetch: mockFetch,
481+
insertMessageBigquery: mockInsertMessageBigquery,
482+
loggerWithContext: mockLoggerWithContext,
483+
})
484+
485+
expect(response.status).toBe(403)
486+
const body = await response.json()
487+
expect(body.error).toBe('requires_paid_plan')
488+
})
489+
490+
it('lets a BYOK free-tier new account through the paid-plan gate', async () => {
491+
const req = new NextRequest(
492+
'http://localhost:3000/api/v1/chat/completions',
493+
{
494+
method: 'POST',
495+
headers: {
496+
Authorization: 'Bearer test-api-key-new-free',
497+
'x-openrouter-api-key': 'sk-or-byok-test',
498+
},
499+
body: JSON.stringify({
500+
model: 'test/test-model',
501+
stream: false,
502+
codebuff_metadata: {
503+
run_id: 'run-123',
504+
client_id: 'test-client-id-123',
505+
},
506+
}),
507+
},
508+
)
509+
510+
const response = await postChatCompletions({
511+
req,
512+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
513+
logger: mockLogger,
514+
trackEvent: mockTrackEvent,
515+
getUserUsageData: mockGetUserUsageData,
516+
getAgentRunFromId: mockGetAgentRunFromId,
517+
fetch: mockFetch,
518+
insertMessageBigquery: mockInsertMessageBigquery,
519+
loggerWithContext: mockLoggerWithContext,
520+
})
521+
522+
expect(response.status).toBe(200)
523+
})
524+
424525
it('skips credit check when in FREE mode even with 0 credits', async () => {
425526
const req = new NextRequest(
426527
'http://localhost:3000/api/v1/chat/completions',
@@ -818,6 +919,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
818919
totalDebt: 0,
819920
netBalance: includeSubscriptionCredits ? 350 : 0,
820921
breakdown: {},
922+
principals: { subscription: 350 },
821923
},
822924
nextQuotaReset,
823925
}))

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

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ const FREE_MODE_ALLOWED_COUNTRIES = new Set([
7474
'NO', 'SE', 'NL', 'DK', 'DE', 'FI', 'BE', 'LU', 'CH', 'IE', 'IS',
7575
])
7676

77+
const MIN_ACCOUNT_AGE_DAYS = 3
78+
const MIN_ACCOUNT_AGE_FOR_PAID_MS = MIN_ACCOUNT_AGE_DAYS * 24 * 60 * 60 * 1000
79+
7780
function extractClientIp(req: NextRequest): string | undefined {
7881
const forwardedFor = req.headers.get('x-forwarded-for')
7982
if (forwardedFor) {
@@ -206,7 +209,7 @@ export async function postChatCompletions(params: {
206209
// Get user info
207210
const userInfo = await getUserInfoFromApiKey({
208211
apiKey,
209-
fields: ['id', 'email', 'discord_id', 'stripe_customer_id', 'banned'],
212+
fields: ['id', 'email', 'discord_id', 'stripe_customer_id', 'banned', 'created_at'],
210213
logger,
211214
})
212215
if (!userInfo) {
@@ -440,10 +443,43 @@ export async function postChatCompletions(params: {
440443

441444
// Fetch user credit data (includes subscription credits when block grant was ensured)
442445
const {
443-
balance: { totalRemaining },
446+
balance: { totalRemaining, principals },
444447
nextQuotaReset,
445448
} = await getUserUsageData({ userId, logger, includeSubscriptionCredits })
446449

450+
// Gate non-free-mode requests behind (a) an established paid relationship
451+
// AND (b) a non-new account. An ongoing abuse campaign uses freshly-signed-up
452+
// self-referral accounts to burn credits via the stream-error billing gap in
453+
// openrouter.ts; restricting to aged + paid accounts cuts off that vector.
454+
// BYOK users bypass — they pay OpenRouter directly, so there's nothing to burn.
455+
const openrouterApiKeyHeader = req.headers.get(BYOK_OPENROUTER_HEADER)
456+
const hasPaidRelationship =
457+
(principals.purchase ?? 0) > 0 || (principals.subscription ?? 0) > 0
458+
const accountAgeMs = userInfo.created_at
459+
? Date.now() - new Date(userInfo.created_at).getTime()
460+
: 0
461+
const accountIsTooNew = accountAgeMs < MIN_ACCOUNT_AGE_FOR_PAID_MS
462+
if (!openrouterApiKeyHeader && (!hasPaidRelationship || accountIsTooNew)) {
463+
trackEvent({
464+
event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR,
465+
userId,
466+
properties: {
467+
error: 'blocked_for_free_tier',
468+
model: typedBody.model,
469+
hasPaidRelationship,
470+
accountAgeMs,
471+
},
472+
logger,
473+
})
474+
return NextResponse.json(
475+
{
476+
error: 'requires_paid_plan',
477+
message: `Non-free mode requires a paid subscription or purchased credits on an account at least ${MIN_ACCOUNT_AGE_DAYS} days old. Visit ${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage to upgrade, or pass an OpenRouter API key to bring your own credits.`,
478+
},
479+
{ status: 403 },
480+
)
481+
}
482+
447483
// Credit check
448484
if (totalRemaining <= 0 && !isFreeModeRequest) {
449485
trackEvent({
@@ -464,7 +500,7 @@ export async function postChatCompletions(params: {
464500
)
465501
}
466502

467-
const openrouterApiKey = req.headers.get(BYOK_OPENROUTER_HEADER)
503+
const openrouterApiKey = openrouterApiKeyHeader
468504

469505
// Handle streaming vs non-streaming
470506
try {

web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe('/api/v1/docs-search POST endpoint', () => {
4141
totalDebt: 0,
4242
netBalance: 10,
4343
breakdown: {},
44+
principals: {},
4445
},
4546
nextQuotaReset: 'soon',
4647
}))
@@ -113,6 +114,7 @@ describe('/api/v1/docs-search POST endpoint', () => {
113114
totalDebt: 0,
114115
netBalance: 0,
115116
breakdown: {},
117+
principals: {},
116118
},
117119
nextQuotaReset: 'soon',
118120
}))
@@ -163,6 +165,7 @@ describe('/api/v1/docs-search POST endpoint', () => {
163165
totalDebt: 0,
164166
netBalance: includeSubscriptionCredits ? 350 : 0,
165167
breakdown: {},
168+
principals: {},
166169
},
167170
nextQuotaReset: 'soon',
168171
}))
@@ -200,6 +203,7 @@ describe('/api/v1/docs-search POST endpoint', () => {
200203
totalDebt: 0,
201204
netBalance: 0,
202205
breakdown: {},
206+
principals: {},
203207
},
204208
nextQuotaReset: 'soon',
205209
}))

web/src/app/api/v1/me/__tests__/me.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe('/api/v1/me route', () => {
2525
referral_code: 'ref-user-123',
2626
stripe_customer_id: 'cus_test_123',
2727
banned: false,
28+
created_at: new Date('2024-01-01T00:00:00Z'),
2829
},
2930
'test-api-key-456': {
3031
id: 'user-456',
@@ -33,6 +34,7 @@ describe('/api/v1/me route', () => {
3334
referral_code: 'ref-user-456',
3435
stripe_customer_id: null,
3536
banned: false,
37+
created_at: new Date('2024-01-01T00:00:00Z'),
3638
},
3739
}
3840

@@ -214,7 +216,7 @@ describe('/api/v1/me route', () => {
214216
const body = await response.json()
215217
expect(body.error).toContain('Invalid fields: invalid_field')
216218
expect(body.error).toContain(
217-
'Valid fields are: id, email, discord_id, referral_code, stripe_customer_id, banned, referral_link',
219+
'Valid fields are: id, email, discord_id, referral_code, stripe_customer_id, banned, created_at, referral_link',
218220
)
219221
})
220222

web/src/app/api/v1/me/_get.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export async function getMe(params: {
129129

130130
// Build response including derived fields
131131
const userInfoRecord = userInfo as Partial<
132-
Record<ValidDbField, string | boolean | null>
132+
Record<ValidDbField, string | boolean | Date | null>
133133
>
134134

135135
const responseBody: Record<string, unknown> = {}

web/src/app/api/v1/web-search/__tests__/web-search.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ describe('/api/v1/web-search POST endpoint', () => {
4343
totalDebt: 0,
4444
netBalance: 10,
4545
breakdown: {},
46+
principals: {},
4647
},
4748
nextQuotaReset: 'soon',
4849
}))
@@ -96,6 +97,7 @@ describe('/api/v1/web-search POST endpoint', () => {
9697
totalDebt: 0,
9798
netBalance: 0,
9899
breakdown: {},
100+
principals: {},
99101
},
100102
nextQuotaReset: 'soon',
101103
}))
@@ -148,6 +150,7 @@ describe('/api/v1/web-search POST endpoint', () => {
148150
totalDebt: 0,
149151
netBalance: includeSubscriptionCredits ? 350 : 0,
150152
breakdown: {},
153+
principals: {},
151154
},
152155
nextQuotaReset: 'soon',
153156
}))
@@ -186,6 +189,7 @@ describe('/api/v1/web-search POST endpoint', () => {
186189
totalDebt: 0,
187190
netBalance: 0,
188191
breakdown: {},
192+
principals: {},
189193
},
190194
nextQuotaReset: 'soon',
191195
}))

web/src/db/user.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const VALID_USER_INFO_FIELDS = [
1515
'referral_code',
1616
'stripe_customer_id',
1717
'banned',
18+
'created_at',
1819
] as const
1920

2021
export async function getUserInfoFromApiKey<T extends UserColumn>({

0 commit comments

Comments
 (0)