mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
`pnpm openapi:check` compares the registry against the committed YAML and never the routes against the registry, so a route that was simply never registered stayed invisible to the gate. Sixteen of them are called by the native client and describe nothing: sign-out, the three per-user preference scalars, the two pre-session discovery reads, passkey login options, the passkey rename and delete, registration status, the consent latest-receipt reader and its revoke, account deletion, the instance service switches, the API-token list and revoke, the threshold overrides, the version read, and the assistant feature matrix. Each contract is derived from the handler rather than from its docblock, so the published statuses are the ones the code can actually produce — the 413 on a 1 KB body cap, the 400 the consent family answers where the rest of the API answers 422, the 403 an instance-wide API switch produces on a token READ, the last-admin and last-Guardian refusals on account deletion, and the fresh-factor arm that a Bearer transport cannot clear at any scope. Four request bodies moved to the validation modules the handlers now parse with, so the published wire cannot drift from the runtime one: the three `/api/auth/me` preference scalars into a new `user-prefs` module, and the passkey rename beside the login schemas. Behaviour is unchanged. The generated YAML is left out; it is regenerated once the remaining groups land.
133 lines
5.2 KiB
TypeScript
133 lines
5.2 KiB
TypeScript
import { z } from "zod/v4";
|
|
import { isValidKvnr, normaliseKvnr } from "@/lib/validations/kvnr";
|
|
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
|
|
|
|
/** Minimum password length — must match checkPasswordStrength() in @/lib/auth/password.ts */
|
|
const PASSWORD_MIN_LENGTH = 12;
|
|
|
|
export const registerSchema = z.object({
|
|
email: z.email("Invalid email address"),
|
|
username: z
|
|
.string()
|
|
.min(3, "At least 3 characters")
|
|
.max(30, "Maximum 30 characters")
|
|
.regex(/^[a-zA-Z0-9_-]+$/, "Only letters, numbers, _ and -"),
|
|
password: z
|
|
.string()
|
|
.min(
|
|
PASSWORD_MIN_LENGTH,
|
|
`Password must be at least ${PASSWORD_MIN_LENGTH} characters`,
|
|
),
|
|
/**
|
|
* v1.4.25 W7 — browser-detected timezone. The signup form pulls
|
|
* `Intl.DateTimeFormat().resolvedOptions().timeZone` and sends it
|
|
* along; the server validates against the runtime IANA list and
|
|
* falls back to the admin-configured server default
|
|
* (`AppSettings.defaultUserTimezone`) when the value is invalid
|
|
* or missing. Optional so legacy clients that predate v1.4.25
|
|
* keep working.
|
|
*/
|
|
timezone: z.string().max(64).optional(),
|
|
/**
|
|
* v1.15.20 — registration invite token (`hlv_<64hex>`, 68 chars).
|
|
* Only consulted when the operator has disabled open registration:
|
|
* a valid, unexpired invite still admits the signup. Ignored when
|
|
* registration is open.
|
|
*/
|
|
inviteToken: z.string().max(80).optional(),
|
|
});
|
|
|
|
export const loginPasswordSchema = z.object({
|
|
email: z.string().trim().min(1, "Email or username required"),
|
|
password: z.string().min(1),
|
|
});
|
|
|
|
export const profileSchema = z.object({
|
|
email: z.email("Invalid email address").nullable().optional(),
|
|
heightCm: z.number().min(50).max(300).nullable().optional(),
|
|
dateOfBirth: z.string().nullable().optional(), // ISO date string
|
|
// Three stored values, and the same clearing contract every other
|
|
// profile field honours: null OR an empty string clears it. A client
|
|
// that renders "no answer" as "" was otherwise 422'd on a field the
|
|
// person never touched, and because the whole profile PATCH is one
|
|
// transaction that rejection took every sibling change with it. The
|
|
// message names the field and the accepted values — a person should
|
|
// never read the validator's own prose off a form.
|
|
gender: z.preprocess(
|
|
(v) => (v === "" ? null : v),
|
|
z
|
|
.enum(["MALE", "FEMALE", "OTHER"], {
|
|
error:
|
|
"Gender must be MALE, FEMALE or OTHER. Send null or an empty string to clear it.",
|
|
})
|
|
.nullable()
|
|
.optional(),
|
|
),
|
|
// v1.7.0 — optional patient-identity fields for the health-record
|
|
// export cover + FHIR Patient resource. All optional, never required.
|
|
fullName: z.string().trim().max(120).nullable().optional(),
|
|
insurerName: z.string().trim().max(120).nullable().optional(),
|
|
// German KVNR. Empty string / null clears it; a non-empty value must
|
|
// pass the mod-10 check-digit validation. Normalised (whitespace
|
|
// stripped + uppercased) before the refine runs.
|
|
insuranceNumber: z
|
|
.string()
|
|
.nullable()
|
|
.optional()
|
|
.transform((v) => {
|
|
if (v === null || v === undefined) return v;
|
|
const cleaned = normaliseKvnr(v);
|
|
return cleaned.length === 0 ? null : cleaned;
|
|
})
|
|
.refine((v) => v === null || v === undefined || isValidKvnr(v), {
|
|
message: "Invalid insurance number (KVNR)",
|
|
}),
|
|
// v1.8.6 — German insurer institution number (IKNR). Optional;
|
|
// empty string / null clears it. A non-empty value is trimmed and
|
|
// must be exactly 9 digits (no checksum — the IKNR carries a mod-10
|
|
// check digit but we deliberately do not enforce it here, mirroring
|
|
// the iOS client contract). Surfaced on the FHIR `Coverage` payor.
|
|
insurerIkNumber: z
|
|
.string()
|
|
.max(32)
|
|
.nullable()
|
|
.optional()
|
|
.transform((v) => {
|
|
if (v === null || v === undefined) return v;
|
|
const trimmed = v.trim();
|
|
return trimmed.length === 0 ? null : trimmed;
|
|
})
|
|
.refine((v) => v === null || v === undefined || /^[0-9]{9}$/.test(v), {
|
|
message: "Invalid insurer IK number (expected 9 digits)",
|
|
}),
|
|
});
|
|
|
|
export const changePasswordSchema = z
|
|
.object({
|
|
currentPassword: z.string().min(1, "Current password is required"),
|
|
newPassword: z
|
|
.string()
|
|
.min(
|
|
PASSWORD_MIN_LENGTH,
|
|
`New password must be at least ${PASSWORD_MIN_LENGTH} characters`,
|
|
),
|
|
confirmPassword: z.string().min(1, "Password confirmation is required"),
|
|
})
|
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
message: "Passwords do not match",
|
|
path: ["confirmPassword"],
|
|
});
|
|
|
|
/**
|
|
* Rename a registered passkey — a PRIMARY sign-in credential, not the
|
|
* second-factor security key `mfaWebauthnRenameSchema` covers. The two carry
|
|
* the same label constraint and are served by different routes; they share the
|
|
* constraint rather than the schema so neither surface can drift the other.
|
|
*/
|
|
export const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema });
|
|
|
|
export type RegisterInput = z.infer<typeof registerSchema>;
|
|
export type LoginPasswordInput = z.infer<typeof loginPasswordSchema>;
|
|
export type ProfileInput = z.infer<typeof profileSchema>;
|
|
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|