mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The address is what single sign-on links an existing account by, and changing it took the password or a session younger than five minutes on every account. A remembered browser skips the second factor at sign-in and still makes a fresh session, so the password alone could move the address of an account the second factor was meant to protect. On an account with a second factor only a second factor or passkey completed in the last five minutes counts now; the password is not checked. The refusal names it (second_factor_required in rejectedFields, or 401 profile.update.emailSecondFactorRequired when the address was all that changed), and the profile form opens the re-proof dialog with the factors the account holds, then saves again, no longer offering the password field. Accounts without a second factor keep the password or a recent sign-in. A token cannot change the address of such an account; the app does not change it.
551 lines
21 KiB
TypeScript
551 lines
21 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 { 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 {
|
|
REPROOF_FAILED_CODE,
|
|
REPROOF_REQUIRED_CODE,
|
|
type SensitiveChangeProof,
|
|
} from "@/lib/auth/existing-factor-proof";
|
|
|
|
const extendedProfileSchema = profileSchema.extend({
|
|
displayName: z.string().min(1).max(80).nullable().optional(),
|
|
locale: z
|
|
.enum(["de", "en"], {
|
|
error: "Locale must be de or en.",
|
|
})
|
|
.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 } : {}),
|
|
};
|
|
}
|