TypeScript 97.5%
SQL 1.4%
Python 0.8%
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// ---------------------------------------------------------------------------121/**122 * Single-plan platform: kept for API compatibility with older UI code, but plans can no longer be123 * changed. Every organization is `unlimited` (see `normalizePlan` in @fetcha/core).124 */125export async function updateOrganizationPlan(orgId: string, plan: string): Promise<AdminActionResult> {126 await requireAdmin();127 void orgId;128 void plan;129 return { ok: false, error: "Plans are not configurable on this platform" };130}131132export async function setOrganizationProviderVisibility(orgId: string, enabled: boolean): Promise<AdminActionResult> {133 const admin = await requireAdmin();134 try {135 const db = getDb();136 const res = await db.update(organizations).set({ providerVisibility: Boolean(enabled), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });137 if (!res.length) return { ok: false, error: "Organization not found" };138 await audit(admin, "org.provider_visibility", orgId, { enabled: Boolean(enabled) }, orgId);139 revalidateAdmin();140 return { ok: true };141 } catch (e) {142 return fail(e);143 }144}145146export async function setOrganizationSuspended(orgId: string, suspended: boolean, reason?: string): Promise<AdminActionResult> {147 const admin = await requireAdmin();148 try {149 const db = getDb();150 const res = await db.update(organizations).set({ suspended: Boolean(suspended), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });151 if (!res.length) return { ok: false, error: "Organization not found" };152 await audit(admin, suspended ? "org.suspend" : "org.unsuspend", orgId, { reason: reason?.trim() || null }, orgId);153 revalidateAdmin();154 return { ok: true };155 } catch (e) {156 return fail(e);157 }158}159160const limitsSchema = z.object({161 softLimitUsd: z.number().min(0).nullable(),162 hardLimitUsd: z.number().min(0).nullable(),163});164165export async function updateOrganizationLimits(orgId: string, input: { softLimitUsd: number | null; hardLimitUsd: number | null }): Promise<AdminActionResult> {166 const admin = await requireAdmin();167 try {168 const d = limitsSchema.parse(input);169 if (d.softLimitUsd !== null && d.hardLimitUsd !== null && d.softLimitUsd > d.hardLimitUsd) return { ok: false, error: "Soft limit must be ≤ hard limit." };170 const db = getDb();171 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 });172 if (!res.length) return { ok: false, error: "Organization not found" };173 await audit(admin, "org.limits", orgId, { ...d }, orgId);174 revalidateAdmin();175 return { ok: true };176 } catch (e) {177 return fail(e);178 }179}180181// ---------------------------------------------------------------------------182// Providers183// ---------------------------------------------------------------------------184const providerConfigSchema = z.object({185 label: z.string().trim().min(1).max(64),186 enabled: z.boolean(),187 networks: z.array(z.enum(["datacenter", "residential", "isp", "mobile"])).min(0),188 pricePerGbUsd: z.record(z.string(), z.number().min(0).max(1000)),189 weight: z.number().min(0).max(10),190 maxConcurrency: z.number().int().min(1).max(100_000),191 notes: z.string().trim().max(2000).nullable(),192});193194export type ProviderConfigInput = z.infer<typeof providerConfigSchema>;195196export async function updateProviderConfig(id: string, input: ProviderConfigInput): Promise<AdminActionResult> {197 const admin = await requireAdmin();198 try {199 const pid = z.string().regex(/^[a-z0-9_-]{2,32}$/, "Invalid provider id").parse(id);200 const d = providerConfigSchema.parse(input);201 const db = getDb();202 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() };203 await db.insert(providerConfigs).values({ id: pid, ...set }).onConflictDoUpdate({ target: providerConfigs.id, set });204 await audit(admin, "provider.config", pid, { ...set, updatedAt: undefined });205 let warning: string | undefined;206 try {207 await internalApi.reloadProviders();208 } catch (e) {209 warning = `Saved, but the API did not reload providers: ${(e as Error).message}`;210 }211 revalidateAdmin();212 return { ok: true, warning };213 } catch (e) {214 return fail(e);215 }216}217218export async function resetCircuit(key?: string): Promise<AdminActionResult> {219 const admin = await requireAdmin();220 try {221 await internalApi.resetCircuit(key);222 await audit(admin, "provider.circuit_reset", key ?? "all", {});223 revalidateAdmin();224 return { ok: true };225 } catch (e) {226 return fail(e);227 }228}229230export async function runProviderProbe(): Promise<AdminActionResult> {231 const admin = await requireAdmin();232 try {233 await internalApi.probeProviders();234 await audit(admin, "provider.probe", null, {});235 revalidateAdmin();236 return { ok: true };237 } catch (e) {238 return fail(e);239 }240}241242// ---------------------------------------------------------------------------243// Domains244// ---------------------------------------------------------------------------245const routeKeyRe = /^[a-z0-9_-]+:(datacenter|residential|isp|mobile)$/;246const policySchema = z.object({247 force_network: z.enum(["datacenter", "residential", "isp", "mobile"]).nullable().optional(),248 order: z.array(z.string().regex(routeKeyRe, "Route keys look like provider:network")).max(16).optional(),249});250251export async function updateDomainPolicy(domain: string, policy: { force_network?: string | null; order?: string[] }): Promise<AdminActionResult> {252 const admin = await requireAdmin();253 try {254 const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase();255 const p = policySchema.parse({ force_network: policy.force_network || null, order: policy.order ?? [] });256 const next: { order?: string[]; force_network?: string } = {};257 if (p.order?.length) next.order = Array.from(new Set(p.order));258 if (p.force_network) next.force_network = p.force_network;259 const value = Object.keys(next).length ? next : null;260 const db = getDb();261 const res = await db.update(domainProfiles).set({ policy: value, updatedAt: new Date() }).where(eq(domainProfiles.domain, dom)).returning({ domain: domainProfiles.domain });262 if (!res.length) {263 await db.insert(domainProfiles).values({ domain: dom, policy: value });264 }265 await audit(admin, "domain.policy", dom, { policy: value });266 revalidateAdmin();267 return { ok: true };268 } catch (e) {269 return fail(e);270 }271}272273export async function resetDomainStats(domain: string): Promise<AdminActionResult> {274 const admin = await requireAdmin();275 try {276 const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase();277 const res = await getDb()278 .update(domainProfiles)279 .set({ requests: 0, successes: 0, blocks: 0, captchas: 0, browserRequired: 0, avgLatencyMs: 0, routeStats: {}, preferredNetwork: null, preferredProvider: null, updatedAt: new Date() })280 .where(eq(domainProfiles.domain, dom))281 .returning({ domain: domainProfiles.domain });282 if (!res.length) return { ok: false, error: "Domain profile not found" };283 await audit(admin, "domain.reset_stats", dom, {});284 revalidateAdmin();285 return { ok: true };286 } catch (e) {287 return fail(e);288 }289}290291// ---------------------------------------------------------------------------292// Abuse293// ---------------------------------------------------------------------------294export async function resolveAbuseEvent(id: string, resolved = true): Promise<AdminActionResult> {295 const admin = await requireAdmin();296 try {297 const res = await getDb().update(abuseEvents).set({ resolved }).where(eq(abuseEvents.id, id)).returning({ id: abuseEvents.id });298 if (!res.length) return { ok: false, error: "Abuse event not found" };299 await audit(admin, resolved ? "abuse.resolve" : "abuse.reopen", id, {});300 revalidateAdmin();301 return { ok: true };302 } catch (e) {303 return fail(e);304 }305}306307const abuseSchema = z.object({308 kind: z.enum(["ssrf_attempt", "prohibited_target", "rate_abuse", "credential_abuse", "manual"]),309 severity: z.enum(["low", "medium", "high", "critical"]),310 organizationId: z.string().trim().max(64).optional().or(z.literal("")),311 projectId: z.string().trim().max(64).optional().or(z.literal("")),312 requestId: z.string().trim().max(64).optional().or(z.literal("")),313 detail: z.string().trim().min(3, "Describe the event").max(2000),314});315316export async function recordAbuseEvent(input: z.input<typeof abuseSchema>): Promise<AdminActionResult<{ id: string }>> {317 const admin = await requireAdmin();318 try {319 const d = abuseSchema.parse(input);320 const db = getDb();321 if (d.organizationId) {322 const [o] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.id, d.organizationId)).limit(1);323 if (!o) return { ok: false, error: "Organization id does not exist" };324 }325 const id = newId("abuse");326 await db.insert(abuseEvents).values({327 id,328 organizationId: d.organizationId || null,329 projectId: d.projectId || null,330 requestId: d.requestId || null,331 kind: d.kind,332 severity: d.severity,333 detail: d.detail,334 });335 await audit(admin, "abuse.record", id, { kind: d.kind, severity: d.severity }, d.organizationId || null);336 revalidateAdmin();337 return { ok: true, data: { id } };338 } catch (e) {339 return fail(e);340 }341}342343// ---------------------------------------------------------------------------344// Feature flags345// ---------------------------------------------------------------------------346export async function setFeatureFlag(key: string, enabled: boolean): Promise<AdminActionResult> {347 const admin = await requireAdmin();348 try {349 const k = z.string().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -").parse(key);350 const res = await getDb().update(featureFlags).set({ enabled: Boolean(enabled), updatedAt: new Date() }).where(eq(featureFlags.key, k)).returning({ key: featureFlags.key });351 if (!res.length) return { ok: false, error: "Flag not found" };352 await audit(admin, "flag.set", k, { enabled: Boolean(enabled) });353 revalidateAdmin();354 return { ok: true };355 } catch (e) {356 return fail(e);357 }358}359360const flagSchema = z.object({361 key: z.string().trim().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -"),362 description: z.string().trim().max(280).optional().or(z.literal("")),363 enabled: z.boolean().default(false),364 plans: z.array(z.enum(PLANS)).optional(),365 organizationIds: z.array(z.string().trim().min(1)).max(200).optional(),366});367368export async function upsertFeatureFlag(input: z.input<typeof flagSchema>): Promise<AdminActionResult> {369 const admin = await requireAdmin();370 try {371 const d = flagSchema.parse(input);372 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() };373 await getDb().insert(featureFlags).values({ key: d.key, ...set }).onConflictDoUpdate({ target: featureFlags.key, set });374 await audit(admin, "flag.upsert", d.key, { ...set, updatedAt: undefined });375 revalidateAdmin();376 return { ok: true };377 } catch (e) {378 return fail(e);379 }380}381382export async function deleteFeatureFlag(key: string): Promise<AdminActionResult> {383 const admin = await requireAdmin();384 try {385 const res = await getDb().delete(featureFlags).where(eq(featureFlags.key, key)).returning({ key: featureFlags.key });386 if (!res.length) return { ok: false, error: "Flag not found" };387 await audit(admin, "flag.delete", key, {});388 revalidateAdmin();389 return { ok: true };390 } catch (e) {391 return fail(e);392 }393}394395// ---------------------------------------------------------------------------396// Status incidents397// ---------------------------------------------------------------------------398const incidentSchema = z.object({399 component: z.enum(["api", "dashboard", "residential", "datacenter", "isp", "mobile", "sessions", "billing", "website"]),400 title: z.string().trim().min(3).max(140),401 body: z.string().trim().max(4000).optional().or(z.literal("")),402 severity: z.enum(["minor", "major", "critical", "maintenance"]),403});404405export async function createIncident(input: z.input<typeof incidentSchema>): Promise<AdminActionResult<{ id: string }>> {406 const admin = await requireAdmin();407 try {408 const d = incidentSchema.parse(input);409 const id = `inc_${randomBytes(8).toString("hex")}`;410 await getDb().insert(statusIncidents).values({ id, component: d.component, title: d.title, body: d.body || null, severity: d.severity });411 await audit(admin, "incident.create", id, { component: d.component, severity: d.severity, title: d.title });412 revalidateAdmin();413 revalidatePath("/status");414 return { ok: true, data: { id } };415 } catch (e) {416 return fail(e);417 }418}419420export async function resolveIncident(id: string, resolved = true): Promise<AdminActionResult> {421 const admin = await requireAdmin();422 try {423 const res = await getDb().update(statusIncidents).set({ resolvedAt: resolved ? new Date() : null }).where(eq(statusIncidents.id, id)).returning({ id: statusIncidents.id });424 if (!res.length) return { ok: false, error: "Incident not found" };425 await audit(admin, resolved ? "incident.resolve" : "incident.reopen", id, {});426 revalidateAdmin();427 revalidatePath("/status");428 return { ok: true };429 } catch (e) {430 return fail(e);431 }432}433