d25d482dc2
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
228 lines
8.0 KiB
TypeScript
228 lines
8.0 KiB
TypeScript
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 })
|
|
}
|
|
})
|