fix(realtime-token): spend the session quota only after a token is minted and stop leaking provider errors
This commit is contained in:
parent
83c2deb561
commit
2251fe2da1
7 changed files with 673 additions and 155 deletions
234
server/supabase/functions/realtime-token/handler.test.ts
Normal file
234
server/supabase/functions/realtime-token/handler.test.ts
Normal file
|
|
@ -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<Response>
|
||||||
|
consumeCalls: Array<{ userId: string; tier: RealtimeTier }>
|
||||||
|
mintCalls: RealtimeSessionConfig[]
|
||||||
|
logs: Array<{ message: string; meta: Record<string, unknown> }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HarnessOptions {
|
||||||
|
tier?: RealtimeTier
|
||||||
|
checkAllowed?: boolean
|
||||||
|
consumeAllowed?: boolean
|
||||||
|
configured?: boolean
|
||||||
|
mint?: () => Promise<ClientSecretResult>
|
||||||
|
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<RealtimeQuotaSnapshot> {
|
||||||
|
return Promise.resolve({
|
||||||
|
allowed: options.checkAllowed ?? true,
|
||||||
|
current: 3,
|
||||||
|
limit: 30,
|
||||||
|
period: 'daily',
|
||||||
|
tier,
|
||||||
|
overageCredits: 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
consume(userId: string, consumeTier: RealtimeTier): Promise<RealtimeQuotaConsumeResult> {
|
||||||
|
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<ClientSecretResult> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
176
server/supabase/functions/realtime-token/handler.ts
Normal file
176
server/supabase/functions/realtime-token/handler.ts
Normal file
|
|
@ -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<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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
|
@ -3,174 +3,61 @@
|
||||||
// 렌더러가 이 토큰으로 OpenAI와 직접 WebRTC 연결한다 (서버 키 비노출).
|
// 렌더러가 이 토큰으로 OpenAI와 직접 WebRTC 연결한다 (서버 키 비노출).
|
||||||
// 요청: application/json { model?, voice?, instructions? }
|
// 요청: application/json { model?, voice?, instructions? }
|
||||||
// 응답: OpenAI client_secrets 응답 그대로 + { model, tier }
|
// 응답: OpenAI client_secrets 응답 그대로 + { model, tier }
|
||||||
|
//
|
||||||
|
// 이 파일은 조립 루트(composition root)다. 유스케이스는 handler.ts,
|
||||||
|
// 정책은 policy.ts, OpenAI 호출은 openai-provider.ts에 있다.
|
||||||
|
|
||||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
import { corsHeaders } from '../_shared/cors.ts'
|
||||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
import { authErrorResponse, requireUser, type AuthError } from '../_shared/auth.ts'
|
||||||
import {
|
import {
|
||||||
checkQuota,
|
checkQuota,
|
||||||
consumeQuota,
|
consumeQuota,
|
||||||
createServiceRoleClient,
|
createServiceRoleClient,
|
||||||
getQuotaPolicy,
|
getQuotaPolicy,
|
||||||
type Tier,
|
|
||||||
} from '../_shared/quota.ts'
|
} from '../_shared/quota.ts'
|
||||||
import { readProviderKey } from '../_shared/provider-key.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 {
|
function isAuthError(err: unknown): err is AuthError {
|
||||||
model?: string
|
return !!err && typeof err === 'object' && 'status' in err && 'message' in err
|
||||||
voice?: string
|
|
||||||
instructions?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 티어별 허용 Realtime 모델 — free 차단, pro는 mini만, 상위 티어는 풀 모델까지 */
|
|
||||||
const TIER_MODELS: Record<Tier, string[]> = {
|
|
||||||
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<Tier, string | null> = {
|
|
||||||
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<AuthenticateResult> {
|
||||||
try {
|
try {
|
||||||
const user = await requireUser(req)
|
const user = await requireUser(req)
|
||||||
const serviceClient = createServiceRoleClient()
|
return { ok: true, user: { id: user.id } }
|
||||||
|
|
||||||
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' },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
if (isAuthError(err)) return { ok: false, response: authErrorResponse(err, corsHeaders) }
|
||||||
return authErrorResponse(err as AuthError, corsHeaders)
|
throw err
|
||||||
}
|
}
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
||||||
return new Response(JSON.stringify({ error: message }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 서비스 롤 클라이언트는 요청 처리 중에 만든다 — 환경 누락이 부팅 실패가 아닌 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)
|
||||||
|
|
|
||||||
55
server/supabase/functions/realtime-token/openai-provider.ts
Normal file
55
server/supabase/functions/realtime-token/openai-provider.ts
Normal file
|
|
@ -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<ClientSecretResult> {
|
||||||
|
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<string, unknown> }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
52
server/supabase/functions/realtime-token/policy.test.ts
Normal file
52
server/supabase/functions/realtime-token/policy.test.ts
Normal file
|
|
@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
82
server/supabase/functions/realtime-token/policy.ts
Normal file
82
server/supabase/functions/realtime-token/policy.ts
Normal file
|
|
@ -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<Record<RealtimeTier, readonly string[]>> = {
|
||||||
|
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<Record<RealtimeTier, string | null>> = {
|
||||||
|
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 } },
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue