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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+150
View File
@@ -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()
})
})
+227
View File
@@ -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 })
}
})