import crypto from "node:crypto"; import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth/server"; import { db, apiKeys, users, eq } from "@epicure/db"; import { applyRateLimit } from "@/lib/rate-limit"; export async function requireSession() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) { return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; } return { session, response: null }; } /** Like requireSession, but never 401s — for endpoints that also accept anonymous * access via a resource-scoped capability (e.g. a public-editable share link). */ export async function getOptionalSession() { return auth.api.getSession({ headers: await headers() }); } export async function requireAdmin(opts?: { allowModerator?: boolean }) { const { session, response } = await requireSession(); if (response) return { session: null, response }; // Don't trust session.user.role — it comes from a 5-minute cookieCache // (see lib/auth/server.ts), so a just-demoted admin would keep access for // up to 5 minutes. Query the current role directly. const [dbUser] = await db .select({ role: users.role }) .from(users) .where(eq(users.id, session!.user.id)) .limit(1); const allowed = dbUser?.role === "admin" || (opts?.allowModerator && dbUser?.role === "moderator"); if (!allowed) { return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) }; } return { session, response: null }; } type SessionLike = { user: { id: string; email: string; name: string; tier: string; role?: string; image?: string | null; }; }; type RateLimitOpts = { limit: number; windowSeconds: number }; export async function requireSessionOrApiKey( req: NextRequest, opts?: { rateLimit?: RateLimitOpts } ): Promise<{ session: SessionLike; response: null } | { session: null; response: NextResponse }> { // 1. Try Bearer API key const authHeader = req.headers.get("authorization"); if (authHeader?.startsWith("Bearer ")) { const rawKey = authHeader.slice(7).trim(); if (rawKey.startsWith("ek_")) { const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex"); const [keyRow] = await db .select({ id: apiKeys.id, userId: apiKeys.userId, scope: apiKeys.scope }) .from(apiKeys) .where(eq(apiKeys.keyHash, keyHash)) .limit(1); if (keyRow) { // Read-scoped keys can't make any state-changing request — enforced // once here rather than in every route, since a route can't tell // whether it's being called by a "read" key without this check. if (keyRow.scope === "read" && !["GET", "HEAD", "OPTIONS"].includes(req.method)) { return { session: null, response: NextResponse.json({ error: "This API key is read-only" }, { status: 403 }), }; } // Update lastUsedAt asynchronously — don't block response void db .update(apiKeys) .set({ lastUsedAt: new Date() }) .where(eq(apiKeys.id, keyRow.id)) .catch((err) => console.error("[api-auth] failed to update apiKeys.lastUsedAt", err)); const [user] = await db .select({ id: users.id, email: users.email, name: users.name, tier: users.tier, role: users.role, }) .from(users) .where(eq(users.id, keyRow.userId)) .limit(1); if (user) { // Rate limit per API key (not per user — a user's other keys shouldn't // share this bucket). if (opts?.rateLimit) { const { limit, windowSeconds } = opts.rateLimit; const rateLimitResponse = await applyRateLimit( `rl:api:key:${keyRow.id}`, limit, windowSeconds ); if (rateLimitResponse) { return { session: null, response: rateLimitResponse }; } } return { session: { user: { ...user, image: null } }, response: null, }; } } return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), }; } } // 2. Fall back to session cookie const result = await requireSession(); if (result.response) return result; if (opts?.rateLimit) { const { limit, windowSeconds } = opts.rateLimit; const rateLimitResponse = await applyRateLimit( `rl:api:session:${result.session!.user.id}`, limit, windowSeconds ); if (rateLimitResponse) { return { session: null, response: rateLimitResponse }; } } return result; }