d3ro-voice/server/supabase/functions/account-delete/index.ts
2026-08-29 18:33:45 +09:00

147 lines
5.2 KiB
TypeScript

import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
interface DeleteAccountRequest {
confirmation: string
}
const CONFIRMATION_PHRASE = 'DELETE_MY_ACCOUNT'
const RECENT_AUTH_SECONDS = 10 * 60
const STORAGE_BUCKETS = ['audio', 'exports', 'avatars'] as const
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
function decodeJwtIssuedAt(authorization: string | null): number | null {
const token = authorization?.replace(/^Bearer\s+/i, '')
if (!token) return null
const payloadPart = token.split('.')[1]
if (!payloadPart) return null
try {
const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded)) as { iat?: unknown }
return typeof payload.iat === 'number' ? payload.iat : null
} catch {
return null
}
}
async function listStorageFiles(
serviceClient: ReturnType<typeof createServiceRoleClient>,
bucket: string,
prefix: string,
): Promise<string[]> {
const result: string[] = []
let offset = 0
while (true) {
const { data, error } = await serviceClient.storage.from(bucket).list(prefix, {
limit: 100,
offset,
sortBy: { column: 'name', order: 'asc' },
})
if (error) throw new Error(`storage_list_failed:${bucket}`)
if (!data || data.length === 0) break
for (const entry of data) {
const childPath = `${prefix}/${entry.name}`
if (entry.id) {
result.push(childPath)
} else {
result.push(...await listStorageFiles(serviceClient, bucket, childPath))
}
}
if (data.length < 100) break
offset += data.length
}
return result
}
Deno.serve(async (req: Request) => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
if (req.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed', code: 'METHOD_NOT_ALLOWED' }, 405)
}
try {
const user = await requireUser(req)
const body = await req.json() as DeleteAccountRequest
if (body.confirmation !== CONFIRMATION_PHRASE) {
return jsonResponse({ error: 'Explicit confirmation is required', code: 'CONFIRMATION_REQUIRED' }, 400)
}
const issuedAt = decodeJwtIssuedAt(req.headers.get('Authorization'))
const nowSeconds = Math.floor(Date.now() / 1000)
if (issuedAt === null || nowSeconds - issuedAt > RECENT_AUTH_SECONDS || issuedAt > nowSeconds + 60) {
return jsonResponse({ error: 'Recent authentication is required', code: 'REAUTHENTICATION_REQUIRED' }, 403)
}
const serviceClient = createServiceRoleClient()
const { data: subscription, error: subscriptionError } = await serviceClient
.from('subscriptions')
.select('tier, status, current_period_end, provider, payment_provider')
.eq('user_id', user.id)
.maybeSingle()
if (subscriptionError) throw new Error('subscription_lookup_failed')
const activeStatuses = new Set(['active', 'trialing', 'past_due', 'on_hold', 'paused'])
const subscriptionRecord = subscription as {
status?: string | null
tier?: string | null
current_period_end?: string | null
provider?: string | null
payment_provider?: string | null
} | null
const hasPaidEntitlement = subscriptionRecord?.tier !== undefined
&& subscriptionRecord.tier !== null
&& subscriptionRecord.tier !== 'free'
const hasExternalBilling = (subscriptionRecord?.provider ?? 'none') !== 'none'
|| (subscriptionRecord?.payment_provider ?? 'none') !== 'none'
if (
subscriptionRecord?.status
&& activeStatuses.has(subscriptionRecord.status)
&& (hasPaidEntitlement || hasExternalBilling)
) {
return jsonResponse({
error: 'Cancel the active subscription before deleting the account',
code: 'ACTIVE_SUBSCRIPTION',
provider: subscriptionRecord.provider ?? subscriptionRecord.payment_provider ?? 'unknown',
current_period_end: subscriptionRecord.current_period_end,
}, 409)
}
for (const bucket of STORAGE_BUCKETS) {
const files = await listStorageFiles(serviceClient, bucket, user.id)
for (let index = 0; index < files.length; index += 100) {
const batch = files.slice(index, index + 100)
const { error } = await serviceClient.storage.from(bucket).remove(batch)
if (error) throw new Error(`storage_delete_failed:${bucket}`)
}
}
const { error: deleteError } = await serviceClient.auth.admin.deleteUser(user.id, false)
if (deleteError) throw new Error('auth_user_delete_failed')
return jsonResponse({ success: true })
} catch (error) {
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
return authErrorResponse(error as AuthError, corsHeaders)
}
const code = error instanceof Error ? error.message : 'account_delete_failed'
return jsonResponse({ error: 'Account deletion failed', code }, 500)
}
})