feat(V2-2): Supabase 인프라 — 스키마 + RLS + Edge Functions 스캐폴딩
server/supabase/ 신규 디렉토리:
config.toml — Supabase CLI 설정
- 프로젝트 ID, DB 포트, auth providers (Google/GitHub/Apple),
edge_runtime, functions.*.verify_jwt 설정
migrations/ (PostgreSQL DDL, 시간순):
- 20260409000001_initial_schema.sql
12개 테이블: profiles, teams, team_members, meetings,
meeting_memos, meeting_documents, transcripts, history,
dictionary, memo_tags, daily_usage, subscriptions
+ 인덱스 + FK CASCADE + transcripts Realtime publication
- 20260409000002_rls_policies.sql
개인 전용(history/dictionary): user_id = auth.uid()
팀 공유(meetings 등): 본인 OR team_members 조회 subquery
daily_usage: 읽기만, 쓰기는 service_role RPC
- 20260409000003_auth_triggers.sql
handle_new_user — auth.users INSERT → profiles+subscriptions 자동 생성
moddatetime — 8개 테이블 updated_at 자동 갱신
increment_daily_usage — service_role 전용 쿼터 RPC
- 20260409000004_storage_buckets.sql
audio/exports/avatars 3개 버킷 + 경로 기반 접근 정책
(파일 경로가 {user_id}/...로 시작해야 쓰기 허용)
functions/ (Deno/TypeScript Edge Functions):
- _shared/cors.ts — CORS 헤더 + preflight 핸들러
- _shared/auth.ts — requireUser (JWT 검증 + User 반환)
- _shared/quota.ts — 티어별 쿼터 체크 + consume + service role client
- stt-proxy/index.ts — Google Cloud STT 래퍼 스캐폴딩
placeholder 응답, 실제 API 호출 코드는 주석으로 포함
- llm-proxy/index.ts — Anthropic Messages API 래퍼 스캐폴딩
티어별 허용 모델 정책 (free=Haiku, pro=Sonnet, team=Opus)
설계 문서:
- docs/v2/phase-V2-2.md — 상세 설계 (스키마/RLS/Edge Functions/Realtime)
- docs/v2/phase-V2-2-setup.md — 사용자 액션 가이드 (Supabase 계정/
OAuth 등록/CLI/배포/검증)
apps/desktop typecheck 통과 (server/는 Deno 런타임이라 별도).
실제 Supabase 프로젝트 배포는 사용자가 phase-V2-2-setup.md 따라 수행.
This commit is contained in:
parent
3524e958ba
commit
97eb886ec3
16 changed files with 1675 additions and 8 deletions
49
server/supabase/functions/_shared/auth.ts
Normal file
49
server/supabase/functions/_shared/auth.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// server/supabase/functions/_shared/auth.ts
|
||||
// JWT 검증 + 인증된 유저 반환
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import (타입 보강은 deno.json 또는 skipLibCheck)
|
||||
import { createClient, type User } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
|
||||
export interface AuthError {
|
||||
status: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export function authErrorResponse(error: AuthError, corsHeaders: Record<string, string>): Response {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: error.status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization 헤더에서 JWT를 추출하여 유저를 검증한다.
|
||||
* 실패 시 예외 대신 AuthError 객체를 throw.
|
||||
*/
|
||||
export async function requireUser(req: Request): Promise<User> {
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
if (!authHeader) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 401, message: 'Missing Authorization header' } as AuthError
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno.env는 Deno 런타임 전역
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
|
||||
// @ts-expect-error — Deno.env는 Deno 런타임 전역
|
||||
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? ''
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
global: { headers: { Authorization: authHeader } }
|
||||
})
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (error || !user) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 401, message: error?.message ?? 'Invalid auth token' } as AuthError
|
||||
}
|
||||
return user
|
||||
}
|
||||
16
server/supabase/functions/_shared/cors.ts
Normal file
16
server/supabase/functions/_shared/cors.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// server/supabase/functions/_shared/cors.ts
|
||||
// Edge Function 공통 CORS 헤더
|
||||
|
||||
export const corsHeaders: Record<string, string> = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers':
|
||||
'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS'
|
||||
}
|
||||
|
||||
export function handleCorsPreflightRequest(req: Request): Response | null {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
return null
|
||||
}
|
||||
108
server/supabase/functions/_shared/quota.ts
Normal file
108
server/supabase/functions/_shared/quota.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// server/supabase/functions/_shared/quota.ts
|
||||
// 티어별 기능 쿼터 확인 + 증가
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
|
||||
export type Tier = 'free' | 'pro' | 'team'
|
||||
export type Feature = 'stt_transcribe' | 'llm_process'
|
||||
|
||||
/** 일일 쿼터 정책 (-1 = 무제한) */
|
||||
const DAILY_QUOTA: Record<Tier, Record<Feature, number>> = {
|
||||
free: {
|
||||
stt_transcribe: 50,
|
||||
llm_process: 50
|
||||
},
|
||||
pro: {
|
||||
stt_transcribe: -1,
|
||||
llm_process: -1
|
||||
},
|
||||
team: {
|
||||
stt_transcribe: -1,
|
||||
llm_process: -1
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuotaCheck {
|
||||
allowed: boolean
|
||||
current: number
|
||||
limit: number
|
||||
tier: Tier
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저의 오늘 사용량을 확인하고 쿼터 초과 여부를 반환.
|
||||
* 실제 증가는 performQuotaConsume 호출 시 수행.
|
||||
*/
|
||||
export async function checkQuota(
|
||||
userId: string,
|
||||
feature: Feature,
|
||||
serviceRoleClient: ReturnType<typeof createClient>
|
||||
): Promise<QuotaCheck> {
|
||||
// 티어 조회
|
||||
const { data: sub } = await serviceRoleClient
|
||||
.from('subscriptions')
|
||||
.select('tier')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const tier: Tier = (sub?.tier as Tier) ?? 'free'
|
||||
const limit = DAILY_QUOTA[tier][feature]
|
||||
|
||||
if (limit === -1) {
|
||||
return { allowed: true, current: 0, limit, tier }
|
||||
}
|
||||
|
||||
// 오늘 사용량 조회
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const { data: usage } = await serviceRoleClient
|
||||
.from('daily_usage')
|
||||
.select('count')
|
||||
.eq('user_id', userId)
|
||||
.eq('date', today)
|
||||
.eq('feature', feature)
|
||||
.maybeSingle()
|
||||
|
||||
const current = (usage?.count as number) ?? 0
|
||||
return {
|
||||
allowed: current < limit,
|
||||
current,
|
||||
limit,
|
||||
tier
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼터 소비. increment_daily_usage 함수 호출 (service_role 전용).
|
||||
*/
|
||||
export async function consumeQuota(
|
||||
userId: string,
|
||||
feature: Feature,
|
||||
serviceRoleClient: ReturnType<typeof createClient>,
|
||||
amount: number = 1
|
||||
): Promise<number> {
|
||||
const { data, error } = await serviceRoleClient.rpc('increment_daily_usage', {
|
||||
p_user_id: userId,
|
||||
p_feature: feature,
|
||||
p_amount: amount
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to increment quota: ${error.message}`)
|
||||
}
|
||||
|
||||
return (data as number) ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* service role 클라이언트 생성 헬퍼
|
||||
*/
|
||||
export function createServiceRoleClient(): ReturnType<typeof createClient> {
|
||||
// @ts-expect-error — Deno.env는 Deno 런타임 전역
|
||||
const url = Deno.env.get('SUPABASE_URL') ?? ''
|
||||
// @ts-expect-error — Deno.env는 Deno 런타임 전역
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
|
||||
return createClient(url, serviceKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false }
|
||||
})
|
||||
}
|
||||
10
server/supabase/functions/deno.json
Normal file
10
server/supabase/functions/deno.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"lib": ["deno.window", "deno.unstable"]
|
||||
},
|
||||
"imports": {
|
||||
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.39.7"
|
||||
}
|
||||
}
|
||||
138
server/supabase/functions/llm-proxy/index.ts
Normal file
138
server/supabase/functions/llm-proxy/index.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// server/supabase/functions/llm-proxy/index.ts
|
||||
// Anthropic Claude Messages API 프록시.
|
||||
// 요청: application/json { messages, system?, max_tokens?, model? }
|
||||
// 응답: JSON (non-stream) 또는 SSE (stream=true)
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { checkQuota, consumeQuota, createServiceRoleClient, type Tier } from '../_shared/quota.ts'
|
||||
|
||||
interface LlmRequest {
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
system?: string
|
||||
max_tokens?: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
/** 티어별 허용 모델 */
|
||||
const TIER_MODELS: Record<Tier, string[]> = {
|
||||
free: ['claude-haiku-4-5-20251001'],
|
||||
pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'],
|
||||
team: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6']
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL: Record<Tier, string> = {
|
||||
free: 'claude-haiku-4-5-20251001',
|
||||
pro: 'claude-sonnet-4-6',
|
||||
team: 'claude-sonnet-4-6'
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
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' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await requireUser(req)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const quota = await checkQuota(user.id, 'llm_process', serviceClient)
|
||||
if (!quota.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'quota_exceeded',
|
||||
current: quota.current,
|
||||
limit: quota.limit,
|
||||
tier: quota.tier
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await req.json()) as LlmRequest
|
||||
|
||||
// 모델 선택 + 티어 검증
|
||||
const requestedModel = body.model ?? DEFAULT_MODEL[quota.tier]
|
||||
if (!TIER_MODELS[quota.tier].includes(requestedModel)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'model_not_allowed',
|
||||
tier: quota.tier,
|
||||
requested: requestedModel,
|
||||
allowed: TIER_MODELS[quota.tier]
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Anthropic API 호출 — placeholder
|
||||
//
|
||||
// 실제 구현 시:
|
||||
// const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY')!
|
||||
// const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// 'x-api-key': anthropicKey,
|
||||
// 'anthropic-version': '2023-06-01'
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// model: requestedModel,
|
||||
// max_tokens: body.max_tokens ?? 2048,
|
||||
// system: body.system,
|
||||
// messages: body.messages,
|
||||
// stream: body.stream ?? false
|
||||
// })
|
||||
// })
|
||||
// if (body.stream) {
|
||||
// return new Response(resp.body, {
|
||||
// headers: { ...corsHeaders, 'Content-Type': 'text/event-stream' }
|
||||
// })
|
||||
// }
|
||||
// const data = await resp.json()
|
||||
// ...
|
||||
|
||||
await consumeQuota(user.id, 'llm_process', serviceClient, 1)
|
||||
|
||||
const placeholder = {
|
||||
id: `msg_placeholder_${Date.now()}`,
|
||||
model: requestedModel,
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[llm-proxy placeholder — Anthropic API not yet wired]'
|
||||
}
|
||||
],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 0, output_tokens: 0 }
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(placeholder), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
} 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' }
|
||||
})
|
||||
}
|
||||
})
|
||||
116
server/supabase/functions/stt-proxy/index.ts
Normal file
116
server/supabase/functions/stt-proxy/index.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// server/supabase/functions/stt-proxy/index.ts
|
||||
// Google Cloud Speech-to-Text 프록시.
|
||||
// 요청: multipart/form-data (audio + sample_rate + language_code)
|
||||
// 응답: { transcript, confidence, language_code, duration_seconds }
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { checkQuota, consumeQuota, createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
||||
interface SttResult {
|
||||
transcript: string
|
||||
confidence: number
|
||||
language_code: string
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
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' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) 인증
|
||||
const user = await requireUser(req)
|
||||
|
||||
// 2) 쿼터 체크
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const quota = await checkQuota(user.id, 'stt_transcribe', serviceClient)
|
||||
if (!quota.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'quota_exceeded',
|
||||
current: quota.current,
|
||||
limit: quota.limit,
|
||||
tier: quota.tier
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 3) 입력 파싱
|
||||
const formData = await req.formData()
|
||||
const audio = formData.get('audio')
|
||||
const sampleRate = Number(formData.get('sample_rate') ?? 16000)
|
||||
const languageCode = String(formData.get('language_code') ?? 'ko-KR')
|
||||
|
||||
if (!(audio instanceof Blob)) {
|
||||
return new Response(JSON.stringify({ error: 'Missing audio field' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// 4) Google Cloud STT 호출 — placeholder
|
||||
//
|
||||
// 실제 구현 시:
|
||||
// const audioBytes = new Uint8Array(await audio.arrayBuffer())
|
||||
// const base64Audio = btoa(String.fromCharCode(...audioBytes))
|
||||
// const gcpKey = Deno.env.get('GOOGLE_CLOUD_STT_KEY')!
|
||||
// const response = await fetch(
|
||||
// `https://speech.googleapis.com/v1/speech:recognize?key=${gcpKey}`,
|
||||
// {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({
|
||||
// config: {
|
||||
// encoding: 'LINEAR16',
|
||||
// sampleRateHertz: sampleRate,
|
||||
// languageCode,
|
||||
// enableAutomaticPunctuation: true
|
||||
// },
|
||||
// audio: { content: base64Audio }
|
||||
// })
|
||||
// }
|
||||
// )
|
||||
// const data = await response.json()
|
||||
// const transcript = data.results?.[0]?.alternatives?.[0]?.transcript ?? ''
|
||||
// const confidence = data.results?.[0]?.alternatives?.[0]?.confidence ?? 0
|
||||
//
|
||||
// 스캐폴딩 단계에서는 placeholder 응답.
|
||||
|
||||
const placeholder: SttResult = {
|
||||
transcript: '[stt-proxy placeholder — Google Cloud STT not yet wired]',
|
||||
confidence: 0,
|
||||
language_code: languageCode,
|
||||
duration_seconds: (audio.size / sampleRate / 2) // 16-bit mono 가정
|
||||
}
|
||||
|
||||
// 5) 쿼터 소비
|
||||
await consumeQuota(user.id, 'stt_transcribe', serviceClient, 1)
|
||||
|
||||
return new Response(JSON.stringify(placeholder), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
} 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' }
|
||||
})
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue