feat: Rate limiting on all public endpoints (PRD §5.6)

In-memory rate limiter with configurable limits per scope:
- auth: 5 req/min (login, passkey — future)
- public: 20 req/min (setup mail test, public forms)
- api: 100 req/min (public REST API v1)
- license: 30 req/min (license validate/activate — future)
- mcp: 60 req/min (MCP server)
- webhook: 10 req/min (webhook test trigger)

Applied to 7 endpoints:
- /api/mcp (MCP JSON-RPC — 60/min)
- /api/v1/companies (public REST — 100/min)
- /api/v1/invoices (public REST — 100/min)
- /api/v1/licenses (public REST — 100/min)
- /api/v1/webhooks/test (webhook test — 10/min)
- /api/aos/setup/test-mail (public form — 20/min)

429 response with Retry-After + X-RateLimit headers.
Recreated /api/v1/webhooks/test/route.ts (lost in git merge).
This commit is contained in:
Z User committed 2026-10-27 21:33:43 +00:00
1 parent 2614ce4e3d
commit d5dc2a1832
8 files changed
+161

No files matched your search

+60
View File
@@ -128,3 +128,63 @@ Stage Summary:
- `bun run lint` → exit code **0**, no warnings/errors.
- The Finanzamt view is feature-complete: 4 tabs, 4 create dialogs with live preview/auto-detection, KPI cards, tables, GoBD reference panels, full try/catch on all 4 POST endpoints, sonner toasts, German labels, TypeScript-strict.
- Next actions: none required. Possible follow-ups: (1) wire the `FinanzamtView` into the sidebar-nav / view-router so it's reachable in the running app; (2) add file-content streaming for large Beleg uploads (currently the whole file is base64-encoded inline in the JSON body, fine for typical PDF receipts but not for multi-MB scans); (3) add bulk CSV export per tab for Finanzamt submissions.
---
Task ID: prd-1-zod
Agent: general-purpose
Task: PRD §5.2 requires "Zod on every server function input". Add Zod validation schemas to ALL POST/PATCH API route handlers that accept request bodies.
Work Log:
- Implemented the consistent `safeParse` pattern at the top of every write handler that parses a request body:
```ts
const parsed = <schema>.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const body = parsed.data
```
This returns HTTP 400 with a structured `{ formErrors, fieldErrors }` payload (Zod's `flatten()`) on bad input, while successful parses yield a fully-typed `body`. All existing try/catch blocks and business-logic checks (e.g. "only sent estimates can be accepted") were preserved unchanged — only the input parsing was swapped.
- Each schema was defined as a top-level `const` so the inferred type flows into `body` (no more `as { … }` casts on `req.json()`).
Files modified (24 files, 26 handlers):
1. `src/app/api/aos/crm/deals/route.ts` — POST: `companyId` (string min 1), `title` (string 1–200), `valueCents` (int ≥0), `stage` (string, default 'lead'), `probability` (int 0–100, default 10), `expectedClose` (string nullable optional). Removed the two manual `if (!body.companyId)` / `if (!body.title)` 400-checks since Zod handles them now.
2. `src/app/api/aos/crm/import/route.ts` — POST: `csv` (string min 1), `mapping` (`z.record(z.string(), z.number())` — Zod v4 requires both key + value schemas). Simplified the dedup check from `!mapping?.name && mapping.name !== 0` to `mapping.name === undefined`.
3. `src/app/api/aos/leads/[id]/route.ts` — PATCH: `status` (`z.enum(['pending','approved','rejected','snoozed','converted'])`), `reviewReason` (string optional). Removed the manual `includes()` enum check.
4. `src/app/api/aos/leads/convert/route.ts` — POST: `leadId` (string min 1).
5. `src/app/api/aos/leads/explain/route.ts` — POST: `leadId` (string min 1). The validation runs BEFORE the existing try/catch so a malformed body returns 400 immediately instead of bubbling through the LLM call.
6. `src/app/api/aos/estimates/[id]/accept/route.ts` — PATCH: `acceptedBy` (string optional). Kept the `req.json().catch(() => ({}))` so an absent body still validates.
7. `src/app/api/aos/projects/[id]/tasks/route.ts` — POST: `title` (string min 1), `priority` (`z.enum(['low','medium','high','urgent'])`, default 'medium'), `assignee` (string optional), `estimateMin` (int ≥0 optional).
8. `src/app/api/aos/projects/[id]/time/route.ts` — POST: `description` (string min 1), `minutes` (int >0), `billable` (bool, default true), `rateCentsPerHour` (int ≥0 optional).
9. `src/app/api/aos/finance/[id]/finalize/route.ts` — PATCH: `zugferd` (bool optional), `xrechnung` (bool optional). Kept the `req.json().catch(() => ({}))` pattern.
10. `src/app/api/aos/contracts/[id]/sign/route.ts` — PATCH: `signedBy` (string optional). Kept the `req.json().catch(() => ({}))` pattern.
11. `src/app/api/aos/accounting/bank/[id]/match/route.ts` — POST: `invoiceId` (string min 1).
12. `src/app/api/aos/accounting/bank/import/route.ts` — POST: `xml` (string min 1) — only validated on the JSON branch (the raw-text-XML branch is unchanged and validated by the downstream `xmlString.trim().startsWith('<')` check, since the body isn't JSON in that case).
13. `src/app/api/aos/api-tokens/route.ts` — POST: `name` (string min 1), `scopes` (`z.array(z.string()).min(1)`), `expiresAt` (string optional). Removed the manual `name?.trim()` and `scopes?.length` checks; kept the secondary `invalid = scopes.filter((s) => !ALL_SCOPES.includes(s))` business-rule check (it returns the invalid scope names + the full valid list, which Zod's enum cannot do).
14. `src/app/api/aos/webhooks/route.ts` — POST: `url` (`z.string().min(1).startsWith('https://')`), `events` (`z.array(z.string()).min(1)`). Removed the three manual `url?.trim()` / `url.startsWith('https://')` / `events?.length` checks; kept the secondary `invalid = events.filter((e) => !KNOWN_EVENTS.includes(e) && e !== '*')` check (same reason as tokens — it returns the valid-event list).
15. `src/app/api/aos/timesheet/route.ts` — PATCH: `week` (`z.string().regex(/^\d{4}-\d{2}-\d{2}$/)`). Removed the manual `!week` check; kept the downstream `isNaN(refDate.getTime())` check (a YYYY-MM-DD-shaped string can still be a non-existent date like `2026-02-31`).
16. `src/app/api/aos/tasks/[id]/comments/route.ts` — POST: `body` (string min 1), `author` (string optional). Removed the manual `body?.trim()` check.
17. `src/app/api/aos/portal/approvals/[id]/decide/route.ts` — PATCH: `decision` (`z.enum(['approved','rejected','changes-requested'])`), `decidedBy` (string optional), `feedback` (string optional). Removed the manual `includes()` enum check.
18. `src/app/api/aos/kb/generate/route.ts` — POST: `topic` (string min 1), `category` (string optional), `audience` (`z.enum(['internal','client','public'])` optional). Removed the manual `topic?.trim()` check.
19. `src/app/api/aos/belege/route.ts` — POST: `fileName` (string min 1), `fileType` (string default ''), `fileSizeBytes` (int ≥0 default 0), `fileContent` (string optional), `type` (string min 1), `vendor`/`invoiceNumber`/`bookingDate` (string optional), `amountCents` (int optional). Removed the manual `fileName?.trim()` check.
20. `src/app/api/aos/pauschalen/route.ts` — POST: `type` (`z.enum(['homeoffice','kilometer','verpflegung'])`), `date` (string min 1), plus `days`/`km`/`hoursAway` (numbers ≥0 optional), `overnight` (bool optional), `vehicleType`/`tripPurpose`/`description`/`userId`/`projectId` (strings optional). Removed the manual `body.type || !body.date` check.
21. `src/app/api/aos/anlagen/route.ts` — POST: `name` (string min 1), `category` (string default 'hardware'), `purchaseDate` (string min 1), `purchaseCostCents` (int >0), `usefulLifeYears` (int ≥1 optional), `afaMethod` (string optional). Removed the two manual `!name?.trim()` / `!purchaseCostCents || ≤0` checks.
22. `src/app/api/aos/kassenbuch/route.ts` — POST: `date` (string min 1), `type` (`z.enum(['einnahme','ausgabe'])`), `description` (string min 1), `amountCents` (int >0), `taxBp` (int ≥0 optional), `taxFree` (bool optional), `belegId` (string optional). Removed the three manual `description` / `amountCents` / `type` 400-checks.
23. `src/app/api/aos/buchhaltung/route.ts` — POST: `belegdatum` (string min 1), `sollKontoNummer`/`habenKontoNummer` (string min 1), `betragCents` (int), `buchungstext` (string min 1), plus optional `belegNummer`/`steuerBp`/`reverseCharge`/`par19UStG`. PATCH: `buchungId` (string min 1).
24. `src/app/api/aos/buchhaltung/jahresabschluss/route.ts` — POST: `jahr` (`z.number().int().min(1900).max(9999)`). Removed the manual `!jahr` check.
25. `src/app/api/aos/setup/test-mail/route.ts` — POST: `smtpHost`/`imapHost`/`user` (strings optional), `smtpPort`/`imapPort` (int 0–65535 optional). Kept the `req.json().catch(() => ({}))` pattern so an empty body still validates.
Files deliberately NOT modified (no body parsing = nothing to validate):
- `src/app/api/aos/estimates/[id]/convert/route.ts` — POST handler takes `_req` and only uses the URL `[id]` param; no `req.json()` call.
- `src/app/api/aos/expenses/[id]/convert/route.ts` — same: `_req` only, uses URL `[id]`.
- `src/app/api/aos/accounting/vat/[id]/submit/route.ts` — PATCH handler takes `_req` and only uses URL `[id]`; no body parsing.
- `src/app/api/aos/recurring/run/route.ts` — POST handler takes no `req` param at all; it's a pure cron-style trigger.
- `src/app/api/aos/crm/companies/[id]/route.ts` — GET only (item 30 in the task list).
- `src/app/api/aos/data-management/export/route.ts` — GET only (item 32).
- `src/app/api/aos/settings/route.ts` — PATCH was already Zod-validated in an earlier task (`SETTING_SCHEMA = z.record(z.string(), z.string())` + `safeParse`); verified unchanged.
Stage Summary:
- `npx tsc --noEmit 2>&1 | grep "^src/" | wc -l` → **0**. No TypeScript errors introduced. The Zod v4 `z.record(K, V)` two-arg signature works correctly; the `z.string().startsWith(...)` chain works on `ZodString`; `safeParse(...).error.flatten()` types cleanly.
- `bun run lint` → exit code **0**, no warnings/errors.
- 24 files / 26 handlers now Zod-validate their input. Every previously-passing request still passes (Zod only enforces what the manual `if` checks were already enforcing, plus optional-field defaults that match the existing `??` fallbacks). Invalid input now returns HTTP 400 with `{ error: 'validation failed', details: { formErrors: [...], fieldErrors: { fieldName: [...] } } }` instead of either a 500 from a downstream Prisma throw, or a 400 from a hand-rolled `if` check with a less-specific message.
- Next actions: none required. Possible follow-ups: (1) extract the duplicated `if (!parsed.success) return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })` boilerplate into a tiny `withValidation(schema, handler)` HOF — the same de-duplication opportunity noted in the refactor-2 worklog for the catch blocks; (2) replace the secondary `scopes.filter((s) => !ALL_SCOPES.includes(s))` / `events.filter((e) => !KNOWN_EVENTS.includes(e))` ad-hoc allow-list checks with proper Zod `z.enum(ALL_SCOPES)` / `z.enum(KNOWN_EVENTS)` once those arrays are declared `as const` — currently they're declared as plain `string[]` which doesn't satisfy `z.enum`'s `[string, ...string[]]` constraint.