import { redirect } from 'next/navigation'; import { NextResponse } from 'next/server'; import { ZodError } from 'zod'; import { hasManageGuildPermission } from '@nexumi/shared'; import { fetchDiscordUserGuildsCached, refreshDiscordToken } from './discord-oauth'; import { BadRequestError, UpstreamError } from './api-errors'; import { hasOwnerGuildBypass } from './owner-auth'; import { prisma } from './prisma'; import { getSession, SessionRequiredError, updateSession, type SessionPayload } from './session'; export { BadRequestError, UpstreamError } from './api-errors'; export class UnauthorizedError extends Error { constructor(message = 'Authentication required') { super(message); this.name = 'UnauthorizedError'; } } export class ForbiddenError extends Error { constructor(message = 'You do not have access to this resource') { super(message); this.name = 'ForbiddenError'; } } const TOKEN_REFRESH_SKEW_MS = 60_000; /** * Refreshes the Discord access token when expired (or about to expire). * Throws {@link UnauthorizedError} when refresh is impossible. */ export async function ensureFreshSession(session: SessionPayload): Promise { const expiresAt = session.tokenExpiresAt ?? 0; if (expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) { return session; } if (!session.refreshToken) { throw new UnauthorizedError('Session expired – please log in again'); } try { const token = await refreshDiscordToken(session.refreshToken); const next: SessionPayload = { ...session, accessToken: token.access_token, refreshToken: token.refresh_token || session.refreshToken, tokenExpiresAt: Date.now() + token.expires_in * 1000 }; await updateSession(next); return next; } catch { throw new UnauthorizedError('Session expired – please log in again'); } } /** * Use in API routes. Throws {@link UnauthorizedError} when no session exists. */ export async function requireAuth(): Promise { const session = await getSession(); if (!session) { throw new UnauthorizedError(); } return ensureFreshSession(session); } /** * Use in Server Components / layouts. Redirects to /login instead of throwing, * since there is no JSON response to return. */ export async function requireAuthOrRedirect(): Promise { const session = await getSession(); if (!session) { redirect('/login'); } try { return await ensureFreshSession(session); } catch { redirect('/login'); } } /** * Ensures the current session user has Manage Guild permission on the given * guild (via Discord OAuth guild list) and that the bot is actually installed * there (tracked via the `Guild` table). Owner-panel users with SUPPORT+ * bypass Discord membership checks. Use in API routes. */ export async function requireGuildAccess(guildId: string): Promise { const session = await requireAuth(); const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } }); if (!dbGuild) { throw new ForbiddenError('Nexumi is not installed on this server'); } if (await hasOwnerGuildBypass(session.user.id)) { return session; } try { const guilds = await fetchDiscordUserGuildsCached(session.accessToken); const guild = guilds.find((entry) => entry.id === guildId); if (!guild || !hasManageGuildPermission(guild.permissions)) { throw new ForbiddenError('Missing Manage Guild permission for this server'); } return session; } catch (error) { if (error instanceof ForbiddenError) { throw error; } throw new UnauthorizedError('Discord session expired – please log in again'); } } /** * Same as {@link requireGuildAccess} but for Server Components: redirects * instead of throwing. */ export async function requireGuildAccessOrRedirect(guildId: string): Promise { const session = await requireAuthOrRedirect(); const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } }); if (!dbGuild) { redirect('/dashboard'); } if (await hasOwnerGuildBypass(session.user.id)) { return session; } try { const guilds = await fetchDiscordUserGuildsCached(session.accessToken); const guild = guilds.find((entry) => entry.id === guildId); if (!guild || !hasManageGuildPermission(guild.permissions)) { redirect('/dashboard'); } return session; } catch { redirect('/login'); } } export function toApiErrorResponse(error: unknown): NextResponse { if (error instanceof UnauthorizedError || error instanceof SessionRequiredError) { return NextResponse.json({ error: error.message }, { status: 401 }); } if (error instanceof ForbiddenError) { return NextResponse.json({ error: error.message }, { status: 403 }); } if (error instanceof BadRequestError) { return NextResponse.json({ error: error.message }, { status: 400 }); } if (error instanceof UpstreamError) { return NextResponse.json({ error: error.message }, { status: error.status }); } if (error instanceof ZodError) { return NextResponse.json( { error: 'Validation failed', issues: error.issues }, { status: 400 } ); } console.error('[api]', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); }