100 lines
3.8 KiB
TypeScript
100 lines
3.8 KiB
TypeScript
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
import { hasRecentAuthentication } from './recent-auth.ts'
|
|
import {
|
|
listStorageFiles,
|
|
removeStorageObjects,
|
|
supabaseStorageBucket,
|
|
} from '../_shared/storage-objects.ts'
|
|
|
|
interface DeleteAccountRequest {
|
|
confirmation: string
|
|
}
|
|
|
|
const CONFIRMATION_PHRASE = 'DELETE_MY_ACCOUNT'
|
|
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' },
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Step-up auth: judged by the session's amr authentication time, never the
|
|
// access token iat (which every refresh_token grant resets).
|
|
const nowSeconds = Math.floor(Date.now() / 1000)
|
|
if (!hasRecentAuthentication(req.headers.get('Authorization'), nowSeconds)) {
|
|
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 storage = supabaseStorageBucket(serviceClient, bucket)
|
|
await removeStorageObjects(storage, await listStorageFiles(storage, user.id))
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|