feat(api): standardize errors as problem details

This commit is contained in:
dmg
2026-08-01 14:42:19 -04:00
parent 40abab7abc
commit 2657399628
16 changed files with 441 additions and 17 deletions
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { GET } from "./route";
describe("unknown API routes", () => {
it("return an RFC 9457 not-found problem", async () => {
const response = GET(new Request("http://localhost/api/does-not-exist"));
expect(response.status).toBe(404);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(await response.json()).toEqual({
type: "urn:error:not-found",
title: "API route not found",
status: 404,
detail: "The requested API route does not exist.",
instance: "/api/does-not-exist",
});
});
});
+20
View File
@@ -0,0 +1,20 @@
import { problemDetails } from "@minecraft-account-manager/contracts";
import { problemInstance, problemResponse } from "@/lib/problem-response";
function notFound(request: Request) {
const instance = problemInstance(request);
return problemResponse(problemDetails(
"urn:error:not-found",
"API route not found",
404,
"The requested API route does not exist.",
instance,
));
}
export const GET = notFound;
export const POST = notFound;
export const PUT = notFound;
export const PATCH = notFound;
export const DELETE = notFound;
export const OPTIONS = notFound;
+19
View File
@@ -0,0 +1,19 @@
import { problemDetails } from "@minecraft-account-manager/contracts";
import { problemInstance, problemResponse } from "@/lib/problem-response";
function notFound(request: Request) {
return problemResponse(problemDetails(
"urn:error:not-found",
"API route not found",
404,
"The requested API route does not exist.",
problemInstance(request),
));
}
export const GET = notFound;
export const POST = notFound;
export const PUT = notFound;
export const PATCH = notFound;
export const DELETE = notFound;
export const OPTIONS = notFound;
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { GET, POST } from "./route";
describe("Velocity access API problems", () => {
it("returns RFC 9457 for unsupported methods", async () => {
const response = GET(new Request("http://localhost/api/velocity/access"));
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("POST");
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(await response.json()).toMatchObject({
type: "urn:error:method-not-allowed",
title: "Method not allowed",
status: 405,
instance: "/api/velocity/access",
});
});
it("returns RFC 9457 when credentials are missing", async () => {
const response = await POST(new Request("http://localhost/api/velocity/access", {
method: "POST",
body: JSON.stringify({}),
}));
expect(response.status).toBe(401);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(await response.json()).toEqual({
type: "urn:error:unauthorized",
title: "Unauthorized",
status: 401,
detail: "A valid Velocity server credential is required.",
instance: "/api/velocity/access",
});
});
it("rejects unsupported media types as Problem Details", async () => {
const response = await POST(new Request("http://localhost/api/velocity/access", {
method: "POST",
headers: {
authorization: "Bearer test-token",
"content-type": "text/plain",
},
body: "not json",
}));
expect(response.status).toBe(415);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(await response.json()).toMatchObject({
type: "urn:error:unsupported-media-type",
title: "Unsupported media type",
status: 415,
instance: "/api/velocity/access",
});
});
it("includes machine-readable validation issues", async () => {
const response = await POST(new Request("http://localhost/api/velocity/access", {
method: "POST",
headers: {
authorization: "Bearer test-token",
"content-type": "application/json",
},
body: JSON.stringify({ serverId: "velocity-main" }),
}));
const problem = await response.json();
expect(response.status).toBe(400);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(problem).toMatchObject({
type: "urn:error:invalid-velocity-access-request",
title: "Invalid Velocity access request",
status: 400,
instance: "/api/velocity/access",
extensions: { issues: expect.any(Array) },
});
expect(problem.extensions.issues.length).toBeGreaterThan(0);
});
});
+96 -14
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
import { velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
import {
appSettings,
events,
@@ -12,25 +12,83 @@ import {
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/database";
import { isUniqueConstraintViolation } from "@/lib/database-errors";
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
import { problemInstance, problemResponse } from "@/lib/problem-response";
const MAX_CLOCK_SKEW_MS = 45_000;
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
export async function POST(request: Request) {
function methodNotAllowed(request: Request) {
const response = problemResponse(problemDetails(
"urn:error:method-not-allowed",
"Method not allowed",
405,
"This endpoint only accepts POST requests.",
problemInstance(request),
));
response.headers.set("allow", "POST");
return response;
}
export const GET = methodNotAllowed;
export const PUT = methodNotAllowed;
export const PATCH = methodNotAllowed;
export const DELETE = methodNotAllowed;
async function handleVelocityAccess(request: Request) {
const instance = problemInstance(request);
const authorization = request.headers.get("authorization") ?? "";
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
if (!token) return NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
if (!token) {
return problemResponse(problemDetails(
"urn:error:unauthorized",
"Unauthorized",
401,
"A valid Velocity server credential is required.",
instance,
));
}
const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
if (mediaType !== "application/json") {
return problemResponse(problemDetails(
"urn:error:unsupported-media-type",
"Unsupported media type",
415,
"Velocity access requests must use application/json.",
instance,
));
}
const parsed = velocityAccessRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ allowed: false, message: "Invalid access request" }, { status: 400 });
return problemResponse(problemDetails(
"urn:error:invalid-velocity-access-request",
"Invalid Velocity access request",
400,
"The request body does not match the required Velocity access contract.",
instance,
{
issues: parsed.error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
code: issue.code,
})),
},
));
}
const input = parsed.data;
const occurredAt = new Date(input.occurredAt);
if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) {
return NextResponse.json({ allowed: false, message: "Expired access request" }, { status: 401 });
return problemResponse(problemDetails(
"urn:error:expired-velocity-access-request",
"Expired Velocity access request",
401,
"The request timestamp is outside the allowed clock-skew window.",
instance,
));
}
const [credential] = await db
@@ -40,7 +98,13 @@ export async function POST(request: Request) {
.limit(1);
if (!credential || !verifyHashedToken(token, credential.secretHash)) {
return NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
return problemResponse(problemDetails(
"urn:error:unauthorized",
"Unauthorized",
401,
"The Velocity server credential is invalid or revoked.",
instance,
));
}
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
@@ -75,8 +139,7 @@ export async function POST(request: Request) {
: { classification: "unknown" as const, provider: null };
const auditIpData = toAuditIpData(intelligence);
try {
const decision = await db.transaction(async (tx) => {
const decision = await db.transaction(async (tx) => {
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
await tx.insert(pluginRequests).values({
requestId: input.requestId,
@@ -203,12 +266,31 @@ export async function POST(request: Request) {
return { allowed: true as const, message: "Account approved." };
});
return NextResponse.json(decision);
return NextResponse.json(decision);
}
export async function POST(request: Request) {
const instance = problemInstance(request);
try {
return await handleVelocityAccess(request);
} catch (error) {
console.error("Velocity access decision failed", error);
return NextResponse.json(
{ allowed: false, message: denialMessage },
{ status: 503 },
);
if (isUniqueConstraintViolation(error, "plugin_requests_pkey")) {
return problemResponse(problemDetails(
"urn:error:replayed-velocity-access-request",
"Replayed Velocity access request",
409,
"The request ID has already been processed.",
instance,
));
}
console.error("Velocity access request failed");
return problemResponse(problemDetails(
"urn:error:service-unavailable",
"Service unavailable",
503,
"The access decision could not be completed.",
instance,
));
}
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { isUniqueConstraintViolation } from "./database-errors";
describe("database error classification", () => {
it("recognizes a nested PostgreSQL unique-constraint violation", () => {
expect(isUniqueConstraintViolation({
cause: { code: "23505", constraint_name: "plugin_requests_pkey" },
}, "plugin_requests_pkey")).toBe(true);
});
it("does not mistake another unique constraint for a replay", () => {
expect(isUniqueConstraintViolation({
code: "23505",
constraint_name: "minecraft_accounts_active_uuid_uidx",
}, "plugin_requests_pkey")).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
export function isUniqueConstraintViolation(
error: unknown,
expectedConstraint: string,
): boolean {
if (!error || typeof error !== "object") return false;
const databaseError = error as {
code?: unknown;
constraint_name?: unknown;
constraint?: unknown;
message?: unknown;
cause?: unknown;
};
const constraint = databaseError.constraint_name ?? databaseError.constraint;
if (
databaseError.code === "23505" &&
(constraint === expectedConstraint || String(databaseError.message).includes(expectedConstraint))
) {
return true;
}
return databaseError.cause
? isUniqueConstraintViolation(databaseError.cause, expectedConstraint)
: false;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { problemInstance, problemResponse } from "./problem-response";
describe("problemResponse", () => {
it("uses the request path and query as the problem instance", () => {
expect(problemInstance(new Request("https://example.com/api/items?cursor=next")))
.toBe("/api/items?cursor=next");
});
it("returns RFC 9457 JSON with the required media type", async () => {
const response = problemResponse({
type: "urn:error:unauthorized",
title: "Unauthorized",
status: 401,
detail: "A valid Velocity server credential is required.",
instance: "/api/velocity/access",
});
expect(response.status).toBe(401);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(await response.json()).toEqual({
type: "urn:error:unauthorized",
title: "Unauthorized",
status: 401,
detail: "A valid Velocity server credential is required.",
instance: "/api/velocity/access",
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { ProblemDetails } from "@minecraft-account-manager/contracts";
export function problemInstance(request: Request) {
const url = new URL(request.url);
return `${url.pathname}${url.search}`;
}
export function problemResponse(problem: ProblemDetails) {
return new Response(JSON.stringify(problem), {
status: problem.status,
headers: {
"cache-control": "no-store",
"content-type": "application/problem+json",
},
});
}