147 lines
5.2 KiB
TypeScript
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)
|
|
}
|
|
})
|