feat: gate a switched browser on the record's modules, not the actor's

Turning a module off for a profile you look after wrote a column nothing
on screen was reading. GET /api/auth/me published the ACTOR's module map
while switched, and the navigation, the dashboard tiles and the insights
sections all gate on it — so a guardian who turned Cycle off for the child
kept seeing a Cycle destination inside the child's record, and a guardian
who tracks it saw one there whether or not the child did (#939).

The map and the resolved cycle flag now answer for the record the session
is inside. They are not display preferences: every surface they gate shows
the record's data, so the record is what decides. Locale, theme and layout
stay the actor's. The record is taken from the grant-validated active
entry rather than the session's own selector, so a revoked switch cannot
publish somebody's configuration, and with no switch the payload is what
it was.

Cycle also gets a row on the guardian's Modules surface, which it never
had. It is a delegated module, so its state is the record's own cycle
profile and modulePreferences.cycle is refused — which left a guardian no
way to turn it off for a record whose sex says it should be on. The family
names the real column instead of a blob entry the gate ignores.

Refs #939
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 00:02:03 +02:00
1 parent f521b03c35
commit fb1cb4e3ed
9 files changed
+556 -46

No files matched your search

@@ -23,7 +23,7 @@ describe("managed record settings configuration contract", () => {
"timeFormat",
"dateFormat",
],
modules: ["modulePreferences"],
modules: ["modulePreferences", "cycleTrackingEnabled"],
notifications: ["moodReminderEnabled", "notificationPreferences"],
thresholds: ["overrides"],
coach: ["disableCoach", "preferences"],
@@ -34,6 +34,13 @@ describe("managed record settings configuration contract", () => {
it.each([
["profile", { email: "not-allowed@example.test" }],
["modules", { role: "ADMIN" }],
// The delegated key by its module name, which is the mistake a stale
// client makes. It has to stay a refusal: accepting it would persist an
// inert `false` behind a green "saved" and change nothing.
["modules", { modulePreferences: { cycle: false } }],
// An empty patch. It parses as a strict object and would audit a change
// nobody made.
["modules", {}],
[
"notifications",
{
@@ -63,6 +70,11 @@ describe("managed record settings configuration contract", () => {
},
],
["modules", { modulePreferences: { mood: false } }],
// v1.38.14 (#939) — the delegated key, sent on its own. `cycle` is not a
// writable module preference and never will be: its user-layer state is
// the record's own cycle profile, so the family names the real column
// rather than a blob entry the gate ignores.
["modules", { cycleTrackingEnabled: false }],
[
"notifications",
{
+77 -33
View File
@@ -9,12 +9,27 @@
* 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.
*
* The own-record case is byte-identical: with no switch the active record IS
* the caller, 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";
@@ -41,26 +56,49 @@ 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] =
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,
}),
// 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;
const [record, cycleProfile, modules, moduleAvailability] = 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 })
: 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 +106,21 @@ 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);
],
);
const cycleTrackingEnabled = 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 +197,10 @@ 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). 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 +208,7 @@ 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; 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
+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": {
@@ -20,9 +20,27 @@ vi.mock("next/navigation", () => ({
const mockModulesRef: { value: Record<string, boolean> | undefined } = {
value: undefined,
};
// v1.37.0 — the shell also filters by which record the browser is inside.
// Unlike the module gate above, that filter is NOT hydration-deferred: the
// server has already resolved which record is active, so it applies on the SSR
// pass and is assertable from a static render.
const mockActiveRecordRef: {
value: { recordKind: "managed" | "shared"; level: string } | null;
} = { value: null };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER", modules: mockModulesRef.value },
user: {
id: "u1",
role: "USER",
modules: mockModulesRef.value,
accountAccess: mockActiveRecordRef.value
? {
accounts: [mockActiveRecordRef.value],
active: mockActiveRecordRef.value,
canSwitch: true,
}
: null,
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
@@ -42,9 +60,11 @@ function renderShell(props: {
pathname?: string;
locale?: "en" | "de";
modules?: Record<string, boolean>;
activeRecord?: { recordKind: "managed" | "shared"; level: string } | null;
}) {
mockPathnameRef.value = props.pathname ?? "/settings/account";
mockModulesRef.value = props.modules;
mockActiveRecordRef.value = props.activeRecord ?? null;
return renderToStaticMarkup(
<I18nProvider initialLocale={props.locale ?? "en"}>
<SettingsShell active={props.active}>
@@ -327,6 +347,39 @@ describe("<SettingsShell>", () => {
expect(optedOut).toContain('href="/settings/gesundheitsakte"');
});
describe("inside somebody else's record", () => {
/**
* #939 — a guardian could turn a module off for the profile they look
* after and had to find the page by URL to do it. The section is
* classified `managed-guardian`, which is what the shell offers a managed
* record; asserting it here is what stops a future narrowing of the filter
* from silently taking the destination away again.
*/
it("offers the Modules destination to a guardian on a managed record", () => {
const html = renderShell({
active: "account",
activeRecord: { recordKind: "managed", level: "manage" },
});
expect(html).toContain('href="/settings/modules"');
// Its own configuration is not the record's, and stays away.
expect(html).not.toContain('href="/settings/security"');
});
it("does not offer it to an adult delegate holding MANAGE", () => {
// A delegate manages somebody's health record, not their account:
// modules, thresholds and notification routing stay with the owner.
// Integrations remains status-only on a managed record, and unreachable
// here — both are `managed-guardian`, which an adult record never opens.
const html = renderShell({
active: "account",
activeRecord: { recordKind: "shared", level: "manage" },
});
expect(html).not.toContain('href="/settings/modules"');
expect(html).not.toContain('href="/settings/integrations"');
expect(html).toContain('href="/settings/anamnesis"');
});
});
it("falls back to `account` when the pathname doesn't match a known slug", () => {
const html = renderShell({ pathname: "/settings/totally-bogus" });
const activeLinkRegex =
@@ -505,6 +505,7 @@ function ModulesSettingsForm({
modulePreferences: Object.fromEntries(
entries.map(([key]) => [key, form.get(key) === "on"]),
),
cycleTrackingEnabled: form.get("cycleTrackingEnabled") === "on",
});
}
@@ -523,6 +524,17 @@ function ModulesSettingsForm({
disabled={disabled}
/>
))}
{/* v1.38.14 (#939) — Cycle sits below the rest because it is not one of
them: it is a DELEGATED module, so its state lives in the record's own
cycle profile rather than in the module blob, and it had no row here
at all. A guardian could not turn it off for a record whose sex says
it should be on, which is the case the issue was opened about. */}
<ManagedSwitchRow
name="cycleTrackingEnabled"
label={t(MODULE_REGISTRY.cycle.labelKey)}
defaultChecked={settings.cycleTrackingEnabled === true}
disabled={disabled}
/>
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
+1 -1
View File
@@ -84,7 +84,7 @@ const moduleMapResolved = z
.meta({
id: "ModuleMap",
description:
"Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag.",
"Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag. On `GET /api/auth/me` it answers for the RECORD the session is inside rather than for the caller, because every surface it gates shows the record's data; with no switch — which is every native request, since the Bearer transport carries none — the two are the same account and the payload is unchanged.",
});
const moduleMapEnvelopeInner = z
+1 -1
View File
@@ -276,7 +276,7 @@ const recordSettingsPatchRequest = z
MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.modules.meta({
id: "RecordModuleSettingsPatch",
description:
"Partial module toggle. Merged onto the record's current preferences; omitted keys keep their value.",
"Partial module toggle. `modulePreferences` is merged onto the record's current preferences; omitted keys keep their value. `cycleTrackingEnabled` is separate because `cycle` is a DELEGATED module: its state lives in the record's own cycle profile and `modulePreferences.cycle` is refused, so this is the field that turns cycle tracking on or off for the record — an explicit boolean overrides the default the record's sex would derive. Both fields are optional and at least one is required; an empty body is a 422 rather than a save that changed nothing.",
}),
MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.notifications.meta({
id: "RecordNotificationSettingsPatch",
+26 -2
View File
@@ -30,7 +30,7 @@ export const MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST = {
"timeFormat",
"dateFormat",
],
modules: ["modulePreferences"],
modules: ["modulePreferences", "cycleTrackingEnabled"],
notifications: ["moodReminderEnabled", "notificationPreferences"],
thresholds: ["overrides"],
coach: ["disableCoach", "preferences"],
@@ -140,7 +140,31 @@ const managedCoachPreferencesSchema = coachPrefsSchema
*/
export const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = {
profile: profilePatchSchema,
modules: z.object({ modulePreferences: modulePrefsPatchSchema }).strict(),
/**
* Two fields because the module foundation has two kinds of key, and only
* one of them lives in `modulePreferencesJson`.
*
* `cycle` is a DELEGATED module: its user-layer state is owned by
* `CycleProfile.cycleTrackingEnabled` (NULL derives from `User.gender`), and
* `modulePreferencesJson.cycle` is ignored on purpose — which is why
* `modulePrefsPatchSchema` refuses the key. A guardian therefore had no way
* to turn Cycle off for a record whose sex says it should be on, and no row
* for it in the list either: it is simply not a writable key (#939). Naming
* the real column here writes the real source of truth rather than minting a
* second one that the gate would ignore.
*
* Both optional so a client may send either alone; at least one required, so
* an empty body is a refusal rather than a save that changed nothing.
*/
modules: z
.object({
modulePreferences: modulePrefsPatchSchema.optional(),
cycleTrackingEnabled: z.boolean().optional(),
})
.strict()
.refine((patch) => Object.values(patch).some((v) => v !== undefined), {
message: "Name at least one field to change",
}),
notifications: z
.object({
moodReminderEnabled: z.boolean().optional(),