diff --git a/app/admin/audit/page.tsx b/app/admin/audit/page.tsx index ce83ad0..809abd7 100644 --- a/app/admin/audit/page.tsx +++ b/app/admin/audit/page.tsx @@ -2,6 +2,7 @@ import { getAuditList } from '@/lib/admin/list'; import { AuditTable } from './audit-table'; export const dynamic = 'force-dynamic'; +export const revalidate = 0; const PER_PAGE = 50; diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 4e1f3ed..6407460 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,6 +4,7 @@ import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { formatBytes, formatDate } from '@/lib/format'; export const dynamic = 'force-dynamic'; +export const revalidate = 0; type OverQuotaRow = { user_id: string; diff --git a/app/admin/tunnels/page.tsx b/app/admin/tunnels/page.tsx index e8efb4c..34d4b23 100644 --- a/app/admin/tunnels/page.tsx +++ b/app/admin/tunnels/page.tsx @@ -2,6 +2,7 @@ import { getTunnelsList } from '@/lib/admin/list'; import { TunnelsTable } from './tunnels-table'; export const dynamic = 'force-dynamic'; +export const revalidate = 0; const PER_PAGE = 25; diff --git a/app/admin/users/[id]/page.tsx b/app/admin/users/[id]/page.tsx index b3fa7d5..c380ccd 100644 --- a/app/admin/users/[id]/page.tsx +++ b/app/admin/users/[id]/page.tsx @@ -7,6 +7,7 @@ import { formatBytes, formatDate } from '@/lib/format'; import { UserActions } from './user-actions'; export const dynamic = 'force-dynamic'; +export const revalidate = 0; type TunnelRow = { subdomain: string; diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 93a4c99..2c3a7da 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -2,6 +2,7 @@ import { getUsersList } from '@/lib/admin/list'; import { UsersTable } from './users-table'; export const dynamic = 'force-dynamic'; +export const revalidate = 0; const PER_PAGE = 25; diff --git a/app/api/admin/audit/route.ts b/app/api/admin/audit/route.ts index 898f0fc..93a2540 100644 --- a/app/api/admin/audit/route.ts +++ b/app/api/admin/audit/route.ts @@ -1,7 +1,8 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators'; import { getAuditList } from '@/lib/admin/list'; +import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -23,8 +24,9 @@ export async function GET(req: NextRequest) { action, targetType, }); - return NextResponse.json({ entries, total, page, perPage }); + return jsonNoStore({ entries, total, page, perPage }); } catch (e) { - return NextResponse.json({ error: (e as Error).message }, { status: 500 }); + console.error('admin audit list failed', e); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } } diff --git a/app/api/admin/metrics/route.ts b/app/api/admin/metrics/route.ts index 4874d23..d0b0653 100644 --- a/app/api/admin/metrics/route.ts +++ b/app/api/admin/metrics/route.ts @@ -1,6 +1,6 @@ -import { NextResponse } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; import { computeMetrics } from '@/lib/admin/metrics'; +import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -10,5 +10,5 @@ export async function GET() { if (!auth.ok) return auth.response; const metrics = await computeMetrics(); - return NextResponse.json(metrics); + return jsonNoStore(metrics); } diff --git a/app/api/admin/reserved/route.ts b/app/api/admin/reserved/route.ts index 4bfd747..908d2aa 100644 --- a/app/api/admin/reserved/route.ts +++ b/app/api/admin/reserved/route.ts @@ -1,8 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; +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 { RESERVED_SUBDOMAINS } from '@/lib/validation'; +import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -19,10 +20,11 @@ export async function GET() { .select('name, created_at') .order('name', { ascending: true }); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin reserved list failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } - return NextResponse.json({ + return jsonNoStore({ reserved: data ?? [], hardcoded: Array.from(RESERVED_SUBDOMAINS).sort(), }); @@ -36,14 +38,14 @@ export async function POST(req: NextRequest) { try { body = (await req.json()) as { name?: unknown }; } catch { - return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + return jsonNoStore({ error: 'invalid json' }, { status: 400 }); } if (typeof body.name !== 'string') { - return NextResponse.json({ error: 'name must be a string' }, { status: 400 }); + return jsonNoStore({ error: 'name must be a string' }, { status: 400 }); } const name = body.name.trim().toLowerCase(); if (!NAME_RE.test(name)) { - return NextResponse.json( + return jsonNoStore( { error: 'name must be 1–63 chars, lowercase a–z, 0–9, hyphen' }, { status: 400 }, ); @@ -54,7 +56,8 @@ export async function POST(req: NextRequest) { .from('reserved_subdomains') .upsert({ name }, { onConflict: 'name' }); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin reserved add failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } await logAdminAction(auth.user, { @@ -63,7 +66,7 @@ export async function POST(req: NextRequest) { target_id: name, }); - return NextResponse.json({ ok: true, name }); + return jsonNoStore({ ok: true, name }); } export async function DELETE(req: NextRequest) { @@ -73,7 +76,7 @@ export async function DELETE(req: NextRequest) { const url = new URL(req.url); const name = (url.searchParams.get('name') ?? '').trim().toLowerCase(); if (!name) { - return NextResponse.json({ error: 'name is required' }, { status: 400 }); + return jsonNoStore({ error: 'name is required' }, { status: 400 }); } const admin = getSupabaseAdmin(); @@ -82,7 +85,8 @@ export async function DELETE(req: NextRequest) { .delete() .eq('name', name); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin reserved remove failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } await logAdminAction(auth.user, { @@ -91,5 +95,5 @@ export async function DELETE(req: NextRequest) { target_id: name, }); - return NextResponse.json({ ok: true }); + return jsonNoStore({ ok: true }); } diff --git a/app/api/admin/tunnels/[id]/active/route.ts b/app/api/admin/tunnels/[id]/active/route.ts index 6fb4261..7bcc0d6 100644 --- a/app/api/admin/tunnels/[id]/active/route.ts +++ b/app/api/admin/tunnels/[id]/active/route.ts @@ -1,9 +1,10 @@ -import { NextResponse, type NextRequest } from 'next/server'; +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, parseBoolean } from '@/lib/admin/validators'; import { redisSet } from '@/lib/redis'; +import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -17,18 +18,18 @@ export async function POST( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid tunnel id' }, { status: 400 }); } let body: { is_active?: unknown }; try { body = (await req.json()) as { is_active?: unknown }; } catch { - return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + return jsonNoStore({ error: 'invalid json' }, { status: 400 }); } const isActive = parseBoolean(body.is_active); if (isActive === null) { - return NextResponse.json( + return jsonNoStore( { error: 'is_active must be a boolean' }, { status: 400 }, ); @@ -42,10 +43,11 @@ export async function POST( .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin tunnel.active failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } if (!data) { - return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } // Best-effort live kill-switch (never throws). @@ -58,5 +60,5 @@ export async function POST( details: { subdomain: data.subdomain, is_active: isActive }, }); - return NextResponse.json({ ok: true, is_active: isActive }); + return jsonNoStore({ ok: true, is_active: isActive }); } diff --git a/app/api/admin/tunnels/[id]/quota/route.ts b/app/api/admin/tunnels/[id]/quota/route.ts index cc16fc9..5838d25 100644 --- a/app/api/admin/tunnels/[id]/quota/route.ts +++ b/app/api/admin/tunnels/[id]/quota/route.ts @@ -1,8 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; +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, parsePositiveInt } from '@/lib/admin/validators'; +import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -19,18 +20,18 @@ export async function POST( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid tunnel id' }, { status: 400 }); } let body: { quota_bytes?: unknown }; try { body = (await req.json()) as { quota_bytes?: unknown }; } catch { - return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + return jsonNoStore({ error: 'invalid json' }, { status: 400 }); } const parsed = parsePositiveInt(body.quota_bytes, MAX_QUOTA); if (!parsed.ok) { - return NextResponse.json( + return jsonNoStore( { error: `quota_bytes ${parsed.error}` }, { status: 400 }, ); @@ -44,10 +45,11 @@ export async function POST( .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin tunnel.quota failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } if (!data) { - return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } await logAdminAction(auth.user, { @@ -57,5 +59,5 @@ export async function POST( details: { subdomain: data.subdomain, quota_bytes: parsed.value }, }); - return NextResponse.json({ ok: true, quota_bytes: parsed.value }); + return jsonNoStore({ ok: true, quota_bytes: parsed.value }); } diff --git a/app/api/admin/tunnels/[id]/reassign/route.ts b/app/api/admin/tunnels/[id]/reassign/route.ts index 46d961b..7436986 100644 --- a/app/api/admin/tunnels/[id]/reassign/route.ts +++ b/app/api/admin/tunnels/[id]/reassign/route.ts @@ -1,10 +1,11 @@ -import { NextResponse, type NextRequest } from 'next/server'; +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 { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -18,26 +19,26 @@ export async function POST( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid tunnel id' }, { status: 400 }); } let body: { subdomain?: unknown }; try { body = (await req.json()) as { subdomain?: unknown }; } catch { - return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + 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 NextResponse.json({ error: v.error }, { status: 400 }); + return jsonNoStore({ error: v.error }, { status: 400 }); } const subdomain = v.value; // Also reject anything reserved in the DB table. if (await isSubdomainReserved(subdomain)) { - return NextResponse.json( + return jsonNoStore( { error: `'${subdomain}' is reserved` }, { status: 400 }, ); @@ -52,7 +53,7 @@ export async function POST( .eq('subdomain', subdomain) .maybeSingle<{ user_id: string }>(); if (existing && existing.user_id !== id) { - return NextResponse.json({ error: 'subdomain taken' }, { status: 409 }); + return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); } const { data, error } = await admin @@ -64,12 +65,13 @@ export async function POST( if (error) { const code = (error as { code?: string }).code; if (code === '23505') { - return NextResponse.json({ error: 'subdomain taken' }, { status: 409 }); + return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); } - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin tunnel.reassign failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } if (!data) { - return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } await logAdminAction(auth.user, { @@ -79,5 +81,5 @@ export async function POST( details: { subdomain }, }); - return NextResponse.json({ ok: true, subdomain }); + return jsonNoStore({ ok: true, subdomain }); } diff --git a/app/api/admin/tunnels/[id]/regenerate-token/route.ts b/app/api/admin/tunnels/[id]/regenerate-token/route.ts index 6fa01db..bb7ee05 100644 --- a/app/api/admin/tunnels/[id]/regenerate-token/route.ts +++ b/app/api/admin/tunnels/[id]/regenerate-token/route.ts @@ -1,9 +1,10 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; import { randomBytes } from 'node:crypto'; 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 { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -17,7 +18,7 @@ export async function POST( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid tunnel id' }, { status: 400 }); } const token = randomBytes(32).toString('hex'); @@ -30,10 +31,11 @@ export async function POST( .select('subdomain, token') .maybeSingle<{ subdomain: string; token: string }>(); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin tunnel.regenerate_token failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } if (!data) { - return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } await logAdminAction(auth.user, { @@ -43,5 +45,5 @@ export async function POST( details: { subdomain: data.subdomain }, }); - return NextResponse.json({ ok: true, token: data.token }); + return jsonNoStore({ ok: true, token: data.token }); } diff --git a/app/api/admin/tunnels/[id]/reset-usage/route.ts b/app/api/admin/tunnels/[id]/reset-usage/route.ts index c1cb516..47bb784 100644 --- a/app/api/admin/tunnels/[id]/reset-usage/route.ts +++ b/app/api/admin/tunnels/[id]/reset-usage/route.ts @@ -1,8 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; +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 { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -16,7 +17,7 @@ export async function POST( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid tunnel id' }, { status: 400 }); } const admin = getSupabaseAdmin(); @@ -27,10 +28,11 @@ export async function POST( .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin tunnel.reset_usage failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } if (!data) { - return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } await logAdminAction(auth.user, { @@ -40,5 +42,5 @@ export async function POST( details: { subdomain: data.subdomain }, }); - return NextResponse.json({ ok: true }); + return jsonNoStore({ ok: true }); } diff --git a/app/api/admin/tunnels/[id]/route.ts b/app/api/admin/tunnels/[id]/route.ts index c8204dd..c597232 100644 --- a/app/api/admin/tunnels/[id]/route.ts +++ b/app/api/admin/tunnels/[id]/route.ts @@ -1,9 +1,10 @@ -import { NextResponse, type NextRequest } from 'next/server'; +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 { redisSet } from '@/lib/redis'; +import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -17,7 +18,7 @@ export async function DELETE( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid tunnel id' }, { status: 400 }); } const admin = getSupabaseAdmin(); @@ -28,10 +29,11 @@ export async function DELETE( .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin tunnel.delete failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } if (!data) { - return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } // Best-effort live kill-switch. @@ -44,5 +46,5 @@ export async function DELETE( details: { subdomain: data.subdomain }, }); - return NextResponse.json({ ok: true }); + return jsonNoStore({ ok: true }); } diff --git a/app/api/admin/tunnels/route.ts b/app/api/admin/tunnels/route.ts index 6562173..c95cfa1 100644 --- a/app/api/admin/tunnels/route.ts +++ b/app/api/admin/tunnels/route.ts @@ -1,7 +1,8 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators'; import { getTunnelsList } from '@/lib/admin/list'; +import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -23,8 +24,9 @@ export async function GET(req: NextRequest) { search, status, }); - return NextResponse.json({ tunnels, total, page, perPage }); + return jsonNoStore({ tunnels, total, page, perPage }); } catch (e) { - return NextResponse.json({ error: (e as Error).message }, { status: 500 }); + console.error('admin tunnels list failed', e); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } } diff --git a/app/api/admin/users/[id]/ban/route.ts b/app/api/admin/users/[id]/ban/route.ts index 5a243a3..1a943f5 100644 --- a/app/api/admin/users/[id]/ban/route.ts +++ b/app/api/admin/users/[id]/ban/route.ts @@ -1,8 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; +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, parseBoolean } from '@/lib/admin/validators'; +import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -19,10 +20,10 @@ export async function POST( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid user id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid user id' }, { status: 400 }); } if (id === auth.user.id) { - return NextResponse.json( + return jsonNoStore( { error: 'you cannot ban your own account' }, { status: 400 }, ); @@ -32,11 +33,11 @@ export async function POST( try { body = (await req.json()) as { banned?: unknown }; } catch { - return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + return jsonNoStore({ error: 'invalid json' }, { status: 400 }); } const banned = parseBoolean(body.banned); if (banned === null) { - return NextResponse.json( + return jsonNoStore( { error: 'banned must be a boolean' }, { status: 400 }, ); @@ -47,7 +48,8 @@ export async function POST( ban_duration: banned ? BAN_DURATION : 'none', } as { ban_duration: string }); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin user.ban failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } await logAdminAction(auth.user, { @@ -57,5 +59,5 @@ export async function POST( details: { banned }, }); - return NextResponse.json({ ok: true, banned }); + return jsonNoStore({ ok: true, banned }); } diff --git a/app/api/admin/users/[id]/role/route.ts b/app/api/admin/users/[id]/role/route.ts index 8b24f09..e1b478f 100644 --- a/app/api/admin/users/[id]/role/route.ts +++ b/app/api/admin/users/[id]/role/route.ts @@ -1,8 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; +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 { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -16,10 +17,10 @@ export async function POST( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid user id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid user id' }, { status: 400 }); } if (id === auth.user.id) { - return NextResponse.json( + return jsonNoStore( { error: 'you cannot change your own role' }, { status: 400 }, ); @@ -29,10 +30,10 @@ export async function POST( try { body = (await req.json()) as { role?: unknown }; } catch { - return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + return jsonNoStore({ error: 'invalid json' }, { status: 400 }); } if (body.role !== 'admin' && body.role !== 'user') { - return NextResponse.json( + return jsonNoStore( { error: "role must be 'admin' or 'user'" }, { status: 400 }, ); @@ -45,7 +46,7 @@ export async function POST( const { data: existing, error: getErr } = await admin.auth.admin.getUserById(id); if (getErr || !existing.user) { - return NextResponse.json({ error: 'user not found' }, { status: 404 }); + return jsonNoStore({ error: 'user not found' }, { status: 404 }); } const merged = { ...(existing.user.app_metadata ?? {}), role }; @@ -53,7 +54,8 @@ export async function POST( app_metadata: merged, }); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('admin user.role failed', error); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } await logAdminAction(auth.user, { @@ -63,5 +65,5 @@ export async function POST( details: { role }, }); - return NextResponse.json({ ok: true, role }); + return jsonNoStore({ ok: true, role }); } diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts index 35e2e73..ece7dc5 100644 --- a/app/api/admin/users/[id]/route.ts +++ b/app/api/admin/users/[id]/route.ts @@ -1,8 +1,9 @@ -import { NextResponse, type NextRequest } from 'next/server'; +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 { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -27,7 +28,7 @@ export async function GET( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid user id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid user id' }, { status: 400 }); } const admin = getSupabaseAdmin(); @@ -35,7 +36,7 @@ export async function GET( const { data: userRes, error: userErr } = await admin.auth.admin.getUserById(id); if (userErr || !userRes.user) { - return NextResponse.json({ error: 'user not found' }, { status: 404 }); + return jsonNoStore({ error: 'user not found' }, { status: 404 }); } const u = userRes.user; @@ -54,7 +55,7 @@ export async function GET( .order('created_at', { ascending: false }) .limit(25); - return NextResponse.json({ + return jsonNoStore({ user: { id: u.id, email: u.email ?? null, @@ -88,10 +89,10 @@ export async function DELETE( const { id } = params; if (!isUuid(id)) { - return NextResponse.json({ error: 'invalid user id' }, { status: 400 }); + return jsonNoStore({ error: 'invalid user id' }, { status: 400 }); } if (id === auth.user.id) { - return NextResponse.json( + return jsonNoStore( { error: 'you cannot delete your own account' }, { status: 400 }, ); @@ -99,12 +100,32 @@ export async function DELETE( const admin = getSupabaseAdmin(); - // Remove the tunnel row first (FK to auth.users). - await admin.from('tunnels').delete().eq('user_id', id); + // 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) { + const httpStatus = (delErr as { status?: number }).status; + const message = (delErr.message ?? '').toLowerCase(); + if (httpStatus === 404 || message.includes('not found')) { + return jsonNoStore({ error: 'user not found' }, { status: 404 }); + } + console.error('admin user.delete: deleteUser failed', delErr); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); + } - const { error } = await admin.auth.admin.deleteUser(id); - if (error) { - return NextResponse.json({ error: error.message }, { 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, { @@ -113,5 +134,5 @@ export async function DELETE( target_id: id, }); - return NextResponse.json({ ok: true }); + return jsonNoStore({ ok: true }); } diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 081759e..a6a577c 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -1,7 +1,8 @@ -import { NextResponse, type NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators'; import { getUsersList } from '@/lib/admin/list'; +import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -17,8 +18,9 @@ export async function GET(req: NextRequest) { try { const { users, total } = await getUsersList({ page, perPage, search }); - return NextResponse.json({ users, total, page, perPage }); + return jsonNoStore({ users, total, page, perPage }); } catch (e) { - return NextResponse.json({ error: (e as Error).message }, { status: 500 }); + console.error('admin users list failed', e); + return jsonNoStore({ error: 'internal error' }, { status: 500 }); } } diff --git a/lib/admin/response.ts b/lib/admin/response.ts new file mode 100644 index 0000000..c220944 --- /dev/null +++ b/lib/admin/response.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; + +/** + * Wrapper around NextResponse.json that marks the response uncacheable. All + * admin API responses must never be stored by browsers, proxies, or Next's + * own caches, since they reflect privileged, frequently-changing state. + */ +export function jsonNoStore(body: unknown, init?: ResponseInit): NextResponse { + const res = NextResponse.json(body, init); + res.headers.set('Cache-Control', 'no-store'); + res.headers.set('Pragma', 'no-cache'); + return res; +} diff --git a/lib/auth/admin-guard.ts b/lib/auth/admin-guard.ts index 82780a2..0427bcb 100644 --- a/lib/auth/admin-guard.ts +++ b/lib/auth/admin-guard.ts @@ -1,7 +1,8 @@ import { redirect } from 'next/navigation'; -import { NextResponse } from 'next/server'; +import type { NextResponse } from 'next/server'; import type { User } from '@supabase/supabase-js'; import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { jsonNoStore } from '@/lib/admin/response'; export function isAdmin(user: User | null | undefined): boolean { return user?.app_metadata?.role === 'admin'; @@ -35,13 +36,13 @@ export async function requireAdminApi(): Promise< if (!user) { return { ok: false, - response: NextResponse.json({ error: 'unauthorized' }, { status: 401 }), + response: jsonNoStore({ error: 'unauthorized' }, { status: 401 }), }; } if (!isAdmin(user)) { return { ok: false, - response: NextResponse.json({ error: 'forbidden' }, { status: 403 }), + response: jsonNoStore({ error: 'forbidden' }, { status: 403 }), }; } return { ok: true, user }; diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts index a93dd72..973cd8e 100644 --- a/lib/supabase/admin.ts +++ b/lib/supabase/admin.ts @@ -11,6 +11,13 @@ export function getSupabaseAdmin(): SupabaseClient { } _admin = createClient(url, key, { auth: { autoRefreshToken: false, persistSession: false }, + global: { + // Force every request the admin client makes (GoTrue listUsers and + // PostgREST reads alike) to bypass Next's fetch Data Cache, so the admin + // surface always reflects current state regardless of page config. + fetch: (input: RequestInfo | URL, init?: RequestInit) => + fetch(input, { ...init, cache: 'no-store' }), + }, }); return _admin; } diff --git a/middleware.ts b/middleware.ts index f876039..835f36e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -31,23 +31,35 @@ export async function middleware(request: NextRequest) { // per-route requireAdmin()/requireAdminApi() checks. const path = request.nextUrl.pathname; if (path.startsWith('/admin') || path.startsWith('/api/admin')) { + // Carry any cookies Supabase rotated onto the working `response` over to a + // deny/redirect response, so a refreshed session/refresh token is always + // persisted — otherwise a fresh NextResponse would drop them and a + // concurrent request could spuriously 401. + const withCookies = (res: NextResponse): NextResponse => { + response.cookies.getAll().forEach((cookie) => res.cookies.set(cookie)); + return res; + }; if (!user) { if (path.startsWith('/api/admin')) { - return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + return withCookies( + NextResponse.json({ error: 'unauthorized' }, { status: 401 }), + ); } const url = request.nextUrl.clone(); url.pathname = '/login'; url.search = ''; - return NextResponse.redirect(url); + return withCookies(NextResponse.redirect(url)); } if (user.app_metadata?.role !== 'admin') { if (path.startsWith('/api/admin')) { - return NextResponse.json({ error: 'forbidden' }, { status: 403 }); + return withCookies( + NextResponse.json({ error: 'forbidden' }, { status: 403 }), + ); } const url = request.nextUrl.clone(); url.pathname = '/dashboard'; url.search = ''; - return NextResponse.redirect(url); + return withCookies(NextResponse.redirect(url)); } }