feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -1,108 +1,139 @@
// server/supabase/functions/stripe-webhook/index.ts
// Stripe Webhook 핸들러 — checkout 완료, 구독 갱신/취소 등 이벤트 처리.
// Supabase 대시보드 → Functions → stripe-webhook 의 verify_jwt를 false로 설정해야 함
// (Stripe는 JWT 없이 호출, 대신 signature로 검증).
//
// 환경변수:
// STRIPE_SECRET_KEY
// STRIPE_WEBHOOK_SECRET (Stripe 대시보드에서 발급)
import { createServiceRoleClient } from '../_shared/quota.ts'
interface StripeEvent {
id: string
type: string
data: {
object: Record<string, unknown>
}
created: number
data: { object: Record<string, unknown> }
}
/**
* Stripe webhook signature Web Crypto API HMAC-SHA256.
*
* Stripe-Signature : "t=TIMESTAMP,v1=SIG,v1=SIG2,..."
* 방식: HMAC_SHA256(secret, `${timestamp}.${payload}`) 16
* v1 valid.
*
* timestamp가 tolerance(5) reject (replay ).
*
* 참고: https://stripe.com/docs/webhooks/signatures
*/
async function verifyStripeSignature(
interface StripeMetadata {
user_id?: string
tier?: string
operation_id?: string
}
interface ProviderApplyResult {
applied?: boolean
duplicate?: boolean
reason?: string
}
export function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
let mismatch = 0
for (let index = 0; index < a.length; index += 1) {
mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index)
}
return mismatch === 0
}
export async function verifyStripeSignature(
payload: string,
signatureHeader: string,
secret: string,
toleranceSec = 300
toleranceSec = 300,
nowMs = Date.now(),
): Promise<boolean> {
if (!signatureHeader || !secret || !payload) return false
if (!signatureHeader || !secret || !payload || toleranceSec <= 0) return false
const parts = signatureHeader.split(',').map((part) => part.trim())
const timestampParts = parts.filter((part) => part.startsWith('t='))
const signatures = parts
.filter((part) => part.startsWith('v1='))
.map((part) => part.slice(3).toLowerCase())
.filter((part) => /^[0-9a-f]{64}$/.test(part))
if (timestampParts.length !== 1 || signatures.length === 0) return false
// 헤더 파싱
const parts = signatureHeader.split(',').map((p) => p.trim())
const timestampEntry = parts.find((p) => p.startsWith('t='))
const v1Signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3))
const timestamp = Number(timestampParts[0].slice(2))
if (!Number.isInteger(timestamp) || timestamp <= 0) return false
const nowSeconds = Math.floor(nowMs / 1000)
if (Math.abs(nowSeconds - timestamp) > toleranceSec) return false
if (!timestampEntry || v1Signatures.length === 0) return false
const timestamp = Number(timestampEntry.slice(2))
if (!Number.isFinite(timestamp)) return false
// Replay 방지: 5분 이상 오래된 요청 거부
const nowSec = Math.floor(Date.now() / 1000)
if (Math.abs(nowSec - timestamp) > toleranceSec) {
return false
}
// HMAC-SHA256 계산
const signedPayload = `${timestamp}.${payload}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
['sign'],
)
const sigBuffer = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload))
const expectedHex = Array.from(new Uint8Array(sigBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
const signature = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(`${timestamp}.${payload}`),
)
const expected = Array.from(new Uint8Array(signature))
.map((part) => part.toString(16).padStart(2, '0'))
.join('')
// 타이밍 공격 방지: constant-time 비교
for (const v1 of v1Signatures) {
if (constantTimeEqual(v1, expectedHex)) {
return true
}
}
return false
return signatures.some((candidate) => constantTimeEqual(candidate, expected))
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
let mismatch = 0
for (let i = 0; i < a.length; i++) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i)
}
return mismatch === 0
export async function sha256Payload(payload: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload))
return Array.from(new Uint8Array(digest))
.map((part) => part.toString(16).padStart(2, '0'))
.join('')
}
// @ts-expect-error — Deno 런타임 전역
Deno.serve(async (req: Request) => {
if (req.method !== 'POST') {
return new Response('Method not allowed', { status: 405 })
export function normalizeStripeTier(value: unknown): 'pro' | 'pro_plus' | null {
if (value === 'pro') return 'pro'
if (value === 'pro_plus' || value === 'team') return 'pro_plus'
return null
}
export function tierFromStripeSubscriptionPrice(
subscription: Record<string, unknown>,
prices: { pro: string; pro_plus: string },
): 'pro' | 'pro_plus' | null {
const items = subscription.items as { data?: unknown } | undefined
if (!Array.isArray(items?.data) || items.data.length !== 1) return null
const item = items.data[0] as { price?: { id?: unknown } } | undefined
const priceId = asString(item?.price?.id)
if (priceId && priceId === prices.pro) return 'pro'
if (priceId && priceId === prices.pro_plus) return 'pro_plus'
return null
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function epochToIso(value: unknown): string | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
return new Date(value * 1000).toISOString()
}
function stripePeriodValue(subscription: Record<string, unknown>, field: 'current_period_start' | 'current_period_end'): unknown {
if (subscription[field] !== undefined) return subscription[field]
const items = subscription.items as { data?: unknown } | undefined
if (!Array.isArray(items?.data) || items.data.length !== 1) return undefined
return (items.data[0] as Record<string, unknown> | undefined)?.[field]
}
function stripeSubscriptionEntitled(status: string): boolean {
return ['active', 'trialing', 'past_due'].includes(status)
}
function isUuid(value: unknown): value is string {
return typeof value === 'string'
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
export async function stripeWebhookHandler(req: Request): Promise<Response> {
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 })
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')?.trim() ?? ''
const stripeSecret = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? ''
if (!webhookSecret || !stripeSecret) {
return new Response('Stripe webhook not configured', { status: 503 })
}
// @ts-expect-error — Deno.env
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? ''
if (!webhookSecret) {
return new Response('Webhook secret not configured', { status: 503 })
}
const signature = req.headers.get('stripe-signature') ?? ''
const payload = await req.text()
const valid = await verifyStripeSignature(payload, signature, webhookSecret)
if (!valid) {
return new Response('Invalid signature', { status: 400 })
}
const validSignature = await verifyStripeSignature(
payload,
req.headers.get('stripe-signature') ?? '',
webhookSecret,
)
if (!validSignature) return new Response('Invalid signature', { status: 400 })
let event: StripeEvent
try {
@ -110,80 +141,180 @@ Deno.serve(async (req: Request) => {
} catch {
return new Response('Invalid JSON', { status: 400 })
}
if (
!/^evt_[A-Za-z0-9]+$/.test(event.id ?? '')
|| typeof event.type !== 'string'
|| !Number.isInteger(event.created)
|| event.created <= 0
|| !event.data?.object
) {
return new Response('Invalid event', { status: 400 })
}
const serviceClient = createServiceRoleClient()
const payloadDigest = await sha256Payload(payload)
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as {
customer: string
subscription: string
metadata?: { user_id?: string; tier?: string }
}
const userId = session.metadata?.user_id
const tier = session.metadata?.tier
if (userId && tier) {
await serviceClient.from('subscriptions').upsert({
user_id: userId,
stripe_customer_id: session.customer,
stripe_subscription_id: session.subscription,
tier,
status: 'active'
})
}
break
if (event.type === 'checkout.session.completed') {
const session = event.data.object
const metadata = (session.metadata ?? {}) as StripeMetadata
const userId = asString(metadata.user_id) ?? asString(session.client_reference_id)
const subscriptionId = asString(session.subscription)
if (!userId || !subscriptionId) {
return new Response(JSON.stringify({ received: true, ignored: 'missing_correlation' }), {
headers: { 'Content-Type': 'application/json' },
})
}
case 'customer.subscription.updated':
case 'customer.subscription.created': {
const sub = event.data.object as {
id: string
customer: string
status: string
current_period_start?: number
current_period_end?: number
cancel_at?: number | null
}
await serviceClient
.from('subscriptions')
.update({
status: sub.status,
current_period_start: sub.current_period_start
? new Date(sub.current_period_start * 1000).toISOString()
: null,
current_period_end: sub.current_period_end
? new Date(sub.current_period_end * 1000).toISOString()
: null,
cancel_at: sub.cancel_at ? new Date(sub.cancel_at * 1000).toISOString() : null
})
.eq('stripe_subscription_id', sub.id)
break
}
const { error: observationError } = await serviceClient.rpc(
'record_payment_provider_observation',
{
p_user_id: userId,
p_provider: 'stripe',
p_event_id: event.id,
p_event_created_at: new Date(event.created * 1000).toISOString(),
p_event_type: event.type,
p_payload_digest: payloadDigest,
p_provider_resource_id: subscriptionId,
},
)
if (observationError) throw new Error('stripe_observation_failed')
case 'customer.subscription.deleted': {
const sub = event.data.object as { id: string }
await serviceClient
.from('subscriptions')
.update({ tier: 'free', status: 'canceled' })
.eq('stripe_subscription_id', sub.id)
break
if (isUuid(metadata.operation_id)) {
const { error: operationError } = await serviceClient.rpc(
'mark_payment_provider_operation',
{
p_operation_id: metadata.operation_id,
p_state: 'external_created',
p_external_reference: asString(session.id) ?? subscriptionId,
p_error_code: null,
},
)
if (operationError) throw new Error('stripe_operation_update_failed')
}
default:
// 기타 이벤트는 무시
break
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
})
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
if (![
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
].includes(event.type)) {
return new Response(JSON.stringify({ received: true, ignored: true }), {
headers: { 'Content-Type': 'application/json' },
})
}
let subscription = event.data.object
const subscriptionId = asString(subscription.id)
if (!subscriptionId) return new Response('Invalid subscription', { status: 400 })
// Stripe signs each delivery but does not guarantee delivery order. For
// non-deletion events, retrieve the subscription's current authoritative
// state so a late event cannot resurrect an older state from its payload.
if (event.type !== 'customer.subscription.deleted') {
const currentResponse = await fetch(
`https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`,
{ headers: { Authorization: `Bearer ${stripeSecret}` } },
)
if (!currentResponse.ok) throw new Error('stripe_subscription_verification_failed')
const current = await currentResponse.json() as Record<string, unknown>
if (asString(current.id) !== subscriptionId) {
throw new Error('stripe_subscription_verification_mismatch')
}
subscription = current
}
const customerId = asString(subscription.customer)
const metadata = (subscription.metadata ?? {}) as StripeMetadata
if (!customerId) return new Response('Invalid subscription', { status: 400 })
const prices = {
pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '',
pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS')
?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '',
}
if (!prices.pro && !prices.pro_plus) {
return new Response('Stripe prices not configured', { status: 503 })
}
const priceTier = tierFromStripeSubscriptionPrice(subscription, prices)
const metadataTier = normalizeStripeTier(metadata.tier)
if (!priceTier || (metadataTier && metadataTier !== priceTier)) {
return new Response('Subscription price mismatch', { status: 422 })
}
let userId = asString(metadata.user_id)
const tier: 'pro' | 'pro_plus' = priceTier
if (!userId) {
const { data: existing, error: lookupError } = await serviceClient
.from('subscriptions')
.select('user_id, tier')
.eq('stripe_subscription_id', subscriptionId)
.maybeSingle()
if (lookupError) throw new Error('stripe_subscription_lookup_failed')
userId ??= asString(existing?.user_id)
const existingTier = normalizeStripeTier(existing?.tier)
if (existingTier && existingTier !== priceTier) {
return new Response('Stored subscription price mismatch', { status: 422 })
}
}
if (!userId) return new Response('Missing subscription owner', { status: 422 })
const status = event.type === 'customer.subscription.deleted'
? 'canceled'
: asString(subscription.status)
if (!status || ![
'active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete',
'incomplete_expired', 'paused',
].includes(status)) {
return new Response('Invalid subscription status', { status: 400 })
}
const entitled = stripeSubscriptionEntitled(status)
const cancelAtPeriodEnd = subscription.cancel_at_period_end === true
const periodEnd = epochToIso(stripePeriodValue(subscription, 'current_period_end'))
const cancelAt = epochToIso(subscription.cancel_at)
?? (cancelAtPeriodEnd ? periodEnd : null)
const { data: applyData, error: applyError } = await serviceClient.rpc(
'apply_payment_provider_event',
{
p_user_id: userId,
p_provider: 'stripe',
p_event_id: event.id,
p_event_created_at: new Date(event.created * 1000).toISOString(),
p_event_type: event.type,
p_payload_digest: payloadDigest,
p_provider_resource_id: subscriptionId,
p_tier: entitled ? tier : 'free',
p_status: status,
p_entitled: entitled,
p_current_period_start: epochToIso(stripePeriodValue(subscription, 'current_period_start')),
p_current_period_end: periodEnd,
p_cancel_at: cancelAt,
p_auto_renewing: entitled && !cancelAtPeriodEnd,
p_provider_customer_id: customerId,
p_provider_order_id: null,
p_store_product_id: null,
p_store_purchase_id: null,
p_operation_id: isUuid(metadata.operation_id) ? metadata.operation_id : null,
},
)
if (applyError) throw new Error('stripe_entitlement_apply_failed')
const result = applyData as ProviderApplyResult | null
return new Response(JSON.stringify({
received: true,
applied: result?.applied ?? false,
duplicate: result?.duplicate ?? false,
reason: result?.reason,
}), {
headers: { 'Content-Type': 'application/json' },
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return new Response(JSON.stringify({ error: message }), {
} catch {
return new Response(JSON.stringify({ error: 'stripe_webhook_processing_failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
headers: { 'Content-Type': 'application/json' },
})
}
})
}
if (import.meta.main) Deno.serve(stripeWebhookHandler)