Files
HealthLog/src/app/api/auth/me/route.ts
T
Marc-André Bombeck bb563b6506 Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
2026-10-08 10:32:11 +02:00

462 lines
25 KiB
TypeScript

/**
* `GET /api/auth/me` — the account payload, and the one surface that keeps
* answering while the browser is acting on somebody else's record.
*
* v1.36.0 made it an ACTOR surface (`requireActorAuth`). It has to be: the
* switcher, the banner naming whose record is open, and the way back out all
* read this payload, and all three are about the person at the keyboard, not
* the record they are looking at. Under a bare `requireAuth()` the whole app
* shell would 403 the moment a switch was on.
*
* So every field below is still the CALLER's own — their preferences, their
* identity — and the one field about the switch, `accountAccess`, says so
* explicitly. Nothing here ever renders the owner's health data; a delegate
* reading their own display preferences while inside somebody else's record is
* the correct behaviour and the reason the mode exists (design §4: locale,
* theme and layout belong to the person, not the record).
*
* TWO fields are the exception, and the exception is the rule applied rather
* than a hole in it: `modules` and `cycleTrackingEnabled` resolve for the
* RECORD the session is inside, not for the actor (#939). They are not display
* preferences. They say what this record tracks — and every consumer of them
* gates a surface that shows the RECORD's data: the navigation, the dashboard
* tiles, the insights sections, quick-add. Publishing the actor's map while
* switched meant a Guardian who turned Cycle off for the child still saw a
* Cycle destination in the child's record, and a Guardian who tracks it saw
* one there whether or not the child did — the toggle in the record's own
* settings wrote a column nothing on screen was reading. Design §4 is about
* whose eyes are on the screen; this is about whose record is on it.
*
* Those two fields are then MASKED to the sections the active grant names.
* `accountAccess.active` is set for any live grant, level and scope included,
* so the record scoping on its own would hand a delegate scoped to
* `measurements` the owner's cycle, screener, illness and nutrient
* configuration — and the cycle flag is a derivation of the owner's recorded
* sex. A section the grant does not open answers `false`, which is the answer
* a module that is off already gives and the answer the browser's own route
* inventory has always acted on. An unscoped grant names every section and is
* unaffected.
*
* The own-record case is byte-identical: with no switch the active record IS
* the caller, there is no grant to narrow to, and every value below is
* resolved from the same row it always was.
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import { isCycleEnabled } from "@/lib/cycle/gate";
import {
resolveModuleMap,
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import {
emptyOnboardingNeeds,
readHeldUnitPreferences,
} from "@/lib/onboarding/needs";
import {
loadOnboardingRecordRow,
toOnboardingStateDto,
} from "@/lib/onboarding/needs-store";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
import {
buildModuleDisclosure,
sectionsOpen,
} from "@/lib/sharing/module-disclosure";
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { storedGlucoseUnit } from "@/lib/glucose";
export const dynamic = "force-dynamic";
/**
* Capability flags for clients that are not shipped with the server. Constant
* by design: a feature the server supports is supported for every account,
* and a per-account answer belongs in `modules` or `ai`, not here.
*/
const SERVER_FEATURES = { trackIntake: true } as const;
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
const { user } = auth;
annotate({ action: { name: "auth.me" } });
// Overlapped, not sequential: the two cookie resyncs (v1.4.22 W5 Sr-H1 —
// the onboarding fall-back for sessions that predate the cookie; new ones
// anchor it inside `createSession`) touch only the cookie store, while the
// access resolution is a Postgres round-trip. Running them one after the
// other added the cookie hop to every /me, and /me sits on every app boot.
const [, , accountAccess] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
// v1.36.0 — which records this caller may open, which one they are
// inside, and what they may do there. Every value resolved server-side;
// see the module docblock for why none of it is left to the client.
resolveAccountAccess(auth),
]);
// The record the two module fields answer for, and the reason this read is
// sequenced after the block above rather than inside it: the active record
// has to be the GRANT-VALIDATED one. The session row's own selector is not
// that — it is a stamp, and a stamp whose grant was revoked a minute ago
// still names an account. `resolveAccountAccess` is what checks the live
// grant, so its answer is what may pick whose configuration is published.
// The cost is one round-trip on a hot path, paid on every /me; publishing a
// revoked record's configuration would be paid once, by somebody else.
const recordId = accountAccess.active?.accountId ?? user.id;
// What the grant opens, and therefore what the two record fields below may
// say. `null` is the whole record — the answer for an unscoped grant and for
// no switch at all, and the reason the own-record payload is untouched.
const sections = accountAccess.active?.sections ?? null;
// Who may put a model to work on that record, stated from the switch this
// payload already resolved rather than read back off the request: this is an
// actor surface, so the request carries no record authority of its own.
// One's own record is the owner's; a managed profile is its guardian's,
// limited to the operator's key; anybody else's is a delegate's, and a
// delegate starts no AI work on a record that is not theirs.
const recordKind = accountAccess.active?.recordKind ?? "self";
const aiAuthority = {
origin:
recordKind === "self"
? ("owner" as const)
: recordKind === "managed"
? ("guardian" as const)
: ("delegate" as const),
recordUserId: recordId,
actorUserId: user.id,
grantId: null,
};
const [
record,
cycleProfile,
resolvedModules,
moduleAvailability,
onboardingRow,
ai,
coachReasoning,
] = await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
recordId === user.id
? Promise.resolve({
gender: user.gender,
glucoseUnit: user.glucoseUnit,
unitPreference: user.unitPreference,
})
: prisma.user.findUnique({
where: { id: recordId },
select: {
gender: true,
// v1.39 (C1) — the record's own unit columns, for the setup
// flow's "never re-ask a value the account holds" rule.
glucoseUnit: true,
unitPreference: true,
},
}),
prisma.cycleProfile.findUnique({
where: { userId: recordId },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(recordId),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
// Server-WIDE, so it is the same map for every record and is not
// re-scoped here.
getOperatorModuleAvailability(),
// v1.39 (C1) — the needs-based setup state for the RECORD, like the two
// module fields above and for the same reason: it says what that record
// tracks, and its only consumer reads it beside that record's reading
// count, medication count and integration state. Ordering a record's rows
// by somebody else's answers is the one thing this field must not do.
//
// The three WRITE routes stay actor-only (`requireAuth()` refuses outright
// under a switch) until a guardian's route into a managed record's setup
// arrives with the rest of that record's configuration (#939 / C2). A read
// that is wider than the write is the honest shape here: the storage is
// keyed by record already, and a delegate seeing the record's own setup
// state is what makes the dashboard they are looking at coherent.
loadOnboardingRecordRow(prisma, recordId),
// v1.39 — the AI capabilities for the RECORD, resolved on the server from
// every layer that can say no: the operator's switches, the record's
// modules (masked to the grant exactly like `moduleAccess`), the
// provider-work authority, provider presence and consent. The module map
// and the operator availability it needs are the memoised reads above, so
// the extra cost is the provider presence (two point reads) and one read
// of the active consent kinds.
loadAiCapabilities({
recordId,
authority: aiAuthority,
sections,
recordKind,
}),
// v1.41 — the Coach's thinking depth, resolved: the caller's own
// preference (Coach preferences belong to the person, like every other
// display preference here) against the operator's switch and cap and the
// record's provider. The provider read is the memoised presence probe the
// `ai` block above already ran.
loadCoachReasoningState({
prefs: parseCoachPrefs(user.coachPrefsJson),
recordId,
authority: aiAuthority,
}),
]);
// The masking step, and the one place the record scoping is narrowed rather
// than resolved. `active` is set for ANY live grant — a READ grant scoped to
// `measurements` included — so publishing the record's whole map would tell
// a delegate whether the owner tracks their cycle, their screeners, their
// illness episodes or their supplement intake, none of which their grant
// opens. A closed section reads `false`, the same answer a module that is
// off already gives, and the navigation the client builds from it drops
// exactly the doors `isSharedRecordPathPresentable` drops anyway.
// Two maps out of one pass: `modules` is the boolean gate map, masked
// exactly as it was, and `moduleAccess` is the same answer with the reason
// attached — the record's own switch, the grant's edge, or the operator's.
// Every client that only wants "paint it or not" keeps reading the boolean.
// v1.39 (C1) — the published steps carry the Q6 resolution: a record whose
// account already holds both unit preferences reads `units` as `done`, so
// the flow never re-asks a value the account holds.
const recordOnboarding = toOnboardingStateDto(
onboardingRow,
readHeldUnitPreferences({
glucoseUnit: record?.glucoseUnit ?? null,
unitPreference: record?.unitPreference ?? null,
}),
);
// The answers name the domains the record tracks, which is exactly what the
// module map below is masked for. A SCOPED grant therefore gets the shape of
// the flow — how far it got, what it ended on — with the answers emptied;
// an unscoped grant, and the record's own session, get them whole.
const onboarding =
sections === null
? recordOnboarding
: { ...recordOnboarding, needs: emptyOnboardingNeeds() };
const { modules, moduleAccess } = buildModuleDisclosure(
resolvedModules,
moduleAvailability,
sections,
);
// The cycle flag is masked on its own rather than read off `modules.cycle`:
// that key also carries the operator's server-wide availability, and this
// field never has. Same section, same answer, without borrowing a second
// layer's decision.
const cycleTrackingEnabled =
sectionsOpen(sections, "cycle") &&
isCycleEnabled(record?.gender ?? null, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
// one row never 500s the whole profile fetch (the field just reads
// null and the user re-enters it).
let insuranceNumber: string | null = null;
if (user.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(user.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
return apiSuccess({
id: user.id,
username: user.username,
email: user.email,
role: user.role ?? "USER",
heightCm: user.heightCm,
dateOfBirth: user.dateOfBirth,
gender: user.gender,
// The zone the server cuts this account's days in: the stored zone, or
// the instance default when the stored one is unusable. Clients bucket
// by this value, so it must be the resolved one, not the raw column.
timezone: await resolveStoredTimezone(user.timezone),
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.39 (C1) — the needs-based setup flow, published additively:
// `steps` (the nine stable ids and their `pending | done | skipped`
// status), `needs` (the answers as given), `completedAt` (the flow's OWN
// completion, distinct from `onboardingCompletedAt` above, which keeps its
// meaning as the first-run redirect's gate) and `firstResult` (the one
// task the flow offered, and whether it produced its result).
//
// Record-scoped, like `modules` and `cycleTrackingEnabled`: it says what
// the record tracks, and the checklist reads it beside that record's
// counts. Under a scoped grant the answers are emptied for the reason the
// module map is masked.
//
// Always present, never null: a record that never entered the flow reads
// as empty answers with nine `pending` steps, so a client branches on the
// step statuses rather than on the field's existence. A client that meets
// a step id it does not know skips it — the list is the contract, not the
// client's own copy of it.
onboarding,
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null
// when never acknowledged; the onboarding welcome gate reads this to
// decide whether to require the acknowledgment before "Get started".
disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt,
// The version that acknowledgment was given for. The welcome gate
// compares it against the current DISCLAIMER_VERSION so a revised
// disclaimer re-prompts instead of riding an old acknowledgment.
disclaimerAcknowledgedVersion: user.disclaimerAcknowledgedVersion,
// v1.18.6 — resumable module-tour progress. Null when the user has
// not started the tour; otherwise the resume point the launcher
// seeds its index from. Fail-soft parse: a corrupt blob degrades to
// null ("start from the top") rather than 500-ing the /me read.
onboardingTourProgress: parseTourProgress(user.onboardingTourProgressJson),
// v1.5.5 — self-hosted avatar. Replaces the Gravatar leak; the
// URL is relative so PWA + native clients render identically
// and the `?v={updatedAtMs}` suffix busts the browser cache on
// a re-upload. Null when the user has not uploaded an avatar
// yet; clients paint the username-initials fallback.
avatarUrl: user.avatarUpdatedAt
? buildAvatarUrl(user.id, user.avatarUpdatedAt)
: null,
// The stored choice, published as the closed pair the contract names.
glucoseUnit: storedGlucoseUnit(user.glucoseUnit),
// v1.7.0 — global metric/imperial display preference. Canonical
// storage stays SI; this only drives the display-time transform
// branch. Null defaults to "metric" on the client.
unitPreference: user.unitPreference === "imperial" ? "imperial" : "metric",
// Hour-cycle display preference (AUTO follows the locale convention,
// H12 / H24 pin the cycle). Clients mirror this into their formatters.
timeFormat: user.timeFormat ?? "AUTO",
// Date-order display preference (AUTO follows the locale convention,
// DMY / MDY / YMD pin the field order). Clients mirror this into their
// formatters and the <DateField> primitive.
dateFormat: user.dateFormat ?? "AUTO",
lastReportPracticeName: user.lastReportPracticeName ?? null,
// The owner's saved report selection — the leaf inclusion list plus the
// format / range / charts choices the export panel restores. `null` when
// the account has never saved one, which is what makes the panel show the
// named template on the first run rather than a silent server default.
reportSelection: user.reportSelectionJson ?? null,
// The account's resolved notification preferences — the same resolver
// `GET /api/auth/me/notification-prefs` answers with, so the two surfaces
// cannot disagree. Published here because the medication detail's
// notification section decides between the server-side reminder switch and
// the client-managed chip on `medication.clientManaged`, and it reads that
// decision off THIS payload: the dedicated endpoint is not on the app-boot
// path. An account that has never opted in resolves to the documented
// defaults rather than to `undefined`, so the switch is what renders.
// No extra query — the session already loads the row this column is on.
notificationPrefs: parseNotificationPrefs(user.notificationPrefs ?? null),
// v1.4.47 W3 — the caller's own stored Coach opt-out, published for the
// switch that edits it. Default `false` if the column is absent
// (partial-deploy rollback safety, see migration 0078 commentary). It is
// not a gate: whether the Coach shows for the record on screen is the
// resolved `ai.capabilities.coach` and `modules.coach` below, which fold
// in the record's own opt-out, the operator's switches and the grant.
disableCoach: user.disableCoach ?? false,
// v1.7.0 — health-record export identity fields. All optional.
fullName: user.fullName ?? null,
insurerName: user.insurerName ?? null,
insurerIkNumber: user.insurerIkNumber ?? null,
insuranceNumber,
// v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS
// hides the whole cycle tab when this is false. v1.38.14 — resolved for
// the ACTIVE RECORD, so a switched browser hides the tab the record does
// not track rather than the one the actor does not (#939), and `false`
// whenever the active grant does not name the `cycle` section. Unchanged
// on the native transport, which carries no switch.
cycleTrackingEnabled,
// v1.18.0 — module enable/disable map. `{ <moduleKey>: boolean }`
// for every toggleable module; `false` means the module is OFF and
// the surface should disappear end-to-end (nav, dashboard, insights,
// …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the
// resolved `disableCoach` + operator master flag, so this map is the
// single thing a client needs to gate every secondary domain.
// v1.38.14 — resolved for the ACTIVE RECORD and masked to the sections
// the active grant opens; see the module docblock.
modules,
// v1.18.0 — operator-layer availability per toggleable module. `false`
// ⇒ the operator turned the module off server-wide (off for every
// account regardless of personal preference). The Modules hub reads
// this to show a "disabled server-wide" read-only row; everywhere else
// the already-AND-ed `modules` map is the single gate to read.
moduleAvailability,
// Why each module's surfaces are or are not there, for the record this
// session is inside: `enabled`, `disabled` (the record's own switch),
// `not_granted` (the active grant does not open the module's section) or
// `unavailable` (the operator switched it off for the whole instance).
// Precedence outside-in: unavailable > not_granted > disabled > enabled.
// `modules[key]` stays exactly `moduleAccess[key] === "enabled"`, so this
// adds a reason without moving a gate; empty states read it to say the
// honest thing rather than offering a switch the reader cannot reach.
moduleAccess,
// v1.39 — which AI capabilities this record has, and why not when it has
// none: `capabilities` maps every capability key to `{ available, reason,
// onDeviceAllowed }` (reason is the outermost layer that said no), and
// `provider` says whether a provider is configured, who manages it, and
// whether the person in front of the screen may set one up. Resolved for
// the RECORD and masked to the grant, like `modules`. Clients render from
// it and never recompute it; data never depends on it.
ai,
// v1.41 — the Coach's thinking depth, resolved on the server so the web
// settings and the native client show the same answer: `level` is what a
// turn runs at now, `preference` what the person picked, `maxLevel` the
// highest option the settings may offer (`off` when the operator switched
// reasoning off), `available` whether reasoning can run at all,
// `offIsReal` whether the lowest option is a real off or "Minimal", and
// `source` which layer decided. Clients never recompute it.
coachReasoning,
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
// `active` is the record this session is inside (null when it is in its
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
// directly. Always present — an account with no grants gets an empty list,
// not a missing field.
accountAccess,
// v1.37.0 — the record-session fence's bootstrap, and the only place a
// browser can learn its context from scratch.
//
// `epoch` is the session row's selector counter; `scope` is the account the
// session is pointed at, or null for its own record. The client adopts both
// and attaches them to every subsequent same-origin request, so the server
// can refuse a request formed under a context that has since moved. This
// response and `POST /api/account/switch` are the only two a client may
// ADOPT from — every other response carries the context in headers, and
// those are used to validate a response, never to learn one.
//
// Null on the Bearer transport, which has no session row and no switch
// state to be stale about. A native client neither sends nor receives the
// fence headers; the frozen contract is untouched.
// Derived by the fence rather than read off the context here: the columns
// behind it have one reader by design, and a route that projected them
// itself would be a second statement of what a record context is.
recordSession: recordSessionForPayload(auth),
// v1.42 — what this server understands, for a client released on its own
// schedule (iOS #116, item 18). `trackIntake: true` says medications take
// the record-only switch, so an add form can offer it before the account
// has a medication to learn it from. A flag only ever appears here once
// the server supports it, and stays `true`; absence means an older server.
features: SERVER_FEATURES,
});
});