From fb993a7730cc8eafc6ce143fe84157bcacf11538 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20/api/v1?= =?UTF-8?q?=20=C3=BCber=20gemeinsamen=20Adapter,=20einheitliches=20Fehlerf?= =?UTF-8?q?ormat,=20Rate=20Limiting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/api/v1/field/bundle/route.ts | 20 ++-- src/app/api/v1/field/documents/[id]/route.ts | 33 ++++--- src/app/api/v1/imports/[id]/confirm/route.ts | 23 ++--- src/app/api/v1/imports/[id]/route.ts | 17 ++-- src/app/api/v1/imports/_context.ts | 36 ------- src/app/api/v1/reports/[id]/approve/route.ts | 17 ++-- .../reports/[id]/files/[documentId]/route.ts | 10 +- src/app/api/v1/reports/[id]/pdf/route.ts | 10 +- src/app/api/v1/sync/route.ts | 20 ++-- src/app/api/v1/uploads/route.ts | 45 ++++----- .../api/v1/work-orders/[id]/assign/route.ts | 34 +++---- .../[id]/completion-report/route.ts | 19 ++-- .../v1/work-orders/[id]/daily-report/route.ts | 17 ++-- .../v1/work-orders/[id]/documents/route.ts | 29 +++--- .../v1/work-orders/[id]/materials/route.ts | 34 +++---- src/app/api/v1/work-orders/[id]/route.ts | 39 +++----- .../v1/work-orders/[id]/transition/route.ts | 32 +++---- src/app/api/v1/work-orders/_http.ts | 48 ---------- src/app/api/v1/work-orders/import/route.ts | 39 +++----- src/app/api/v1/work-orders/route.ts | 38 +++----- src/components/imports/uploader.tsx | 6 +- src/lib/field/upload.ts | 2 +- src/lib/offline/outbox.ts | 3 +- src/server/api/context.ts | 31 +++--- src/server/api/respond.ts | 95 ++++++++++++++++--- src/server/rate-limit.ts | 10 ++ src/server/services/reports/dto.ts | 4 + src/server/services/reports/http.ts | 48 ---------- src/server/services/sync/api-context.ts | 57 ----------- 29 files changed, 333 insertions(+), 483 deletions(-) delete mode 100644 src/app/api/v1/imports/_context.ts delete mode 100644 src/app/api/v1/work-orders/_http.ts create mode 100644 src/server/services/reports/dto.ts delete mode 100644 src/server/services/reports/http.ts delete mode 100644 src/server/services/sync/api-context.ts diff --git a/src/app/api/v1/field/bundle/route.ts b/src/app/api/v1/field/bundle/route.ts index 0fe60db..51cd780 100644 --- a/src/app/api/v1/field/bundle/route.ts +++ b/src/app/api/v1/field/bundle/route.ts @@ -1,14 +1,12 @@ -import { NextResponse } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, withApi } from "@/server/api/respond"; import { getFieldBundle } from "@/server/services/field/queries"; -import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; /** GET /api/v1/field/bundle?since= — offline pull of the orders in scope (ARCHITEKTUR §4.6). */ -export async function GET(req: Request) { - return withApi(req, async () => { - const ctx = await requireApiContext("field", "field:execute"); - const raw = new URL(req.url).searchParams.get("since"); - const since = raw ? new Date(raw) : null; - if (since && Number.isNaN(since.getTime())) return apiError("invalid", 400, "invalid since"); - return NextResponse.json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } }); - }); -} +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("field", "field:execute"); + const raw = new URL(req.url).searchParams.get("since"); + const since = raw ? new Date(raw) : null; + if (since && Number.isNaN(since.getTime())) throw new ApiError("invalid", "invalid since"); + return json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } }); +}); diff --git a/src/app/api/v1/field/documents/[id]/route.ts b/src/app/api/v1/field/documents/[id]/route.ts index 778985b..3a844a2 100644 --- a/src/app/api/v1/field/documents/[id]/route.ts +++ b/src/app/api/v1/field/documents/[id]/route.ts @@ -1,5 +1,6 @@ +import { requireApiContext } from "@/server/api/context"; +import { withApi } from "@/server/api/respond"; import { openFieldDocument } from "@/server/services/field/documents"; -import { requireApiContext, withApi } from "@/server/services/sync/api-context"; /** * GET /api/v1/field/documents/[?variant=preview] — authorised document delivery for the mobile @@ -8,20 +9,18 @@ import { requireApiContext, withApi } from "@/server/services/sync/api-context"; */ const INLINE = /^(image\/(jpeg|png|webp)|application\/pdf|audio\/(webm|ogg|mp4|mpeg|wav))$/; -export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { - return withApi(req, async () => { - const ctx = await requireApiContext("field"); - const { id } = await params; - const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original"; - const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant); - const safeName = fileName.replace(/["\\\r\n]/g, "_"); - const headers = new Headers({ - "Content-Type": mimeType, - "Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`, - "X-Content-Type-Options": "nosniff", - "Cache-Control": "private, max-age=300", - }); - if (content.size != null) headers.set("Content-Length", String(content.size)); - return new Response(content.stream, { headers }); +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("field"); + const { id } = await params; + const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original"; + const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant); + const safeName = fileName.replace(/["\\\r\n]/g, "_"); + const headers = new Headers({ + "Content-Type": mimeType, + "Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, max-age=300", }); -} + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); +}); diff --git a/src/app/api/v1/imports/[id]/confirm/route.ts b/src/app/api/v1/imports/[id]/confirm/route.ts index 829ff5d..9935007 100644 --- a/src/app/api/v1/imports/[id]/confirm/route.ts +++ b/src/app/api/v1/imports/[id]/confirm/route.ts @@ -1,22 +1,13 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJson, withApi } from "@/server/api/respond"; import { confirmImport } from "@/server/services/imports/confirm"; -import { apiError, importsApiContext } from "../../_context"; /** * POST /api/v1/imports/[id]/confirm — JSON body = review form (src/lib/imports/review.ts * `reviewFormSchema`). Creates/assigns customer, site, contact and the work order. Lane L3. */ -export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { - try { - const ctx = await importsApiContext("import:write", "work_order:write"); - const { id } = await params; - let body: unknown; - try { - body = await req.json(); - } catch { - return Response.json({ error: "invalid", message: "json_required" }, { status: 400 }); - } - return Response.json(await confirmImport(ctx, id, body)); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("imports", "import:write", "work_order:write"); + const { id } = await params; + return json(await confirmImport(ctx, id, await readJson(req))); +}); diff --git a/src/app/api/v1/imports/[id]/route.ts b/src/app/api/v1/imports/[id]/route.ts index c35a845..37cdc8d 100644 --- a/src/app/api/v1/imports/[id]/route.ts +++ b/src/app/api/v1/imports/[id]/route.ts @@ -1,13 +1,10 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, withApi } from "@/server/api/respond"; import { getImportDetail } from "@/server/services/imports/queries"; -import { apiError, importsApiContext } from "../_context"; /** GET /api/v1/imports/[id] — import status, extraction (with confidences), candidates. Lane L3. */ -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - try { - const ctx = await importsApiContext("import:write"); - const { id } = await params; - return Response.json(await getImportDetail(ctx, id)); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("imports", "import:write"); + const { id } = await params; + return json(await getImportDetail(ctx, id)); +}); diff --git a/src/app/api/v1/imports/_context.ts b/src/app/api/v1/imports/_context.ts deleted file mode 100644 index ebdf8a2..0000000 --- a/src/app/api/v1/imports/_context.ts +++ /dev/null @@ -1,36 +0,0 @@ -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"; - -/** - * Lane L3 helper for the /api/v1 import handlers (thin adapters). Uses the same DB-authoritative - * guard as the server actions (session → account/identity status → permissions → module). - * Not a route: files starting with "_" are ignored by the App Router. - * TODO(architecture): replace with a shared `requireApiContext` once it exists. - */ -const guard = moduleGuard("imports"); - -export async function importsApiContext(...permissions: Permission[]): Promise { - return ctxFromGuard(await guard(...permissions)); -} - -const STATUS: Record = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 409 }; - -/** Map service/guard errors to JSON responses without leaking internals. */ -export function apiError(err: unknown): Response { - if (err instanceof ServiceError) { - return Response.json({ error: err.code, message: err.message, details: err.code === "invalid" ? err.details : undefined }, { status: STATUS[err.code] }); - } - if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) { - return Response.json({ error: "forbidden" }, { status: 403 }); - } - if (err instanceof Error && /Nicht angemeldet|nicht mehr gueltig/.test(err.message)) { - return Response.json({ error: "unauthorized" }, { status: 401 }); - } - if (err instanceof Error && /Konto ist nicht aktiv|Passwortwechsel/.test(err.message)) { - return Response.json({ error: "forbidden" }, { status: 403 }); - } - console.error("[api/imports]", err); - return Response.json({ error: "internal" }, { status: 500 }); -} diff --git a/src/app/api/v1/reports/[id]/approve/route.ts b/src/app/api/v1/reports/[id]/approve/route.ts index cc72288..cae4fb0 100644 --- a/src/app/api/v1/reports/[id]/approve/route.ts +++ b/src/app/api/v1/reports/[id]/approve/route.ts @@ -1,11 +1,12 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, withApi } from "@/server/api/respond"; import { approveReport } from "@/server/services/reports/approve"; -import { reportDto, withReportsApi } from "@/server/services/reports/http"; +import { reportDto } from "@/server/services/reports/dto"; -/** POST /api/v1/reports/:id/approve — team lead → team_approved, backoffice → approved (+ PDF job). */ -export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { +/** POST /api/v1/reports/:id/approve — team lead → team_approved, backoffice → approved (+ PDF job). Approval rights are checked in the service. */ +export const POST = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:read"); const { id } = await params; - return withReportsApi(["report:read"], async (ctx) => { - const report = await approveReport(ctx, { reportId: id }); - return Response.json({ report: reportDto(report) }); - }); -} + const report = await approveReport(ctx, { reportId: id }); + return json({ report: reportDto(report) }); +}); diff --git a/src/app/api/v1/reports/[id]/files/[documentId]/route.ts b/src/app/api/v1/reports/[id]/files/[documentId]/route.ts index b3cb5b4..d825b64 100644 --- a/src/app/api/v1/reports/[id]/files/[documentId]/route.ts +++ b/src/app/api/v1/reports/[id]/files/[documentId]/route.ts @@ -1,9 +1,11 @@ +import { requireApiContext } from "@/server/api/context"; +import { withApi } from "@/server/api/respond"; import { fileResponse, openReportFile } from "@/server/services/reports/files"; -import { withReportsApi } from "@/server/services/reports/http"; /** GET /api/v1/reports/:id/files/:documentId — photo/signature/logo referenced by the report snapshot. */ -export async function GET(req: Request, { params }: { params: Promise<{ id: string; documentId: string }> }) { +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const ctx = await requireApiContext("reports", "report:read"); const { id, documentId } = await params; const download = new URL(req.url).searchParams.get("download") === "1"; - return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, documentId), { download })); -} + return fileResponse(await openReportFile(ctx, id, documentId), { download }); +}); diff --git a/src/app/api/v1/reports/[id]/pdf/route.ts b/src/app/api/v1/reports/[id]/pdf/route.ts index d24183d..9fa9097 100644 --- a/src/app/api/v1/reports/[id]/pdf/route.ts +++ b/src/app/api/v1/reports/[id]/pdf/route.ts @@ -1,9 +1,11 @@ +import { requireApiContext } from "@/server/api/context"; +import { withApi } from "@/server/api/respond"; import { fileResponse, openReportFile } from "@/server/services/reports/files"; -import { withReportsApi } from "@/server/services/reports/http"; /** GET /api/v1/reports/:id/pdf — the immutable PDF of an approved report (?download=1 for attachment). */ -export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:read"); const { id } = await params; const download = new URL(req.url).searchParams.get("download") === "1"; - return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, "pdf"), { download })); -} + return fileResponse(await openReportFile(ctx, id, "pdf"), { download }); +}); diff --git a/src/app/api/v1/sync/route.ts b/src/app/api/v1/sync/route.ts index 145838a..da4dd6d 100644 --- a/src/app/api/v1/sync/route.ts +++ b/src/app/api/v1/sync/route.ts @@ -1,14 +1,14 @@ -import { NextResponse } from "next/server"; import { syncRequestSchema } from "@/lib/sync/envelope"; +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readJson, withApi } from "@/server/api/respond"; import { applyOperations } from "@/server/services/sync/apply"; -import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; /** POST /api/v1/sync — batch of offline/online operations (ARCHITEKTUR §4.6). */ -export async function POST(req: Request) { - return withApi(req, async () => { - const ctx = await requireApiContext("field"); - const body = syncRequestSchema.safeParse(await req.json().catch(() => null)); - if (!body.success) return apiError("invalid", 400, "invalid sync request", body.error.issues.slice(0, 10)); - return NextResponse.json(await applyOperations(ctx, body.data), { headers: { "Cache-Control": "no-store" } }); - }); -} +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("field"); + const body = syncRequestSchema.safeParse(await readJson(req)); + if (!body.success) { + throw new ApiError("invalid", "invalid sync request", body.error.issues.slice(0, 10).map((i) => ({ path: i.path.join("."), code: i.code }))); + } + return json(await applyOperations(ctx, body.data)); +}); diff --git a/src/app/api/v1/uploads/route.ts b/src/app/api/v1/uploads/route.ts index 0eac1ca..d2a8180 100644 --- a/src/app/api/v1/uploads/route.ts +++ b/src/app/api/v1/uploads/route.ts @@ -1,6 +1,6 @@ -import { NextResponse } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, withApi } from "@/server/api/respond"; import { storeFieldUpload, uploadMetaSchema } from "@/server/services/field/uploads"; -import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; /** * POST /api/v1/uploads — multipart: file, clientId (uuid), workOrderId, kind (photo|voice_note), @@ -8,27 +8,24 @@ import { apiError, requireApiContext, withApi } from "@/server/services/sync/api */ const MAX_BYTES = 25 * 1024 * 1024; -export async function POST(req: Request) { - return withApi(req, async () => { - const ctx = await requireApiContext("field", "field:execute"); - const declared = Number(req.headers.get("content-length") ?? "0"); - if (declared > MAX_BYTES + 3 * 1024 * 1024) return apiError("invalid", 413, "file too large"); +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("field", "field:execute"); + const declared = Number(req.headers.get("content-length") ?? "0"); + if (declared > MAX_BYTES + 3 * 1024 * 1024) throw new ApiError("payload_too_large", "file too large"); - const form = await req.formData().catch(() => null); - if (!form) return apiError("invalid", 400, "multipart body expected"); - const file = form.get("file"); - if (!(file instanceof File)) return apiError("invalid", 400, "file missing"); - if (file.size > MAX_BYTES) return apiError("invalid", 413, "file too large"); - const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") }); - if (!meta.success) return apiError("invalid", 400, "invalid upload metadata"); - const preview = form.get("preview"); + const form = await readFormData(req); + const file = form.get("file"); + if (!(file instanceof File)) throw new ApiError("invalid", "file missing"); + if (file.size > MAX_BYTES) throw new ApiError("payload_too_large", "file too large"); + const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") }); + if (!meta.success) throw new ApiError("invalid", "invalid upload metadata"); + const preview = form.get("preview"); - const result = await storeFieldUpload( - ctx, - meta.data, - { bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type }, - preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null, - ); - return NextResponse.json(result, { status: result.duplicate ? 200 : 201, headers: { "Cache-Control": "no-store" } }); - }); -} + const result = await storeFieldUpload( + ctx, + meta.data, + { bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type }, + preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null, + ); + return json(result, { status: result.duplicate ? 200 : 201 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/assign/route.ts b/src/app/api/v1/work-orders/[id]/assign/route.ts index 077d166..262ca1a 100644 --- a/src/app/api/v1/work-orders/[id]/assign/route.ts +++ b/src/app/api/v1/work-orders/[id]/assign/route.ts @@ -1,22 +1,18 @@ -import { NextResponse, type NextRequest } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond"; import { assignWorkOrder } from "@/server/services/work-orders/assign"; -import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; /** POST /api/v1/work-orders/[id]/assign — body { teamId, userIds?, teamLeadUserId?, baseVersion? }. */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params; - const ctx = await apiContext("work_order:assign"); - const body = await readJson(req); - const res = await assignWorkOrder(ctx, { - workOrderId: id, - teamId: String(body.teamId ?? ""), - userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [], - teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null, - baseVersion: optionalVersion(body.baseVersion), - }); - return NextResponse.json(res); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("work_orders", "work_order:assign"); + const { id } = await params; + const body = await readJsonObject(req); + const res = await assignWorkOrder(ctx, { + workOrderId: id, + teamId: String(body.teamId ?? ""), + userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [], + teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return json(res); +}); diff --git a/src/app/api/v1/work-orders/[id]/completion-report/route.ts b/src/app/api/v1/work-orders/[id]/completion-report/route.ts index 1ee5d45..8199286 100644 --- a/src/app/api/v1/work-orders/[id]/completion-report/route.ts +++ b/src/app/api/v1/work-orders/[id]/completion-report/route.ts @@ -1,12 +1,13 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { createCompletionReport } from "@/server/services/reports/create"; -import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http"; +import { reportDto } from "@/server/services/reports/dto"; -/** POST /api/v1/work-orders/:id/completion-report — create (or return) the completion report draft; 422 + blockers if blocked. */ -export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { +/** POST /api/v1/work-orders/:id/completion-report — create (or return) the completion report draft; 422 blocked + blockers if incomplete. */ +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:write"); const { id } = await params; - return withReportsApi(["report:write"], async (ctx) => { - const body = await readJson(req); - const res = await createCompletionReport(ctx, { ...body, workOrderId: id } as Parameters[1]); - return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); - }); -} + const body = await readJsonObject(req, { allowEmpty: true }); + const res = await createCompletionReport(ctx, { ...body, workOrderId: id } as Parameters[1]); + return json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/daily-report/route.ts b/src/app/api/v1/work-orders/[id]/daily-report/route.ts index c0bf641..44d09e0 100644 --- a/src/app/api/v1/work-orders/[id]/daily-report/route.ts +++ b/src/app/api/v1/work-orders/[id]/daily-report/route.ts @@ -1,12 +1,13 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { createDailyReport } from "@/server/services/reports/create"; -import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http"; +import { reportDto } from "@/server/services/reports/dto"; /** POST /api/v1/work-orders/:id/daily-report — create (or return) the daily report draft. Body: { reportDate?, clientId? } */ -export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:write"); const { id } = await params; - return withReportsApi(["report:write"], async (ctx) => { - const body = await readJson(req); - const res = await createDailyReport(ctx, { ...body, workOrderId: id } as Parameters[1]); - return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); - }); -} + const body = await readJsonObject(req, { allowEmpty: true }); + const res = await createDailyReport(ctx, { ...body, workOrderId: id } as Parameters[1]); + return json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/documents/route.ts b/src/app/api/v1/work-orders/[id]/documents/route.ts index 020ab21..94e10be 100644 --- a/src/app/api/v1/work-orders/[id]/documents/route.ts +++ b/src/app/api/v1/work-orders/[id]/documents/route.ts @@ -1,25 +1,26 @@ -import { NextResponse, type NextRequest } from "next/server"; import type { DocumentCategory, DocumentVisibility } from "@prisma/client"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond"; import { ServiceError } from "@/server/services/context"; import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents"; -import { apiContext, apiError } from "../../_http"; /** * POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?). * Used by the backoffice form (HTML post → 303 back to the documents tab) and by API clients (JSON). * A route handler instead of a server action avoids the 1 MB server-action body limit. + * Not wrapped in withApi because browser form posts get redirects instead of JSON errors. */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const origin = new URL(req.url).origin; const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html"); try { - // CSRF defence for the cookie-authenticated form post: same-origin only. - const origin = req.headers.get("origin"); - if (origin && origin !== req.nextUrl.origin) throw new ServiceError("forbidden", "cross_origin"); - const ctx = await apiContext("document:write"); - const form = await req.formData(); + assertSameOrigin(req); + const ctx = await requireApiContext("work_orders", "document:write"); + const form = await readFormData(req); const file = form.get("file"); if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing"); + const title = form.get("title"); const doc = await uploadWorkOrderDocument(ctx, { workOrderId: id, bytes: new Uint8Array(await file.arrayBuffer()), @@ -27,14 +28,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: declaredMime: file.type, category: String(form.get("category") ?? "other") as DocumentCategory, visibility: String(form.get("visibility") ?? "team") as DocumentVisibility, - title: typeof form.get("title") === "string" && String(form.get("title")).trim() ? String(form.get("title")).trim() : null, + title: typeof title === "string" && title.trim() ? title.trim() : null, }); - if (wantsHtml) return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, req.nextUrl.origin), 303); - return NextResponse.json(doc, { status: 201 }); + if (wantsHtml) return Response.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, origin), 303); + return json(doc, { status: 201 }); } catch (err) { - if (wantsHtml && err instanceof ServiceError) { - return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, req.nextUrl.origin), 303); + if (wantsHtml && (err instanceof ServiceError || (err instanceof ApiError && err.code !== "unauthorized"))) { + return Response.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, origin), 303); } - return apiError(err); + return toErrorResponse(err); } } diff --git a/src/app/api/v1/work-orders/[id]/materials/route.ts b/src/app/api/v1/work-orders/[id]/materials/route.ts index 72db57e..19bb75f 100644 --- a/src/app/api/v1/work-orders/[id]/materials/route.ts +++ b/src/app/api/v1/work-orders/[id]/materials/route.ts @@ -1,29 +1,21 @@ -import { NextResponse, type NextRequest } from "next/server"; import type { MaterialPlanInput } from "@/lib/work-orders/schemas"; +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { addMaterialPlan, getMaterialOverview } from "@/server/services/work-orders/materials"; -import { apiContext, apiError, readJson } from "../../_http"; type Params = { params: Promise<{ id: string }> }; /** GET /api/v1/work-orders/[id]/materials — planned vs. actual incl. deviations. */ -export async function GET(_req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext(); - return NextResponse.json({ items: await getMaterialOverview(ctx, id) }); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (_req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + return json({ items: await getMaterialOverview(ctx, id) }); +}); /** POST /api/v1/work-orders/[id]/materials — add a material plan item { name, articleNumber?, plannedQuantity, unit, notes? }. */ -export async function POST(req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext("work_order:write"); - const plan = await addMaterialPlan(ctx, id, (await readJson(req)) as MaterialPlanInput); - return NextResponse.json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 }); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders", "work_order:write"); + const { id } = await params; + const plan = await addMaterialPlan(ctx, id, (await readJsonObject(req)) as MaterialPlanInput); + return json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/route.ts b/src/app/api/v1/work-orders/[id]/route.ts index dd77e41..c982888 100644 --- a/src/app/api/v1/work-orders/[id]/route.ts +++ b/src/app/api/v1/work-orders/[id]/route.ts @@ -1,34 +1,25 @@ -import { NextResponse, type NextRequest } from "next/server"; import type { UpdateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { requireApiContext } from "@/server/api/context"; +import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond"; import { computeCompletionBlockers } from "@/server/services/work-orders/completion"; import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail"; import { updateWorkOrder } from "@/server/services/work-orders/update"; -import { apiContext, apiError, optionalVersion, readJson } from "../_http"; type Params = { params: Promise<{ id: string }> }; /** GET /api/v1/work-orders/[id] — detail incl. transitions available to the caller and completion blockers. */ -export async function GET(_req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext(); - const wo = await getWorkOrderDetail(ctx, id); - const [blockers] = await Promise.all([computeCompletionBlockers(ctx, id)]); - return NextResponse.json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers }); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (_req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + const wo = await getWorkOrderDetail(ctx, id); + const blockers = await computeCompletionBlockers(ctx, id); + return json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers }); +}); /** PATCH /api/v1/work-orders/[id] — body: partial master data + optional baseVersion (409 on mismatch). */ -export async function PATCH(req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext(); - const { baseVersion, ...patch } = await readJson(req); - const res = await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion)); - return NextResponse.json(res); - } catch (err) { - return apiError(err); - } -} +export const PATCH = withApi(async (req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + const { baseVersion, ...patch } = await readJsonObject(req); + return json(await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion))); +}); diff --git a/src/app/api/v1/work-orders/[id]/transition/route.ts b/src/app/api/v1/work-orders/[id]/transition/route.ts index ab0fdf8..038ff70 100644 --- a/src/app/api/v1/work-orders/[id]/transition/route.ts +++ b/src/app/api/v1/work-orders/[id]/transition/route.ts @@ -1,24 +1,20 @@ -import { NextResponse, type NextRequest } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond"; import { transitionWorkOrder } from "@/server/services/work-orders/transition"; -import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; /** * POST /api/v1/work-orders/[id]/transition — body { to, reason?, baseVersion? }. * 403 forbidden · 404 not in scope · 409 version conflict · 422 invalid / blocked (details: CompletionBlocker[]). */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params; - const ctx = await apiContext(); - const body = await readJson(req); - const res = await transitionWorkOrder(ctx, { - workOrderId: id, - to: String(body.to ?? "") as never, - reason: typeof body.reason === "string" ? body.reason : null, - baseVersion: optionalVersion(body.baseVersion), - }); - return NextResponse.json(res); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + const body = await readJsonObject(req); + const res = await transitionWorkOrder(ctx, { + workOrderId: id, + to: String(body.to ?? "") as never, + reason: typeof body.reason === "string" ? body.reason : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return json(res); +}); diff --git a/src/app/api/v1/work-orders/_http.ts b/src/app/api/v1/work-orders/_http.ts deleted file mode 100644 index 76f29f3..0000000 --- a/src/app/api/v1/work-orders/_http.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextResponse } from "next/server"; -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"; - -/** - * /api/v1/work-orders helpers (lane L2). Authentication/authorisation reuses the DB-authoritative - * moduleGuard (session cookie); the fundament's generic `requireApiContext` does not exist yet — - * see docs/craftvia/lanes/auftraege.md. - */ -export async function apiContext(...permissions: Permission[]): Promise { - const g = await moduleGuard("work_orders")(...permissions); - return ctxFromGuard(g); -} - -const STATUS: Record = { - not_found: 404, - forbidden: 403, - invalid: 422, - conflict: 409, - blocked: 422, -}; - -export function apiError(err: unknown): NextResponse { - if (err instanceof ServiceError) { - return NextResponse.json({ error: { code: err.code, message: err.message, details: err.details ?? null } }, { status: STATUS[err.code] }); - } - if (err instanceof ForbiddenError) return NextResponse.json({ error: { code: "forbidden", message: "forbidden" } }, { status: 403 }); - if (err instanceof ModuleDisabledError) return NextResponse.json({ error: { code: "forbidden", message: "module_disabled" } }, { status: 403 }); - if (err instanceof SyntaxError) return NextResponse.json({ error: { code: "invalid", message: "invalid_json" } }, { status: 400 }); - const msg = err instanceof Error ? err.message : ""; - if (/Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(msg)) { - return NextResponse.json({ error: { code: "unauthorized", message: "unauthorized" } }, { status: 401 }); - } - console.error("[api/v1/work-orders]", err); - return NextResponse.json({ error: { code: "internal", message: "internal" } }, { status: 500 }); -} - -export async function readJson(req: Request): Promise> { - const body = (await req.json()) as unknown; - if (!body || typeof body !== "object" || Array.isArray(body)) throw new ServiceError("invalid", "body_must_be_object"); - return body as Record; -} - -export function optionalVersion(v: unknown): number | undefined { - return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined; -} diff --git a/src/app/api/v1/work-orders/import/route.ts b/src/app/api/v1/work-orders/import/route.ts index 7f63506..34e97dd 100644 --- a/src/app/api/v1/work-orders/import/route.ts +++ b/src/app/api/v1/work-orders/import/route.ts @@ -1,30 +1,21 @@ +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, withApi } from "@/server/api/respond"; import { createImport } from "@/server/services/imports/upload"; -import { apiError, importsApiContext } from "../../imports/_context"; /** * POST /api/v1/work-orders/import — multipart upload of an order document (field `file`). * Lane L3 (import). Response 201 `{ id, status }`; the extraction runs in the background. - * Note: bodies > 10 MB need `experimental.proxyClientMaxBodySize` in next.config.ts (see lane report). */ -export async function POST(req: Request) { - try { - const ctx = await importsApiContext("import:write"); - let form: FormData; - try { - form = await req.formData(); - } catch { - return Response.json({ error: "invalid", message: "multipart_required" }, { status: 400 }); - } - const file = form.get("file"); - if (!(file instanceof File)) return Response.json({ error: "invalid", message: "file_missing" }, { status: 400 }); - const job = await createImport(ctx, { - bytes: Buffer.from(await file.arrayBuffer()), - fileName: file.name, - mimeType: file.type, - }); - const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } }); - return Response.json(current ?? { id: job.id, status: job.status }, { status: 201 }); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("imports", "import:write"); + const form = await readFormData(req); + const file = form.get("file"); + if (!(file instanceof File)) throw new ApiError("invalid", "file_missing"); + const job = await createImport(ctx, { + bytes: Buffer.from(await file.arrayBuffer()), + fileName: file.name, + mimeType: file.type, + }); + const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } }); + return json(current ?? { id: job.id, status: job.status }, { status: 201 }); +}); diff --git a/src/app/api/v1/work-orders/route.ts b/src/app/api/v1/work-orders/route.ts index 9d580f3..8e351dd 100644 --- a/src/app/api/v1/work-orders/route.ts +++ b/src/app/api/v1/work-orders/route.ts @@ -1,30 +1,22 @@ -import { NextResponse, type NextRequest } from "next/server"; import { parseListParams } from "@/lib/work-orders/filters"; import type { CreateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { createWorkOrder } from "@/server/services/work-orders/create"; import { listWorkOrders } from "@/server/services/work-orders/list"; -import { apiContext, apiError, readJson } from "./_http"; /** GET /api/v1/work-orders — filters as in the backoffice list (§21), always within workOrderScope. */ -export async function GET(req: NextRequest) { - try { - const ctx = await apiContext(); - return NextResponse.json(await listWorkOrders(ctx, parseListParams(req.nextUrl.searchParams))); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders"); + return json(await listWorkOrders(ctx, parseListParams(new URL(req.url).searchParams))); +}); -/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). */ -export async function POST(req: NextRequest) { - try { - const ctx = await apiContext(); - const body = await readJson(req); - // Import linkage and number keys are reserved for the import/emergency services. - delete body.sourceImportId; - const created = await createWorkOrder(ctx, body as CreateWorkOrderInput); - return NextResponse.json(created, { status: 201 }); - } catch (err) { - return apiError(err); - } -} +/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). Rights are checked in the service. */ +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders"); + const body = await readJsonObject(req); + // Import linkage and number keys are reserved for the import/emergency services. + delete body.sourceImportId; + const created = await createWorkOrder(ctx, body as CreateWorkOrderInput); + return json(created, { status: 201 }); +}); diff --git a/src/components/imports/uploader.tsx b/src/components/imports/uploader.tsx index 5559291..b190225 100644 --- a/src/components/imports/uploader.tsx +++ b/src/components/imports/uploader.tsx @@ -43,12 +43,14 @@ export function ImportUploader() { if (e.lengthComputable) setState({ kind: "uploading", percent: Math.round((e.loaded / e.total) * 100), name: file.name }); }; xhr.onload = () => { - const res = (xhr.response ?? {}) as { id?: string; error?: string; message?: string }; + // Unified /api/v1 error format: { error: { code, message, details? } } + const res = (xhr.response ?? {}) as { id?: string; error?: { code?: string; message?: string } }; if (xhr.status === 201 && res.id) { setState({ kind: "done", name: file.name, id: res.id }); router.refresh(); } else { - const code = res.message && KNOWN_ERRORS.has(res.message) ? res.message : res.error && KNOWN_ERRORS.has(res.error) ? res.error : "error"; + const { code: errCode, message } = res.error ?? {}; + const code = message && KNOWN_ERRORS.has(message) ? message : errCode && KNOWN_ERRORS.has(errCode) ? errCode : "error"; setState({ kind: "error", code }); } if (inputRef.current) inputRef.current.value = ""; diff --git a/src/lib/field/upload.ts b/src/lib/field/upload.ts index a14cd0f..391895d 100644 --- a/src/lib/field/upload.ts +++ b/src/lib/field/upload.ts @@ -40,7 +40,7 @@ export function uploadFieldFile(opts: { } return; } - const error = xhr.status === 400 || xhr.status === 413 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal"; + const error = xhr.status === 400 || xhr.status === 413 || xhr.status === 422 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal"; resolve({ ok: false, error }); }; xhr.send(form); diff --git a/src/lib/offline/outbox.ts b/src/lib/offline/outbox.ts index 6dd9f40..6954390 100644 --- a/src/lib/offline/outbox.ts +++ b/src/lib/offline/outbox.ts @@ -141,7 +141,8 @@ const browserTransport: Transport = { return; } if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" }); - if (xhr.status === 400 || xhr.status === 413) return resolve({ ok: false, error: "invalid" }); + // 422 = unified /api/v1 validation error; 429 (rate limit) stays transient → backoff + if (xhr.status === 400 || xhr.status === 413 || xhr.status === 422) return resolve({ ok: false, error: "invalid" }); if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" }); if (xhr.status === 404) return resolve({ ok: false, error: "not_found" }); resolve({ ok: false, error: xhr.status === 0 ? "network" : "internal" }); diff --git a/src/server/api/context.ts b/src/server/api/context.ts index 4fbcce9..ae852fd 100644 --- a/src/server/api/context.ts +++ b/src/server/api/context.ts @@ -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/). @@ -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 }); } } diff --git a/src/server/api/respond.ts b/src/server/api/respond.ts index a4ae630..e65f69e 100644 --- a/src/server/api/respond.ts +++ b/src/server/api/respond.ts @@ -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 = { +export const API_ERROR_STATUS: Record = { 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): 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 }): Response { + return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store", ...init?.headers } }); } export function paginated(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(handler: (...args: A) => Promise) { +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(handler: (...args: A) => Promise) { return async (...args: A): Promise => { 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 { 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> { + 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; +} + +/** Read a multipart body; anything else → 422. */ +export async function readFormData(req: Request): Promise { + 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; +} diff --git a/src/server/rate-limit.ts b/src/server/rate-limit.ts index 0b82897..03775c7 100644 --- a/src/server/rate-limit.ts +++ b/src/server/rate-limit.ts @@ -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; export type RateLimitScope = keyof typeof RATE_LIMITS; diff --git a/src/server/services/reports/dto.ts b/src/server/services/reports/dto.ts new file mode 100644 index 0000000..e7085ee --- /dev/null +++ b/src/server/services/reports/dto.ts @@ -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) }; +} diff --git a/src/server/services/reports/http.ts b/src/server/services/reports/http.ts deleted file mode 100644 index 073ffae..0000000 --- a/src/server/services/reports/http.ts +++ /dev/null @@ -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 = { 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): Promise { - try { - const g = await guard(...permissions); - return await handler(ctxFromGuard(g)); - } catch (err) { - return apiError(err); - } -} - -export async function readJson(req: Request): Promise> { - 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) }; -} diff --git a/src/server/services/sync/api-context.ts b/src/server/services/sync/api-context.ts deleted file mode 100644 index f04c4fe..0000000 --- a/src/server/services/sync/api-context.ts +++ /dev/null @@ -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 { - return ctxFromGuard(await moduleGuard(moduleKey)(...permissions)); -} - -const STATUS_FOR: Record = { - 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): Promise { - 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); - } -}