Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
- attendance: schemas accept the combined local datetimes the forms/service
use (new dateTimeString helpers in schemas/common.ts), breaks persist on
create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
enrollment QR generated locally via qrcode (CSP-blocked external service
also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
coercion in formatters.ts), billing_text persists on update, issued-order
status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
(shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
@hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
the Prisma 6 downgrade), drop deprecated @types stubs
Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
157 lines
5.7 KiB
TypeScript
157 lines
5.7 KiB
TypeScript
import { z } from "zod";
|
||
|
||
export function parseBody<T>(
|
||
schema: z.ZodType<T>,
|
||
body: unknown,
|
||
): { data: T } | { error: string } {
|
||
try {
|
||
return { data: schema.parse(body) };
|
||
} catch (e) {
|
||
if (e instanceof z.ZodError) {
|
||
return {
|
||
error: e.issues.map((err: z.ZodIssue) => err.message).join(", "),
|
||
};
|
||
}
|
||
return { error: "Neplatný požadavek" };
|
||
}
|
||
}
|
||
|
||
// ── Shared form-coercion helpers ──────────────────────────────────────────
|
||
// HTTP bodies arrive as strings (multipart) or numbers (JSON). These helpers
|
||
// coerce number|string → number with a NaN/Infinity guard, replacing the
|
||
// `z.union([z.number(), z.string()]).transform(Number)` idiom that was
|
||
// duplicated ~150× across schemas and silently produced NaN on bad input
|
||
// (which then flowed into Prisma / business math). User-facing messages Czech.
|
||
|
||
const coerceNum = (v: unknown): number =>
|
||
typeof v === "number"
|
||
? v
|
||
: typeof v === "string" && v.trim() !== ""
|
||
? Number(v)
|
||
: NaN;
|
||
|
||
/** number|string → number; rejects NaN/Infinity/empty. */
|
||
export const numberFromForm = z
|
||
.union([z.number(), z.string()])
|
||
.transform(coerceNum)
|
||
.refine((n) => Number.isFinite(n), { message: "Neplatné číslo" });
|
||
|
||
/** number|string → number within [min,max]; rejects NaN/out-of-range. */
|
||
export const numberInRange = (min: number, max: number, message?: string) =>
|
||
z
|
||
.union([z.number(), z.string()])
|
||
.transform(coerceNum)
|
||
.refine((n) => Number.isFinite(n) && n >= min && n <= max, {
|
||
message: message ?? `Číslo musí být mezi ${min} a ${max}`,
|
||
});
|
||
|
||
/** number|string → non-negative number (e.g. quantities, prices, odometers). */
|
||
export const nonNegativeNumberFromForm = z
|
||
.union([z.number(), z.string()])
|
||
.transform(coerceNum)
|
||
.refine((n) => Number.isFinite(n) && n >= 0, {
|
||
message: "Číslo nesmí být záporné",
|
||
});
|
||
|
||
/** number|string → positive number ( > 0 ). */
|
||
export const positiveNumberFromForm = z
|
||
.union([z.number(), z.string()])
|
||
.transform(coerceNum)
|
||
.refine((n) => Number.isFinite(n) && n > 0, {
|
||
message: "Číslo musí být kladné",
|
||
});
|
||
|
||
/** number|string → positive integer id; rejects NaN/≤0/non-integer. */
|
||
export const intIdFromForm = z
|
||
.union([z.number(), z.string()])
|
||
.transform(coerceNum)
|
||
.refine((n) => Number.isInteger(n) && n > 0, { message: "Neplatné ID" });
|
||
|
||
/** number|string|null → number|null; rejects NaN when a value is present. */
|
||
export const nullableNumberFromForm = z
|
||
.union([z.number(), z.string(), z.null()])
|
||
.transform((v) => (v === null || v === "" ? null : coerceNum(v)))
|
||
.refine((n) => n === null || Number.isFinite(n), {
|
||
message: "Neplatné číslo",
|
||
});
|
||
|
||
/** number|string|null → positive-int id | null; rejects NaN/≤0 when present. */
|
||
export const nullableIntIdFromForm = z
|
||
.union([z.number(), z.string(), z.null()])
|
||
.transform((v) => (v === null || v === "" ? null : coerceNum(v)))
|
||
.refine((n) => n === null || (Number.isInteger(n) && n > 0), {
|
||
message: "Neplatné ID",
|
||
});
|
||
|
||
/** Truthy form flag → boolean: true | 1 | "1" | "true" → true. */
|
||
export const booleanFromForm = z.preprocess(
|
||
(v) => v === true || v === 1 || v === "1" || v === "true",
|
||
z.boolean(),
|
||
);
|
||
|
||
/**
|
||
* YYYY-MM-DD date string. Tolerant of a trailing time component: an edit form
|
||
* round-trips a `@db.Date` that the `Date.toJSON` override serialises as
|
||
* "YYYY-MM-DDT00:00:00", so we strip anything after the date before validating
|
||
* (and the validated output is always the date-only `YYYY-MM-DD`).
|
||
*/
|
||
export const isoDateString = z.preprocess(
|
||
(v) => (typeof v === "string" ? v.slice(0, 10) : v),
|
||
z
|
||
.string()
|
||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD"),
|
||
);
|
||
|
||
/** HH:MM (24h) time string. Tolerant of a trailing ":ss" seconds component. */
|
||
export const timeString = z.preprocess(
|
||
(v) =>
|
||
typeof v === "string" && /^\d{2}:\d{2}:\d{2}/.test(v) ? v.slice(0, 5) : v,
|
||
z
|
||
.string()
|
||
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Čas musí být ve formátu HH:MM"),
|
||
);
|
||
|
||
/**
|
||
* Combined local datetime string "YYYY-MM-DDTHH:MM" — the wire format the
|
||
* attendance forms produce by joining a date input and a time input
|
||
* (`${date}T${time}:00`). Tolerant of a trailing seconds component (":ss" —
|
||
* stripped before validating), because the client appends ":00" and an edit
|
||
* form round-trips a value the `Date.toJSON` override serialised as
|
||
* "YYYY-MM-DDTHH:MM:SS". The service parses the value with `new Date(...)`
|
||
* as LOCAL time (Europe/Prague) — no timezone suffix is part of the format.
|
||
*/
|
||
export const dateTimeString = z.preprocess(
|
||
(v) =>
|
||
typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)
|
||
? v.slice(0, 16)
|
||
: v,
|
||
z
|
||
.string()
|
||
.regex(
|
||
/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d$/,
|
||
"Datum a čas musí být ve formátu YYYY-MM-DD HH:MM",
|
||
),
|
||
);
|
||
|
||
/**
|
||
* Optional-datetime variant: ""/null mean "not set" → null (the attendance
|
||
* forms historically submitted empty strings for unfilled time fields, and a
|
||
* hard reject would 400 the whole form — including leave records that have no
|
||
* times at all). Pair with `.optional()` at the call site for fields that may
|
||
* be absent entirely (absent stays `undefined`, e.g. "don't change" on update).
|
||
*/
|
||
export const nullableDateTimeString = z.preprocess(
|
||
(v) => (v === "" || v === null ? null : v),
|
||
z.union([z.null(), dateTimeString]),
|
||
);
|
||
|
||
/**
|
||
* An email field that also accepts an empty string (meaning "not set"). Many
|
||
* settings/supplier forms submit "" for un-filled optional emails; a bare
|
||
* `.email()` would 400 the whole form. Pair with `.nullish()` at the call site.
|
||
*/
|
||
export const emailOrEmpty = z.union([
|
||
z.literal(""),
|
||
z.string().max(255).email("Neplatný email"),
|
||
]);
|