Skip to content

Commit a8b812f

Browse files
committed
refactor(chat): share the deployed-chat auth gate across voice routes
Review of the previous commits surfaced duplication and one more gap: - The TTS and STT routes had grown near-identical copies of the chat auth + payer lookup. Extracted to resolveDeployedChatCaller, so the gate and the payer resolve together and cannot drift per route — that duplication is how the unmetered TTS path shipped in the first place. - Neither copy filtered chat.archivedAt, so an archived chat could still authorize spend against its former owner's workspace. The shared lookup now filters it, fixing both routes at once. Note: not covered by a test — the db chain mock does not evaluate WHERE clauses, so an assertion here could not fail. - Replaced the route's hand-rolled 429 builder with the existing enforceIpRateLimit helper, and added enforceChatRateLimit alongside the per-user/IP/workspace helpers. Gains the standard Retry-After and X-RateLimit-Reset headers plus throttle logging. - Dropped a test that asserted a module the route no longer imports was never called: it could not fail. - Narrowed the contract: unexported the single-use allowlists and dropped .passthrough() now that the body is a closed shape.
1 parent 7b236fd commit a8b812f

7 files changed

Lines changed: 173 additions & 230 deletions

File tree

apps/sim/app/(interfaces)/chat/hooks/use-audio-streaming.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { type RefObject, useCallback, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { DEFAULT_TTS_MODEL_ID } from '@/lib/api/contracts/media/tts-stream'
56

67
const logger = createLogger('UseAudioStreaming')
78

@@ -79,7 +80,7 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
7980
const { text, options } = item
8081
const {
8182
voiceId,
82-
modelId = 'eleven_flash_v2_5',
83+
modelId = DEFAULT_TTS_MODEL_ID,
8384
chatId,
8485
onAudioStart,
8586
onAudioEnd,

apps/sim/app/api/proxy/tts/stream/route.test.ts

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@ const {
1717
mockResolveSystemBillingAttribution,
1818
mockCheckAttributedUsageLimits,
1919
mockToBillingContext,
20-
mockCheckAndBillPayerOverageThreshold,
21-
mockCheckRateLimitDirect,
20+
mockEnforceIpRateLimit,
21+
mockEnforceChatRateLimit,
2222
} = vi.hoisted(() => ({
2323
mockRecordUsage: vi.fn(),
2424
mockCheckActorUsageLimits: vi.fn(),
2525
mockResolveSystemBillingAttribution: vi.fn(),
2626
mockCheckAttributedUsageLimits: vi.fn(),
2727
mockToBillingContext: vi.fn(),
28-
mockCheckAndBillPayerOverageThreshold: vi.fn(),
29-
mockCheckRateLimitDirect: vi.fn(),
28+
mockEnforceIpRateLimit: vi.fn(),
29+
mockEnforceChatRateLimit: vi.fn(),
3030
}))
3131

3232
const SYSTEM_BILLING_ATTRIBUTION = {
@@ -54,14 +54,9 @@ vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
5454
checkActorUsageLimits: mockCheckActorUsageLimits,
5555
}))
5656

57-
vi.mock('@/lib/billing/threshold-billing', () => ({
58-
checkAndBillPayerOverageThreshold: mockCheckAndBillPayerOverageThreshold,
59-
}))
60-
61-
vi.mock('@/lib/core/rate-limiter', () => ({
62-
RateLimiter: class {
63-
checkRateLimitDirect = mockCheckRateLimitDirect
64-
},
57+
vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({
58+
enforceIpRateLimit: mockEnforceIpRateLimit,
59+
enforceChatRateLimit: mockEnforceChatRateLimit,
6560
}))
6661

6762
vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: vi.fn(() => false) }))
@@ -107,7 +102,8 @@ beforeEach(() => {
107102
vi.clearAllMocks()
108103
resetDbChainMock()
109104
setEnv({ ELEVENLABS_API_KEY: 'test-key' })
110-
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true, remaining: 10 })
105+
mockEnforceIpRateLimit.mockResolvedValue(null)
106+
mockEnforceChatRateLimit.mockResolvedValue(null)
111107
mockRecordUsage.mockResolvedValue(undefined)
112108
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
113109
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
@@ -167,26 +163,32 @@ describe('POST /api/proxy/tts/stream — spend controls', () => {
167163
})
168164

169165
it('throttles per IP before any chat lookup so a flood cannot be amplified into queries', async () => {
170-
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 30_000 })
166+
mockEnforceIpRateLimit.mockResolvedValue(new Response('Rate limit exceeded', { status: 429 }))
171167

