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