API error contract

This commit is contained in:
adilallo
2026-04-22 19:15:04 -06:00
parent 4d066dad0e
commit 5457d3554b
18 changed files with 717 additions and 117 deletions
+18 -26
View File
@@ -10,13 +10,20 @@ import {
} from "../../../../../lib/server/hash";
import { sendMagicLinkEmail } from "../../../../../lib/server/mail";
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
import { dbUnavailable } from "../../../../../lib/server/responses";
import { logger } from "../../../../../lib/logger";
import {
dbUnavailable,
errorJson,
rateLimited,
serverMisconfigured,
} from "../../../../../lib/server/responses";
import { logRouteError } from "../../../../../lib/server/requestId";
import { apiRoute } from "../../../../../lib/server/apiRoute";
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
const IP_MIN_INTERVAL_MS = 20 * 1000;
const SCOPE = "auth.magicLink.request";
function normalizeEmail(raw: unknown): string | null {
if (typeof raw !== "string") return null;
@@ -32,7 +39,7 @@ function readNextPath(body: unknown): string | null {
return safeInternalPath(n);
}
export async function POST(request: NextRequest) {
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
@@ -41,7 +48,7 @@ export async function POST(request: NextRequest) {
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
return errorJson("invalid_json", "Invalid JSON", 400);
}
const email = normalizeEmail(
@@ -50,10 +57,7 @@ export async function POST(request: NextRequest) {
: null,
);
if (!email) {
return NextResponse.json(
{ error: "Valid email required" },
{ status: 400 },
);
return errorJson("validation_error", "Valid email required", 400);
}
const ip =
@@ -63,28 +67,19 @@ export async function POST(request: NextRequest) {
const rlEmail = rateLimitKey(`magic-email:${email}`, EMAIL_MIN_INTERVAL_MS);
if (rlEmail.ok === false) {
return NextResponse.json(
{ error: "Too many requests", retryAfterMs: rlEmail.retryAfterMs },
{ status: 429 },
);
return rateLimited(rlEmail.retryAfterMs);
}
const rlIp = rateLimitKey(`magic-ip:${ip}`, IP_MIN_INTERVAL_MS);
if (rlIp.ok === false) {
return NextResponse.json(
{ error: "Too many requests", retryAfterMs: rlIp.retryAfterMs },
{ status: 429 },
);
return rateLimited(rlIp.retryAfterMs);
}
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return NextResponse.json(
{ error: "Server misconfiguration" },
{ status: 500 },
);
return serverMisconfigured();
}
const token = newSessionToken();
@@ -108,13 +103,10 @@ export async function POST(request: NextRequest) {
try {
await sendMagicLinkEmail(email, verifyUrl);
} catch (err) {
logger.error("sendMagicLinkEmail failed:", err);
logRouteError(SCOPE, requestId, err, { phase: "sendMagicLinkEmail", email });
await prisma.magicLinkToken.deleteMany({ where: { email } });
return NextResponse.json(
{ error: "Could not send email" },
{ status: 502 },
);
return errorJson("mail_failed", "Could not send email", 502);
}
return NextResponse.json({ ok: true });
}
});
+67 -36
View File
@@ -10,52 +10,83 @@ import {
setSessionCookie,
} from "../../../../../lib/server/session";
import { dbUnavailable } from "../../../../../lib/server/responses";
import {
REQUEST_ID_HEADER,
getOrCreateRequestId,
logRouteError,
} from "../../../../../lib/server/requestId";
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
const SCOPE = "auth.magicLink.verify";
export async function GET(request: NextRequest) {
const requestId = getOrCreateRequestId(request);
if (!isDatabaseConfigured()) {
return dbUnavailable();
const res = dbUnavailable();
res.headers.set(REQUEST_ID_HEADER, requestId);
return res;
}
const token = request.nextUrl.searchParams.get("token");
if (!token || token.length < 10) {
return NextResponse.redirect(
new URL("/login?error=invalid_link", request.url),
);
}
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return NextResponse.redirect(new URL("/login?error=server", request.url));
}
const token = request.nextUrl.searchParams.get("token");
if (!token || token.length < 10) {
return redirectWithRequestId(
request,
"/login?error=invalid_link",
requestId,
);
}
const tokenHash = hashSessionToken(token, pepper);
let pepper: string;
try {
pepper = getSessionPepper();
} catch (err) {
logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" });
return redirectWithRequestId(request, "/login?error=server", requestId);
}
const row = await prisma.magicLinkToken.findUnique({
where: { tokenHash },
});
const tokenHash = hashSessionToken(token, pepper);
if (!row || row.expiresAt < new Date()) {
return NextResponse.redirect(
new URL("/login?error=expired_link", request.url),
const row = await prisma.magicLinkToken.findUnique({
where: { tokenHash },
});
if (!row || row.expiresAt < new Date()) {
return redirectWithRequestId(
request,
"/login?error=expired_link",
requestId,
);
}
await prisma.magicLinkToken.delete({ where: { id: row.id } });
const user = await prisma.user.upsert({
where: { email: row.email },
create: { email: row.email },
update: {},
});
const { token: sessionToken, expiresAt } = await createSessionForUser(
user.id,
);
await setSessionCookie(sessionToken, expiresAt);
const dest = safeInternalPath(row.nextPath);
return redirectWithRequestId(request, dest, requestId);
} catch (err) {
logRouteError(SCOPE, requestId, err);
return redirectWithRequestId(request, "/login?error=server", requestId);
}
await prisma.magicLinkToken.delete({ where: { id: row.id } });
const user = await prisma.user.upsert({
where: { email: row.email },
create: { email: row.email },
update: {},
});
const { token: sessionToken, expiresAt } = await createSessionForUser(
user.id,
);
await setSessionCookie(sessionToken, expiresAt);
const dest = safeInternalPath(row.nextPath);
return NextResponse.redirect(new URL(dest, request.url));
}
function redirectWithRequestId(
request: NextRequest,
path: string,
requestId: string,
): NextResponse {
const res = NextResponse.redirect(new URL(path, request.url));
res.headers.set(REQUEST_ID_HEADER, requestId);
return res;
}