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, 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, bucket: string, prefix: string, ): Promise { 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) } })