"use server"; import { randomBytes } from "node:crypto"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { getDb, users, sessions, organizations, providerConfigs, domainProfiles, abuseEvents, featureFlags, statusIncidents, eq, } from "@fetcha/db"; import { newId, PLANS } from "@fetcha/core"; import { requireAdmin, requestMeta } from "@/lib/session"; import { writeAudit } from "@/lib/audit"; import { internalApi, InternalApiError } from "@/lib/api"; import type { AuthUser } from "@/lib/auth"; export type AdminActionResult = { ok: true; data?: T; warning?: string } | { ok: false; error: string }; /** Every admin mutation is recorded as `admin.action` with a `type` discriminator in metadata. */ async function audit(admin: AuthUser, type: string, target: string | null, metadata: Record = {}, organizationId?: string | null) { const meta = await requestMeta(); await writeAudit({ userId: admin.id, organizationId: organizationId ?? null, action: "admin.action", target, metadata: { type, ...metadata }, ipAddress: meta.ip, userAgent: meta.userAgent, }); } function fail(e: unknown): { ok: false; error: string } { if (e instanceof InternalApiError) return { ok: false, error: `${e.code}: ${e.message}` }; if (e instanceof z.ZodError) return { ok: false, error: e.issues[0]?.message ?? "Invalid input" }; return { ok: false, error: (e as Error)?.message ?? "Unexpected error" }; } function revalidateAdmin() { revalidatePath("/admin", "layout"); } // --------------------------------------------------------------------------- // Users // --------------------------------------------------------------------------- export async function banUser(userId: string, reason: string): Promise { const admin = await requireAdmin(); try { const r = z.string().trim().min(3, "Give a reason (3+ characters)").max(500).parse(reason); if (userId === admin.id) return { ok: false, error: "You cannot ban yourself." }; const db = getDb(); const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1); if (!u) return { ok: false, error: "User not found" }; await db.update(users).set({ banned: true, banReason: r, updatedAt: new Date() }).where(eq(users.id, userId)); await db.delete(sessions).where(eq(sessions.userId, userId)); await audit(admin, "user.ban", userId, { email: u.email, reason: r }); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } export async function unbanUser(userId: string): Promise { const admin = await requireAdmin(); try { const db = getDb(); const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1); if (!u) return { ok: false, error: "User not found" }; await db.update(users).set({ banned: false, banReason: null, updatedAt: new Date() }).where(eq(users.id, userId)); await audit(admin, "user.unban", userId, { email: u.email }); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } export async function setUserRole(userId: string, role: "admin" | "user"): Promise { const admin = await requireAdmin(); try { z.enum(["admin", "user"]).parse(role); if (userId === admin.id && role !== "admin") return { ok: false, error: "You cannot demote yourself." }; const db = getDb(); const [u] = await db.select({ id: users.id, email: users.email, role: users.role }).from(users).where(eq(users.id, userId)).limit(1); if (!u) return { ok: false, error: "User not found" }; await db.update(users).set({ role, updatedAt: new Date() }).where(eq(users.id, userId)); await audit(admin, role === "admin" ? "user.promote" : "user.demote", userId, { email: u.email, from: u.role, to: role }); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } export async function forceVerifyEmail(userId: string): Promise { const admin = await requireAdmin(); try { const db = getDb(); const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1); if (!u) return { ok: false, error: "User not found" }; await db.update(users).set({ emailVerified: true, updatedAt: new Date() }).where(eq(users.id, userId)); await audit(admin, "user.force_verify", userId, { email: u.email }); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } // --------------------------------------------------------------------------- // Organizations // --------------------------------------------------------------------------- /** * Single-plan platform: kept for API compatibility with older UI code, but plans can no longer be * changed. Every organization is `unlimited` (see `normalizePlan` in @fetcha/core). */ export async function updateOrganizationPlan(orgId: string, plan: string): Promise { await requireAdmin(); void orgId; void plan; return { ok: false, error: "Plans are not configurable on this platform" }; } export async function setOrganizationProviderVisibility(orgId: string, enabled: boolean): Promise { const admin = await requireAdmin(); try { const db = getDb(); const res = await db.update(organizations).set({ providerVisibility: Boolean(enabled), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id }); if (!res.length) return { ok: false, error: "Organization not found" }; await audit(admin, "org.provider_visibility", orgId, { enabled: Boolean(enabled) }, orgId); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } export async function setOrganizationSuspended(orgId: string, suspended: boolean, reason?: string): Promise { const admin = await requireAdmin(); try { const db = getDb(); const res = await db.update(organizations).set({ suspended: Boolean(suspended), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id }); if (!res.length) return { ok: false, error: "Organization not found" }; await audit(admin, suspended ? "org.suspend" : "org.unsuspend", orgId, { reason: reason?.trim() || null }, orgId); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } const limitsSchema = z.object({ softLimitUsd: z.number().min(0).nullable(), hardLimitUsd: z.number().min(0).nullable(), }); export async function updateOrganizationLimits(orgId: string, input: { softLimitUsd: number | null; hardLimitUsd: number | null }): Promise { const admin = await requireAdmin(); try { const d = limitsSchema.parse(input); if (d.softLimitUsd !== null && d.hardLimitUsd !== null && d.softLimitUsd > d.hardLimitUsd) return { ok: false, error: "Soft limit must be ≤ hard limit." }; const db = getDb(); 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 }); if (!res.length) return { ok: false, error: "Organization not found" }; await audit(admin, "org.limits", orgId, { ...d }, orgId); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } // --------------------------------------------------------------------------- // Providers // --------------------------------------------------------------------------- const providerConfigSchema = z.object({ label: z.string().trim().min(1).max(64), enabled: z.boolean(), networks: z.array(z.enum(["datacenter", "residential", "isp", "mobile"])).min(0), pricePerGbUsd: z.record(z.string(), z.number().min(0).max(1000)), weight: z.number().min(0).max(10), maxConcurrency: z.number().int().min(1).max(100_000), notes: z.string().trim().max(2000).nullable(), }); export type ProviderConfigInput = z.infer; export async function updateProviderConfig(id: string, input: ProviderConfigInput): Promise { const admin = await requireAdmin(); try { const pid = z.string().regex(/^[a-z0-9_-]{2,32}$/, "Invalid provider id").parse(id); const d = providerConfigSchema.parse(input); const db = getDb(); 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() }; await db.insert(providerConfigs).values({ id: pid, ...set }).onConflictDoUpdate({ target: providerConfigs.id, set }); await audit(admin, "provider.config", pid, { ...set, updatedAt: undefined }); let warning: string | undefined; try { await internalApi.reloadProviders(); } catch (e) { warning = `Saved, but the API did not reload providers: ${(e as Error).message}`; } revalidateAdmin(); return { ok: true, warning }; } catch (e) { return fail(e); } } export async function resetCircuit(key?: string): Promise { const admin = await requireAdmin(); try { await internalApi.resetCircuit(key); await audit(admin, "provider.circuit_reset", key ?? "all", {}); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } export async function runProviderProbe(): Promise { const admin = await requireAdmin(); try { await internalApi.probeProviders(); await audit(admin, "provider.probe", null, {}); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } // --------------------------------------------------------------------------- // Domains // --------------------------------------------------------------------------- const routeKeyRe = /^[a-z0-9_-]+:(datacenter|residential|isp|mobile)$/; const policySchema = z.object({ force_network: z.enum(["datacenter", "residential", "isp", "mobile"]).nullable().optional(), order: z.array(z.string().regex(routeKeyRe, "Route keys look like provider:network")).max(16).optional(), }); export async function updateDomainPolicy(domain: string, policy: { force_network?: string | null; order?: string[] }): Promise { const admin = await requireAdmin(); try { const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase(); const p = policySchema.parse({ force_network: policy.force_network || null, order: policy.order ?? [] }); const next: { order?: string[]; force_network?: string } = {}; if (p.order?.length) next.order = Array.from(new Set(p.order)); if (p.force_network) next.force_network = p.force_network; const value = Object.keys(next).length ? next : null; const db = getDb(); const res = await db.update(domainProfiles).set({ policy: value, updatedAt: new Date() }).where(eq(domainProfiles.domain, dom)).returning({ domain: domainProfiles.domain }); if (!res.length) { await db.insert(domainProfiles).values({ domain: dom, policy: value }); } await audit(admin, "domain.policy", dom, { policy: value }); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } export async function resetDomainStats(domain: string): Promise { const admin = await requireAdmin(); try { const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase(); const res = await getDb() .update(domainProfiles) .set({ requests: 0, successes: 0, blocks: 0, captchas: 0, browserRequired: 0, avgLatencyMs: 0, routeStats: {}, preferredNetwork: null, preferredProvider: null, updatedAt: new Date() }) .where(eq(domainProfiles.domain, dom)) .returning({ domain: domainProfiles.domain }); if (!res.length) return { ok: false, error: "Domain profile not found" }; await audit(admin, "domain.reset_stats", dom, {}); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } // --------------------------------------------------------------------------- // Abuse // --------------------------------------------------------------------------- export async function resolveAbuseEvent(id: string, resolved = true): Promise { const admin = await requireAdmin(); try { const res = await getDb().update(abuseEvents).set({ resolved }).where(eq(abuseEvents.id, id)).returning({ id: abuseEvents.id }); if (!res.length) return { ok: false, error: "Abuse event not found" }; await audit(admin, resolved ? "abuse.resolve" : "abuse.reopen", id, {}); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } const abuseSchema = z.object({ kind: z.enum(["ssrf_attempt", "prohibited_target", "rate_abuse", "credential_abuse", "manual"]), severity: z.enum(["low", "medium", "high", "critical"]), organizationId: z.string().trim().max(64).optional().or(z.literal("")), projectId: z.string().trim().max(64).optional().or(z.literal("")), requestId: z.string().trim().max(64).optional().or(z.literal("")), detail: z.string().trim().min(3, "Describe the event").max(2000), }); export async function recordAbuseEvent(input: z.input): Promise> { const admin = await requireAdmin(); try { const d = abuseSchema.parse(input); const db = getDb(); if (d.organizationId) { const [o] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.id, d.organizationId)).limit(1); if (!o) return { ok: false, error: "Organization id does not exist" }; } const id = newId("abuse"); await db.insert(abuseEvents).values({ id, organizationId: d.organizationId || null, projectId: d.projectId || null, requestId: d.requestId || null, kind: d.kind, severity: d.severity, detail: d.detail, }); await audit(admin, "abuse.record", id, { kind: d.kind, severity: d.severity }, d.organizationId || null); revalidateAdmin(); return { ok: true, data: { id } }; } catch (e) { return fail(e); } } // --------------------------------------------------------------------------- // Feature flags // --------------------------------------------------------------------------- export async function setFeatureFlag(key: string, enabled: boolean): Promise { const admin = await requireAdmin(); try { const k = z.string().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -").parse(key); const res = await getDb().update(featureFlags).set({ enabled: Boolean(enabled), updatedAt: new Date() }).where(eq(featureFlags.key, k)).returning({ key: featureFlags.key }); if (!res.length) return { ok: false, error: "Flag not found" }; await audit(admin, "flag.set", k, { enabled: Boolean(enabled) }); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } const flagSchema = z.object({ key: z.string().trim().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -"), description: z.string().trim().max(280).optional().or(z.literal("")), enabled: z.boolean().default(false), plans: z.array(z.enum(PLANS)).optional(), organizationIds: z.array(z.string().trim().min(1)).max(200).optional(), }); export async function upsertFeatureFlag(input: z.input): Promise { const admin = await requireAdmin(); try { const d = flagSchema.parse(input); 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() }; await getDb().insert(featureFlags).values({ key: d.key, ...set }).onConflictDoUpdate({ target: featureFlags.key, set }); await audit(admin, "flag.upsert", d.key, { ...set, updatedAt: undefined }); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } export async function deleteFeatureFlag(key: string): Promise { const admin = await requireAdmin(); try { const res = await getDb().delete(featureFlags).where(eq(featureFlags.key, key)).returning({ key: featureFlags.key }); if (!res.length) return { ok: false, error: "Flag not found" }; await audit(admin, "flag.delete", key, {}); revalidateAdmin(); return { ok: true }; } catch (e) { return fail(e); } } // --------------------------------------------------------------------------- // Status incidents // --------------------------------------------------------------------------- const incidentSchema = z.object({ component: z.enum(["api", "dashboard", "residential", "datacenter", "isp", "mobile", "sessions", "billing", "website"]), title: z.string().trim().min(3).max(140), body: z.string().trim().max(4000).optional().or(z.literal("")), severity: z.enum(["minor", "major", "critical", "maintenance"]), }); export async function createIncident(input: z.input): Promise> { const admin = await requireAdmin(); try { const d = incidentSchema.parse(input); const id = `inc_${randomBytes(8).toString("hex")}`; await getDb().insert(statusIncidents).values({ id, component: d.component, title: d.title, body: d.body || null, severity: d.severity }); await audit(admin, "incident.create", id, { component: d.component, severity: d.severity, title: d.title }); revalidateAdmin(); revalidatePath("/status"); return { ok: true, data: { id } }; } catch (e) { return fail(e); } } export async function resolveIncident(id: string, resolved = true): Promise { const admin = await requireAdmin(); try { const res = await getDb().update(statusIncidents).set({ resolvedAt: resolved ? new Date() : null }).where(eq(statusIncidents.id, id)).returning({ id: statusIncidents.id }); if (!res.length) return { ok: false, error: "Incident not found" }; await audit(admin, resolved ? "incident.resolve" : "incident.reopen", id, {}); revalidateAdmin(); revalidatePath("/status"); return { ok: true }; } catch (e) { return fail(e); } }