mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
- 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.
462 lines
25 KiB
TypeScript
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,
|
|
});
|
|
});
|