d3ro-voice/server/supabase/functions/realtime-token/handler.ts

176 lines
5.8 KiB
TypeScript

// server/supabase/functions/realtime-token/handler.ts
// realtime-token 유스케이스. IO는 모두 포트(인터페이스)로 주입받는다.
//
// 순서가 핵심이다:
// 1. 인증
// 2. 쿼터 조회(읽기 전용) → 티어/모델 403, 소진 429
// 3. 공급자 키 확인 → 없으면 503 (쿼터를 건드리기 전)
// 4. OpenAI client secret 발급 → 실패하면 502 (쿼터를 건드리기 전)
// 5. 발급에 성공한 뒤에만 세션 쿼터를 원자적으로 소비 → 거부되면 429, 토큰은 버린다
// 발급된 ephemeral secret은 연결에 쓰이기 전까지 비용이 없고 곧 만료되므로,
// 소비가 거부되어 버려져도 손실이 없다. 반대로 소비를 먼저 하면 공급자 장애 시
// 환불 수단 없이 세션이 차감된다.
//
// 공급자 에러 본문(조직 ID, 마스킹된 키 힌트 등)은 절대 응답에 싣지 않는다.
// 상태 코드만 서버 로그에 남기고 고정 코드를 반환한다.
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import {
buildRealtimeSessionConfig,
type RealtimeSessionConfig,
type RealtimeTier,
type RealtimeTokenRequest,
resolveRealtimeModel,
} from './policy.ts'
export interface RealtimeUser {
id: string
}
export type AuthenticateResult =
| { ok: true; user: RealtimeUser }
| { ok: false; response: Response }
export interface RealtimeQuotaSnapshot {
allowed: boolean
current: number
limit: number
period: string
tier: RealtimeTier
overageCredits: number
}
export interface RealtimeQuotaConsumeResult {
allowed: boolean
current: number
limit: number
overageCredits: number
}
/** 세션 쿼터 포트 — check는 부작용 없음, consume은 원자적 1회 차감. */
export interface RealtimeQuotaPort {
check(userId: string): Promise<RealtimeQuotaSnapshot>
consume(userId: string, tier: RealtimeTier): Promise<RealtimeQuotaConsumeResult>
}
export type ClientSecretResult =
| { ok: true; data: Record<string, unknown> }
| { ok: false; reason: 'upstream_status'; status: number }
| { ok: false; reason: 'network' | 'invalid_response' }
/** Realtime 공급자 포트 — 에러 본문은 포트 밖으로 내보내지 않는다. */
export interface RealtimeProviderPort {
isConfigured(): boolean
mintClientSecret(userId: string, session: RealtimeSessionConfig): Promise<ClientSecretResult>
}
export interface RealtimeLogger {
error(message: string, meta: Record<string, unknown>): void
}
export interface RealtimeTokenDeps {
authenticate(req: Request): Promise<AuthenticateResult>
quota: RealtimeQuotaPort
provider: RealtimeProviderPort
logger: RealtimeLogger
}
function json(
status: number,
payload: Record<string, unknown>,
): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
async function readBody(req: Request): Promise<RealtimeTokenRequest> {
const parsed: unknown = await req.json().catch(() => ({}))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as RealtimeTokenRequest
: {}
}
export function createRealtimeTokenHandler(deps: RealtimeTokenDeps): (req: Request) => Promise<Response> {
const { quota, provider, logger } = deps
return async (req: Request): Promise<Response> => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
if (req.method !== 'POST') {
return json(405, { error: 'Method not allowed' })
}
try {
const auth = await deps.authenticate(req)
if (!auth.ok) return auth.response
const user = auth.user
const body = await readBody(req)
// 1단계: 티어 + 세션 쿼터 확인 (읽기 전용)
const snapshot = await quota.check(user.id)
const tier = snapshot.tier
const resolution = resolveRealtimeModel(tier, body.model)
if (!resolution.allowed) {
return json(403, {
error: resolution.error,
tier,
requested: resolution.requested,
allowed: resolution.allowedModels,
})
}
if (!snapshot.allowed) {
return json(429, {
error: 'quota_exceeded',
current: snapshot.current,
limit: snapshot.limit,
period: snapshot.period,
tier,
overage_credits: snapshot.overageCredits,
})
}
// 2단계: 공급자 가용성 — 쿼터 차감 전에 확인
if (!provider.isConfigured()) {
return json(503, { error: 'not_configured', message: 'OPENAI_API_KEY 미설정' })
}
// 3단계: OpenAI ephemeral client secret 발급
const session = buildRealtimeSessionConfig(body, resolution.model)
const minted = await provider.mintClientSecret(user.id, session)
if (!minted.ok) {
logger.error('realtime-token provider failed', {
reason: minted.reason,
...(minted.reason === 'upstream_status' ? { status: minted.status } : {}),
})
const error = minted.reason === 'invalid_response'
? 'provider_invalid_response'
: 'provider_request_failed'
return json(502, { error })
}
// 4단계: 발급 성공 후에만 세션 1회를 원자적으로 소비
const consumed = await quota.consume(user.id, tier)
if (!consumed.allowed) {
return json(429, {
error: 'quota_exceeded',
current: consumed.current,
limit: consumed.limit,
tier,
overage_credits: consumed.overageCredits,
})
}
return json(200, { ...minted.data, model: resolution.model, tier })
} catch (err) {
logger.error('realtime-token failed', {
kind: err instanceof Error ? err.name : typeof err,
})
return json(500, { error: 'internal_error' })
}
}
}