feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue