import { type NextRequest } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { logAdminAction } from '@/lib/auth/audit'; import { isUuid } from '@/lib/admin/validators'; import { validateSubdomain } from '@/lib/validation'; import { isSubdomainReserved } from '@/lib/admin/reserved'; import { setTunnelActive } from '@/lib/redis'; import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export async function POST( 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 tunnel id' }, { status: 400 }); } let body: { subdomain?: unknown }; try { body = (await req.json()) as { subdomain?: unknown }; } catch { return jsonNoStore({ error: 'invalid json' }, { status: 400 }); } // Same validation as the user-facing claim flow (format + hardcoded reserved). const v = validateSubdomain(body.subdomain); if (!v.ok) { return jsonNoStore({ error: v.error }, { status: 400 }); } const subdomain = v.value; // Also reject anything reserved in the DB table. if (await isSubdomainReserved(subdomain)) { return jsonNoStore( { error: `'${subdomain}' is reserved` }, { status: 400 }, ); } const admin = getSupabaseAdmin(); // Reject if taken by a different tunnel (keyed by owner user_id). const { data: existing } = await admin .from('tunnels') .select('user_id, subdomain') .eq('subdomain', subdomain) .maybeSingle<{ user_id: string; subdomain: string }>(); if (existing && existing.user_id !== id) { return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); } // Capture the current subdomain so we can drop the OLD hostname's live // connection once it is freed by the rename. const { data: current } = await admin .from('tunnels') .select('subdomain') .eq('user_id', id) .maybeSingle<{ subdomain: string }>(); const oldSubdomain = current?.subdomain ?? null; const { data, error } = await admin .from('tunnels') .update({ subdomain }) .eq('user_id', id) .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { const code = (error as { code?: string }).code; if (code === '23505') { return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); } console.error('admin tunnel.reassign failed', error); return jsonNoStore({ error: 'internal error' }, { status: 500 }); } if (!data) { return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } // Best-effort: drop any live connection on the OLD subdomain so the former // hostname stops resolving as active. No-op when REDIS_URL is unset. if (oldSubdomain && oldSubdomain !== subdomain) { await setTunnelActive(oldSubdomain, false); } await logAdminAction(auth.user, { action: 'tunnel.reassign', target_type: 'tunnel', target_id: id, details: { subdomain, previous_subdomain: oldSubdomain }, }); return jsonNoStore({ ok: true, subdomain }); }