"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { getDb, apiKeys, projects, eq, and } from "@fetcha/db"; import { API_KEY_SCOPES, generateApiKey, newId, type ApiKeyScope } from "@fetcha/core"; import { getEmailService } from "@fetcha/email"; import { getWorkspace, requestMeta } from "@/lib/session"; import { writeAudit } from "@/lib/audit"; import { internalApi } from "@/lib/api"; import type { ActionResult } from "./projects"; const createSchema = z.object({ name: z.string().trim().min(1, "Give the key a name").max(64), projectId: z.string().min(1), mode: z.enum(["live", "test"]).default("live"), scopes: z.array(z.enum(API_KEY_SCOPES)).min(1, "Select at least one scope"), expiresInDays: z.coerce.number().int().min(0).max(3650).optional(), }); export interface CreatedKey { id: string; plaintext: string; prefix: string; name: string; projectId: string; } export async function createApiKey(input: { name: string; projectId: string; mode?: "live" | "test"; scopes: string[]; expiresInDays?: number }): Promise> { const ws = await getWorkspace(); const parsed = createSchema.safeParse(input); if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; const d = parsed.data; const db = getDb(); const [project] = await db.select().from(projects).where(and(eq(projects.id, d.projectId), eq(projects.organizationId, ws.organization.id))).limit(1); if (!project) return { ok: false, error: "Project not found" }; const key = generateApiKey(d.mode); const id = newId("key"); await db.insert(apiKeys).values({ id, organizationId: ws.organization.id, projectId: project.id, createdByUserId: ws.user.id, name: d.name, keyHash: key.hash, keyPrefix: key.prefix, last4: key.last4, mode: d.mode, scopes: d.scopes as ApiKeyScope[], expiresAt: d.expiresInDays ? new Date(Date.now() + d.expiresInDays * 86_400_000) : null, }); const meta = await requestMeta(); await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "api_key.created", target: id, metadata: { name: d.name, project: project.name, prefix: key.prefix, mode: d.mode }, ipAddress: meta.ip, userAgent: meta.userAgent }); getEmailService() .sendApiKeyCreated(ws.user.email, { keyName: d.name, prefix: key.prefix, projectName: project.name }) .catch(() => {}); revalidatePath("/dashboard", "layout"); return { ok: true, data: { id, plaintext: key.plaintext, prefix: key.prefix, name: d.name, projectId: project.id } }; } export async function revokeApiKey(keyId: string): Promise { const ws = await getWorkspace(); const db = getDb(); const [k] = await db.select().from(apiKeys).where(and(eq(apiKeys.id, keyId), eq(apiKeys.organizationId, ws.organization.id))).limit(1); if (!k) return { ok: false, error: "Key not found" }; if (k.revokedAt) return { ok: true }; await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, keyId)); await internalApi.invalidateKey(k.keyHash); const meta = await requestMeta(); await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "api_key.revoked", target: keyId, metadata: { name: k.name, prefix: k.keyPrefix }, ipAddress: meta.ip, userAgent: meta.userAgent }); getEmailService() .sendApiKeyRevoked(ws.user.email, { keyName: k.name, prefix: k.keyPrefix }) .catch(() => {}); revalidatePath("/dashboard", "layout"); return { ok: true }; } /** Rotate: create a new key with the same settings, revoke the old one after a short grace period (immediately in V1). */ export async function rotateApiKey(keyId: string): Promise> { const ws = await getWorkspace(); const db = getDb(); const [k] = await db.select().from(apiKeys).where(and(eq(apiKeys.id, keyId), eq(apiKeys.organizationId, ws.organization.id))).limit(1); if (!k) return { ok: false, error: "Key not found" }; const key = generateApiKey(k.mode as "live" | "test"); const id = newId("key"); await db.insert(apiKeys).values({ id, organizationId: ws.organization.id, projectId: k.projectId, createdByUserId: ws.user.id, name: k.name, keyHash: key.hash, keyPrefix: key.prefix, last4: key.last4, mode: k.mode, scopes: k.scopes, expiresAt: k.expiresAt, rotatedFromId: k.id, }); await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, k.id)); await internalApi.invalidateKey(k.keyHash); const meta = await requestMeta(); await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "api_key.rotated", target: id, metadata: { from: k.id, name: k.name }, ipAddress: meta.ip, userAgent: meta.userAgent }); revalidatePath("/dashboard", "layout"); return { ok: true, data: { id, plaintext: key.plaintext, prefix: key.prefix, name: k.name, projectId: k.projectId } }; }