172168
const res = await POST(createMockRequest('POST', validBody()))
173169

174170
expect(res.status).toBe(429)
175-
expect(res.headers.get('Retry-After')).toBe('30')
176-
expect(mockCheckRateLimitDirect.mock.calls[0][0]).toContain('tts-stream:ip:')
171+
expect(mockEnforceIpRateLimit).toHaveBeenCalledWith('tts-stream', expect.anything(), {
172+
maxTokens: 60,
173+
refillRate: 30,
174+
refillIntervalMs: 60_000,
175+
})
176+
expect(mockEnforceChatRateLimit).not.toHaveBeenCalled()
177177
expect(global.fetch).not.toHaveBeenCalled()
178178
})
179179

180180
it('throttles per chat so many callers cannot drain one public chat', async () => {
181181
queueTableRows(schemaMock.chat, [publicChatRow])
182-
mockCheckRateLimitDirect
183-
.mockResolvedValueOnce({ allowed: true, remaining: 10 })
184-
.mockResolvedValueOnce({ allowed: false, retryAfterMs: 60_000 })
182+
mockEnforceChatRateLimit.mockResolvedValue(new Response('Rate limit exceeded', { status: 429 }))
185183

186184
const res = await POST(createMockRequest('POST', validBody()))
187185

188186
expect(res.status).toBe(429)
189-
expect(mockCheckRateLimitDirect.mock.calls[1][0]).toBe('tts-stream:chat:chat-1')
187+
expect(mockEnforceChatRateLimit).toHaveBeenCalledWith('tts-stream', 'chat-1', {
188+
maxTokens: 120,
189+
refillRate: 60,
190+
refillIntervalMs: 60_000,
191+
})
190192
expect(global.fetch).not.toHaveBeenCalled()
191193
expect(mockRecordUsage).not.toHaveBeenCalled()
192194
})
@@ -230,14 +232,6 @@ describe('POST /api/proxy/tts/stream — attribution', () => {
230232
expect(first).not.toBe(second)
231233
})
232234

