11import { randomUUID } from 'node:crypto'
2- import { db } from '@sim/db'
3- import { chat , workflow } from '@sim/db/schema'
42import { createLogger } from '@sim/logger'
5- import { eq } from 'drizzle-orm'
63import { type NextRequest , NextResponse } from 'next/server'
74import { ttsStreamContract } from '@/lib/api/contracts/media/tts-stream'
85import { parseRequest } from '@/lib/api/server'
96import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
107import {
11- type BillingAttributionSnapshot ,
128 checkAttributedUsageLimits ,
139 resolveSystemBillingAttribution ,
1410 toBillingContext ,
1511} from '@/lib/billing/core/billing-attribution'
1612import { recordUsage } from '@/lib/billing/core/usage-log'
13+ import { resolveDeployedChatCaller } from '@/lib/chat/deployed-chat-caller'
1714import { env } from '@/lib/core/config/env'
1815import { 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'
2217import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2318
2419const 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+ */
4741const TTS_CHAT_RATE_LIMIT = {
4842 maxTokens : 120 ,
4943 refillRate : 60 ,
@@ -65,81 +59,11 @@ const TTS_COST_PER_1K_CHARS = 0.05
6559 */
6660const 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-
12962export 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