Files
HealthLog/src/lib/auth/profile-update.ts
T
Marc-André Bombeck 6f60812132 fix(ai): accept every app language on the chat and status requests
The Coach chat, the document chat and the fenced chat declared their
locale field as de/en, so a body naming French, Korean, Brazilian
Portuguese or any other shipped language was refused with 422 before
resolveServerLocale, which carries every locale, ever saw it. The same
pair sat on the profile update, so an account could not store any other
language there either, and the stored language is what the cookie-blind
background work (the Coach nudge, notification texts) writes in.

All four now take z.enum(locales), and the published contract for the
metric, insight and biomarker status queries and the profile update
lists every UI locale instead of two.
2026-10-29 09:19:56 +01:00

556 lines
22 KiB
TypeScript

/**
* Shared profile-update logic. The web client hits `/api/auth/profile` PUT
* and the iOS client hits `/api/user/profile` PATCH — both funnel through
* `applyProfileUpdate` so we don't fork the validation/audit path.
*/
import { optionalDisplayNameSchema } from "@/lib/validations/display-name";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { profileSchema } from "@/lib/validations/auth";
import { isValidTimezone } from "@/lib/tz/format";
import { encrypt } from "@/lib/crypto";
import {
invalidateUserMedications,
invalidateUserProfile,
} from "@/lib/cache/invalidate";
import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response";
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import {
REPROOF_FAILED_CODE,
REPROOF_REQUIRED_CODE,
type SensitiveChangeProof,
} from "@/lib/auth/existing-factor-proof";
const extendedProfileSchema = profileSchema.extend({
// Trimmed first; "" (or only spaces) clears the name to null below.
displayName: optionalDisplayNameSchema.nullable().optional(),
// Every language the app ships: the stored language is what background
// work (the Coach nudge, the notification texts) writes in.
locale: z
.enum(locales, {
error: "Locale must be one of the languages the app ships.",
})
.nullable()
.optional(),
timezone: z
.string()
.min(1)
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone")
.optional(),
moodReminderEnabled: z.boolean().optional(),
// Hour-cycle display preference. AUTO follows the locale convention,
// H12 forces AM/PM, H24 forces 24-hour.
timeFormat: z
.enum(["AUTO", "H12", "H24"], {
error: "Time format must be AUTO, H12 or H24.",
})
.optional(),
// Date-order display preference. AUTO follows the locale convention,
// DMY pins day-month-year, MDY month-day-year, YMD ISO yyyy-MM-dd.
dateFormat: z
.enum(["AUTO", "DMY", "MDY", "YMD"], {
error: "Date format must be AUTO, DMY, MDY or YMD.",
})
.optional(),
});
type ExtendedProfileInput = z.infer<typeof extendedProfileSchema>;
/**
* A new email address on an account with a second factor, without that
* factor proved in the last five minutes. The client answers it with the
* re-proof dialog (second factor or passkey) and saves again.
*/
export const EMAIL_SECOND_FACTOR_REQUIRED_CODE =
"profile.update.emailSecondFactorRequired";
/**
* The only profile fields a demo instance may write: the three the setup
* flow's baseline step collects.
*
* `PUT /api/auth/profile` sits on the proxy's `DEMO_MUTATION_ALLOWLIST` so
* that step can complete. That list is an EDGE control — it admits a path and
* a method, and it cannot see that the handler behind the path also writes
* this account's contact email, its display name, its full name and its
* insurer fields. On an ordinary instance those are the caller's own record;
* on the demo, which is ONE published account every visitor signs into, each
* of them is the record the next visitor arrives at, until a reseed.
*
* So the narrowing is here and not in the client. The web form sends only
* these three plus a display name, but a form is not a control: the whole
* reason the allowlist exists is that the caller is not trusted.
*/
export const DEMO_WRITABLE_PROFILE_FIELDS = [
"heightCm",
"dateOfBirth",
"gender",
] as const;
/**
* The profile schema has no cross-field `.refine()` — every field is an
* independent scalar column. That means a rejected `gender` has no
* bearing on whether `heightCm` is safe to write, so a single bad field
* no longer has to take every sibling change down with it (the report:
* "editing height and gender together lost both").
*
* Re-validates each key present in `body` against its own field schema
* (`extendedProfileSchema.shape[key]`), independent of the others.
* Fields that pass land in `valid`; fields that fail land in `issues`
* with `path` set to the field name (a field-level `safeParse` reports
* an empty path, since it never saw the parent object). Unknown keys
* are silently dropped, matching `z.object`'s own default behaviour.
*/
function splitValidProfileFields(body: Record<string, unknown>): {
valid: Partial<ExtendedProfileInput>;
issues: SanitisedZodIssue[];
} {
const shape = extendedProfileSchema.shape as Record<string, z.ZodTypeAny>;
const valid: Record<string, unknown> = {};
const issues: SanitisedZodIssue[] = [];
for (const key of Object.keys(body)) {
const fieldSchema = shape[key];
if (!fieldSchema) continue;
const result = fieldSchema.safeParse(body[key]);
if (result.success) {
valid[key] = result.data;
} else {
for (const issue of result.error.issues) {
issues.push({ path: key, code: issue.code, message: issue.message });
}
}
}
return { valid: valid as Partial<ExtendedProfileInput>, issues };
}
export interface ApplyProfileResult {
ok: true;
user: {
id: string;
username: string;
displayName: string | null;
email: string | null;
role: string;
heightCm: number | null;
dateOfBirth: Date | null;
gender: string | null;
timezone: string;
locale: string | null;
timeFormat: "AUTO" | "H12" | "H24";
dateFormat: "AUTO" | "DMY" | "MDY" | "YMD";
moodReminderEnabled: boolean;
// v1.7.0 — patient-identity fields. `insuranceNumber` is returned in
// plaintext (decrypted on read by the route) so the client can render
// the value back into the form; the route is responsible for the read
// decryption. Here we only echo the two plaintext columns and a
// boolean presence flag for the encrypted KVNR.
fullName: string | null;
insurerName: string | null;
insurerIkNumber: string | null;
hasInsuranceNumber: boolean;
};
/**
* Present only when the caller sent at least one invalid field
* alongside at least one valid one. The fields named here were NOT
* written — everything absent from this list landed. Empty/undefined
* means every touched field saved cleanly. The web client resolves
* `path` against its own field labels to name the rejected field(s);
* the message stays machine-readable, never shown verbatim.
*/
rejectedFields?: SanitisedZodIssue[];
}
export interface ApplyProfileError {
ok: false;
status: number;
/**
* A sentence safe for a person to read — never a raw Zod message (no
* enum literals, no validator prose). Native/non-i18n callers that
* ignore `errorCode` still get something a person can act on.
*/
message: string;
/**
* Stable code the web client resolves through `apiErrors.<errorCode>`
* for a localized sentence (see `localizedApiError`). Absent for
* non-validation failures (e.g. the 409 email-conflict below) that
* already carry a safe, specific message.
*/
errorCode?: string;
/**
* Full per-field detail, relocated (not deleted) from the top-level
* `message`. Matches the `details.issues` multi-issue envelope the
* iOS contract already expects from other write routes.
*/
issues?: SanitisedZodIssue[];
}
/**
* Validate and persist profile updates for `userId`. Returns either an
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
* code so callers can shape the wire response themselves.
*
* The profile schema has no cross-field dependency, so an invalid field
* never has to block its valid siblings: a full-object parse is tried
* first (the common case), and only on failure does the update fall
* back to `splitValidProfileFields` to salvage whatever validated. If
* NOTHING in the payload validates, nothing is written — an honest
* all-or-nothing rejection naming the field that blocked it, rather
* than a raw validator string.
*/
export async function applyProfileUpdate(
userId: string,
body: unknown,
ipAddress: string | null | undefined,
opts: {
/**
* Decides whether this request may move the account to a NEW email
* address. The address is what single sign-on links an existing account
* by, so changing it asks for a fresh proof (`authorizeSensitiveChange`).
* Receives the `currentPassword` the body carried, if any; the password is
* never written anywhere.
*/
authorizeEmailChange: (
currentPassword: string | null,
) => Promise<SensitiveChangeProof>;
},
): Promise<ApplyProfileResult | ApplyProfileError> {
const parsed = extendedProfileSchema.safeParse(body);
let data: Partial<ExtendedProfileInput>;
let rejectedFields: SanitisedZodIssue[] = [];
if (parsed.success) {
data = parsed.data;
} else {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
return {
ok: false,
status: 422,
message: "Nothing was saved. The profile update was not an object.",
errorCode: "profile.update.invalidBody",
issues: sanitiseZodIssues(parsed.error.issues),
};
}
const split = splitValidProfileFields(body as Record<string, unknown>);
data = split.valid;
rejectedFields = split.issues;
if (Object.keys(data).length === 0) {
// Every touched field failed — there is nothing to salvage.
// Name the field that blocked it so the person fixes one thing
// instead of re-entering everything.
const first = rejectedFields[0];
return {
ok: false,
status: 422,
message: first
? `Nothing was saved. Fix the "${first.path}" field and try again.`
: "Nothing was saved. The submitted profile data was invalid.",
errorCode: "profile.update.nothingSaved",
issues: rejectedFields,
};
}
}
// Ahead of the conflict check below, deliberately: that check answers 409
// when another account holds the address, which on a public demo is an
// existence oracle over the instance's accounts. Dropping the field first
// closes the write and the question in one move.
if (process.env.DEMO_MODE === "true") {
const writable = DEMO_WRITABLE_PROFILE_FIELDS as readonly string[];
data = Object.fromEntries(
Object.entries(data).filter(([key]) => writable.includes(key)),
) as Partial<ExtendedProfileInput>;
}
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
if (data.email !== undefined && normalizedEmail) {
// A settings form re-posts the whole profile, so the address arrives
// with every save whether or not it changed. Those saves ask nothing
// about anyone else's account — the address on file already belongs to
// this caller — so they skip both the uniqueness probe and the budget.
// Charging them would spend an honest afternoon of edits on the
// ceiling and lock the account out of its own settings page.
//
// The stored side is normalised for the comparison too. Registration
// writes the address exactly as typed, so an account registered as
// `MixedCase@Example.com` would otherwise fail to match its own address
// and pay for its first ordinary save.
const current = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
const addressOnFile = current?.email?.trim().toLowerCase() ?? null;
if (addressOnFile !== normalizedEmail) {
// Asked before the uniqueness probe and before the budget: a caller who
// cannot show it is the account owner learns nothing about which
// addresses exist here and spends nothing.
const rawPassword =
typeof body === "object" && body !== null && !Array.isArray(body)
? (body as Record<string, unknown>).currentPassword
: undefined;
const currentPassword =
typeof rawPassword === "string" &&
rawPassword.length > 0 &&
rawPassword.length <= 512
? rawPassword
: null;
const proof = await opts.authorizeEmailChange(currentPassword);
if (proof !== "ok") {
annotate({
action: { name: "profile.email.reproof_refused" },
meta: { proof },
});
const code =
proof === "rate_limited"
? "rate_limited"
: proof === "failed"
? "reproof_failed"
: proof === "second_factor_required"
? "second_factor_required"
: "reproof_required";
// Same narrowing as the email budget below: the address is dropped,
// the rest of the save lands, and `rejectedFields` says why.
const { email: _unproven, ...keepable } = data;
if (Object.keys(keepable).length === 0) {
return {
ok: false,
status: proof === "rate_limited" ? 429 : 401,
message:
proof === "failed"
? "Verification failed"
: proof === "rate_limited"
? "Too many attempts. Please wait 15 minutes."
: proof === "second_factor_required"
? "Confirm with your second factor or a passkey to change the email address."
: "Confirm your current password to change the email address.",
errorCode:
proof === "failed"
? REPROOF_FAILED_CODE
: proof === "rate_limited"
? "profile.update.emailRateLimited"
: proof === "second_factor_required"
? EMAIL_SECOND_FACTOR_REQUIRED_CODE
: REPROOF_REQUIRED_CODE,
};
}
data = keepable;
rejectedFields = [
...rejectedFields,
{
path: "email",
code,
message:
proof === "second_factor_required"
? "Confirm with your second factor or a passkey to change the email address."
: "Confirm your current password to change the email address.",
},
];
}
}
if (data.email !== undefined && addressOnFile !== normalizedEmail) {
// Charged BEFORE the probe, not after: a refused request must not
// learn the answer it was refused for. The 409 below tells any
// signed-in caller whether an address is registered here, and until
// this bucket existed neither route that reaches it had a ceiling of
// any kind — `apiHandler` supplies no default.
const rl = await checkProfileEmailRateLimit(userId);
if (!rl.allowed) {
annotate({ action: { name: "profile.email.throttled" } });
await auditLog("profile.email.rate_limited", {
userId,
ipAddress: ipAddress ?? null,
});
// Narrow the refusal to the field, the way the demo narrowing above
// does: the web form posts the whole profile in one request, so
// failing all of it would stop somebody setting their timezone for
// an hour and tell them it was about email addresses. The address is
// dropped, everything else is saved, and `rejectedFields` says which
// field did not go through.
const { email: _refusedAddress, ...keepable } = data;
if (Object.keys(keepable).length > 0) {
data = keepable;
rejectedFields = [
...rejectedFields,
{
path: "email",
code: "rate_limited",
message: "Too many email-address changes.",
},
];
} else {
// Nothing else was asked for, so there is nothing to salvage and a
// partial success would be a lie — the same rule the all-fields-
// rejected 422 above follows. This is the arm the published 429
// describes.
return {
ok: false,
status: 429,
message: "Too many email-address changes. Try again later.",
errorCode: "profile.update.emailRateLimited",
};
}
} else {
// Case-sensitive, because the column is: registration writes the
// address as typed, so `Mixed@Example.com` and `mixed@example.com`
// are two rows this probe cannot see as one. Pre-existing and not
// widened here — the fix is a normalisation pass on the register
// write, which is where the two spellings are created.
const existing = await prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (existing && existing.id !== userId) {
// The informative answer is the one worth recording: it says an
// account holds that address. The ordinary save's `profile.update`
// row is written further down and this path never reaches it, so
// without a row here the only probes in the ledger would be the
// ones that came back negative.
annotate({ action: { name: "profile.email.conflict" } });
await auditLog("profile.email.conflict", {
userId,
ipAddress: ipAddress ?? null,
});
return {
ok: false,
status: 409,
message: "Email already in use",
errorCode: "profile.update.emailInUse",
};
}
}
}
}
const updates: Record<string, unknown> = {};
if (data.email !== undefined) updates.email = normalizedEmail;
if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null;
if (data.dateOfBirth !== undefined) {
updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null;
}
if (data.gender !== undefined) updates.gender = data.gender;
if (data.displayName !== undefined) {
updates.displayName =
data.displayName === null || data.displayName === ""
? null
: data.displayName.trim();
}
if (data.locale !== undefined) updates.locale = data.locale;
if (data.timezone !== undefined) updates.timezone = data.timezone;
if (data.timeFormat !== undefined) updates.timeFormat = data.timeFormat;
if (data.dateFormat !== undefined) updates.dateFormat = data.dateFormat;
if (data.moodReminderEnabled !== undefined) {
updates.moodReminderEnabled = data.moodReminderEnabled;
}
// v1.7.0 — patient-identity fields. Plaintext for fullName/insurerName;
// KVNR is encrypted at rest. An empty string collapses to null so the
// export cover + FHIR Patient omit the line entirely.
if (data.fullName !== undefined) {
updates.fullName =
data.fullName === null || data.fullName === "" ? null : data.fullName;
}
if (data.insurerName !== undefined) {
updates.insurerName =
data.insurerName === null || data.insurerName === ""
? null
: data.insurerName;
}
// v1.8.6 — IKNR plaintext (matches insurerName). The Zod transform
// already mapped empty → null, so a null here means "clear it".
if (data.insurerIkNumber !== undefined) {
updates.insurerIkNumber = data.insurerIkNumber ?? null;
}
if (data.insuranceNumber !== undefined) {
updates.insuranceNumberEncrypted =
data.insuranceNumber === null || data.insuranceNumber === ""
? null
: encrypt(data.insuranceNumber);
}
// v1.4.18 — capture the prior locale so we can emit a separate
// `settings.locale.update` audit row when the locale actually
// changes. The hidden "polyglot" achievement watches that action,
// and we don't want to fire it on profile updates that happen to
// touch other fields.
const priorLocale =
data.locale !== undefined
? ((
await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
})
)?.locale ?? null)
: null;
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updates,
});
// Anthropometrics feed the cached targets grid (BMI band, BP-by-age
// range, body-fat band), the derived-score baseline profile (age /
// sex / height) and the analytics snapshot. Only those three fields
// matter here — a display-name or email edit must not cold-rebuild
// the analytics caches.
if (
data.heightCm !== undefined ||
data.dateOfBirth !== undefined ||
data.gender !== undefined
) {
invalidateUserProfile(userId);
}
// v1.16.9 — the medication payloads (list next-due, today counts, the
// compliance day buckets) are computed in the user's timezone, but the
// list cache key carries only the userId. A timezone change must evict
// the bucket or the next read serves the previous zone's day grid.
if (data.timezone !== undefined) {
invalidateUserMedications(userId, { evict: true });
}
await auditLog("profile.update", {
userId: updatedUser.id,
ipAddress: ipAddress ?? null,
});
if (data.locale !== undefined && priorLocale !== data.locale) {
await auditLog("settings.locale.update", {
userId: updatedUser.id,
ipAddress: ipAddress ?? null,
details: { from: priorLocale, to: data.locale ?? null },
});
}
return {
ok: true,
user: {
id: updatedUser.id,
username: updatedUser.username,
displayName: updatedUser.displayName,
email: updatedUser.email,
role: updatedUser.role,
heightCm: updatedUser.heightCm,
dateOfBirth: updatedUser.dateOfBirth,
gender: updatedUser.gender,
timezone: updatedUser.timezone,
locale: updatedUser.locale,
timeFormat: updatedUser.timeFormat ?? "AUTO",
dateFormat: updatedUser.dateFormat ?? "AUTO",
moodReminderEnabled: updatedUser.moodReminderEnabled,
fullName: updatedUser.fullName,
insurerName: updatedUser.insurerName,
insurerIkNumber: updatedUser.insurerIkNumber,
hasInsuranceNumber: updatedUser.insuranceNumberEncrypted != null,
},
...(rejectedFields.length > 0 ? { rejectedFields } : {}),
};
}