176 lines
5.8 KiB
TypeScript
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' })
|
|
}
|
|
}
|
|
}
|