Skip to content

Commit 16682e8

Browse files
waleedlatif1claude
andcommitted
fix(chat): derive the chat ceiling from the plan table it must stay under
The 60/min ceiling still sat above the free plan's 50/min sync rate, so on free the shared counter — the one the owner's API, webhook and scheduled runs also draw from — still emptied before the ceiling refused. Every plan rate is also operator-overridable through `RATE_LIMIT_*_SYNC`, which no hardcoded number can track. It is now derived: 80% of the smallest configured plan sync rate, which is 40/min with the defaults and stays under every plan by construction. The per-IP bucket follows at half that. Tests assert the invariant against each plan in `RATE_LIMITS`, on burst as well as sustained rate, rather than pinning numbers that would need editing the next time a plan default moves. This floor is shared by all plans, so enterprise is held to the same 40/min as free. Sizing the slice to the payer's own plan needs the subscription, which `preprocessExecution` resolves just after this runs — that is the follow-up, and the same hook bounds the generic-webhook surface that is still unbounded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QWh9WFMYNZ6uTFQFUzF8Bj
1 parent e79ed74 commit 16682e8

2 files changed

Lines changed: 61 additions & 30 deletions

File tree

apps/sim/app/api/chat/[identifier]/route.test.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -378,12 +378,12 @@ describe('Chat Identifier API Route', () => {
378378
expect(mockEnforceIpRateLimit).toHaveBeenCalledWith(
379379
'chat-execute:chat-id',
380380
req,
381-
expect.objectContaining({ refillRate: 30, refillIntervalMs: 60_000 })
381+
expect.objectContaining({ refillIntervalMs: 60_000 })
382382
)
383383
expect(mockEnforceResourceRateLimit).toHaveBeenCalledWith(
384384
'chat-execute',
385385
'chat-id',
386-
expect.objectContaining({ refillRate: 60, refillIntervalMs: 60_000 })
386+
expect.objectContaining({ refillIntervalMs: 60_000 })
387387
)
388388
})
389389

@@ -397,20 +397,37 @@ describe('Chat Identifier API Route', () => {
397397
})
398398

399399
/**
400-
* A chat execution debits the workspace `sync` counter that the owner's
401-
* API, webhook and scheduled runs share. A per-deployment ceiling at or
402-
* above the plan's own rate would never refuse before that shared counter
403-
* was drained, which is the availability half of the attack.
400+
* The invariant the ceiling exists to hold. A chat execution debits the
401+
* workspace `sync` counter the owner's API, webhook and scheduled runs
402+
* share, so a ceiling at or above a plan's own rate never refuses before
403+
* that shared counter is drained — the availability half of the attack.
404+
* Asserted against every plan, including free, and on burst as well as
405+
* sustained rate, since either one reaching the plan bucket first is the
406+
* same hole.
404407
*/
405-
it('stays below the cheapest paid plan sync rate', async () => {
408+
it.each(Object.keys(RATE_LIMITS))(
409+
'stays under the %s plan sync budget it debits',
410+
async (plan) => {
411+
const req = createMockNextRequest('POST', { input: 'hello' })
412+
413+
await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) })
414+
415+
const planBucket = RATE_LIMITS[plan as keyof typeof RATE_LIMITS].sync
416+
const [, , config] = mockEnforceResourceRateLimit.mock.calls[0]
417+
expect(config.refillRate).toBeLessThan(planBucket.refillRate)
418+
expect(config.maxTokens).toBeLessThan(planBucket.maxTokens)
419+
}
420+
)
421+
422+
/** One host must not be able to take the whole deployment's allowance. */
423+
it('holds the per-IP bucket under the per-deployment one', async () => {
406424
const req = createMockNextRequest('POST', { input: 'hello' })
407425

408426
await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) })
409427

410-
const [, , config] = mockEnforceResourceRateLimit.mock.calls[0]
411-
expect(config.refillRate).toBeLessThan(RATE_LIMITS.pro.sync.refillRate)
412-
expect(config.refillRate).toBeLessThan(RATE_LIMITS.team.sync.refillRate)
413-
expect(config.refillRate).toBeLessThan(RATE_LIMITS.enterprise.sync.refillRate)
428+
const [, , ipConfig] = mockEnforceIpRateLimit.mock.calls[0]
429+
const [, , deploymentConfig] = mockEnforceResourceRateLimit.mock.calls[0]
430+
expect(ipConfig.refillRate).toBeLessThan(deploymentConfig.refillRate)
414431
})
415432

