diff --git a/db/custom.db b/db/custom.db index 2ad47eb..9948d2e 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/src/app/api/aos/setup/test-mail/route.ts b/src/app/api/aos/setup/test-mail/route.ts index 8ca01fb..00db474 100755 --- a/src/app/api/aos/setup/test-mail/route.ts +++ b/src/app/api/aos/setup/test-mail/route.ts @@ -1,3 +1,4 @@ +import { checkRateLimit } from '@/lib/rate-limit' import { NextResponse } from 'next/server' import { z } from 'zod' import * as net from 'net' @@ -91,6 +92,8 @@ export async function POST(req: Request) { } const body = parsed.data + const rl = checkRateLimit(req, 'public') + if (rl) return rl const [smtp, imap] = await Promise.all([ testConnection(body.smtpHost ?? '', Number(body.smtpPort) || 0), testConnection(body.imapHost ?? '', Number(body.imapPort) || 0), diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 1b1e6f4..d9a5648 100755 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1,5 +1,6 @@ import { db } from '@/lib/db' import { verifyToken, hasScope } from '@/lib/api-auth' +import { checkRateLimit } from '@/lib/rate-limit' export const dynamic = 'force-dynamic' export const maxDuration = 30 @@ -904,6 +905,10 @@ const TOOLS: McpTool[] = [ // JSON-RPC handler export async function POST(req: Request) { + // Rate limit (PRD §5.6: rate limiting on license endpoints and public forms) + const rateLimited = checkRateLimit(req, 'mcp') + if (rateLimited) return rateLimited + const authHeader = req.headers.get('authorization') const auth = await verifyToken(authHeader) diff --git a/src/app/api/v1/companies/route.ts b/src/app/api/v1/companies/route.ts index 3da396d..8c57c66 100755 --- a/src/app/api/v1/companies/route.ts +++ b/src/app/api/v1/companies/route.ts @@ -1,5 +1,6 @@ import { db } from '@/lib/db' import { verifyToken, requireScope } from '@/lib/api-auth' +import { checkRateLimit } from '@/lib/rate-limit' export const dynamic = 'force-dynamic' @@ -7,6 +8,9 @@ export const dynamic = 'force-dynamic' // Public API: list companies. Requires scope "crm.company:read". export async function GET(req: Request) { try { + const rateLimited = checkRateLimit(req, 'api') + if (rateLimited) return rateLimited + const auth = await verifyToken(req.headers.get('authorization')) const check = requireScope(auth, 'crm.company:read') if (!check.allowed) return check.error! diff --git a/src/app/api/v1/invoices/route.ts b/src/app/api/v1/invoices/route.ts index cffd7ca..5b64668 100755 --- a/src/app/api/v1/invoices/route.ts +++ b/src/app/api/v1/invoices/route.ts @@ -1,5 +1,6 @@ import { db } from '@/lib/db' import { verifyToken, requireScope } from '@/lib/api-auth' +import { checkRateLimit } from '@/lib/rate-limit' export const dynamic = 'force-dynamic' @@ -7,6 +8,8 @@ export const dynamic = 'force-dynamic' // Public API: list invoices. Requires scope "finance.invoice:read". export async function GET(req: Request) { try { + const rl = checkRateLimit(req, 'api') + if (rl) return rl const auth = await verifyToken(req.headers.get('authorization')) const check = requireScope(auth, 'finance.invoice:read') if (!check.allowed) return check.error! diff --git a/src/app/api/v1/licenses/route.ts b/src/app/api/v1/licenses/route.ts index 84759b3..6824746 100755 --- a/src/app/api/v1/licenses/route.ts +++ b/src/app/api/v1/licenses/route.ts @@ -1,5 +1,6 @@ import { db } from '@/lib/db' import { verifyToken, requireScope } from '@/lib/api-auth' +import { checkRateLimit } from '@/lib/rate-limit' export const dynamic = 'force-dynamic' @@ -7,6 +8,8 @@ export const dynamic = 'force-dynamic' // Public API: list issued licenses. Requires scope "licenses.license:read". export async function GET(req: Request) { try { + const rl = checkRateLimit(req, 'api') + if (rl) return rl const auth = await verifyToken(req.headers.get('authorization')) const check = requireScope(auth, 'licenses.license:read') if (!check.allowed) return check.error! diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..4b897b6 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,83 @@ +// Simple in-memory rate limiter (PRD §5.6: "Rate limiting on auth, license endpoints and public forms") +// For production with multiple instances, use Redis instead. + +interface RateLimitEntry { + count: number + resetAt: number +} + +const store = new Map() + +// Check + increment rate limit +// Returns { allowed: boolean, remaining: number, resetAt: number } +export function rateLimit( + key: string, + maxRequests: number = 10, + windowMs: number = 60_000 +): { allowed: boolean; remaining: number; resetAt: number } { + const now = Date.now() + const entry = store.get(key) + + if (!entry || entry.resetAt < now) { + // First request or window expired + const resetAt = now + windowMs + store.set(key, { count: 1, resetAt }) + return { allowed: true, remaining: maxRequests - 1, resetAt } + } + + if (entry.count >= maxRequests) { + return { allowed: false, remaining: 0, resetAt: entry.resetAt } + } + + entry.count++ + return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt } +} + +// Get client identifier from request (IP + path for scoping) +export function getRateLimitKey(req: Request, scope: string): string { + const headers = new Headers(req.headers) + const ip = headers.get('x-forwarded-for')?.split(',')[0]?.trim() + ?? headers.get('x-real-ip') + ?? 'unknown' + return `${scope}:${ip}` +} + +// Rate limit configs per endpoint type (PRD §5.6) +export const RATE_LIMITS = { + auth: { max: 5, window: 60_000 }, // 5 attempts/minute (login, passkey) + public: { max: 20, window: 60_000 }, // 20 requests/minute (public forms) + api: { max: 100, window: 60_000 }, // 100 requests/minute (authenticated API) + license: { max: 30, window: 60_000 }, // 30 requests/minute (license validate/activate) + mcp: { max: 60, window: 60_000 }, // 60 requests/minute (MCP server) + webhook: { max: 10, window: 60_000 }, // 10 triggers/minute (webhook test) +} as const + +// Helper: check rate limit and return 429 Response if exceeded +export function checkRateLimit(req: Request, scope: keyof typeof RATE_LIMITS): Response | null { + const config = RATE_LIMITS[scope] + const key = getRateLimitKey(req, scope) + const result = rateLimit(key, config.max, config.window) + + if (!result.allowed) { + const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000) + return new Response( + JSON.stringify({ + error: 'rate_limit_exceeded', + message: `Too many requests. Try again in ${retryAfter} seconds.`, + retryAfter, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': String(retryAfter), + 'x-rate-limit-limit': String(config.max), + 'x-rate-limit-remaining': '0', + 'x-rate-limit-reset': String(result.resetAt), + }, + } + ) + } + + return null // allowed +} diff --git a/worklog.md b/worklog.md index c3fe943..4186cdc 100644 --- a/worklog.md +++ b/worklog.md @@ -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 = .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. +