Merge managed-profile editing, sex at creation and module switches per record

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:19:35 +02:00
commit 06d2104808
36 files changed
+2795 -147

No files matched your search

+108 -31
View File
@@ -9,12 +9,37 @@
* shell would 403 the moment a switch was on.
*
* So every field below is still the CALLER's own — their preferences, their
* modules, their identity — and the one field about the switch,
* `accountAccess`, says so explicitly. Nothing here ever renders the owner's
* 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).
* 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";
@@ -31,6 +56,10 @@ import {
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
import {
maskModulesToSections,
sectionsOpen,
} from "@/lib/sharing/module-disclosure";
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
export const dynamic = "force-dynamic";
@@ -41,26 +70,54 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.me" } });
// Two independent awaits, overlapped: the onboarding-cookie resync
// (v1.4.22 W5 Sr-H1 — fall-back for legacy sessions that predate the
// cookie; new sessions anchor it inside `createSession`) touches only
// the cookie store, while the cycle-profile read (v1.15.0 — resolved
// cycle-tracking gate; no row is forced, a NULL toggle derives from
// gender) is a Postgres round-trip. Running them sequentially added
// the cookie hop to every /me — and /me sits on every app boot path.
const [, , cycleProfile, modules, moduleAvailability, accountAccess] =
// 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;
const [record, cycleProfile, resolvedModules, moduleAvailability] =
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,
}),
// 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 })
: prisma.user.findUnique({
where: { id: recordId },
select: { gender: true },
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
where: { userId: recordId },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
@@ -68,19 +125,33 @@ export const GET = apiHandler(async () => {
// 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(user.id),
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.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),
]);
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
// 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.
const modules = maskModulesToSections(resolvedModules, 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
@@ -157,7 +228,11 @@ export const GET = apiHandler(async () => {
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.
// 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
@@ -165,6 +240,8 @@ export const GET = apiHandler(async () => {
// …). `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
+151 -6
View File
@@ -3,17 +3,166 @@ import { NextRequest } from "next/server";
import {
apiHandler,
MFA_STEP_UP_MAX_AGE_SECONDS,
requireCookieAuth,
requireFreshMfa,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import {
deleteManagedProfile,
ManagedProfileLifecycleError,
readManagedProfileForGuardian,
updateManagedProfile,
} from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { updateManagedProfileSchema } from "@/lib/validations/managed-profiles";
type RouteParams = { params: Promise<{ id: string }> };
/** The one refusal this file answers with for every "you may not have it". */
const notFound = () =>
apiError("Managed profile not found", 404, {
errorCode: "managed_profile.not_found",
});
/**
* The record as it stands.
*
* The read the edit form fills itself from, and the reason it exists at all:
* `accountAccess.accounts` on the account payload carries a managed record's
* NAME and nothing else, so a form offered from the Guardian's own panel had
* no way to show the timezone, the language or the date of birth it was about
* to overwrite. A form that opens with blank fields and PUTs them is how a
* record loses its timezone to an edit of its name.
*
* Cookie-only and NOT step-up gated, exactly like the guardian roster beside
* it and for the same reason: this is a read, it discloses to a Guardian what
* they already administer, and the gate belongs to the acts. (Named
* `requireCookieAuth` rather than described by the session helper it calls —
* see the roster route's docblock for why that distinction is load-bearing.)
*
* An ACTOR surface: the profile is named in the path and the caller acts as
* themselves, so it answers from the Guardian's own account rather than only
* while switched into the record.
*/
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireCookieAuth();
const { id } = await params;
const profile = await readManagedProfileForGuardian({
profileId: id,
guardianId: user.id,
});
// One refusal for "no such account", "not a managed profile" and "not a
// Guardian of it", so the route is not an enumeration oracle.
if (!profile) return notFound();
annotate({
action: { name: "managed_profile.read" },
meta: { profile_id: id },
});
return apiSuccess(profile);
},
);
/**
* The ceiling the create sibling carries, on the act that changes what it
* created. Ten an hour per caller: a person cannot mistype their way into ten
* edits of a record's identity, and the number matching creation and guardian
* invitation keeps one family answering one way.
*
* The GET beside it stays unlimited, matching the guardian roster read next to
* it: both are cheap identity reads a Guardian may already make, and neither
* family sibling has ever carried a read bucket.
*/
const UPDATE_LIMIT = 10;
const UPDATE_WINDOW_MS = 60 * 60 * 1000;
/**
* Change a managed record's identity.
*
* ## Why the same gate as deletion
*
* `requireFreshMfa`, unconditionally, which is what creation and deletion
* already carry. The three acts are one family: they mint, change and end an
* account that can never prove anything about itself, so the proof has to come
* from the person doing it. Softening the gate here — "it is only a rename" —
* would leave the display name of every record somebody looks after writable
* from an ambient cookie, and the display name is what every other Guardian,
* the switcher and the record banner identify the record by.
*
* A Guardian who is already switched INTO the record edits the same five
* columns through `PATCH /api/record-settings/profile` without a step-up, and
* that is not a contradiction: reaching that surface at all requires the
* switch, which is itself an act on the record. This route is the one reachable
* from the Guardian's own panel, where nothing else has been proved.
*
* ## Which fields, and why not more
*
* The five the creation takes. Everything else a `User` row carries —
* credentials, provider connections, notification routing, the health profile
* — is deliberately out of reach: this is the record's identity, not its
* account. The wider configuration surface is `PATCH
* /api/record-settings/{family}`, which is guardian-fenced and switched-in.
*/
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
// Before the id is read, exactly as the create sibling does it. The cheap
// refusal has to come first here for a second reason: `updateManagedProfile`
// opens a transaction and takes an advisory lock on the id it was HANDED
// before it decides the caller is not a Guardian of it, so an unthrottled
// caller can name any id they like and make the database hold a lock about
// it. Same ceiling as creating a record and inviting a Guardian — the three
// acts are one family and answering the same abuse differently is the
// asymmetry nobody notices until it is used.
const rateLimit = await checkRateLimit(
`managed-profile:update:${user.id}`,
UPDATE_LIMIT,
UPDATE_WINDOW_MS,
);
if (!rateLimit.allowed) {
return apiError("Too many profile edits, try again later", 429);
}
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateManagedProfileSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// The parsed patch goes to the service, which assembles the Prisma payload
// field by field. One assembly rather than one here and one there: two
// statements of which fields are writable is one statement too many.
let profile;
try {
profile = await updateManagedProfile({
profileId: id,
guardianId: user.id,
patch: parsed.data,
});
} catch (error) {
if (error instanceof ManagedProfileLifecycleError) return notFound();
throw error;
}
annotate({
action: { name: "managed_profile.update" },
meta: { profile_id: id, changed: Object.keys(parsed.data) },
});
return apiSuccess(profile);
},
);
/** Delete a managed record only after its cookie-backed Guardian proves MFA. */
export const DELETE = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
@@ -23,11 +172,7 @@ export const DELETE = apiHandler(
try {
await deleteManagedProfile({ profileId: id, guardianId: user.id });
} catch (error) {
if (error instanceof ManagedProfileLifecycleError) {
return apiError("Managed profile not found", 404, {
errorCode: "managed_profile.not_found",
});
}
if (error instanceof ManagedProfileLifecycleError) return notFound();
throw error;
}
+6 -11
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
@@ -62,6 +63,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
: null,
locale: parsed.data.locale,
timezone: parsed.data.timezone,
// Absent and null both mean "not recorded". The record keeps the same
// NULL an account that never answered carries, and the cycle module
// derives its default from it rather than from a guess about the person.
gender: parsed.data.gender ?? null,
});
await auditLog("managed_profile.created", {
@@ -73,15 +78,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
meta: { profile_id: profile.id },
});
return apiSuccess(
{
id: profile.id,
displayName: profile.displayName,
dateOfBirth: profile.dateOfBirth,
locale: profile.locale,
timezone: profile.timezone,
recordKind: "managed" as const,
},
201,
);
return apiSuccess(toManagedProfileView(profile), 201);
});
+50 -7
View File
@@ -30,6 +30,8 @@ import {
parseManagedRecordSettingsPatch,
resolveGuardianRecordSettingsAccess,
} from "@/lib/record-settings";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import {
resolveInsightsLayout,
serializeInsightsLayout,
@@ -101,13 +103,23 @@ export const GET = apiHandler(
dateFormat: record.dateFormat,
};
break;
case "modules":
case "modules": {
// The delegated key needs its real source, which is not the module
// blob. `isCycleEnabled` resolves the record's own opt-in against its
// sex exactly as `/api/auth/me` does for the account itself, so the
// switch a guardian sees is the state the gate will answer with.
const cycleProfile = await prisma.cycleProfile.findUnique({
where: { userId: access.recordId },
select: { cycleTrackingEnabled: true },
});
settings = {
modulePreferences: managedModulePreferencesFrom(
record.modulePreferencesJson,
),
cycleTrackingEnabled: isCycleEnabled(record.gender, cycleProfile),
};
break;
}
case "notifications": {
const preferences = parseNotificationPrefs(record.notificationPrefs);
settings = {
@@ -233,19 +245,50 @@ export const PATCH = apiHandler(
const patch = parseManagedRecordSettingsPatch("modules", body);
const current = await prisma.user.findUnique({
where: { id: access.recordId },
select: { modulePreferencesJson: true },
select: { modulePreferencesJson: true, gender: true },
});
const modulePreferences = {
...managedModulePreferencesFrom(current?.modulePreferencesJson),
...patch.modulePreferences,
};
await prisma.user.update({
where: { id: access.recordId },
data: { modulePreferencesJson: toJson(modulePreferences) },
if (patch.modulePreferences !== undefined) {
await prisma.user.update({
where: { id: access.recordId },
data: { modulePreferencesJson: toJson(modulePreferences) },
});
}
// The delegated key writes its own column. An explicit boolean is
// what the cycle gate honours over the sex-derived default, so a
// guardian turning Cycle off for a record whose sex says otherwise
// is recorded as the deliberate answer it is.
if (patch.cycleTrackingEnabled !== undefined) {
await getOrCreateCycleProfile(access.recordId);
await prisma.cycleProfile.update({
where: { userId: access.recordId },
data: { cycleTrackingEnabled: patch.cycleTrackingEnabled },
});
}
const cycleProfile = await prisma.cycleProfile.findUnique({
where: { userId: access.recordId },
select: { cycleTrackingEnabled: true },
});
invalidateUserHealthScore(access.recordId);
settings = { modulePreferences };
changed = Object.keys(patch.modulePreferences);
settings = {
modulePreferences,
cycleTrackingEnabled: isCycleEnabled(
current?.gender ?? null,
cycleProfile,
),
};
// The module keys themselves, not the wrapper field: the record's
// trail should say which module moved. `cycleTrackingEnabled` is
// named as itself because that is the column it writes.
changed = [
...Object.keys(patch.modulePreferences ?? {}),
...(patch.cycleTrackingEnabled !== undefined
? ["cycleTrackingEnabled"]
: []),
];
break;
}
case "notifications": {