mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
A guardian with a fresh second factor mints a one-time handover link (hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per profile, withdrawable) with a proposed access level per guardian: end, view only or manage, view only by default. The link is shown once with a copy button and a QR code; nothing is mailed. The panel sits at the end of each managed-profile row and hints when the person is under 16. The person opens /claim/<token>, sees a minimal preview (name, expiry, who keeps which access, no health data) and chooses a username, a required email and a password under the registration policy. The claim is one transaction under the managed-profile lock: the token is consumed, the credentials are set, the marker is cleared, every pending invitation the record offered is withdrawn, each guardian's access is set to its proposal, the disclaimer, onboarding and AI consent are reset, and every step is audited on the record and on each guardian's own account. A failure anywhere leaves the profile managed and the link unused. Each guardian is notified on their own channels. The new owner then sees each guardian's access on the first screen after signing in and decides finally; until then the proposal holds, and the decision waits at the top of Shared access. A guardian whose access moved since the claim is left alone. The same screen points the owner at their own notification channels, since reminders no longer reach the guardians. Both anonymous routes refuse a single-sign-on-only instance, refuse a live session before spending the per-address rate limit, check the link before probing names, and answer one 404 for every unusable token. The token travels in request bodies only; hlp_ joins the redaction and secret-shape rules and /claim/ the path-secret registry. Usernames starting with managed- are reserved. The session-surface and acting- account guards list the new surfaces with their reasons.
164 lines
6.6 KiB
TypeScript
164 lines
6.6 KiB
TypeScript
import { z } from "zod/v4";
|
|
import { isValidKvnr, normaliseKvnr } from "@/lib/validations/kvnr";
|
|
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
|
|
import { clientTransportSchema } from "@/lib/validations/client-transport";
|
|
|
|
/** Minimum password length — must match checkPasswordStrength() in @/lib/auth/password.ts */
|
|
const PASSWORD_MIN_LENGTH = 12;
|
|
|
|
/**
|
|
* v1.42 (#959) — the prefix a managed profile's generated username carries
|
|
* (`managed-<hex>`, `src/lib/managed-profiles/create.ts`). An account a person
|
|
* names themselves may not start with it: a self-chosen `managed-…` would read
|
|
* as a profile nobody signs into, in every list that shows usernames, and a
|
|
* profile being claimed has to leave the generated name behind rather than
|
|
* keep it.
|
|
*/
|
|
export const MANAGED_USERNAME_PREFIX = "managed-";
|
|
|
|
/** A username a person chooses: registration and claiming a profile. */
|
|
export const accountUsernameSchema = z
|
|
.string()
|
|
.min(3, "At least 3 characters")
|
|
.max(30, "Maximum 30 characters")
|
|
.regex(/^[a-zA-Z0-9_-]+$/, "Only letters, numbers, _ and -")
|
|
.refine(
|
|
(value) => !value.toLowerCase().startsWith(MANAGED_USERNAME_PREFIX),
|
|
`A username cannot start with "${MANAGED_USERNAME_PREFIX}"`,
|
|
);
|
|
|
|
/** A first password: registration and claiming a profile. */
|
|
export const newAccountPasswordSchema = z
|
|
.string()
|
|
.min(
|
|
PASSWORD_MIN_LENGTH,
|
|
`Password must be at least ${PASSWORD_MIN_LENGTH} characters`,
|
|
);
|
|
|
|
export const registerSchema = z.object({
|
|
email: z.email("Invalid email address"),
|
|
username: accountUsernameSchema,
|
|
password: newAccountPasswordSchema,
|
|
/**
|
|
* 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),
|
|
clientTransport: clientTransportSchema.optional(),
|
|
});
|
|
|
|
/** `POST /api/auth/passkey/login-options`: an optional body, web client only. */
|
|
export const passkeyLoginOptionsSchema = z
|
|
.object({ clientTransport: clientTransportSchema.optional() })
|
|
.meta({
|
|
id: "PasskeyLoginOptionsRequest",
|
|
description:
|
|
"Optional. The web client sends its page transport so a sign-in that could not keep its cookie is refused before a challenge is issued. Any other caller may send no body at all.",
|
|
});
|
|
|
|
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>;
|