L10b Betrieb & Aufräumen: /api/v1 über gemeinsamen Adapter, einheitliches Fehlerformat, Rate Limiting

Aufräumpunkt a: Die lane-lokalen API-Kontexte (imports/_context.ts, sync/api-context.ts,
reports/http.ts, work-orders/_http.ts mit moduleGuard) sind entfernt. Alle v1-Routen laufen über
requireApiContext (DB-autoritative Rechte, 401/403) und withApi/toErrorResponse (respond.ts):
- Fehlerformat überall { error: { code, message, details? } }; invalid und blocked → 422,
  conflict → 409, payload_too_large → 413, rate_limited → 429 + Retry-After.
- Same-Origin-Prüfung in withApi für jede Mutation vor der Anmeldung (vorher fehlte sie bei
  imports, reports und work-orders).
- Rate Limiting je Nutzer mit rate-limit.ts: api (API_RATE_LIMIT_PER_MINUTE, 300/min) und
  apiField für sync/uploads/field (API_FIELD_RATE_LIMIT_PER_MINUTE, 1200/min).
- Clients angepasst: Import-Uploader liest das neue Fehlerformat, Upload/Outbox werten 422 als
  endgültig ungültig (429 bleibt transient mit Backoff).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:19:19 +02:00
co-authored by Claude Opus 5
parent a7d4b02a13
commit fb993a7730
29 changed files with 333 additions and 483 deletions
+16 -15
View File
@@ -7,6 +7,11 @@ import type { Permission } from "@/server/rbac";
import type { ModuleKey } from "@/lib/modules";
import type { ServiceCtx } from "@/server/services/context";
import { ApiError } from "@/server/api/respond";
import { consumeRateLimit } from "@/server/rate-limit";
// assertSameOrigin lives in respond.ts (withApi applies it to every mutation); re-exported for
// route handlers that do not use withApi (e.g. /documents/upload with HTML redirects).
export { assertSameOrigin } from "@/server/api/respond";
/**
* Service context for /api/v1 route handlers and other route handlers (e.g. /files/<id>).
@@ -57,27 +62,23 @@ export async function requireApiContext(moduleKey: ModuleKey | null, ...permissi
}
}
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
if (moduleKey) enforceApiRateLimit(session.user.id, moduleKey);
return { db, tenantId, userId: session.user.id, permissions: effective };
}
/**
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
* Per-user request budget for /api/v1 (in-memory, per app instance — see rate-limit.ts).
* Field endpoints (sync outbox, uploads, offline pre-download, document cache) get the generous
* `apiField` bucket, everything else `api`. `moduleKey = null` callers (/files downloads, the
* EXEMPT lotse-settings action) are not /api/v1 endpoints and are not counted.
* Exceeded → ApiError `rate_limited` (429 + Retry-After).
*/
export function assertSameOrigin(req: Request): void {
const site = req.headers.get("sec-fetch-site");
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
const origin = req.headers.get("origin");
if (origin) {
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
let originHost: string | null = null;
try {
originHost = new URL(origin).host;
} catch {
originHost = null;
}
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
export function enforceApiRateLimit(userId: string, moduleKey: ModuleKey): void {
const scope = moduleKey === "field" ? "apiField" : "api";
const res = consumeRateLimit(scope, userId);
if (!res.allowed) {
throw new ApiError("rate_limited", "too many requests", { retryAfterSeconds: res.retryAfterSeconds });
}
}
+84 -11
View File
@@ -4,10 +4,12 @@ import { ForbiddenError } from "@/server/rbac";
import { ModuleDisabledError } from "@/server/modules";
/**
* JSON response helpers for /api/v1 route handlers (spec §29.2).
* JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter —
* the former lane-local variants in imports/_context.ts, sync/api-context.ts, reports/http.ts and
* work-orders/_http.ts are gone).
* Error format: `{ error: { code, message, details? } }`; list format:
* `{ data: [...], pagination: { page, pageSize, total } }`.
* Internal error details never leave the server (CWE-209).
* Internal error details never leave the server (CWE-209). Documented in docs/craftvia/API.md.
*/
export type ApiErrorCode =
@@ -18,16 +20,19 @@ export type ApiErrorCode =
| "conflict"
| "blocked"
| "payload_too_large"
| "rate_limited"
| "internal";
const STATUS: Record<ApiErrorCode, number> = {
export const API_ERROR_STATUS: Record<ApiErrorCode, number> = {
unauthorized: 401,
forbidden: 403,
not_found: 404,
invalid: 422,
conflict: 409,
blocked: 409,
// domain rule prevents the action (e.g. completion blockers) — request itself is well-formed
blocked: 422,
payload_too_large: 413,
rate_limited: 429,
internal: 500,
};
@@ -42,16 +47,22 @@ export class ApiError extends Error {
}
}
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response {
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown, headers?: Record<string, string>): Response {
return Response.json(
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
{ status: STATUS[code], headers: { "Cache-Control": "no-store" } },
{ status: API_ERROR_STATUS[code], headers: { "Cache-Control": "no-store", ...headers } },
);
}
/** Map any thrown error to a JSON error response. */
export function toErrorResponse(err: unknown): Response {
if (err instanceof ApiError) return errorResponse(err.code, err.message, err.details);
if (err instanceof ApiError) {
if (err.code === "rate_limited") {
const retry = (err.details as { retryAfterSeconds?: number } | undefined)?.retryAfterSeconds ?? 60;
return errorResponse(err.code, err.message, err.details, { "Retry-After": String(retry) });
}
return errorResponse(err.code, err.message, err.details);
}
if (err instanceof ServiceError) return errorResponse(err.code, err.message, err.details);
if (err instanceof ZodError) {
return errorResponse(
@@ -67,8 +78,8 @@ export function toErrorResponse(err: unknown): Response {
return errorResponse("internal", "internal error");
}
export function json(data: unknown, init?: { status?: number }): Response {
return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store" } });
export function json(data: unknown, init?: { status?: number; headers?: Record<string, string> }): Response {
return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store", ...init?.headers } });
}
export function paginated<T>(items: T[], total: number, page: number, pageSize: number): Response {
@@ -83,10 +94,38 @@ export function parsePagination(url: URL | string, defaults = { pageSize: 25 }):
return { page, pageSize };
}
/** Wrap a handler so every thrown error becomes a JSON error response. */
export function withApi<A extends unknown[]>(handler: (...args: A) => Promise<Response>) {
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
* Requests without Origin/Sec-Fetch-Site (server-to-server clients, curl) pass — they carry no
* ambient browser cookies of a victim.
*/
export function assertSameOrigin(req: Request): void {
const site = req.headers.get("sec-fetch-site");
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
const origin = req.headers.get("origin");
if (origin) {
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
let originHost: string | null = null;
try {
originHost = new URL(origin).host;
} catch {
originHost = null;
}
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
}
}
/**
* Wrap a handler: same-origin check for every non-safe method (before authentication, so a
* cross-site request never reaches a service) + every thrown error becomes a JSON error response.
*/
export function withApi<A extends [Request, ...unknown[]]>(handler: (...args: A) => Promise<Response>) {
return async (...args: A): Promise<Response> => {
try {
if (!SAFE_METHODS.has(args[0].method.toUpperCase())) assertSameOrigin(args[0]);
return await handler(...args);
} catch (err) {
return toErrorResponse(err);
@@ -102,3 +141,37 @@ export async function readJson(req: Request): Promise<unknown> {
throw new ApiError("invalid", "malformed JSON body");
}
}
/**
* Read a JSON object body. Arrays/primitives → 422. With `allowEmpty` an empty body is `{}`
* (endpoints whose body fields are all optional).
*/
export async function readJsonObject(req: Request, opts: { allowEmpty?: boolean } = {}): Promise<Record<string, unknown>> {
const text = await req.text();
if (!text.trim()) {
if (opts.allowEmpty) return {};
throw new ApiError("invalid", "JSON body required");
}
let body: unknown;
try {
body = JSON.parse(text);
} catch {
throw new ApiError("invalid", "malformed JSON body");
}
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ApiError("invalid", "JSON object expected");
return body as Record<string, unknown>;
}
/** Read a multipart body; anything else → 422. */
export async function readFormData(req: Request): Promise<FormData> {
try {
return await req.formData();
} catch {
throw new ApiError("invalid", "multipart body expected");
}
}
/** Optimistic-locking version from a body field (positive integer), otherwise undefined. */
export function optionalVersion(v: unknown): number | undefined {
return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
}
+10
View File
@@ -40,6 +40,12 @@ function keyOf(scope: string, identifier: string): string {
export type RateLimitRule = { limit: number; windowMs: number };
/** Positive integer from the environment, otherwise the default (read once at module load). */
function perMinute(name: string, fallback: number): number {
const v = Number(process.env[name]);
return Number.isInteger(v) && v > 0 ? v : fallback;
}
/** Voreinstellungen der sicherheitskritischen Aktionen (SEC2 §6). */
export const RATE_LIMITS = {
/** Reset-Anfrage: 5 pro Stunde je IP und je Konto. */
@@ -50,6 +56,10 @@ export const RATE_LIMITS = {
passwordVerify: { limit: 10, windowMs: 15 * 60_000 },
/** Anforderung einer E-Mail-Änderung. */
emailChangeRequest: { limit: 5, windowMs: 60 * 60_000 },
/** L10b: /api/v1/** je Nutzer (Integrationen, Backoffice-Clients). */
api: { limit: perMinute("API_RATE_LIMIT_PER_MINUTE", 300), windowMs: 60_000 },
/** L10b: Einsatz-/Sync-Endpunkte (/sync, /uploads, /field/**) je Nutzer — großzügig (Outbox, Vorab-Download). */
apiField: { limit: perMinute("API_FIELD_RATE_LIMIT_PER_MINUTE", 1200), windowMs: 60_000 },
} as const satisfies Record<string, RateLimitRule>;
export type RateLimitScope = keyof typeof RATE_LIMITS;
+4
View File
@@ -0,0 +1,4 @@
/** Public shape of a report in /api/v1 responses (no snapshot content). */
export function reportDto(r: { id: string; type: string; status: string; version: number; workOrderId: string; lineageId: string; pdfDocumentId?: string | null }) {
return { id: r.id, type: r.type, status: r.status, version: r.version, workOrderId: r.workOrderId, lineageId: r.lineageId, hasPdf: Boolean(r.pdfDocumentId) };
}
-48
View File
@@ -1,48 +0,0 @@
import { ZodError } from "zod";
import { moduleGuard } from "@/server/action-guard";
import { ModuleDisabledError } from "@/server/modules";
import { ForbiddenError, type Permission } from "@/server/rbac";
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* Thin adapter for /api/v1 report route handlers: module gate + DB-authoritative permissions → ServiceCtx,
* uniform JSON error mapping. (No shared requireApiContext exists yet; replace at merge if the architect adds one.)
*/
const guard = moduleGuard("reports");
const STATUS: Record<ServiceError["code"], number> = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 422 };
export function apiError(err: unknown): Response {
if (err instanceof ServiceError) return Response.json({ error: err.code, details: err.details ?? null }, { status: STATUS[err.code] });
if (err instanceof ZodError) return Response.json({ error: "invalid", details: err.issues.map((i) => ({ path: i.path.join("."), code: i.code })) }, { status: 400 });
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return Response.json({ error: "forbidden" }, { status: 403 });
if (err instanceof Error && /Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(err.message)) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
console.error("[api/reports]", err);
return Response.json({ error: "internal" }, { status: 500 });
}
export async function withReportsApi(permissions: Permission[], handler: (ctx: ServiceCtx) => Promise<Response>): Promise<Response> {
try {
const g = await guard(...permissions);
return await handler(ctxFromGuard(g));
} catch (err) {
return apiError(err);
}
}
export async function readJson(req: Request): Promise<Record<string, unknown>> {
const text = await req.text();
if (!text.trim()) return {};
try {
const v = JSON.parse(text);
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
} catch {
throw new ServiceError("invalid", "body must be JSON");
}
}
export function reportDto(r: { id: string; type: string; status: string; version: number; workOrderId: string; lineageId: string; pdfDocumentId?: string | null }) {
return { id: r.id, type: r.type, status: r.status, version: r.version, workOrderId: r.workOrderId, lineageId: r.lineageId, hasPdf: Boolean(r.pdfDocumentId) };
}
-57
View File
@@ -1,57 +0,0 @@
import { NextResponse } from "next/server";
import { moduleGuard } from "@/server/action-guard";
import { ForbiddenError, type Permission } from "@/server/rbac";
import { ModuleDisabledError } from "@/server/modules";
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
import type { ModuleKey } from "@/lib/modules";
/**
* Context for /api/v1 route handlers (lane L4: sync, uploads, field). Reuses moduleGuard, so
* route handlers get exactly the same DB-authoritative checks as server actions (session, account
* status, kill switch, password change, permissions, module enabled).
* NOTE for the architect: a shared `requireApiContext` is referenced in services/context.ts but not
* provided by the foundation — this is the lane-local implementation.
*/
export async function requireApiContext(moduleKey: ModuleKey, ...permissions: Permission[]): Promise<ServiceCtx> {
return ctxFromGuard(await moduleGuard(moduleKey)(...permissions));
}
const STATUS_FOR: Record<ServiceError["code"], number> = {
not_found: 404,
forbidden: 403,
invalid: 400,
conflict: 409,
blocked: 422,
};
export function apiError(code: string, status: number, message?: string, details?: unknown) {
return NextResponse.json({ error: code, ...(message ? { message } : {}), ...(details !== undefined ? { details } : {}) }, { status, headers: { "Cache-Control": "no-store" } });
}
/** Wraps a handler: same-origin check for mutations + uniform error mapping (no internals leaked). */
export async function withApi(req: Request, fn: () => Promise<Response>): Promise<Response> {
if (req.method !== "GET" && req.method !== "HEAD") {
const origin = req.headers.get("origin");
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
if (origin && host) {
let originHost: string | null = null;
try {
originHost = new URL(origin).host;
} catch {
originHost = null;
}
if (originHost !== host) return apiError("forbidden", 403, "cross-origin request");
}
}
try {
return await fn();
} catch (err) {
if (err instanceof ServiceError) return apiError(err.code, STATUS_FOR[err.code], err.message, err.code === "blocked" ? err.details : undefined);
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return apiError("forbidden", 403);
const msg = err instanceof Error ? err.message : "";
if (/Nicht angemeldet|nicht mehr gueltig/.test(msg)) return apiError("unauthorized", 401);
if (/Konto ist nicht aktiv|Passwortwechsel erforderlich/.test(msg)) return apiError("forbidden", 403);
console.error("[api/v1] unhandled error:", err);
return apiError("internal", 500);
}
}