L10b Betrieb & Aufräumen: OpenAPI 3.1 unter /api/v1/openapi.json, API-Doku und API-Test

- src/lib/api/openapi.ts: statisch gepflegte Spezifikation aller v1-Routen inkl. Fehlerformat,
  Pagination, Idempotenz (clientOpId/clientId), Konflikte, Rate Limits, Rechte je Operation.
- GET /api/v1/openapi.json liefert das Dokument (angemeldete Nutzer).
- docs/craftvia/API.md: Kurzdoku mit Endpunkt-Tabelle.
- scripts/test-betrieb-api.ts: jede Route nutzt requireApiContext/respond.ts, 401 ohne Sitzung
  im einheitlichen Format, 403 bei fremdem Origin/Sec-Fetch-Site, Fehler-Mapping, Rate Limit je
  Nutzer (Standard/Einsatz getrennt), OpenAPI deckt jede route.ts ab.

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 fb993a7730
commit 49f05c8db3
4 changed files with 1419 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
# Craftvia API (`/api/v1`)
Versionierte JSON-API für Backoffice-Formulare, die Mobile-App/PWA (Offline-Sync) und künftige Integrationen. Die maschinenlesbare Spezifikation (OpenAPI 3.1) liefert `GET /api/v1/openapi.json` (gepflegt in `src/lib/api/openapi.ts`, `API_ROUTES` listet alle dokumentierten Pfade). Jede neue oder geänderte `src/app/api/v1/**/route.ts` muss dort nachgetragen werden.
## Authentifizierung und CSRF
- **Session-Cookie** von Auth.js: `authjs.session-token` (unter HTTPS `__Secure-authjs.session-token`). Ohne Cookie antwortet bereits der Proxy (`src/proxy.ts`) mit `401`.
- **Rechte** werden bei jedem Request aus der Datenbank gelesen (Mitgliedschaft, Identitätsstatus, Session-Kill-Switch, Passwortwechsel, effektive Rechte), nie aus dem JWT. Fehlt ein Recht oder ist das Modul des Mandanten deaktiviert, kommt `403`.
- **Sichtbarkeit:** Objekte eines fremden Mandanten oder außerhalb des eigenen Scopes (z. B. Monteur ↔ fremder Auftrag) liefern `404`, nicht `403`.
- **CSRF:** Schreibende Methoden (POST/PATCH) nur Same-Origin: Der `Origin`-Header muss zum Host passen, `Sec-Fetch-Site` muss `same-origin` oder `none` sein. Sonst `403 forbidden`.
## Fehlerformat
Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
```json
{ "error": { "code": "invalid", "message": "validation failed", "details": [{ "path": "customerId", "code": "too_small" }] } }
```
| Code | HTTP | Bedeutung / `details` |
|---|---|---|
| `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert |
| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request |
| `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes |
| `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) |
| `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart |
| `blocked` | 422 | fachlich gesperrt, z. B. `details = CompletionBlocker[]` |
| `payload_too_large` | 413 | Datei/Body zu groß |
| `rate_limited` | 429 | Header `Retry-After` (Sekunden), `details.retryAfterSeconds` |
| `internal` | 500 | unerwarteter Fehler, keine internen Details |
## Pagination
`GET /customers`, `GET /sites` und `GET /sites/{id}/history` verwenden `?page` (≥ 1) und `?pageSize` (1–100, Standard 25, bei der Historie 50). Antwort: `{ "data": [...], "pagination": { "page", "pageSize", "total" } }` (Historie zusätzlich `meta.onlyApproved`).
`GET /work-orders` hat ein eigenes Format: `{ items, total, page, pageSize, groupCounts }` (`groupCounts` = Anzahl je Statusgruppe ohne Status-/Gruppenfilter).
## Idempotenz und Konflikte (Sync)
- `POST /sync` nimmt `{ deviceId, operations[] }` mit 1–100 Operationen an (die PWA-Outbox schickt Batches ≤ 50). Jede Operation hat eine `clientOpId` (UUID) und wird einzeln angewendet. Die HTTP-Antwort ist `200`, das Ergebnis steht je Operation in `results[]`: `applied` | `duplicate` | `conflict` | `rejected` (mit `errorCode`, `message`, `idMap`, `entityVersion`).
- **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`.
- **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet).
- Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`).
- REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`.
## Uploads
- `POST /uploads` (Einsatz): multipart mit `file`, `clientId` (UUID), `workOrderId`, `kind` (`photo` | `voice_note`) und optional `preview` (Thumbnail ≤ 2 MB). Maximal 25 MB, der Inhalt wird per Magic Bytes geprüft. Idempotent über `clientId`: dieselbe clientId liefert `200 { documentId, duplicate: true }`, ein neuer Upload `201 { documentId, duplicate: false }`. Die `documentId` wird danach in `photo.attach`/`voice.attach` referenziert.
- `POST /work-orders/{id}/documents`: multipart mit `file`, `category`, `visibility`, `title?`. Antwort `201`. Mit `Accept: text/html` kommt stattdessen ein `303`-Redirect (Backoffice-Formular).
- `POST /work-orders/import`: multipart mit `file` (PDF/JPEG/PNG, ≤ 25 MB), Antwort `201 { id, status }`. Die Extraktion läuft asynchron.
## Rate Limits
Die Zählung erfolgt je Nutzer in einem Fenster von einer Minute, im Speicher je App-Instanz (bei mehreren Instanzen also pro Instanz).
- Standard: `API_RATE_LIMIT_PER_MINUTE` (Default 300)
- Einsatz-Endpunkte `/sync`, `/uploads`, `/field/**`: `API_FIELD_RATE_LIMIT_PER_MINUTE` (Default 1200)
Bei Überschreitung kommt `429` mit `Retry-After`.
## Endpunkte
Die Pfade sind relativ zu `/api/v1`. „Recht“ nennt das Gate der Route. Mit „Service“ markierte Rechte prüft der Service (zusätzlich zum Scope).
| Methode | Pfad | Modul | Recht | Beschreibung |
|---|---|---|---|---|
| GET | `/customers` | customers | `customer:read` | Kunden suchen (`q`, `status`, paginiert) |
| POST | `/customers` | customers | `customer:write` | Kunde anlegen (409 bei möglichen Dubletten ohne `acknowledgeDuplicates`) |
| GET | `/customers/{id}` | customers | `customer:read` | Kunde inkl. Ansprechpartner |
| PATCH | `/customers/{id}` | customers | `customer:write` | Kunde ändern (fehlt = unverändert, `null` = leeren) |
| GET | `/sites` | sites | `site:read` | Standorte suchen (`q`, `customerId`, `status`, paginiert) |
| POST | `/sites` | sites | `site:write` | Standort anlegen |
| GET | `/sites/{id}/history` | sites | `site:read` | Einsatzhistorie (Außendienst: nur freigegebene Einsätze) |
| GET | `/work-orders` | work_orders | Scope (`work_order:read_all`/`read_team`) | Auftragsliste mit Filtern/Presets |
| POST | `/work-orders` | work_orders | Service: `work_order:write` (Notfall: `emergency:create`) | Auftrag anlegen |
| GET | `/work-orders/{id}` | work_orders | Scope | Detail + `availableTransitions` + `completionBlockers` |
| PATCH | `/work-orders/{id}` | work_orders | Service: `work_order:write` | Stammdaten ändern (`baseVersion`) |
| POST | `/work-orders/{id}/assign` | work_orders | `work_order:assign` | Team/Monteure zuweisen |
| POST | `/work-orders/{id}/transition` | work_orders | je Übergang (`requiredPermission`) | Statuswechsel (422 `blocked` mit Blockern) |
| GET | `/work-orders/{id}/materials` | work_orders | Scope | Material Soll/Ist |
| POST | `/work-orders/{id}/materials` | work_orders | `work_order:write` | Materialvorgabe hinzufügen |
| POST | `/work-orders/{id}/documents` | work_orders | `document:write` | Dokument hochladen (multipart) |
| POST | `/work-orders/{id}/daily-report` | reports | `report:write` | Tagesbericht-Entwurf anlegen/holen (201/200) |
| POST | `/work-orders/{id}/completion-report` | reports | `report:write` | Abschlussbericht-Entwurf anlegen/holen (422 bei Blockern) |
| POST | `/work-orders/import` | imports | `import:write` | Auftragsdokument importieren (multipart) |
| GET | `/imports/{id}` | imports | `import:write` | Importstatus, Extraktion, Kandidaten |
| POST | `/imports/{id}/confirm` | imports | `import:write`, `work_order:write` | Prüfformular bestätigen → Auftrag |
| POST | `/reports/{id}/approve` | reports | `report:read` + Service: `report:approve_team`/`report:approve` | Bericht freigeben |
| GET | `/reports/{id}/pdf` | reports | `report:read` | PDF des freigegebenen Berichts (`?download=1`) |
| GET | `/reports/{id}/files/{documentId}` | reports | `report:read` | Foto/Unterschrift/Logo aus dem Bericht |
| POST | `/sync` | field | Service je opType (`field:execute`, `emergency:create`, …) | Batch-Operationen (offline/online) |
| POST | `/uploads` | field | `field:execute` | Foto/Sprachnotiz hochladen → `documentId` |
| GET | `/field/bundle` | field | `field:execute` | Offline-Pull (`?since=<ISO>`, max. 200 Aufträge) |
| GET | `/field/documents/{id}` | field | Service: `document:read` + Sichtbarkeit/Scope | Dokument für die Mobile-App (`?variant=preview`) |
| GET | `/openapi.json` | – | angemeldet | OpenAPI-3.1-Dokument |
+178
View File
@@ -0,0 +1,178 @@
// Lane L10b „Betrieb & Aufräumen" — /api/v1 vereinheitlicht (Aufräumpunkt a) + Rate Limiting:
// Jede Route läuft über requireApiContext + withApi (respond.ts): einheitliches Fehlerformat
// { error: { code, message, details? } }, Statuscodes je Code, Same-Origin-Prüfung für jede
// Mutation (vor der Authentifizierung), 401 ohne Sitzung, 429 + Retry-After beim Rate Limit.
//
// Lauf: npx tsx scripts/test-betrieb-api.ts (keine DB-Schreibzugriffe)
import "dotenv/config";
// Kleine Limits für den Test — rate-limit.ts liest die Env beim Laden des Moduls.
process.env.API_RATE_LIMIT_PER_MINUTE = "5";
process.env.API_FIELD_RATE_LIMIT_PER_MINUTE = "12";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { pathToFileURL } from "node:url";
import { z } from "zod";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const ROOT = join(process.cwd(), "src/app/api/v1");
const HOST = "localhost:3111";
const METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const;
const MUTATING = new Set(["POST", "PATCH", "PUT", "DELETE"]);
function routeFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const p = join(dir, name);
if (statSync(p).isDirectory()) return routeFiles(p);
return name === "route.ts" ? [p] : [];
});
}
/** `src/app/api/v1/work-orders/[id]/route.ts` → `/api/v1/work-orders/{id}` */
function apiPath(file: string): string {
const rel = relative(ROOT, file).replace(/\/?route\.ts$/, "");
return `/api/v1${rel ? `/${rel}` : ""}`.replace(/\[([^\]]+)\]/g, "{$1}");
}
type Handler = (req: Request, ctx: { params: Promise<Record<string, string>> }) => Promise<Response>;
async function errorBody(res: Response): Promise<{ code?: string; message?: string } | null> {
try {
const body = (await res.json()) as { error?: { code?: string; message?: string } };
return body.error && typeof body.error === "object" ? body.error : null;
} catch {
return null;
}
}
async function main() {
const { ServiceError } = await import("../src/server/services/context");
const { ApiError, API_ERROR_STATUS, readJsonObject, toErrorResponse } = await import("../src/server/api/respond");
const { enforceApiRateLimit } = await import("../src/server/api/context");
const { resetRateLimits } = await import("../src/server/rate-limit");
const files = routeFiles(ROOT).sort();
const apiFiles = files.filter((f) => !f.includes("openapi.json"));
ok(apiFiles.length >= 22, `alle v1-Routen gefunden (${apiFiles.length})`);
console.log("\n— OpenAPI deckt jede Route ab —");
const { API_ROUTES, openApiDocument } = await import("../src/lib/api/openapi");
const documented = new Set<string>(API_ROUTES);
for (const file of files) ok(documented.has(apiPath(file)), `OpenAPI dokumentiert ${apiPath(file)}`);
ok(documented.size === files.length, `keine veralteten OpenAPI-Pfade (${documented.size} dokumentiert, ${files.length} Routen)`);
const doc = openApiDocument as { openapi?: string; paths?: Record<string, Record<string, unknown>> };
ok(typeof doc.openapi === "string" && doc.openapi.startsWith("3.1"), "OpenAPI 3.1");
const specRes = await ((await import(pathToFileURL(join(ROOT, "openapi.json/route.ts")).href)) as { GET: () => Promise<Response> }).GET();
const spec = (await specRes.json()) as { paths?: Record<string, unknown> };
ok(specRes.status === 200 && Object.keys(spec.paths ?? {}).length === files.length, "GET /api/v1/openapi.json liefert das Dokument");
console.log("\n— Statisch: ein gemeinsamer Adapter —");
for (const file of apiFiles) {
const src = readFileSync(file, "utf8");
const path = apiPath(file);
ok(src.includes("requireApiContext(") && !/moduleGuard|action-guard|_context|api-context|reports\/http|_http/.test(src), `${path}: requireApiContext, keine lane-lokalen Kontexte`);
ok(/withApi\(|toErrorResponse\(/.test(src), `${path}: Fehler über respond.ts`);
}
console.log("\n— Ohne Sitzung: 401 im einheitlichen Format —");
const params = Promise.resolve({ id: "zz-unknown", documentId: "zz-unknown" });
for (const file of apiFiles) {
const mod = (await import(pathToFileURL(file).href)) as Record<string, unknown>;
const path = apiPath(file).replace(/\{[^}]+\}/g, "zz-unknown");
for (const method of METHODS) {
const handler = mod[method] as Handler | undefined;
if (typeof handler !== "function") continue;
const headers: Record<string, string> = { host: HOST, accept: "application/json" };
const init: RequestInit = { method, headers };
if (MUTATING.has(method)) {
headers.origin = `http://${HOST}`;
headers["sec-fetch-site"] = "same-origin";
headers["content-type"] = "application/json";
init.body = "{}";
}
const res = await handler(new Request(`http://${HOST}${path}`, init), { params });
const err = await errorBody(res);
ok(res.status === 401 && err?.code === "unauthorized" && res.headers.get("cache-control") === "no-store", `${method} ${apiPath(file)} ohne Sitzung → 401 unauthorized`);
if (MUTATING.has(method)) {
const cross = await handler(
new Request(`http://${HOST}${path}`, { method, headers: { host: HOST, origin: "https://evil.example", "content-type": "application/json" }, body: "{}" }),
{ params },
);
const crossErr = await errorBody(cross);
ok(cross.status === 403 && crossErr?.code === "forbidden", `${method} ${apiPath(file)} fremder Origin → 403 (vor der Anmeldung)`);
const site = await handler(
new Request(`http://${HOST}${path}`, { method, headers: { host: HOST, "sec-fetch-site": "cross-site", "content-type": "application/json" }, body: "{}" }),
{ params },
);
ok(site.status === 403, `${method} ${apiPath(file)} Sec-Fetch-Site cross-site → 403`);
}
}
}
console.log("\n— Fehler-Mapping (respond.ts) —");
const expected: Record<string, number> = { not_found: 404, forbidden: 403, invalid: 422, conflict: 409, blocked: 422 };
for (const [code, status] of Object.entries(expected)) {
const res = toErrorResponse(new ServiceError(code as "not_found", `msg ${code}`, code === "blocked" ? [{ kind: "checklist_item", id: "c1" }] : undefined));
const body = (await res.json()) as { error: { code: string; message: string; details?: unknown } };
ok(res.status === status && body.error.code === code && body.error.message === `msg ${code}`, `ServiceError ${code} → ${status}`);
if (code === "blocked") ok(Array.isArray(body.error.details), "blocked → details (CompletionBlocker[])");
}
const zodErr = z.object({ name: z.string() }).safeParse({ name: 1 });
const zres = toErrorResponse(zodErr.error);
const zbody = (await zres.json()) as { error: { code: string; details: { path: string }[] } };
ok(zres.status === 422 && zbody.error.code === "invalid" && zbody.error.details[0]?.path === "name", "ZodError → 422 invalid mit Feldpfaden");
const origError = console.error;
console.error = () => {};
const internal = toErrorResponse(new Error("SELECT secret FROM users"));
console.error = origError;
const ibody = await internal.text();
ok(internal.status === 500 && !ibody.includes("secret"), "unbekannter Fehler → 500 ohne interne Details");
ok(API_ERROR_STATUS.rate_limited === 429 && API_ERROR_STATUS.payload_too_large === 413 && API_ERROR_STATUS.unauthorized === 401, "Statuscodes 429/413/401");
const req = (body: string) => new Request(`http://${HOST}/x`, { method: "POST", body });
const code = async (p: Promise<unknown>) => p.then(() => "ok", (e: { code?: string }) => e.code ?? "error");
ok(JSON.stringify(await readJsonObject(req(""), { allowEmpty: true })) === "{}", "readJsonObject: leerer Body mit allowEmpty → {}");
ok((await code(readJsonObject(req("")))) === "invalid", "readJsonObject: leerer Body → invalid");
ok((await code(readJsonObject(req("[1]")))) === "invalid", "readJsonObject: Array → invalid");
ok((await code(readJsonObject(req("{nope")))) === "invalid", "readJsonObject: kaputtes JSON → invalid");
console.log("\n— Rate Limiting je Nutzer —");
resetRateLimits();
const hit = (user: string, moduleKey: "customers" | "field") => {
try {
enforceApiRateLimit(user, moduleKey);
return null;
} catch (err) {
return err as InstanceType<typeof ApiError>;
}
};
let firstBlocked = -1;
for (let i = 1; i <= 6; i++) if (hit("zz-user-a", "customers") && firstBlocked < 0) firstBlocked = i;
ok(firstBlocked === 6, "Standard-Bucket: 5 Anfragen erlaubt, die 6. abgelehnt");
const blocked = hit("zz-user-a", "customers");
ok(blocked?.code === "rate_limited" && ((blocked.details as { retryAfterSeconds: number }).retryAfterSeconds ?? 0) > 0, "Ablehnung als rate_limited mit retryAfterSeconds");
const res429 = toErrorResponse(blocked);
ok(res429.status === 429 && Number(res429.headers.get("retry-after")) > 0, "429 mit Retry-After-Header");
ok(hit("zz-user-b", "customers") === null, "anderer Nutzer hat eigenes Kontingent");
let fieldBlocked = -1;
for (let i = 1; i <= 13; i++) if (hit("zz-user-a", "field") && fieldBlocked < 0) fieldBlocked = i;
ok(fieldBlocked === 13, "Einsatz-Bucket (sync/uploads/field) getrennt und großzügiger: 12 erlaubt, 13. abgelehnt");
resetRateLimits();
}
main()
.catch((err) => {
console.error(err);
failures++;
})
.finally(() => {
console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle API-Prüfungen grün");
process.exit(failures ? 1 : 0);
});
+12
View File
@@ -0,0 +1,12 @@
import { openApiDocument } from "@/lib/api/openapi";
/**
* GET /api/v1/openapi.json — the statically maintained OpenAPI 3.1 document (src/lib/api/openapi.ts).
*
* Auth: src/proxy.ts rejects every /api/v1 request without a session cookie with 401, so the
* document is only reachable for signed-in users. It contains no tenant data, therefore no
* further permission/module check is done here (deliberately — any API client may read it).
*/
export function GET() {
return Response.json(openApiDocument, { headers: { "Cache-Control": "private, max-age=300" } });
}
File diff suppressed because it is too large Load Diff