fix: audit fix pass #1 — all 19 verified HIGH findings + critical dep cleanup
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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
intIdFromForm,
|
||||
nullableDateTimeString,
|
||||
nullableIntIdFromForm,
|
||||
numberFromForm,
|
||||
numberInRange,
|
||||
@@ -66,19 +67,25 @@ export const AttendancePunchSchema = z.object({
|
||||
address: z.string().max(500).nullish(),
|
||||
});
|
||||
|
||||
// arrival/departure/break fields travel as COMBINED local datetimes
|
||||
// ("YYYY-MM-DDTHH:MM:00", built by the admin forms' combineDatetime from a
|
||||
// date + time input) — the service parses them with `new Date(...)`. They are
|
||||
// NOT bare HH:MM times.
|
||||
export const CreateAttendanceSchema = z.object({
|
||||
user_id: intIdFromForm.optional(),
|
||||
shift_date: z.string(),
|
||||
arrival_time: timeString.nullish(),
|
||||
arrival_time: nullableDateTimeString.optional(),
|
||||
arrival_lat: numberFromForm.nullish(),
|
||||
arrival_lng: numberFromForm.nullish(),
|
||||
arrival_accuracy: numberFromForm.nullish(),
|
||||
arrival_address: z.string().nullish(),
|
||||
departure_time: timeString.nullish(),
|
||||
departure_time: nullableDateTimeString.optional(),
|
||||
departure_lat: numberFromForm.nullish(),
|
||||
departure_lng: numberFromForm.nullish(),
|
||||
departure_accuracy: numberFromForm.nullish(),
|
||||
departure_address: z.string().nullish(),
|
||||
break_start: nullableDateTimeString.optional(),
|
||||
break_end: nullableDateTimeString.optional(),
|
||||
notes: z.string().nullish(),
|
||||
project_id: nullableIntIdFromForm.nullish(),
|
||||
leave_type: z
|
||||
@@ -90,10 +97,10 @@ export const CreateAttendanceSchema = z.object({
|
||||
});
|
||||
|
||||
export const UpdateAttendanceSchema = z.object({
|
||||
arrival_time: timeString.nullish(),
|
||||
departure_time: timeString.nullish(),
|
||||
break_start: timeString.nullish(),
|
||||
break_end: timeString.nullish(),
|
||||
arrival_time: nullableDateTimeString.optional(),
|
||||
departure_time: nullableDateTimeString.optional(),
|
||||
break_start: nullableDateTimeString.optional(),
|
||||
break_end: nullableDateTimeString.optional(),
|
||||
notes: z.string().nullish(),
|
||||
project_id: nullableIntIdFromForm.optional(),
|
||||
leave_type: z.enum(["work", "vacation", "sick", "unpaid"]).optional(),
|
||||
|
||||
@@ -23,6 +23,7 @@ export const TotpBackupSchema = z.object({
|
||||
// Cap length so a request with bodyLimit=10KB can't trigger N×bcrypt on
|
||||
// a multi-KB string. Real backup codes are 8-16 chars; 64 is generous.
|
||||
.max(64, "Záložní kód je příliš dlouhý"),
|
||||
remember_me: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export const TotpEnableSchema = z.object({
|
||||
|
||||
@@ -111,6 +111,40 @@ export const timeString = z.preprocess(
|
||||
.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
|
||||
|
||||
@@ -54,6 +54,7 @@ export const UpdateInvoiceSchema = z.object({
|
||||
bank_iban: z.string().max(255).nullish(),
|
||||
bank_account: z.string().max(255).nullish(),
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
billing_text: z.string().max(8000).nullish(),
|
||||
customer_id: nullableIntIdFromForm.optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
|
||||
@@ -21,6 +21,8 @@ export const CreateTripSchema = z.object({
|
||||
});
|
||||
|
||||
export const UpdateTripSchema = z.object({
|
||||
// trips.vehicle_id is a non-nullable FK — when present it must be a valid id
|
||||
vehicle_id: intIdFromForm.optional(),
|
||||
trip_date: isoDateString.optional(),
|
||||
start_km: nonNegativeNumberFromForm.optional(),
|
||||
end_km: nonNegativeNumberFromForm.optional(),
|
||||
|
||||
Reference in New Issue
Block a user