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
147
server/supabase/functions/account-delete/index.ts
Normal file
147
server/supabase/functions/account-delete/index.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue