SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
18.1 KB · 433 lines typescript
Raw Blame History
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