// 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 consume(userId: string, tier: RealtimeTier): Promise } export type ClientSecretResult = | { ok: true; data: Record } | { 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 } export interface RealtimeLogger { error(message: string, meta: Record): void } export interface RealtimeTokenDeps { authenticate(req: Request): Promise quota: RealtimeQuotaPort provider: RealtimeProviderPort logger: RealtimeLogger } function json( status: number, payload: Record, ): Response { return new Response(JSON.stringify(payload), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } async function readBody(req: Request): Promise { 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 { const { quota, provider, logger } = deps return async (req: Request): Promise => { 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' }) } } }