API error contract
This commit is contained in:
@@ -2,12 +2,13 @@ import { NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||
import { destroySessionFromRequest } from "../../../../lib/server/session";
|
||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||
|
||||
export async function POST() {
|
||||
export const POST = apiRoute("auth.logout", async () => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
await destroySessionFromRequest();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../lib/server/session";
|
||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||
|
||||
export async function GET() {
|
||||
export const GET = apiRoute("auth.session", async () => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
@@ -16,4 +17,4 @@ export async function GET() {
|
||||
return NextResponse.json({
|
||||
user: { id: user.id, email: user.email },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+14
-10
@@ -2,20 +2,24 @@ import type { Prisma } from "@prisma/client";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||
import {
|
||||
dbUnavailable,
|
||||
unauthorized,
|
||||
} from "../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../lib/server/session";
|
||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||
import { putDraftBodySchema } from "../../../../lib/server/validation/createFlowSchemas";
|
||||
import { readLimitedJson } from "../../../../lib/server/validation/requestBody";
|
||||
import { jsonFromZodError } from "../../../../lib/server/validation/zodHttp";
|
||||
|
||||
export async function GET() {
|
||||
export const GET = apiRoute("drafts.me.get", async () => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const draft = await prisma.ruleDraft.findUnique({
|
||||
@@ -27,16 +31,16 @@ export async function GET() {
|
||||
? { payload: draft.payload, updatedAt: draft.updatedAt }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
export const PUT = apiRoute("drafts.me.put", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
@@ -67,16 +71,16 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function DELETE() {
|
||||
export const DELETE = apiRoute("drafts.me.delete", async () => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
// Idempotent: missing draft is a no-op so callers can fire-and-forget after
|
||||
@@ -84,4 +88,4 @@ export async function DELETE() {
|
||||
await prisma.ruleDraft.deleteMany({ where: { userId: user.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
+16
-12
@@ -1,21 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||
import { dbUnavailable, notFound } from "../../../../lib/server/responses";
|
||||
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
export const GET = apiRoute<RouteContext>(
|
||||
"rules.byId",
|
||||
async (_request, context) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const { id } = await context.params;
|
||||
|
||||
const rule = await getPublicPublishedRuleById(id);
|
||||
if (!rule) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const rule = await getPublicPublishedRuleById(id);
|
||||
if (!rule) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return NextResponse.json({ rule });
|
||||
}
|
||||
return NextResponse.json({ rule });
|
||||
},
|
||||
);
|
||||
|
||||
+10
-6
@@ -2,13 +2,17 @@ import type { Prisma } from "@prisma/client";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import { dbUnavailable } from "../../../lib/server/responses";
|
||||
import {
|
||||
dbUnavailable,
|
||||
unauthorized,
|
||||
} from "../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../lib/server/session";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas";
|
||||
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
@@ -29,16 +33,16 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
return NextResponse.json({ rules });
|
||||
}
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = apiRoute("rules.publish", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
@@ -70,4 +74,4 @@ export async function POST(request: NextRequest) {
|
||||
createdAt: rule.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user