416433
it('leaves the gate-configuration fetch unmetered', async () => {

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
enforceResourceRateLimit,
1515
type TokenBucketConfig,
1616
} from '@/lib/core/rate-limiter'
17+
import { RATE_LIMITS } from '@/lib/core/rate-limiter/types'
1718
import { generateRequestId } from '@/lib/core/utils/request'
1819
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1920
import { preprocessExecution } from '@/lib/execution/preprocessing'
@@ -60,31 +61,44 @@ function executionsPerMinute(perMinute: number): TokenBucketConfig {
6061
}
6162

6263
/**
63-
* Executions one client IP may drive against a single deployed chat.
64+
* What one deployed chat may spend of its owner's workspace allowance.
65+
*
66+
* A chat execution debits the workspace `sync` counter, which is the same
67+
* counter the owner's API, webhook and scheduled runs draw from. So this
68+
* ceiling only does its job while it sits *below* that counter: above it, a
69+
* flood empties the shared budget before this bucket ever refuses, and the
70+
* billing attack becomes an availability attack on unrelated production
71+
* workloads.
72+
*
73+
* Derived from the plan table rather than picked, because no fixed number holds
74+
* that invariant — the rates differ per plan and every one is operator
75+
* overridable through `RATE_LIMIT_*_SYNC`. A fraction of the smallest
76+
* configured rate keeps a public chat under the shared budget on every plan and
77+
* cannot drift if one of those defaults changes.
6478
*
65-
* A deployed chat runs the owner's workflow on the owner's plan bucket, credit
66-
* balance and concurrency reservation for whoever holds the link, so every
67-
* ceiling on that path belongs to the payer and none of them bound the caller.
68-
* Half the per-deployment rate, so one host cannot monopolize the deployment's
69-
* whole allowance, and still far above human chat cadence — a burst of 60 then
70-
* one message every two seconds — so shared NAT does not cost a real audience
71-
* its session.
79+
* The floor is deliberately shared by all plans for now. Sizing the slice to
80+
* the *payer's* own plan needs the subscription, which `preprocessExecution`
81+
* resolves a few lines after this runs, not here.
7282
*/
73-
const CHAT_EXECUTION_IP_LIMIT = executionsPerMinute(30)
83+
const CHAT_EXECUTION_RATE_PER_MINUTE = Math.max(
84+
1,
85+
Math.floor(Math.min(...Object.values(RATE_LIMITS).map((plan) => plan.sync.refillRate)) * 0.8)
86+
)
87+
88+
const CHAT_EXECUTION_LIMIT = executionsPerMinute(CHAT_EXECUTION_RATE_PER_MINUTE)
7489

7590
/**
76-
* What one deployed chat may spend of its owner's workspace allowance.
91+
* Executions one client IP may drive against a single deployed chat.
7792
*
78-
* This has to sit *below* the owner's plan bucket to do its job. A chat
79-
* execution debits the workspace `sync` counter — 50/min on free, 150 on pro,
80-
* 300 on team, 600 on enterprise — which is the same counter the owner's API,
81-
* webhook and scheduled runs draw from. A ceiling above it would let a flood
82-
* empty that shared counter before this bucket ever refused, which is how a
83-
* billing attack becomes an availability attack on unrelated production
84-
* workloads. At 60/min a public chat can spend at most a fraction of even the
85-
* cheapest paid plan and the owner's other triggers keep their headroom.
93+
* Half the per-deployment rate, so a single source can never consume the whole
94+
* allowance and leave the rest of the audience with none. It is above one
95+
* person's chat cadence but not above a busy office behind one NAT — which
96+
* costs little in practice, since traffic that heavy from one address would
97+
* meet the per-deployment ceiling moments later anyway.
8698
*/
87-
const CHAT_EXECUTION_LIMIT = executionsPerMinute(60)
99+
const CHAT_EXECUTION_IP_LIMIT = executionsPerMinute(
100+
Math.max(1, Math.floor(CHAT_EXECUTION_RATE_PER_MINUTE / 2))
101+
)
88102

89103
export const POST = withRouteHandler(
90104
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {

0 commit comments

Comments
 (0)