import { type NextRequest } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { withAdminRetry } from '@/lib/admin/retry'; import { logAdminAction } from '@/lib/auth/audit'; import { isUuid } from '@/lib/admin/validators'; import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; type TunnelRow = { user_id: string; subdomain: string; token: string; is_active: boolean; bytes_used: number; quota_bytes: number; last_seen_at: string | null; created_at: string; }; export async function GET( _req: NextRequest, { params }: { params: { id: string } }, ) { const auth = await requireAdminApi(); if (!auth.ok) return auth.response; const { id } = params; if (!isUuid(id)) { return jsonNoStore({ error: 'invalid user id' }, { status: 400 }); } const admin = getSupabaseAdmin(); // Retry transient empty-body GoTrue responses so a burst-induced flake isn't // misreported as a 404 for a user that actually exists. A genuine not-found // (non-transient) still falls through to the clean 404 below. const { data: userRes, error: userErr } = await withAdminRetry(() => admin.auth.admin.getUserById(id), ); if (userErr || !userRes.user) { return jsonNoStore({ error: 'user not found' }, { status: 404 }); } const u = userRes.user; const { data: tunnel } = await admin .from('tunnels') .select( 'user_id, subdomain, token, is_active, bytes_used, quota_bytes, last_seen_at, created_at', ) .eq('user_id', id) .maybeSingle(); const { data: audit } = await admin .from('admin_audit_log') .select('id, actor_email, action, target_type, target_id, details, created_at') .eq('target_id', id) .order('created_at', { ascending: false }) .limit(25); return jsonNoStore({ user: { id: u.id, email: u.email ?? null, role: (u.app_metadata?.role as string | undefined) ?? 'user', banned_until: (u as unknown as { banned_until?: string | null }).banned_until ?? null, email_confirmed_at: u.email_confirmed_at ?? null, created_at: u.created_at, last_sign_in_at: u.last_sign_in_at ?? null, }, tunnel: tunnel ? { subdomain: tunnel.subdomain, is_active: tunnel.is_active, bytes_used: tunnel.bytes_used, quota_bytes: tunnel.quota_bytes, last_seen_at: tunnel.last_seen_at, created_at: tunnel.created_at, } : null, audit: audit ?? [], }); } export async function DELETE( _req: NextRequest, { params }: { params: { id: string } }, ) { const auth = await requireAdminApi(); if (!auth.ok) return auth.response; const { id } = params; if (!isUuid(id)) { return jsonNoStore({ error: 'invalid user id' }, { status: 400 }); } if (id === auth.user.id) { return jsonNoStore( { error: 'you cannot delete your own account' }, { status: 400 }, ); } const admin = getSupabaseAdmin(); // Confirm the user exists up front. GoTrue replies with an empty body when // deleting a non-existent user, which supabase-js surfaces as an opaque // JSON-parse error (no status / "not found" text); a positive existence // check lets us return a clean 404 instead of a misleading 500. Retry the // lookup so a transient empty body under load isn't mistaken for not-found. const { data: existing, error: lookupErr } = await withAdminRetry(() => admin.auth.admin.getUserById(id), ); if (lookupErr || !existing.user) { return jsonNoStore({ error: 'user not found' }, { status: 404 }); } // Delete the AUTH USER first. Only if that succeeds do we remove the tunnel // row, so a mid-failure never leaves an orphaned auth user with a dangling // tunnel (or half-deletes the tunnel of an already-gone user). const { error: delErr } = await admin.auth.admin.deleteUser(id); if (delErr) { console.error('admin user.delete: deleteUser failed', delErr); return jsonNoStore({ error: 'internal error' }, { status: 500 }); } // The auth user is gone, so no orphaned-user state is possible. Removing the // tunnel row is best-effort cleanup: if it fails we log server-side but still // report the (already-completed) user deletion as successful. const { error: tunnelErr } = await admin .from('tunnels') .delete() .eq('user_id', id); if (tunnelErr) { console.error( `admin user.delete: tunnel cleanup failed for ${id}`, tunnelErr, ); } await logAdminAction(auth.user, { action: 'user.delete', target_type: 'user', target_id: id, }); return jsonNoStore({ ok: true }); }