1"use server";23import { randomBytes } from "node:crypto";4import { revalidatePath } from "next/cache";5import { z } from "zod";6import {7 getDb,8 users,9 sessions,10 organizations,11 providerConfigs,12 domainProfiles,13 abuseEvents,14 featureFlags,15 statusIncidents,16 eq,17} from "@fetcha/db";18import { newId, PLANS } from "@fetcha/core";19import { requireAdmin, requestMeta } from "@/lib/session";20import { writeAudit } from "@/lib/audit";21import { internalApi, InternalApiError } from "@/lib/api";22import type { AuthUser } from "@/lib/auth";2324export type AdminActionResult<T = undefined> = { ok: true; data?: T; warning?: string } | { ok: false; error: string };2526/** Every admin mutation is recorded as `admin.action` with a `type` discriminator in metadata. */27async function audit(admin: AuthUser, type: string, target: string | null, metadata: Record<string, unknown> = {}, organizationId?: string | null) {28 const meta = await requestMeta();29 await writeAudit({30 userId: admin.id,31 organizationId: organizationId ?? null,32 action: "admin.action",33 target,34 metadata: { type, ...metadata },35 ipAddress: meta.ip,36 userAgent: meta.userAgent,37 });38}3940function fail(e: unknown): { ok: false; error: string } {41 if (e instanceof InternalApiError) return { ok: false, error: `${e.code}: ${e.message}` };42 if (e instanceof z.ZodError) return { ok: false, error: e.issues[0]?.message ?? "Invalid input" };43 return { ok: false, error: (e as Error)?.message ?? "Unexpected error" };44}4546function revalidateAdmin() {47 revalidatePath("/admin", "layout");48}4950// ---------------------------------------------------------------------------51// Users52// ---------------------------------------------------------------------------53export async function banUser(userId: string, reason: string): Promise<AdminActionResult> {54 const admin = await requireAdmin();55 try {56 const r = z.string().trim().min(3, "Give a reason (3+ characters)").max(500).parse(reason);57 if (userId === admin.id) return { ok: false, error: "You cannot ban yourself." };58 const db = getDb();59 const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);60 if (!u) return { ok: false, error: "User not found" };61 await db.update(users).set({ banned: true, banReason: r, updatedAt: new Date() }).where(eq(users.id, userId));62 await db.delete(sessions).where(eq(sessions.userId, userId));63 await audit(admin, "user.ban", userId, { email: u.email, reason: r });64 revalidateAdmin();65 return { ok: true };66 } catch (e) {67 return fail(e);68 }69}7071export async function unbanUser(userId: string): Promise<AdminActionResult> {72 const admin = await requireAdmin();73 try {74 const db = getDb();75 const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);76 if (!u) return { ok: false, error: "User not found" };77 await db.update(users).set({ banned: false, banReason: null, updatedAt: new Date() }).where(eq(users.id, userId));78 await audit(admin, "user.unban", userId, { email: u.email });79 revalidateAdmin();80 return { ok: true };81 } catch (e) {82 return fail(e);83 }84}8586export async function setUserRole(userId: string, role: "admin" | "user"): Promise<AdminActionResult> {87 const admin = await requireAdmin();88 try {89 z.enum(["admin", "user"]).parse(role);90 if (userId === admin.id && role !== "admin") return { ok: false, error: "You cannot demote yourself." };91 const db = getDb();92 const [u] = await db.select({ id: users.id, email: users.email, role: users.role }).from(users).where(eq(users.id, userId)).limit(1);93 if (!u) return { ok: false, error: "User not found" };94 await db.update(users).set({ role, updatedAt: new Date() }).where(eq(users.id, userId));95 await audit(admin, role === "admin" ? "user.promote" : "user.demote", userId, { email: u.email, from: u.role, to: role });96 revalidateAdmin();97 return { ok: true };98 } catch (e) {99 return fail(e);100 }101}102103export async function forceVerifyEmail(userId: string): Promise<AdminActionResult> {104 const admin = await requireAdmin();105 try {106 const db = getDb();107 const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);108 if (!u) return { ok: false, error: "User not found" };109 await db.update(users).set({ emailVerified: true, updatedAt: new Date() }).where(eq(users.id, userId));110 await audit(admin, "user.force_verify", userId, { email: u.email });111 revalidateAdmin();112 return { ok: true };113 } catch (e) {114 return fail(e);115 }116}117118// ---------------------------------------------------------------------------119// Organizations120// ---------------------------------------------------------------------------121export async function updateOrganizationPlan(orgId: string, plan: string): Promise<AdminActionResult> {122 const admin = await requireAdmin();123 try {124 const p = z.enum(PLANS).parse(plan);125 const db = getDb();126 const [o] = await db.select({ id: organizations.id, plan: organizations.plan }).from(organizations).where(eq(organizations.id, orgId)).limit(1);127 if (!o) return { ok: false, error: "Organization not found" };128 await db.update(organizations).set({ plan: p, updatedAt: new Date() }).where(eq(organizations.id, orgId));129 await audit(admin, "org.plan", orgId, { from: o.plan, to: p }, orgId);130 revalidateAdmin();131 return { ok: true };132 } catch (e) {133 return fail(e);134 }135}136137export async function setOrganizationProviderVisibility(orgId: string, enabled: boolean): Promise<AdminActionResult> {138 const admin = await requireAdmin();139 try {140 const db = getDb();141 const res = await db.update(organizations).set({ providerVisibility: Boolean(enabled), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });142 if (!res.length) return { ok: false, error: "Organization not found" };143 await audit(admin, "org.provider_visibility", orgId, { enabled: Boolean(enabled) }, orgId);144 revalidateAdmin();145 return { ok: true };146 } catch (e) {147 return fail(e);148 }149}150151export async function setOrganizationSuspended(orgId: string, suspended: boolean, reason?: string): Promise<AdminActionResult> {152 const admin = await requireAdmin();153 try {154 const db = getDb();155 const res = await db.update(organizations).set({ suspended: Boolean(suspended), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });156 if (!res.length) return { ok: false, error: "Organization not found" };157 await audit(admin, suspended ? "org.suspend" : "org.unsuspend", orgId, { reason: reason?.trim() || null }, orgId);158 revalidateAdmin();159 return { ok: true };160 } catch (e) {161 return fail(e);162 }163}164165const limitsSchema = z.object({166 softLimitUsd: z.number().min(0).nullable(),167 hardLimitUsd: z.number().min(0).nullable(),168});169170export async function updateOrganizationLimits(orgId: string, input: { softLimitUsd: number | null; hardLimitUsd: number | null }): Promise<AdminActionResult> {171 const admin = await requireAdmin();172 try {173 const d = limitsSchema.parse(input);174 if (d.softLimitUsd !== null && d.hardLimitUsd !== null && d.softLimitUsd > d.hardLimitUsd) return { ok: false, error: "Soft limit must be ≤ hard limit." };175 const db = getDb();176 const res = await db.update(organizations).set({ softLimitUsd: d.softLimitUsd, hardLimitUsd: d.hardLimitUsd, updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });177 if (!res.length) return { ok: false, error: "Organization not found" };178 await audit(admin, "org.limits", orgId, { ...d }, orgId);179 revalidateAdmin();180 return { ok: true };181 } catch (e) {182 return fail(e);183 }184}185186// ---------------------------------------------------------------------------187// Providers188// ---------------------------------------------------------------------------189const providerConfigSchema = z.object({190 label: z.string().trim().min(1).max(64),191 enabled: z.boolean(),192 networks: z.array(z.enum(["datacenter", "residential", "isp", "mobile"])).min(0),193 pricePerGbUsd: z.record(z.string(), z.number().min(0).max(1000)),194 weight: z.number().min(0).max(10),195 maxConcurrency: z.number().int().min(1).max(100_000),196 notes: z.string().trim().max(2000).nullable(),197});198199export type ProviderConfigInput = z.infer<typeof providerConfigSchema>;200201export async function updateProviderConfig(id: string, input: ProviderConfigInput): Promise<AdminActionResult> {202 const admin = await requireAdmin();203 try {204 const pid = z.string().regex(/^[a-z0-9_-]{2,32}$/, "Invalid provider id").parse(id);205 const d = providerConfigSchema.parse(input);206 const db = getDb();207 const set = { label: d.label, enabled: d.enabled, networks: d.networks, pricePerGbUsd: d.pricePerGbUsd, weight: d.weight, maxConcurrency: d.maxConcurrency, notes: d.notes, updatedAt: new Date() };208 await db.insert(providerConfigs).values({ id: pid, ...set }).onConflictDoUpdate({ target: providerConfigs.id, set });209 await audit(admin, "provider.config", pid, { ...set, updatedAt: undefined });210 let warning: string | undefined;211 try {212 await internalApi.reloadProviders();213 } catch (e) {214 warning = `Saved, but the API did not reload providers: ${(e as Error).message}`;215 }216 revalidateAdmin();217 return { ok: true, warning };218 } catch (e) {219 return fail(e);220 }221}222223export async function resetCircuit(key?: string): Promise<AdminActionResult> {224 const admin = await requireAdmin();225 try {226 await internalApi.resetCircuit(key);227 await audit(admin, "provider.circuit_reset", key ?? "all", {});228 revalidateAdmin();229 return { ok: true };230 } catch (e) {231 return fail(e);232 }233}234235export async function runProviderProbe(): Promise<AdminActionResult> {236 const admin = await requireAdmin();237 try {238 await internalApi.probeProviders();239 await audit(admin, "provider.probe", null, {});240 revalidateAdmin();241 return { ok: true };242 } catch (e) {243 return fail(e);244 }245}246247// ---------------------------------------------------------------------------248// Domains249// ---------------------------------------------------------------------------250const routeKeyRe = /^[a-z0-9_-]+:(datacenter|residential|isp|mobile)$/;251const policySchema = z.object({252 force_network: z.enum(["datacenter", "residential", "isp", "mobile"]).nullable().optional(),253 order: z.array(z.string().regex(routeKeyRe, "Route keys look like provider:network")).max(16).optional(),254});255256export async function updateDomainPolicy(domain: string, policy: { force_network?: string | null; order?: string[] }): Promise<AdminActionResult> {257 const admin = await requireAdmin();258 try {259 const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase();260 const p = policySchema.parse({ force_network: policy.force_network || null, order: policy.order ?? [] });261 const next: { order?: string[]; force_network?: string } = {};262 if (p.order?.length) next.order = Array.from(new Set(p.order));263 if (p.force_network) next.force_network = p.force_network;264 const value = Object.keys(next).length ? next : null;265 const db = getDb();266 const res = await db.update(domainProfiles).set({ policy: value, updatedAt: new Date() }).where(eq(domainProfiles.domain, dom)).returning({ domain: domainProfiles.domain });267 if (!res.length) {268 await db.insert(domainProfiles).values({ domain: dom, policy: value });269 }270 await audit(admin, "domain.policy", dom, { policy: value });271 revalidateAdmin();272 return { ok: true };273 } catch (e) {274 return fail(e);275 }276}277278export async function resetDomainStats(domain: string): Promise<AdminActionResult> {279 const admin = await requireAdmin();280 try {281 const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase();282 const res = await getDb()283 .update(domainProfiles)284 .set({ requests: 0, successes: 0, blocks: 0, captchas: 0, browserRequired: 0, avgLatencyMs: 0, routeStats: {}, preferredNetwork: null, preferredProvider: null, updatedAt: new Date() })285 .where(eq(domainProfiles.domain, dom))286 .returning({ domain: domainProfiles.domain });287 if (!res.length) return { ok: false, error: "Domain profile not found" };288 await audit(admin, "domain.reset_stats", dom, {});289 revalidateAdmin();290 return { ok: true };291 } catch (e) {292 return fail(e);293 }294}295296// ---------------------------------------------------------------------------297// Abuse298// ---------------------------------------------------------------------------299export async function resolveAbuseEvent(id: string, resolved = true): Promise<AdminActionResult> {300 const admin = await requireAdmin();301 try {302 const res = await getDb().update(abuseEvents).set({ resolved }).where(eq(abuseEvents.id, id)).returning({ id: abuseEvents.id });303 if (!res.length) return { ok: false, error: "Abuse event not found" };304 await audit(admin, resolved ? "abuse.resolve" : "abuse.reopen", id, {});305 revalidateAdmin();306 return { ok: true };307 } catch (e) {308 return fail(e);309 }310}311312const abuseSchema = z.object({313 kind: z.enum(["ssrf_attempt", "prohibited_target", "rate_abuse", "credential_abuse", "manual"]),314 severity: z.enum(["low", "medium", "high", "critical"]),315 organizationId: z.string().trim().max(64).optional().or(z.literal("")),316 projectId: z.string().trim().max(64).optional().or(z.literal("")),317 requestId: z.string().trim().max(64).optional().or(z.literal("")),318 detail: z.string().trim().min(3, "Describe the event").max(2000),319});320321export async function recordAbuseEvent(input: z.input<typeof abuseSchema>): Promise<AdminActionResult<{ id: string }>> {322 const admin = await requireAdmin();323 try {324 const d = abuseSchema.parse(input);325 const db = getDb();326 if (d.organizationId) {327 const [o] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.id, d.organizationId)).limit(1);328 if (!o) return { ok: false, error: "Organization id does not exist" };329 }330 const id = newId("abuse");331 await db.insert(abuseEvents).values({332 id,333 organizationId: d.organizationId || null,334 projectId: d.projectId || null,335 requestId: d.requestId || null,336 kind: d.kind,337 severity: d.severity,338 detail: d.detail,339 });340 await audit(admin, "abuse.record", id, { kind: d.kind, severity: d.severity }, d.organizationId || null);341 revalidateAdmin();342 return { ok: true, data: { id } };343 } catch (e) {344 return fail(e);345 }346}347348// ---------------------------------------------------------------------------349// Feature flags350// ---------------------------------------------------------------------------351export async function setFeatureFlag(key: string, enabled: boolean): Promise<AdminActionResult> {352 const admin = await requireAdmin();353 try {354 const k = z.string().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -").parse(key);355 const res = await getDb().update(featureFlags).set({ enabled: Boolean(enabled), updatedAt: new Date() }).where(eq(featureFlags.key, k)).returning({ key: featureFlags.key });356 if (!res.length) return { ok: false, error: "Flag not found" };357 await audit(admin, "flag.set", k, { enabled: Boolean(enabled) });358 revalidateAdmin();359 return { ok: true };360 } catch (e) {361 return fail(e);362 }363}364365const flagSchema = z.object({366 key: z.string().trim().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -"),367 description: z.string().trim().max(280).optional().or(z.literal("")),368 enabled: z.boolean().default(false),369 plans: z.array(z.enum(PLANS)).optional(),370 organizationIds: z.array(z.string().trim().min(1)).max(200).optional(),371});372373export async function upsertFeatureFlag(input: z.input<typeof flagSchema>): Promise<AdminActionResult> {374 const admin = await requireAdmin();375 try {376 const d = flagSchema.parse(input);377 const set = { description: d.description || null, enabled: d.enabled, plans: d.plans?.length ? d.plans : null, organizationIds: d.organizationIds?.length ? d.organizationIds : null, updatedAt: new Date() };378 await getDb().insert(featureFlags).values({ key: d.key, ...set }).onConflictDoUpdate({ target: featureFlags.key, set });379 await audit(admin, "flag.upsert", d.key, { ...set, updatedAt: undefined });380 revalidateAdmin();381 return { ok: true };382 } catch (e) {383 return fail(e);384 }385}386387export async function deleteFeatureFlag(key: string): Promise<AdminActionResult> {388 const admin = await requireAdmin();389 try {390 const res = await getDb().delete(featureFlags).where(eq(featureFlags.key, key)).returning({ key: featureFlags.key });391 if (!res.length) return { ok: false, error: "Flag not found" };392 await audit(admin, "flag.delete", key, {});393 revalidateAdmin();394 return { ok: true };395 } catch (e) {396 return fail(e);397 }398}399400// ---------------------------------------------------------------------------401// Status incidents402// ---------------------------------------------------------------------------403const incidentSchema = z.object({404 component: z.enum(["api", "dashboard", "residential", "datacenter", "isp", "mobile", "sessions", "billing", "website"]),405 title: z.string().trim().min(3).max(140),406 body: z.string().trim().max(4000).optional().or(z.literal("")),407 severity: z.enum(["minor", "major", "critical", "maintenance"]),408});409410export async function createIncident(input: z.input<typeof incidentSchema>): Promise<AdminActionResult<{ id: string }>> {411 const admin = await requireAdmin();412 try {413 const d = incidentSchema.parse(input);414 const id = `inc_${randomBytes(8).toString("hex")}`;415 await getDb().insert(statusIncidents).values({ id, component: d.component, title: d.title, body: d.body || null, severity: d.severity });416 await audit(admin, "incident.create", id, { component: d.component, severity: d.severity, title: d.title });417 revalidateAdmin();418 revalidatePath("/status");419 return { ok: true, data: { id } };420 } catch (e) {421 return fail(e);422 }423}424425export async function resolveIncident(id: string, resolved = true): Promise<AdminActionResult> {426 const admin = await requireAdmin();427 try {428 const res = await getDb().update(statusIncidents).set({ resolvedAt: resolved ? new Date() : null }).where(eq(statusIncidents.id, id)).returning({ id: statusIncidents.id });429 if (!res.length) return { ok: false, error: "Incident not found" };430 await audit(admin, resolved ? "incident.resolve" : "incident.reopen", id, {});431 revalidateAdmin();432 revalidatePath("/status");433 return { ok: true };434 } catch (e) {435 return fail(e);436 }437}438