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%
4.9 KB · 107 lines typescript
Raw Blame History
1"use server";23import { revalidatePath } from "next/cache";4import { z } from "zod";5import { getDb, apiKeys, projects, eq, and } from "@fetcha/db";6import { API_KEY_SCOPES, generateApiKey, newId, type ApiKeyScope } from "@fetcha/core";7import { getEmailService } from "@fetcha/email";8import { getWorkspace, requestMeta } from "@/lib/session";9import { writeAudit } from "@/lib/audit";10import { internalApi } from "@/lib/api";11import type { ActionResult } from "./projects";1213const createSchema = z.object({14  name: z.string().trim().min(1, "Give the key a name").max(64),15  projectId: z.string().min(1),16  mode: z.enum(["live", "test"]).default("live"),17  scopes: z.array(z.enum(API_KEY_SCOPES)).min(1, "Select at least one scope"),18  expiresInDays: z.coerce.number().int().min(0).max(3650).optional(),19});2021export interface CreatedKey {22  id: string;23  plaintext: string;24  prefix: string;25  name: string;26  projectId: string;27}2829export async function createApiKey(input: { name: string; projectId: string; mode?: "live" | "test"; scopes: string[]; expiresInDays?: number }): Promise<ActionResult<CreatedKey>> {30  const ws = await getWorkspace();31  const parsed = createSchema.safeParse(input);32  if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };33  const d = parsed.data;34  const db = getDb();35  const [project] = await db.select().from(projects).where(and(eq(projects.id, d.projectId), eq(projects.organizationId, ws.organization.id))).limit(1);36  if (!project) return { ok: false, error: "Project not found" };37  const key = generateApiKey(d.mode);38  const id = newId("key");39  await db.insert(apiKeys).values({40    id,41    organizationId: ws.organization.id,42    projectId: project.id,43    createdByUserId: ws.user.id,44    name: d.name,45    keyHash: key.hash,46    keyPrefix: key.prefix,47    last4: key.last4,48    mode: d.mode,49    scopes: d.scopes as ApiKeyScope[],50    expiresAt: d.expiresInDays ? new Date(Date.now() + d.expiresInDays * 86_400_000) : null,51  });52  const meta = await requestMeta();53  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 });54  getEmailService()55    .sendApiKeyCreated(ws.user.email, { keyName: d.name, prefix: key.prefix, projectName: project.name })56    .catch(() => {});57  revalidatePath("/dashboard", "layout");58  return { ok: true, data: { id, plaintext: key.plaintext, prefix: key.prefix, name: d.name, projectId: project.id } };59}6061export async function revokeApiKey(keyId: string): Promise<ActionResult> {62  const ws = await getWorkspace();63  const db = getDb();64  const [k] = await db.select().from(apiKeys).where(and(eq(apiKeys.id, keyId), eq(apiKeys.organizationId, ws.organization.id))).limit(1);65  if (!k) return { ok: false, error: "Key not found" };66  if (k.revokedAt) return { ok: true };67  await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, keyId));68  await internalApi.invalidateKey(k.keyHash);69  const meta = await requestMeta();70  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 });71  getEmailService()72    .sendApiKeyRevoked(ws.user.email, { keyName: k.name, prefix: k.keyPrefix })73    .catch(() => {});74  revalidatePath("/dashboard", "layout");75  return { ok: true };76}7778/** Rotate: create a new key with the same settings, revoke the old one after a short grace period (immediately in V1). */79export async function rotateApiKey(keyId: string): Promise<ActionResult<CreatedKey>> {80  const ws = await getWorkspace();81  const db = getDb();82  const [k] = await db.select().from(apiKeys).where(and(eq(apiKeys.id, keyId), eq(apiKeys.organizationId, ws.organization.id))).limit(1);83  if (!k) return { ok: false, error: "Key not found" };84  const key = generateApiKey(k.mode as "live" | "test");85  const id = newId("key");86  await db.insert(apiKeys).values({87    id,88    organizationId: ws.organization.id,89    projectId: k.projectId,90    createdByUserId: ws.user.id,91    name: k.name,92    keyHash: key.hash,93    keyPrefix: key.prefix,94    last4: key.last4,95    mode: k.mode,96    scopes: k.scopes,97    expiresAt: k.expiresAt,98    rotatedFromId: k.id,99  });100  await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, k.id));101  await internalApi.invalidateKey(k.keyHash);102  const meta = await requestMeta();103  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 });104  revalidatePath("/dashboard", "layout");105  return { ok: true, data: { id, plaintext: key.plaintext, prefix: key.prefix, name: k.name, projectId: k.projectId } };106}107