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:
@@ -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) };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user