From 2251fe2da1f554df634f4f7161514389859bb3c2 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:54:00 +0900 Subject: [PATCH] fix(realtime-token): spend the session quota only after a token is minted and stop leaking provider errors --- .../functions/realtime-token/handler.test.ts | 234 ++++++++++++++++++ .../functions/realtime-token/handler.ts | 176 +++++++++++++ .../realtime-token/index.contract.test.ts | 32 +++ .../functions/realtime-token/index.ts | 197 ++++----------- .../realtime-token/openai-provider.ts | 55 ++++ .../functions/realtime-token/policy.test.ts | 52 ++++ .../functions/realtime-token/policy.ts | 82 ++++++ 7 files changed, 673 insertions(+), 155 deletions(-) create mode 100644 server/supabase/functions/realtime-token/handler.test.ts create mode 100644 server/supabase/functions/realtime-token/handler.ts create mode 100644 server/supabase/functions/realtime-token/index.contract.test.ts create mode 100644 server/supabase/functions/realtime-token/openai-provider.ts create mode 100644 server/supabase/functions/realtime-token/policy.test.ts create mode 100644 server/supabase/functions/realtime-token/policy.ts diff --git a/server/supabase/functions/realtime-token/handler.test.ts b/server/supabase/functions/realtime-token/handler.test.ts new file mode 100644 index 0000000..6be19e6 --- /dev/null +++ b/server/supabase/functions/realtime-token/handler.test.ts @@ -0,0 +1,234 @@ +import { + type ClientSecretResult, + createRealtimeTokenHandler, + type RealtimeProviderPort, + type RealtimeQuotaConsumeResult, + type RealtimeQuotaPort, + type RealtimeQuotaSnapshot, +} from './handler.ts' +import type { RealtimeSessionConfig, RealtimeTier } from './policy.ts' +import { createOpenAiRealtimeProvider } from './openai-provider.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function assertEquals(actual: unknown, expected: unknown, message: string): void { + const a = JSON.stringify(actual) + const e = JSON.stringify(expected) + if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`) +} + +const LEAKY_BODY = JSON.stringify({ + error: { + message: 'Rate limit reached for org-SECRETORG123 on requests. Incorrect API key provided: sk-proj-****abcd', + }, +}) + +interface Harness { + handler: (req: Request) => Promise + consumeCalls: Array<{ userId: string; tier: RealtimeTier }> + mintCalls: RealtimeSessionConfig[] + logs: Array<{ message: string; meta: Record }> +} + +interface HarnessOptions { + tier?: RealtimeTier + checkAllowed?: boolean + consumeAllowed?: boolean + configured?: boolean + mint?: () => Promise + provider?: RealtimeProviderPort + consumeThrows?: Error +} + +function harness(options: HarnessOptions = {}): Harness { + const consumeCalls: Harness['consumeCalls'] = [] + const mintCalls: RealtimeSessionConfig[] = [] + const logs: Harness['logs'] = [] + const tier = options.tier ?? 'pro' + + const quota: RealtimeQuotaPort = { + check(): Promise { + return Promise.resolve({ + allowed: options.checkAllowed ?? true, + current: 3, + limit: 30, + period: 'daily', + tier, + overageCredits: 0, + }) + }, + consume(userId: string, consumeTier: RealtimeTier): Promise { + consumeCalls.push({ userId, tier: consumeTier }) + if (options.consumeThrows) return Promise.reject(options.consumeThrows) + return Promise.resolve({ + allowed: options.consumeAllowed ?? true, + current: 4, + limit: 30, + overageCredits: 0, + }) + }, + } + + const provider: RealtimeProviderPort = options.provider ?? { + isConfigured: () => options.configured ?? true, + mintClientSecret(_userId: string, session: RealtimeSessionConfig): Promise { + mintCalls.push(session) + return options.mint?.() ?? + Promise.resolve({ ok: true, data: { value: 'ek_test', expires_at: 123 } }) + }, + } + + const handler = createRealtimeTokenHandler({ + authenticate: () => Promise.resolve({ ok: true, user: { id: 'user-1' } }), + quota, + provider, + logger: { error: (message, meta) => logs.push({ message, meta }) }, + }) + return { handler, consumeCalls, mintCalls, logs } +} + +function post(body: unknown = {}): Request { + return new Request('http://localhost/realtime-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +Deno.test('missing provider key returns 503 without consuming the session quota', async () => { + const h = harness({ configured: false }) + const res = await h.handler(post()) + assertEquals(res.status, 503, 'status') + assertEquals((await res.json()).error, 'not_configured', 'error code') + assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed') + assertEquals(h.mintCalls.length, 0, 'provider must not be called') +}) + +Deno.test('upstream 4xx/5xx returns a fixed code, keeps quota, and never leaks the provider body', async () => { + for (const status of [401, 429, 500, 503]) { + const fetchImpl: typeof fetch = () => Promise.resolve(new Response(LEAKY_BODY, { status })) + const h = harness({ + provider: createOpenAiRealtimeProvider({ readApiKey: () => 'sk-test', fetchImpl }), + }) + const res = await h.handler(post()) + const text = await res.text() + assertEquals(res.status, 502, `status for upstream ${status}`) + assertEquals(JSON.parse(text), { error: 'provider_request_failed' }, 'fixed error body') + assert(!text.includes('org-SECRETORG123'), 'org id leaked') + assert(!text.includes('sk-proj'), 'key hint leaked') + assertEquals(h.consumeCalls.length, 0, `quota consumed on upstream ${status}`) + assertEquals(h.logs[0]?.meta, { reason: 'upstream_status', status }, 'status is logged server-side') + assert(!JSON.stringify(h.logs).includes('SECRETORG'), 'provider body must not reach logs either') + } +}) + +Deno.test('network failure returns 502 without consuming quota', async () => { + const fetchImpl: typeof fetch = () => Promise.reject(new TypeError('dns failure for api.openai.com')) + const h = harness({ + provider: createOpenAiRealtimeProvider({ readApiKey: () => 'sk-test', fetchImpl }), + }) + const res = await h.handler(post()) + assertEquals(res.status, 502, 'status') + assertEquals(await res.json(), { error: 'provider_request_failed' }, 'body') + assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed') +}) + +Deno.test('non-object upstream success body returns provider_invalid_response without consuming quota', async () => { + const fetchImpl: typeof fetch = () => Promise.resolve(new Response('not json', { status: 200 })) + const h = harness({ + provider: createOpenAiRealtimeProvider({ readApiKey: () => 'sk-test', fetchImpl }), + }) + const res = await h.handler(post()) + assertEquals(res.status, 502, 'status') + assertEquals(await res.json(), { error: 'provider_invalid_response' }, 'body') + assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed') +}) + +Deno.test('successful mint consumes exactly one session and returns the secret with model and tier', async () => { + const h = harness({ tier: 'pro' }) + const res = await h.handler(post({ voice: 'cedar', instructions: 'be brief' })) + assertEquals(res.status, 200, 'status') + assertEquals( + await res.json(), + { value: 'ek_test', expires_at: 123, model: 'gpt-realtime-2.1-mini', tier: 'pro' }, + 'body', + ) + assertEquals(h.consumeCalls, [{ userId: 'user-1', tier: 'pro' }], 'consume once') + assertEquals(h.mintCalls[0], { + type: 'realtime', + model: 'gpt-realtime-2.1-mini', + instructions: 'be brief', + audio: { output: { voice: 'cedar' } }, + }, 'session config') +}) + +Deno.test('a consume race lost after minting returns 429 and discards the secret', async () => { + const h = harness({ consumeAllowed: false }) + const res = await h.handler(post()) + const text = await res.text() + assertEquals(res.status, 429, 'status') + assertEquals(JSON.parse(text).error, 'quota_exceeded', 'error code') + assert(!text.includes('ek_test'), 'minted secret must not be returned when quota is denied') +}) + +Deno.test('tier and quota denials happen before the provider is called', async () => { + const free = harness({ tier: 'free' }) + const freeRes = await free.handler(post()) + assertEquals(freeRes.status, 403, 'free status') + assertEquals((await freeRes.json()).error, 'tier_not_allowed', 'free error') + + const wrongModel = harness({ tier: 'pro' }) + const wrongRes = await wrongModel.handler(post({ model: 'gpt-realtime-2.1' })) + assertEquals(wrongRes.status, 403, 'model status') + assertEquals(await wrongRes.json(), { + error: 'model_not_allowed', + tier: 'pro', + requested: 'gpt-realtime-2.1', + allowed: ['gpt-realtime-2.1-mini'], + }, 'model body') + + const exhausted = harness({ checkAllowed: false }) + const exhaustedRes = await exhausted.handler(post()) + assertEquals(exhaustedRes.status, 429, 'exhausted status') + await exhaustedRes.body?.cancel() + + for (const h of [free, wrongModel, exhausted]) { + assertEquals(h.mintCalls.length, 0, 'provider must not be called') + assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed') + } +}) + +Deno.test('unexpected errors return a generic internal_error without the exception message', async () => { + const h = harness({ consumeThrows: new Error('Failed to consume quota: relation daily_usage secret detail') }) + const res = await h.handler(post()) + const text = await res.text() + assertEquals(res.status, 500, 'status') + assertEquals(JSON.parse(text), { error: 'internal_error' }, 'body') + assert(!text.includes('secret detail'), 'exception message leaked') +}) + +Deno.test('auth rejection response is returned as-is and non-POST is 405', async () => { + const handler = createRealtimeTokenHandler({ + authenticate: () => + Promise.resolve({ ok: false, response: new Response('{"error":"Invalid auth token"}', { status: 401 }) }), + quota: { + check: () => Promise.reject(new Error('must not be called')), + consume: () => Promise.reject(new Error('must not be called')), + }, + provider: { isConfigured: () => true, mintClientSecret: () => Promise.reject(new Error('unused')) }, + logger: { error: () => undefined }, + }) + const res = await handler(post()) + assertEquals(res.status, 401, 'auth status') + await res.body?.cancel() + + const getRes = await handler(new Request('http://localhost/realtime-token', { method: 'GET' })) + assertEquals(getRes.status, 405, 'method status') + await getRes.body?.cancel() + + const preflight = await handler(new Request('http://localhost/realtime-token', { method: 'OPTIONS' })) + assertEquals(preflight.status, 200, 'preflight status') + await preflight.body?.cancel() +}) diff --git a/server/supabase/functions/realtime-token/handler.ts b/server/supabase/functions/realtime-token/handler.ts new file mode 100644 index 0000000..3574f6c --- /dev/null +++ b/server/supabase/functions/realtime-token/handler.ts @@ -0,0 +1,176 @@ +// 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' }) + } + } +} diff --git a/server/supabase/functions/realtime-token/index.contract.test.ts b/server/supabase/functions/realtime-token/index.contract.test.ts new file mode 100644 index 0000000..87dd704 --- /dev/null +++ b/server/supabase/functions/realtime-token/index.contract.test.ts @@ -0,0 +1,32 @@ +// Source-order contract for realtime-token: provider availability and minting +// must precede the quota spend, and raw errors must never reach the response. +const index = await Deno.readTextFile(new URL('./index.ts', import.meta.url)) +const handler = await Deno.readTextFile(new URL('./handler.ts', import.meta.url)) +const provider = await Deno.readTextFile(new URL('./openai-provider.ts', import.meta.url)) + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +Deno.test('index is only a composition root over the tested handler', () => { + assert(index.includes('createRealtimeTokenHandler('), 'index must wire the tested handler') + assert(index.includes('Deno.serve(handler)'), 'index must serve the composed handler') + assert(!index.includes('api.openai.com'), 'provider IO must live in openai-provider.ts') +}) + +Deno.test('provider checks and minting precede the quota spend', () => { + const configuredIndex = handler.indexOf('provider.isConfigured()') + const mintIndex = handler.indexOf('provider.mintClientSecret(', configuredIndex) + const consumeIndex = handler.indexOf('quota.consume(', mintIndex) + assert(configuredIndex >= 0, 'provider availability check missing') + assert(mintIndex > configuredIndex, 'minting must follow the availability check') + assert(consumeIndex > mintIndex, 'quota must be consumed only after a successful mint') +}) + +Deno.test('raw provider and exception text are never returned', () => { + for (const source of [index, handler, provider]) { + assert(!source.includes('JSON.stringify({ error: message })'), 'raw exception message returned') + assert(!source.includes('errText'), 'provider error body is read into the response path') + } + assert(handler.includes("json(500, { error: 'internal_error' })"), 'generic internal error missing') +}) diff --git a/server/supabase/functions/realtime-token/index.ts b/server/supabase/functions/realtime-token/index.ts index fa82e4e..7149c08 100644 --- a/server/supabase/functions/realtime-token/index.ts +++ b/server/supabase/functions/realtime-token/index.ts @@ -3,174 +3,61 @@ // 렌더러가 이 토큰으로 OpenAI와 직접 WebRTC 연결한다 (서버 키 비노출). // 요청: application/json { model?, voice?, instructions? } // 응답: OpenAI client_secrets 응답 그대로 + { model, tier } +// +// 이 파일은 조립 루트(composition root)다. 유스케이스는 handler.ts, +// 정책은 policy.ts, OpenAI 호출은 openai-provider.ts에 있다. -import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' -import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' +import { corsHeaders } from '../_shared/cors.ts' +import { authErrorResponse, requireUser, type AuthError } from '../_shared/auth.ts' import { checkQuota, consumeQuota, createServiceRoleClient, getQuotaPolicy, - type Tier, } from '../_shared/quota.ts' import { readProviderKey } from '../_shared/provider-key.ts' +import { + type AuthenticateResult, + createRealtimeTokenHandler, + type RealtimeQuotaPort, +} from './handler.ts' +import { createOpenAiRealtimeProvider } from './openai-provider.ts' -interface RealtimeTokenRequest { - model?: string - voice?: string - instructions?: string +function isAuthError(err: unknown): err is AuthError { + return !!err && typeof err === 'object' && 'status' in err && 'message' in err } -/** 티어별 허용 Realtime 모델 — free 차단, pro는 mini만, 상위 티어는 풀 모델까지 */ -const TIER_MODELS: Record = { - free: [], - pro: ['gpt-realtime-2.1-mini'], - pro_plus: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'], - team: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'], - enterprise: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'], -} - -const DEFAULT_MODEL: Record = { - free: null, - pro: 'gpt-realtime-2.1-mini', - pro_plus: 'gpt-realtime-2.1', - team: 'gpt-realtime-2.1', - enterprise: 'gpt-realtime-2.1', -} - -const DEFAULT_VOICE = 'marin' -const MAX_INSTRUCTIONS_LENGTH = 2000 - -Deno.serve(async (req: Request) => { - const preflight = handleCorsPreflightRequest(req) - if (preflight) return preflight - - if (req.method !== 'POST') { - return new Response(JSON.stringify({ error: 'Method not allowed' }), { - status: 405, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }) - } - +async function authenticate(req: Request): Promise { try { const user = await requireUser(req) - const serviceClient = createServiceRoleClient() - - const body = (await req.json().catch(() => ({}))) as RealtimeTokenRequest - - // 1단계: 티어 + 세션 쿼터 확인 - const quota = await checkQuota(user.id, 'realtime_session', serviceClient) - const tier = quota.tier - - const requestedModel = body.model ?? DEFAULT_MODEL[tier] - if (!requestedModel || !TIER_MODELS[tier].includes(requestedModel)) { - return new Response( - JSON.stringify({ - error: tier === 'free' ? 'tier_not_allowed' : 'model_not_allowed', - tier, - requested: body.model ?? null, - allowed: TIER_MODELS[tier], - }), - { - status: 403, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }, - ) - } - - if (!quota.allowed) { - return new Response( - JSON.stringify({ - error: 'quota_exceeded', - current: quota.current, - limit: quota.limit, - period: quota.period, - tier, - overage_credits: quota.overageCredits, - }), - { - status: 429, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }, - ) - } - - // 2단계: 쿼터 소비 (세션 시작 = 1회) - const policy = getQuotaPolicy(tier, 'realtime_session') - const consume = await consumeQuota(user.id, 'realtime_session', serviceClient, policy.limit) - if (!consume.allowed) { - return new Response( - JSON.stringify({ - error: 'quota_exceeded', - current: consume.current, - limit: consume.limit, - tier, - overage_credits: consume.overageCredits, - }), - { - status: 429, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }, - ) - } - - const openaiKey = readProviderKey('OPENAI_API_KEY') - if (!openaiKey) { - return new Response( - JSON.stringify({ error: 'not_configured', message: 'OPENAI_API_KEY 미설정' }), - { - status: 503, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }, - ) - } - - // 3단계: OpenAI ephemeral client secret 발급 - const instructions = - typeof body.instructions === 'string' - ? body.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH) - : undefined - - const openaiResp = await fetch('https://api.openai.com/v1/realtime/client_secrets', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${openaiKey}`, - 'OpenAI-Safety-Identifier': user.id, - }, - body: JSON.stringify({ - session: { - type: 'realtime', - model: requestedModel, - ...(instructions ? { instructions } : {}), - audio: { - output: { voice: body.voice ?? DEFAULT_VOICE }, - }, - }, - }), - }) - - if (!openaiResp.ok) { - const errText = await openaiResp.text() - throw new Error(`OpenAI ${openaiResp.status}: ${errText.slice(0, 500)}`) - } - - const data = await openaiResp.json() - return new Response( - JSON.stringify({ ...data, model: requestedModel, tier }), - { - status: 200, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }, - ) + return { ok: true, user: { id: user.id } } } catch (err) { - if (err && typeof err === 'object' && 'status' in err && 'message' in err) { - return authErrorResponse(err as AuthError, corsHeaders) - } - const message = err instanceof Error ? err.message : 'Unknown error' - return new Response(JSON.stringify({ error: message }), { - status: 500, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }) + if (isAuthError(err)) return { ok: false, response: authErrorResponse(err, corsHeaders) } + throw err } +} + +/** 서비스 롤 클라이언트는 요청 처리 중에 만든다 — 환경 누락이 부팅 실패가 아닌 500이 되도록. */ +function createSupabaseRealtimeQuota(): RealtimeQuotaPort { + return { + check: (userId) => checkQuota(userId, 'realtime_session', createServiceRoleClient()), + consume: (userId, tier) => + consumeQuota( + userId, + 'realtime_session', + createServiceRoleClient(), + getQuotaPolicy(tier, 'realtime_session').limit, + ), + } +} + +const handler = createRealtimeTokenHandler({ + authenticate, + quota: createSupabaseRealtimeQuota(), + provider: createOpenAiRealtimeProvider({ readApiKey: () => readProviderKey('OPENAI_API_KEY') }), + logger: { + error: (message, meta) => console.error(message, meta), + }, }) + +Deno.serve(handler) diff --git a/server/supabase/functions/realtime-token/openai-provider.ts b/server/supabase/functions/realtime-token/openai-provider.ts new file mode 100644 index 0000000..0af52d6 --- /dev/null +++ b/server/supabase/functions/realtime-token/openai-provider.ts @@ -0,0 +1,55 @@ +// server/supabase/functions/realtime-token/openai-provider.ts +// RealtimeProviderPort의 OpenAI 어댑터. 공급자 에러 본문은 읽지 않고 버린다 +// (조직 ID·마스킹된 키 힌트가 섞여 있을 수 있음) — 상태 코드만 돌려준다. + +import type { RealtimeSessionConfig } from './policy.ts' +import type { ClientSecretResult, RealtimeProviderPort } from './handler.ts' + +export const OPENAI_CLIENT_SECRETS_URL = 'https://api.openai.com/v1/realtime/client_secrets' + +export interface OpenAiRealtimeProviderOptions { + /** 호출마다 읽는다 — 시크릿 교체가 재배포 없이 반영되도록. 빈 문자열이면 미설정. */ + readApiKey(): string + fetchImpl?: typeof fetch +} + +export function createOpenAiRealtimeProvider(options: OpenAiRealtimeProviderOptions): RealtimeProviderPort { + const fetchImpl = options.fetchImpl ?? fetch + + return { + isConfigured(): boolean { + return options.readApiKey() !== '' + }, + + async mintClientSecret(userId: string, session: RealtimeSessionConfig): Promise { + const apiKey = options.readApiKey() + if (!apiKey) return { ok: false, reason: 'network' } + + let response: Response + try { + response = await fetchImpl(OPENAI_CLIENT_SECRETS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + 'OpenAI-Safety-Identifier': userId, + }, + body: JSON.stringify({ session }), + }) + } catch { + return { ok: false, reason: 'network' } + } + + if (!response.ok) { + await response.body?.cancel().catch(() => undefined) + return { ok: false, reason: 'upstream_status', status: response.status } + } + + const data: unknown = await response.json().catch(() => null) + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return { ok: false, reason: 'invalid_response' } + } + return { ok: true, data: data as Record } + }, + } +} diff --git a/server/supabase/functions/realtime-token/policy.test.ts b/server/supabase/functions/realtime-token/policy.test.ts new file mode 100644 index 0000000..43dd7a1 --- /dev/null +++ b/server/supabase/functions/realtime-token/policy.test.ts @@ -0,0 +1,52 @@ +import { + buildRealtimeSessionConfig, + DEFAULT_VOICE, + MAX_INSTRUCTIONS_LENGTH, + resolveRealtimeModel, +} from './policy.ts' + +function assertEquals(actual: unknown, expected: unknown, message: string): void { + const a = JSON.stringify(actual) + const e = JSON.stringify(expected) + if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`) +} + +Deno.test('resolveRealtimeModel applies tier defaults and allow-lists', () => { + assertEquals(resolveRealtimeModel('pro', undefined), { allowed: true, model: 'gpt-realtime-2.1-mini' }, 'pro default') + assertEquals(resolveRealtimeModel('pro_plus', undefined), { allowed: true, model: 'gpt-realtime-2.1' }, 'pro+ default') + assertEquals( + resolveRealtimeModel('team', 'gpt-realtime-2.1-mini'), + { allowed: true, model: 'gpt-realtime-2.1-mini' }, + 'team explicit mini', + ) + assertEquals(resolveRealtimeModel('free', undefined), { + allowed: false, + error: 'tier_not_allowed', + requested: null, + allowedModels: [], + }, 'free blocked') + assertEquals(resolveRealtimeModel('pro', 'gpt-realtime-2.1'), { + allowed: false, + error: 'model_not_allowed', + requested: 'gpt-realtime-2.1', + allowedModels: ['gpt-realtime-2.1-mini'], + }, 'pro cannot pick full model') +}) + +Deno.test('buildRealtimeSessionConfig clamps instructions and defaults voice', () => { + const long = 'x'.repeat(MAX_INSTRUCTIONS_LENGTH + 50) + const config = buildRealtimeSessionConfig({ instructions: long }, 'gpt-realtime-2.1-mini') + assertEquals(config.instructions?.length, MAX_INSTRUCTIONS_LENGTH, 'instructions clamped') + assertEquals(config.audio.output.voice, DEFAULT_VOICE, 'default voice') + + assertEquals( + buildRealtimeSessionConfig({ voice: 'cedar', instructions: '' }, 'm'), + { type: 'realtime', model: 'm', audio: { output: { voice: 'cedar' } } }, + 'empty instructions omitted', + ) + assertEquals( + buildRealtimeSessionConfig({ voice: 42, instructions: 7 }, 'm'), + { type: 'realtime', model: 'm', audio: { output: { voice: DEFAULT_VOICE } } }, + 'non-string fields ignored', + ) +}) diff --git a/server/supabase/functions/realtime-token/policy.ts b/server/supabase/functions/realtime-token/policy.ts new file mode 100644 index 0000000..02e8855 --- /dev/null +++ b/server/supabase/functions/realtime-token/policy.ts @@ -0,0 +1,82 @@ +// server/supabase/functions/realtime-token/policy.ts +// Realtime 세션 발급 정책 — 순수 함수만 둔다 (IO·Deno 전역·네트워크 import 없음). +// 티어별 허용 모델 결정과 OpenAI client_secrets 세션 설정 조립을 담당한다. + +import type { PlanQuotaTier } from '../_shared/core-contract.generated.ts' + +export type RealtimeTier = PlanQuotaTier + +export interface RealtimeTokenRequest { + model?: unknown + voice?: unknown + instructions?: unknown +} + +/** 티어별 허용 Realtime 모델 — free 차단, pro는 mini만, 상위 티어는 풀 모델까지 */ +export const TIER_MODELS: Readonly> = { + free: [], + pro: ['gpt-realtime-2.1-mini'], + pro_plus: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'], + team: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'], + enterprise: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'], +} + +export const DEFAULT_MODEL: Readonly> = { + free: null, + pro: 'gpt-realtime-2.1-mini', + pro_plus: 'gpt-realtime-2.1', + team: 'gpt-realtime-2.1', + enterprise: 'gpt-realtime-2.1', +} + +export const DEFAULT_VOICE = 'marin' +export const MAX_INSTRUCTIONS_LENGTH = 2000 + +export type ModelResolution = + | { allowed: true; model: string } + | { + allowed: false + error: 'tier_not_allowed' | 'model_not_allowed' + requested: string | null + allowedModels: readonly string[] + } + +/** 요청 모델(없으면 티어 기본값)이 티어에서 허용되는지 판정한다. */ +export function resolveRealtimeModel(tier: RealtimeTier, requested: unknown): ModelResolution { + const requestedModel = typeof requested === 'string' ? requested : null + const allowedModels = TIER_MODELS[tier] ?? [] + const model = requestedModel ?? DEFAULT_MODEL[tier] ?? null + if (model && allowedModels.includes(model)) { + return { allowed: true, model } + } + return { + allowed: false, + error: tier === 'free' ? 'tier_not_allowed' : 'model_not_allowed', + requested: requestedModel, + allowedModels, + } +} + +export interface RealtimeSessionConfig { + type: 'realtime' + model: string + instructions?: string + audio: { output: { voice: string } } +} + +/** OpenAI client_secrets 요청의 session 객체를 조립한다. */ +export function buildRealtimeSessionConfig( + body: RealtimeTokenRequest, + model: string, +): RealtimeSessionConfig { + const instructions = typeof body.instructions === 'string' + ? body.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH) + : undefined + const voice = typeof body.voice === 'string' && body.voice ? body.voice : DEFAULT_VOICE + return { + type: 'realtime', + model, + ...(instructions ? { instructions } : {}), + audio: { output: { voice } }, + } +}