Skip to content

Commit 7bb2789

Browse files
committed
fix(security): fail closed when voice-output usage cannot be recorded
Review round 1 findings: - A ledger write failure previously logged and streamed the audio anyway, leaving the spend unrecorded and the payer's usage understated. The caller is anonymous, so serving audio we could not charge for is the unmetered spend this route exists to prevent — it now returns 500. - Use generateId() from @sim/utils/id rather than crypto.randomUUID, per the AGENTS.md ID rule. generateId returns a full UUID v4, so the per-call uniqueness the usage_log event_key depends on is unchanged.
1 parent c687668 commit 7bb2789

2 files changed

Lines changed: 21 additions & 4 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,16 @@ describe('POST /api/proxy/tts/stream — attribution', () => {
257257
expect(mockRecordUsage).not.toHaveBeenCalled()
258258
})
259259

260+
it('refuses to stream audio it could not record a charge for', async () => {
261+
queueTableRows(schemaMock.chat, [publicChatRow])
262+
mockRecordUsage.mockRejectedValue(new Error('ledger unavailable'))
263+
264+
const res = await POST(createMockRequest('POST', validBody()))
265+
266+
expect(res.status).toBe(500)
267+
expect(res.headers.get('Content-Type')).not.toBe('audio/mpeg')
268+
})
269+
260270
it('rejects an unknown chat without touching the platform key', async () => {
261271
queueTableRows(schemaMock.chat, [])
262272

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { randomUUID } from 'node:crypto'
21
import { createLogger } from '@sim/logger'
2+
import { generateId } from '@sim/utils/id'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { ttsStreamContract } from '@/lib/api/contracts/media/tts-stream'
55
import { parseRequest } from '@/lib/api/server'
@@ -157,9 +157,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
157157
* entry's stable fields — without this, two synthesis calls of equal length
158158
* in the same workspace would collide and the second would silently go
159159
* unbilled. Each call is a separate charge from ElevenLabs, so each needs
160-
* its own row rather than being deduplicated. `randomUUID` rather than
160+
* its own row rather than being deduplicated. `generateId` rather than
161161
* `generateRequestId`, whose fallback truncates to 8 characters.
162162
*
163+
* A ledger failure fails the request rather than streaming anyway: the
164+
* caller is anonymous, so serving audio we could not charge for is exactly
165+
* the unmetered spend this route exists to prevent. The vendor call is
166+
* already paid for at this point, but the caller gains nothing from it, so
167+
* there is no incentive to farm ledger outages.
168+
*
163169
* No threshold settlement here: it runs per metered event elsewhere and is
164170
* far too heavy for a per-sentence realtime path. The workflow execution
165171
* that produced this text already settles the payer.
@@ -175,12 +181,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
175181
source: 'voice-output',
176182
description: `Voice output (${text.length} characters)`,
177183
cost: (text.length / 1000) * TTS_COST_PER_1K_CHARS * getCostMultiplier(),
178-
sourceReference: `voice-output:${chatId}:${randomUUID()}`,
184+
sourceReference: `voice-output:${chatId}:${generateId()}`,
179185
},
180186
],
181187
})
182188
} catch (err) {
183-
logger.warn('Failed to record voice output usage, continuing:', err)
189+
logger.error('Failed to record voice output usage, refusing to stream:', err)
190+
return new Response('Unable to record usage for this request', { status: 500 })
184191
}
185192

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

0 commit comments

Comments
 (0)