233-
it('does not run per-request threshold settlement on the realtime path', async () => {
234-
queueTableRows(schemaMock.chat, [publicChatRow])
235-
236-
await POST(createMockRequest('POST', validBody()))
237-
238-
expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled()
239-
})
240-
241235
it('falls back to the chat owner when the workflow has no workspace', async () => {
242236
queueTableRows(schemaMock.chat, [{ ...publicChatRow, workspaceId: null }])
243237

apps/sim/app/api/proxy/tts/stream/route.ts

Lines changed: 50 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,27 @@
11
import { randomUUID } from 'node:crypto'
2-
import { db } from '@sim/db'
3-
import { chat, workflow } from '@sim/db/schema'
42
import { createLogger } from '@sim/logger'
5-
import { eq } from 'drizzle-orm'
63
import { type NextRequest, NextResponse } from 'next/server'
74
import { ttsStreamContract } from '@/lib/api/contracts/media/tts-stream'
85
import { parseRequest } from '@/lib/api/server'
96
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
107
import {
11-
type BillingAttributionSnapshot,
128
checkAttributedUsageLimits,
139
resolveSystemBillingAttribution,
1410
toBillingContext,
1511
} from '@/lib/billing/core/billing-attribution'
1612
import { recordUsage } from '@/lib/billing/core/usage-log'
13+
import { resolveDeployedChatCaller } from '@/lib/chat/deployed-chat-caller'
1714
import { env } from '@/lib/core/config/env'
1815
import { getCostMultiplier } from '@/lib/core/config/env-flags'
19-
import { RateLimiter } from '@/lib/core/rate-limiter'
20-
import { validateAuthToken } from '@/lib/core/security/deployment'
21-
import { getClientIp } from '@/lib/core/utils/request'
16+
import { enforceChatRateLimit, enforceIpRateLimit } from '@/lib/core/rate-limiter/route-helpers'
2217
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2318

2419
const logger = createLogger('ProxyTTSStreamAPI')
2520

26-
const rateLimiter = new RateLimiter()
27-
2821
/**
29-
* Public chats hand their id to every visitor, so the id alone cannot gate
30-
* spend on the platform ElevenLabs key.
31-
*
32-
* The per-IP bucket only filters naive floods: `getClientIp` trusts the
33-
* leftmost `X-Forwarded-For` value, which the caller controls, so a deliberate
34-
* attacker rotates past it. The per-chat bucket is the load-bearing control —
35-
* it is keyed on server-held state and bounds total spend per chat regardless
36-
* of how many source addresses the traffic claims to come from.
22+
* Filters naive floods only: `getClientIp` trusts the leftmost
23+
* `X-Forwarded-For` value, which the caller controls, so a deliberate attacker
24+
* rotates past this bucket. See {@link TTS_CHAT_RATE_LIMIT}.
3725
*
3826
* Deployed chat synthesizes sentence by sentence, so a real conversation issues
3927
* several requests per answer — hence the generous burst.
@@ -44,6 +32,12 @@ const TTS_IP_RATE_LIMIT = {
4432
refillIntervalMs: 60 * 1000,
4533
} as const
4634

35+
/**
36+
* The load-bearing spend control. Public chats hand their id to every visitor,
37+
* so the id alone cannot gate use of the platform ElevenLabs key. This bucket
38+
* is keyed on server-held state, bounding total spend per chat regardless of
39+
* how many source addresses the traffic claims to come from.
40+
*/
4741
const TTS_CHAT_RATE_LIMIT = {
4842
maxTokens: 120,
4943
refillRate: 60,
@@ -65,81 +59,11 @@ const TTS_COST_PER_1K_CHARS = 0.05
6559
*/
6660
const MAX_TTS_BODY_BYTES = 16 * 1024
6761

68-
interface ChatAuthResult {
69-
valid: boolean
70-
ownerId?: string
71-
workspaceId?: string | null
72-
}
73-
74-
/**
75-
* Validates chat-based authentication for deployed chat voice mode, resolving
76-
* the owning workspace so the synthesis can be attributed to a payer.
77-
*/
78-
async function validateChatAuth(request: NextRequest, chatId: string): Promise<ChatAuthResult> {
79-
try {
80-
const chatResult = await db
81-
.select({
82-
id: chat.id,
83-
userId: chat.userId,
84-
isActive: chat.isActive,
85-
authType: chat.authType,
86-
password: chat.password,
87-
workspaceId: workflow.workspaceId,
88-
})
89-
.from(chat)
90-
.leftJoin(workflow, eq(workflow.id, chat.workflowId))
91-
.where(eq(chat.id, chatId))
92-
.limit(1)
93-
94-
if (chatResult.length === 0 || !chatResult[0].isActive) {
95-
logger.warn('Chat not found or inactive for TTS auth:', chatId)
96-
return { valid: false }
97-
}
98-
99-
const chatData = chatResult[0]
100-
101-
if (chatData.authType === 'public') {
102-
return { valid: true, ownerId: chatData.userId, workspaceId: chatData.workspaceId }
103-
}
104-
105-
const cookieName = `chat_auth_${chatId}`
106-
const authCookie = request.cookies.get(cookieName)
107-
108-
if (
109-
authCookie &&
110-
validateAuthToken(authCookie.value, chatId, chatData.authType, chatData.password)
111-
) {
112-
return { valid: true, ownerId: chatData.userId, workspaceId: chatData.workspaceId }
113-
}
114-
115-
return { valid: false }
116-
} catch (error) {
117-
logger.error('Error validating chat auth for TTS:', error)
118-
return { valid: false }
119-
}
120-
}
121-
122-
function rateLimitResponse(retryAfterMs: number | undefined): Response {
123-
return new NextResponse('Rate limit exceeded', {
124-
status: 429,
125-
headers: { 'Retry-After': String(Math.ceil((retryAfterMs ?? 60_000) / 1000)) },
126-
})
127-
}
128-
12962
export const POST = withRouteHandler(async (request: NextRequest) => {
13063
try {
131-
/**
132-
* Throttle per IP before any database work so an anonymous flood cannot be
133-
* amplified into chat lookups.
134-
*/
135-
const clientIp = getClientIp(request)
136-
const ipRateCheck = await rateLimiter.checkRateLimitDirect(
137-
`tts-stream:ip:${clientIp}`,
138-
TTS_IP_RATE_LIMIT
139-
)
140-
if (!ipRateCheck.allowed) {
141-
return rateLimitResponse(ipRateCheck.retryAfterMs)
142-
}
64+
// Throttle per IP before any database work so a flood cannot be amplified into chat lookups.
65+
const ipLimited = await enforceIpRateLimit('tts-stream', request, TTS_IP_RATE_LIMIT)
66+
if (ipLimited) return ipLimited
14367

14468
const parsed = await parseRequest(
14569
ttsStreamContract,
@@ -160,39 +84,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
16084

16185
const { text, voiceId, modelId, chatId } = parsed.data.body
16286

163-
const chatAuth = await validateChatAuth(request, chatId)
164-
if (!chatAuth.valid) {
87+
const caller = await resolveDeployedChatCaller(request, chatId)
88+
if (!caller.authorized) {
16589
logger.warn('Chat authentication failed for TTS, chatId:', chatId)
16690
return new Response('Unauthorized', { status: 401 })
16791
}
16892

169-
const chatRateCheck = await rateLimiter.checkRateLimitDirect(
170-
`tts-stream:chat:${chatId}`,
171-
TTS_CHAT_RATE_LIMIT
172-
)
173-
if (!chatRateCheck.allowed) {
174-
return rateLimitResponse(chatRateCheck.retryAfterMs)
175-
}
176-
177-
/**
178-
* Anonymous deployed chats have no human request actor, so resolve the
179-
* system actor and immutable workspace payer together.
180-
*/
181-
const workspaceId = chatAuth.workspaceId ?? undefined
182-
let billingAttribution: BillingAttributionSnapshot | undefined
183-
let actorUserId = chatAuth.ownerId
184-
if (workspaceId) {
185-
billingAttribution = await resolveSystemBillingAttribution(workspaceId)
186-
actorUserId = billingAttribution.actorUserId
187-
}
188-
189-
if (actorUserId) {
190-
const usageCheck = billingAttribution
191-
? await checkAttributedUsageLimits(billingAttribution)
192-
: await checkActorUsageLimits(actorUserId)
193-
if (usageCheck.isExceeded) {
194-
return new Response(usageCheck.message || 'Usage limit exceeded.', { status: 402 })
195-
}
93+
const chatLimited = await enforceChatRateLimit('tts-stream', chatId, TTS_CHAT_RATE_LIMIT)
94+
if (chatLimited) return chatLimited
95+
96+
// Anonymous deployed chats have no human request actor, so the workspace payer is charged.
97+
const workspaceId = caller.workspaceId ?? undefined
98+
const billingAttribution = workspaceId
99+
? await resolveSystemBillingAttribution(workspaceId)
100+
: undefined
101+
const actorUserId = billingAttribution?.actorUserId ?? caller.ownerId
102+
103+
const usageCheck = billingAttribution
104+
? await checkAttributedUsageLimits(billingAttribution)
105+
: await checkActorUsageLimits(actorUserId)
106+
if (usageCheck.isExceeded) {
107+
return new Response(usageCheck.message || 'Usage limit exceeded.', { status: 402 })
196108
}
197109

198110
const apiKey = env.ELEVENLABS_API_KEY
@@ -245,31 +157,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
245157
* entry's stable fields — without this, two synthesis calls of equal length
246158
* in the same workspace would collide and the second would silently go
247159
* unbilled. Each call is a separate charge from ElevenLabs, so each needs
248-
* its own row rather than being deduplicated.
160+
* its own row rather than being deduplicated. `randomUUID` rather than
161+
* `generateRequestId`, whose fallback truncates to 8 characters.
249162
*
250163
* No threshold settlement here: it runs per metered event elsewhere and is
251164
* far too heavy for a per-sentence realtime path. The workflow execution
252165
* that produced this text already settles the payer.
253166
*/
254-
if (actorUserId) {
255-
try {
256-
await recordUsage({
257-
userId: actorUserId,
258-
workspaceId,
259-
...(billingAttribution ? toBillingContext(billingAttribution) : {}),
260-
entries: [
261-
{
262-
category: 'fixed',
263-
source: 'voice-output',
264-
description: `Voice output (${text.length} characters)`,
265-
cost: (text.length / 1000) * TTS_COST_PER_1K_CHARS * getCostMultiplier(),
266-
sourceReference: `voice-output:${chatId}:${randomUUID()}`,
267-
},
268-
],
269-
})
270-
} catch (err) {
271-
logger.warn('Failed to record voice output usage, continuing:', err)
272-
}
167+
try {
168+
await recordUsage({
169+
userId: actorUserId,
170+
workspaceId,
171+
...(billingAttribution ? toBillingContext(billingAttribution) : {}),
172+
entries: [
173+
{
174+
category: 'fixed',
175+
source: 'voice-output',
176+
description: `Voice output (${text.length} characters)`,
177+
cost: (text.length / 1000) * TTS_COST_PER_1K_CHARS * getCostMultiplier(),
178+
sourceReference: `voice-output:${chatId}:${randomUUID()}`,
179+
},
180+
],
181+
})
182+
} catch (err) {
183+
logger.warn('Failed to record voice output usage, continuing:', err)
273184
}
274185

275186
const { readable, writable } = new TransformStream({

0 commit comments

Comments
 (0)