chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockRecordUsage,
|
||||
mockCheckActorUsageLimits,
|
||||
mockGetWorkspaceBilledAccountUserId,
|
||||
mockVerifyWorkspaceMembership,
|
||||
mockChatRows,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockRecordUsage: vi.fn(),
|
||||
mockCheckActorUsageLimits: vi.fn(),
|
||||
mockGetWorkspaceBilledAccountUserId: vi.fn(),
|
||||
mockVerifyWorkspaceMembership: vi.fn(),
|
||||
mockChatRows: { value: [] as Array<Record<string, unknown>> },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: () => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.from = () => chain
|
||||
chain.leftJoin = () => chain
|
||||
chain.where = () => chain
|
||||
chain.limit = () => Promise.resolve(mockChatRows.value)
|
||||
return chain
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage-log', () => ({ recordUsage: mockRecordUsage }))
|
||||
|
||||
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
|
||||
checkActorUsageLimits: mockCheckActorUsageLimits,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/utils', () => ({
|
||||
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/workflows/utils', () => ({
|
||||
verifyWorkspaceMembership: mockVerifyWorkspaceMembership,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({ env: { ELEVENLABS_API_KEY: 'test-key' } }))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
isBillingEnabled: false,
|
||||
getCostMultiplier: () => 1,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/rate-limiter', () => ({
|
||||
RateLimiter: class {
|
||||
checkRateLimitDirect = vi.fn().mockResolvedValue({ allowed: true })
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: vi.fn(() => false) }))
|
||||
|
||||
import { POST } from '@/app/api/speech/token/route'
|
||||
|
||||
const publicChatRow = {
|
||||
id: 'chat-1',
|
||||
userId: 'owner-1',
|
||||
isActive: true,
|
||||
authType: 'public',
|
||||
password: null,
|
||||
workspaceId: 'ws-1',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockChatRows.value = []
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'member-1' } })
|
||||
mockRecordUsage.mockResolvedValue(undefined)
|
||||
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
|
||||
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-acct')
|
||||
mockVerifyWorkspaceMembership.mockResolvedValue('admin')
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'tok-123' }),
|
||||
// double-cast-allowed: minimal fetch stub for the ElevenLabs token call
|
||||
}) as unknown as typeof fetch
|
||||
})
|
||||
|
||||
describe('POST /api/speech/token — usage attribution', () => {
|
||||
it('editor voice: bills the session user and stamps the verified workspace', async () => {
|
||||
const res = await POST(createMockRequest('POST', { workspaceId: 'ws-1' }))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('member-1', 'ws-1')
|
||||
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
|
||||
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
|
||||
userId: 'member-1',
|
||||
workspaceId: 'ws-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('editor voice: rejects an unverified workspace id (requires an attributable workspace)', async () => {
|
||||
mockVerifyWorkspaceMembership.mockResolvedValue(null)
|
||||
|
||||
const res = await POST(createMockRequest('POST', { workspaceId: 'ws-not-mine' }))
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockRecordUsage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deployed chat: bills the workspace billed account and stamps the chat workspace', async () => {
|
||||
mockChatRows.value = [publicChatRow]
|
||||
|
||||
const res = await POST(createMockRequest('POST', { chatId: 'chat-1' }))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockGetSession).not.toHaveBeenCalled()
|
||||
expect(mockGetWorkspaceBilledAccountUserId).toHaveBeenCalledWith('ws-1')
|
||||
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
|
||||
userId: 'billed-acct',
|
||||
workspaceId: 'ws-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('deployed chat: falls back to the chat owner when no billed account resolves', async () => {
|
||||
mockChatRows.value = [publicChatRow]
|
||||
mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null)
|
||||
|
||||
const res = await POST(createMockRequest('POST', { chatId: 'chat-1' }))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
|
||||
userId: 'owner-1',
|
||||
workspaceId: 'ws-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an oversized body before any auth/billing work runs', async () => {
|
||||
const oversizedBody = { chatId: 'x'.repeat(64 * 1024) }
|
||||
const res = await POST(createMockRequest('POST', oversizedBody))
|
||||
|
||||
expect(res.status).toBe(413)
|
||||
expect(mockGetSession).not.toHaveBeenCalled()
|
||||
expect(mockRecordUsage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { speechTokenBodySchema } from '@/lib/api/contracts/media/speech'
|
||||
import { parseOptionalJsonBody } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { recordUsage } from '@/lib/billing/core/usage-log'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { validateAuthToken } from '@/lib/core/security/deployment'
|
||||
import { getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('SpeechTokenAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const ELEVENLABS_TOKEN_URL = 'https://api.elevenlabs.io/v1/single-use-token/realtime_scribe'
|
||||
|
||||
const VOICE_SESSION_COST_PER_MIN = 0.008
|
||||
const WORKSPACE_SESSION_MAX_MINUTES = 3
|
||||
const CHAT_SESSION_MAX_MINUTES = 1
|
||||
|
||||
const STT_TOKEN_RATE_LIMIT = {
|
||||
maxTokens: 30,
|
||||
refillRate: 3,
|
||||
refillIntervalMs: 72 * 1000,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* This body only ever carries an optional chatId/workspaceId string, so a
|
||||
* tight cap keeps an unauthenticated caller from forcing a large in-memory
|
||||
* allocation before the auth checks below run.
|
||||
*/
|
||||
const MAX_SPEECH_TOKEN_BODY_BYTES = 16 * 1024
|
||||
|
||||
function hashVoiceToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
async function validateChatAuth(
|
||||
request: NextRequest,
|
||||
chatId: string
|
||||
): Promise<{ valid: boolean; ownerId?: string; workspaceId?: string | null }> {
|
||||
try {
|
||||
const chatResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
userId: chat.userId,
|
||||
isActive: chat.isActive,
|
||||
authType: chat.authType,
|
||||
password: chat.password,
|
||||
workspaceId: workflow.workspaceId,
|
||||
})
|
||||
.from(chat)
|
||||
.leftJoin(workflow, eq(workflow.id, chat.workflowId))
|
||||
.where(eq(chat.id, chatId))
|
||||
.limit(1)
|
||||
|
||||
if (chatResult.length === 0 || !chatResult[0].isActive) {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
const chatData = chatResult[0]
|
||||
|
||||
if (chatData.authType === 'public') {
|
||||
return { valid: true, ownerId: chatData.userId, workspaceId: chatData.workspaceId }
|
||||
}
|
||||
|
||||
const cookieName = `chat_auth_${chatId}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
if (
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, chatId, chatData.authType, chatData.password)
|
||||
) {
|
||||
return { valid: true, ownerId: chatData.userId, workspaceId: chatData.workspaceId }
|
||||
}
|
||||
|
||||
return { valid: false }
|
||||
} catch (error) {
|
||||
logger.error('Error validating chat auth for STT:', error)
|
||||
return { valid: false }
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const parsedBody = await parseOptionalJsonBody(request, MAX_SPEECH_TOKEN_BODY_BYTES)
|
||||
if (!parsedBody.success) return parsedBody.response
|
||||
const body = speechTokenBodySchema.safeParse(parsedBody.data ?? {})
|
||||
const chatId =
|
||||
body.success && typeof body.data.chatId === 'string' ? body.data.chatId : undefined
|
||||
|
||||
let billingUserId: string | undefined
|
||||
let workspaceId: string | undefined
|
||||
|
||||
if (chatId) {
|
||||
const chatAuth = await validateChatAuth(request, chatId)
|
||||
if (!chatAuth.valid) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
// A deployed chat is used by anonymous end-users, so the cost belongs to the
|
||||
// workspace's billed account (the deployment's payer) — matching how the
|
||||
// chat's workflow execution bills. Fall back to the chat owner only when no
|
||||
// billed account resolves.
|
||||
workspaceId = chatAuth.workspaceId ?? undefined
|
||||
const billedAccountUserId = workspaceId
|
||||
? await getWorkspaceBilledAccountUserId(workspaceId)
|
||||
: null
|
||||
billingUserId = billedAccountUserId ?? chatAuth.ownerId
|
||||
} else {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
billingUserId = session.user.id
|
||||
// Editor voice: only attribute to a workspace the caller actually belongs to,
|
||||
// so a client-supplied id can't misattribute (or dodge) per-member usage.
|
||||
const requestedWorkspaceId =
|
||||
body.success && typeof body.data.workspaceId === 'string'
|
||||
? body.data.workspaceId
|
||||
: undefined
|
||||
if (requestedWorkspaceId) {
|
||||
const permission = await verifyWorkspaceMembership(session.user.id, requestedWorkspaceId)
|
||||
if (permission) workspaceId = requestedWorkspaceId
|
||||
}
|
||||
// Editor voice is always workspace-scoped; require an attributable workspace
|
||||
// so per-member usage can't be skipped and the cost stamped workspace-less.
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'Workspace context is required.' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
if (isBillingEnabled) {
|
||||
const rateLimitKey = chatId
|
||||
? `stt-token:chat:${chatId}:${getClientIp(request)}`
|
||||
: `stt-token:user:${billingUserId}`
|
||||
|
||||
const rateCheck = await rateLimiter.checkRateLimitDirect(rateLimitKey, STT_TOKEN_RATE_LIMIT)
|
||||
if (!rateCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Voice input rate limit exceeded. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(Math.ceil((rateCheck.retryAfterMs ?? 60000) / 1000)),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (billingUserId) {
|
||||
const usageCheck = await checkActorUsageLimits(billingUserId, workspaceId)
|
||||
if (usageCheck.isExceeded) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
usageCheck.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
|
||||
scope: usageCheck.scope,
|
||||
},
|
||||
{ status: 402 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = env.ELEVENLABS_API_KEY
|
||||
if (!apiKey?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Speech-to-text service is not configured' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(ELEVENLABS_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'xi-api-key': apiKey },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await response.json().catch(() => ({}))
|
||||
const message =
|
||||
errBody.detail || errBody.message || `Token request failed (${response.status})`
|
||||
logger.error('ElevenLabs token request failed', { status: response.status, message })
|
||||
return NextResponse.json({ error: message }, { status: 502 })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (billingUserId) {
|
||||
const maxMinutes = chatId ? CHAT_SESSION_MAX_MINUTES : WORKSPACE_SESSION_MAX_MINUTES
|
||||
const sessionCost = VOICE_SESSION_COST_PER_MIN * maxMinutes
|
||||
|
||||
await recordUsage({
|
||||
userId: billingUserId,
|
||||
workspaceId,
|
||||
entries: [
|
||||
{
|
||||
category: 'fixed',
|
||||
source: 'voice-input',
|
||||
description: `Voice input session (${maxMinutes} min)`,
|
||||
cost: sessionCost * getCostMultiplier(),
|
||||
sourceReference: `voice-input:${hashVoiceToken(data.token)}`,
|
||||
},
|
||||
],
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to record voice input usage, continuing:', err)
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ token: data.token })
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Failed to generate speech token')
|
||||
logger.error('Speech token error:', error)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user