import type { z } from "zod"; import { auth } from "@/lib/auth"; import { canAccess, getApiAdminContext } from "@/lib/permissions"; import { logAuthorizationEvent } from "@/lib/admin/authorization-events"; import { getRequestId, runWithStore, createStore, setContextUserId } from "./request-context"; import { NotFoundError, UnauthorizedError, ForbiddenError, ValidationError, RateLimitError, DatabaseError, } from "./errors"; import type { ActionResult, ActionSuccess, ActionFailure, AppSession, AdminActionContext, IpAddress, RequestId, } from "./types"; import { extractClientIpAsync } from "./security"; import { rateLimit } from "@/lib/rate-limit"; function ok>(data?: T): ActionSuccess { return { ok: true, data: (data ?? {}) as T } as unknown as ActionSuccess; } function fail(error: string, fieldErrors?: Record): ActionFailure { return { ok: false, error, fieldErrors }; } export { ok as actionOk, fail as actionError }; interface AdminOpts { permission?: string; schema?: TSchema; rateLimitKey?: string; rateLimitMax?: number; rateLimitWindowMs?: number; } type ActionHandler = ( ctx: AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer } : Record), ) => Promise; export function adminAction( opts: AdminOpts, handler: ActionHandler, ) { return async (input: TSchema extends z.ZodType ? z.input : void): Promise => { const ip = await extractClientIpAsync(); const store = createStore(ip); return runWithStore(store, async () => { try { const apiCtx = await getApiAdminContext(); if (!apiCtx) return fail("Unauthorized"); setContextUserId(Number(apiCtx.session.user.id) as never); if (opts.permission) { if (!canAccess(apiCtx.permissions, opts.permission, apiCtx.session.user.rank)) { await logAuthorizationEvent({ kind: "permission.denied", userId: Number(apiCtx.session.user.id), username: apiCtx.session.user.name ?? undefined, rank: apiCtx.session.user.rank, permission: opts.permission, source: "adminAction", reason: "Permission check denied", }); return fail("Unauthorized"); } } if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) { const rlKey = `${opts.rateLimitKey}:${ip}`; const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs); if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`); } let data: unknown; if (opts.schema) { const parsed = opts.schema.safeParse(input); if (!parsed.success) { return fail("Validation failed", parsed.error.flatten().fieldErrors as Record); } data = parsed.data; } const ctx = { session: apiCtx.session, permissions: apiCtx.permissions, requestId: getRequestId(), ip, ...(opts.schema ? { data: data as z.infer> } : {}), } as AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer } : Record); return await handler(ctx); } catch (error) { return handleActionError(error); } }); }; } interface AuthOpts { schema?: TSchema; rateLimitKey?: string; rateLimitMax?: number; rateLimitWindowMs?: number; } export function authAction( opts: AuthOpts, handler: ( ctx: { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer } : Record), ) => Promise, ) { return async (input: TSchema extends z.ZodType ? z.input : void): Promise => { const ip = await extractClientIpAsync(); const store = createStore(ip); return runWithStore(store, async () => { try { const session = await auth(); if (!session?.user) return fail("Unauthorized"); setContextUserId(Number(session.user.id) as never); if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) { const rlKey = `${opts.rateLimitKey}:${ip}`; const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs); if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`); } let data: unknown; if (opts.schema) { const parsed = opts.schema.safeParse(input); if (!parsed.success) { return fail("Validation failed", parsed.error.flatten().fieldErrors as Record); } data = parsed.data; } const ctx = { session: session as unknown as AppSession, requestId: getRequestId(), ip, } as { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer } : Record); if (opts.schema) { (ctx as Record).data = data as z.infer>; } return await handler(ctx); } catch (error) { return handleActionError(error); } }); }; } export function handleActionError(error: unknown): ActionFailure { if (error instanceof ValidationError) { return fail(error.message, error.fieldErrors); } if (error instanceof UnauthorizedError || error instanceof ForbiddenError) { return fail(error.message); } if (error instanceof NotFoundError) { return fail(error.message); } if (error instanceof RateLimitError) { return fail(error.message); } if (error instanceof DatabaseError) { return fail("A database error occurred"); } if (error instanceof Error && error.name === "ZodError") { return fail("Validation failed"); } if ( error instanceof Error && (error.constructor.name === "PrismaClientKnownRequestError" || error.name === "PrismaClientKnownRequestError") && (error as Error & { code?: string }).code === "P2025" ) { return fail("Not found"); } console.error( "[Action error]", error instanceof Error ? { message: error.message, name: error.name } : error, ); return fail("Internal server error"); }