feat(api): say why a module's surfaces are not there, not only that they are not

`GET /api/auth/me` publishes a boolean per module, and three different
situations collapse into the same `false`: the record has the module switched
off, the active grant's sections do not open it, or the operator switched it
off for the whole instance. A client holding the boolean has to guess between
them, so the empty state either says nothing or says something that may be
untrue -- offering a delegate a switch that is not theirs to reach, or telling
an account holder to turn on a module the operator removed.

`moduleAccess` carries the reason beside the booleans, which keep their exact
meaning: `enabled`, `disabled`, `not_granted`, `unavailable`, resolved
outside-in so the reason published is the one nothing further in can change.
`modules[key]` stays exactly `moduleAccess[key] === "enabled"` for every key,
and the two halves are built independently -- the boolean still runs through
`maskModulesToSections` -- so the test of that pairing is an assertion rather
than a restatement of a derivation. It is checked on the pure builder across a
matrix of grants, records and operator blobs, and again on the real route for a
scoped delegate, an unscoped one, and an operator-disabled module.

Both ends in one change, because a payload field with no reader is the failure
this repository keeps rediscovering. The reader is `ModuleDisabledNotice`, the
shared module-off empty state the empty-states audit asks for: it names the
reason in the reader's own language and offers the enable control only when the
record's own switch is what is off, since neither of the other two is fixable
from that page. The nutrients page adopts it and drops its bespoke block, which
had been handing every delegate a button that would refuse them.

Registered in the OpenAPI registry as `ModuleAccessMap` and carried on the
account payload; the native contract gains a field and loses nothing, since a
Bearer request carries no switch and never reads `not_granted`.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:41:38 +02:00
1 parent 0c46df1413
commit 47a5079cfa
17 files changed
+859 -21

No files matched your search

+191 -1
View File
@@ -65763,6 +65763,8 @@ components:
type: string
accountAccess:
$ref: "#/components/schemas/AccountAccess"
moduleAccess:
$ref: "#/components/schemas/ModuleAccessMap"
recordSession:
anyOf:
- $ref: "#/components/schemas/RecordSessionState"
@@ -65771,6 +65773,7 @@ components:
- id
- username
- accountAccess
- moduleAccess
- recordSession
additionalProperties: {}
description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do.
@@ -65780,7 +65783,9 @@ components:
`cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and
they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it
rather than the record's true state. With no switch — which is every native request, since the Bearer transport
carries none — the two are the same account and nothing is masked."
carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is
not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the
`modules` booleans it never contradicts."
AccountAccess:
type: object
properties:
@@ -65985,6 +65990,191 @@ components:
be a second program deciding one person's access to a health record, and the two answer differently the first
time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list,
not a missing field."
ModuleAccessMap:
type: object
properties:
cycle:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "cycle" module is or is not available here.
mood:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "mood" module is or is not available here.
sleep:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "sleep" module is or is not available here.
glucose:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "glucose" module is or is not available here.
workouts:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "workouts" module is or is not available here.
recovery:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "recovery" module is or is not available here.
labs:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "labs" module is or is not available here.
illness:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "illness" module is or is not available here.
achievements:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "achievements" module is or is not available here.
coach:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "coach" module is or is not available here.
insights:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "insights" module is or is not available here.
medications:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "medications" module is or is not available here.
doctorReport:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "doctorReport" module is or is not available here.
environment:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "environment" module is or is not available here.
mcp:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "mcp" module is or is not available here.
inboundDocuments:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "inboundDocuments" module is or is not available here.
mentalHealth:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "mentalHealth" module is or is not available here.
nutrients:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "nutrients" module is or is not available here.
vaccinations:
type: string
enum:
- enabled
- disabled
- not_granted
- unavailable
description: Why the "vaccinations" module is or is not available here.
required:
- cycle
- mood
- sleep
- glucose
- workouts
- recovery
- labs
- illness
- achievements
- coach
- insights
- medications
- doctorReport
- environment
- mcp
- inboundDocuments
- mentalHealth
- nutrients
- vaccinations
additionalProperties: false
description: "Why each toggleable module is or is not available for the record this session is inside. `enabled` — the
module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the
active grant's sections do not open the module's section, which also covers every module that reads across the
whole record (achievements, the assistant surfaces, the export, environment, the account's own MCP endpoint)
since no scoped grant opens one. `unavailable` — the operator switched the module off for the whole instance.
Precedence, highest first: `unavailable` > `not_granted` > `disabled` > `enabled`, so the reason published is
the one nothing further in can change. Own-record sessions and unscoped grants never produce `not_granted`.
Every key is present, and `modules[key] === (moduleAccess[key] === \"enabled\")` holds for every key — this
field adds a reason, never a different gate."
RecordSessionState:
type: object
properties:
+14 -1
View File
@@ -8682,6 +8682,20 @@
"description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung."
}
},
"moduleOff": {
"disabled": {
"description": "{module} ist für diese Akte ausgeschaltet, deshalb bleiben die Seiten verborgen. Schalte das Modul wieder ein, um sie zurückzuholen.",
"title": "{module} ist ausgeschaltet"
},
"notGranted": {
"description": "{module} liegt außerhalb der Bereiche, die mit dir geteilt wurden. Was ein geteilter Zugriff umfasst, legt die Person fest, der die Akte gehört.",
"title": "Gehört nicht zum geteilten Zugriff"
},
"unavailable": {
"description": "Der Betreiber hat {module} für alle Konten auf diesem Server ausgeschaltet. Keine eigene Einstellung holt es zurück.",
"title": "Auf diesem Server nicht verfügbar"
}
},
"vaccinations": {
"title": "Impfungen",
"subtitle": "Dein Impfpass, Dosis für Dosis.",
@@ -9920,7 +9934,6 @@
"page": {
"title": "Nährstoffaufnahme",
"description": "Flüssigkeits- und Vitamin-/Mineralstoffaufnahme aus der Gesundheits-App deines Telefons, plus manuelle Wassereinträge.",
"moduleOffTitle": "Nährstoffaufnahme ist aus",
"moduleOffDescription": "Aktiviere das Nährstoffe-Modul, um Flüssigkeits- und Vitamin-/Mineralstoffaufnahme hier zu sehen. Synchronisierte Werte werden Teil der Aufzeichnung, die der Coach lesen kann.",
"moduleOffCta": "Nährstoffe aktivieren",
"emptyTitle": "Noch keine Nährstoffdaten",
+14 -1
View File
@@ -8682,6 +8682,20 @@
"description": "The vaccination record, structured: dose, series, next booster."
}
},
"moduleOff": {
"disabled": {
"description": "{module} is off for this record, so its pages stay hidden. Turn it back on to bring them back.",
"title": "{module} is switched off"
},
"notGranted": {
"description": "{module} sits outside the sections shared with you. What a shared record covers is decided by the person who owns it.",
"title": "Not part of the shared access"
},
"unavailable": {
"description": "The operator switched {module} off for every account on this server. No setting of your own can bring it back.",
"title": "Not available on this server"
}
},
"vaccinations": {
"title": "Vaccinations",
"subtitle": "Your immunization record, dose by dose.",
@@ -9920,7 +9934,6 @@
"page": {
"title": "Nutrient intake",
"description": "Hydration and vitamin/mineral intake synced from your phone's health app, plus manual water entries.",
"moduleOffTitle": "Nutrient intake is off",
"moduleOffDescription": "Turn on the nutrients module to see hydration and vitamin/mineral intake here. Synced intake becomes part of the record the Coach can read.",
"moduleOffCta": "Turn on nutrients",
"emptyTitle": "No nutrient data yet",
+14 -1
View File
@@ -8682,6 +8682,20 @@
"description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo."
}
},
"moduleOff": {
"disabled": {
"description": "{module} está desactivado en este registro, así que sus páginas quedan ocultas. Vuelve a activarlo para recuperarlas.",
"title": "{module} está desactivado"
},
"notGranted": {
"description": "{module} queda fuera de las secciones que se han compartido contigo. Lo que abarca un registro compartido lo decide la persona propietaria.",
"title": "No forma parte del acceso compartido"
},
"unavailable": {
"description": "El operador ha desactivado {module} para todas las cuentas de este servidor. Ningún ajuste tuyo puede recuperarlo.",
"title": "No disponible en este servidor"
}
},
"vaccinations": {
"title": "Vacunas",
"subtitle": "Tu registro de vacunación, dosis a dosis.",
@@ -9920,7 +9934,6 @@
"page": {
"title": "Ingesta de nutrientes",
"description": "Hidratación e ingesta de vitaminas/minerales sincronizadas desde la app de salud de tu teléfono, más entradas manuales de agua.",
"moduleOffTitle": "La ingesta de nutrientes está desactivada",
"moduleOffDescription": "Activa el módulo de nutrientes para ver aquí la hidratación y la ingesta de vitaminas/minerales. Los datos sincronizados pasarán a formar parte del registro que el Coach puede leer.",
"moduleOffCta": "Activar nutrientes",
"emptyTitle": "Aún no hay datos de nutrientes",
+14 -1
View File
@@ -8682,6 +8682,20 @@
"description": "Le carnet de vaccination structuré : dose, série, prochain rappel."
}
},
"moduleOff": {
"disabled": {
"description": "{module} est désactivé pour ce dossier, ses pages restent donc masquées. Réactivez le module pour les retrouver.",
"title": "{module} est désactivé"
},
"notGranted": {
"description": "{module} se trouve en dehors des sections partagées avec vous. Ce que couvre un dossier partagé est décidé par la personne à qui il appartient.",
"title": "Ne fait pas partie de l'accès partagé"
},
"unavailable": {
"description": "L'exploitant a désactivé {module} pour tous les comptes de ce serveur. Aucun réglage de votre côté ne peut le rétablir.",
"title": "Non disponible sur ce serveur"
}
},
"vaccinations": {
"title": "Vaccinations",
"subtitle": "Votre carnet de vaccination, dose par dose.",
@@ -9920,7 +9934,6 @@
"page": {
"title": "Apports en nutriments",
"description": "Hydratation et apports en vitamines/minéraux synchronisés depuis l'appli santé de votre téléphone, plus les entrées manuelles d'eau.",
"moduleOffTitle": "Le suivi des nutriments est désactivé",
"moduleOffDescription": "Activez le module nutriments pour voir ici l'hydratation et les apports en vitamines/minéraux. Les données synchronisées feront partie du dossier que le Coach peut lire.",
"moduleOffCta": "Activer les nutriments",
"emptyTitle": "Pas encore de données nutritionnelles",
+14 -1
View File
@@ -8682,6 +8682,20 @@
"description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo."
}
},
"moduleOff": {
"disabled": {
"description": "{module} è disattivato per questi dati, quindi le sue pagine restano nascoste. Riattivalo per ritrovarle.",
"title": "{module} è disattivato"
},
"notGranted": {
"description": "{module} è fuori dalle sezioni condivise con te. Cosa comprende un accesso condiviso lo decide la persona a cui appartengono i dati.",
"title": "Non fa parte dell'accesso condiviso"
},
"unavailable": {
"description": "Chi gestisce il server ha disattivato {module} per tutti gli account. Nessuna impostazione tua può riportarlo indietro.",
"title": "Non disponibile su questo server"
}
},
"vaccinations": {
"title": "Vaccinazioni",
"subtitle": "Il tuo libretto vaccinale, dose per dose.",
@@ -9920,7 +9934,6 @@
"page": {
"title": "Apporto di nutrienti",
"description": "Idratazione e apporto di vitamine/minerali sincronizzati dall'app salute del telefono, più le voci di acqua inserite manualmente.",
"moduleOffTitle": "L'apporto di nutrienti è disattivato",
"moduleOffDescription": "Attiva il modulo nutrienti per vedere qui idratazione e apporto di vitamine/minerali. I dati sincronizzati diventano parte dei dati che il Coach può leggere.",
"moduleOffCta": "Attiva nutrienti",
"emptyTitle": "Ancora nessun dato sui nutrienti",
+14 -1
View File
@@ -8682,6 +8682,20 @@
"description": "예방접종 기록을 구조화해서: 용량, 차수, 다음 추가 접종."
}
},
"moduleOff": {
"disabled": {
"description": "이 기록에서 {module} 모듈이 꺼져 있어서 관련 페이지가 보이지 않아요. 다시 켜면 돌아와요.",
"title": "{module} 모듈이 꺼져 있어요"
},
"notGranted": {
"description": "{module} 모듈은 공유된 영역 밖에 있어요. 공유된 기록이 어디까지 포함되는지는 기록의 주인이 정해요.",
"title": "공유 범위에 없는 항목"
},
"unavailable": {
"description": "운영자가 이 서버의 모든 계정에서 {module} 모듈을 껐어요. 개인 설정으로는 되돌릴 수 없어요.",
"title": "이 서버에서는 사용할 수 없어요"
}
},
"vaccinations": {
"title": "예방접종",
"subtitle": "접종 한 건씩 남기는 예방접종 기록이에요.",
@@ -9920,7 +9934,6 @@
"page": {
"title": "영양소 섭취",
"description": "휴대폰 건강 앱에서 동기화된 수분·비타민/미네랄 섭취량과 직접 입력한 수분 기록이에요.",
"moduleOffTitle": "영양소 섭취가 꺼져 있어요",
"moduleOffDescription": "영양소 모듈을 켜면 수분과 비타민/미네랄 섭취량을 여기서 볼 수 있어요. 동기화된 섭취량은 코치가 읽는 기록에 포함돼요.",
"moduleOffCta": "영양소 켜기",
"emptyTitle": "아직 영양소 데이터가 없어요",
+14 -1
View File
@@ -8682,6 +8682,20 @@
"description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie."
}
},
"moduleOff": {
"disabled": {
"description": "{module} jest wyłączone dla tego zapisu, więc jego strony pozostają ukryte. Włącz moduł ponownie, aby je odzyskać.",
"title": "{module} jest wyłączone"
},
"notGranted": {
"description": "{module} jest poza sekcjami udostępnionymi tobie. O tym, co obejmuje udostępniony zapis, decyduje osoba, do której należy.",
"title": "Nie należy do udostępnionego dostępu"
},
"unavailable": {
"description": "Operator wyłączył {module} dla wszystkich kont na tym serwerze. Żadne twoje ustawienie tego nie przywróci.",
"title": "Niedostępne na tym serwerze"
}
},
"vaccinations": {
"title": "Szczepienia",
"subtitle": "Twoja karta szczepień, dawka po dawce.",
@@ -9920,7 +9934,6 @@
"page": {
"title": "Spożycie składników odżywczych",
"description": "Nawodnienie oraz spożycie witamin/minerałów zsynchronizowane z aplikacji zdrowia telefonu, plus ręczne wpisy wody.",
"moduleOffTitle": "Śledzenie składników odżywczych jest wyłączone",
"moduleOffDescription": "Włącz moduł składników odżywczych, aby zobaczyć tu nawodnienie i spożycie witamin/minerałów. Zsynchronizowane dane staną się częścią zapisu, który może odczytać Coach.",
"moduleOffCta": "Włącz składniki odżywcze",
"emptyTitle": "Brak jeszcze danych o składnikach odżywczych",
+19 -2
View File
@@ -57,7 +57,7 @@ import {
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
import {
maskModulesToSections,
buildModuleDisclosure,
sectionsOpen,
} from "@/lib/sharing/module-disclosure";
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
@@ -144,7 +144,15 @@ export const GET = apiHandler(async () => {
// 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);
// 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.
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
@@ -249,6 +257,15 @@ export const GET = apiHandler(async () => {
// 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.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
+18 -9
View File
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { HydrationCard } from "@/components/insights/nutrients/hydration-card";
import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card";
@@ -52,11 +53,12 @@ function lastAttemptReasonKey(reason: string): string {
* (self-gates to nothing without data), micronutrients (self-gates to
* an EmptyState without data). Degradation ladder:
*
* - module off → one EmptyState with an in-context enable CTA. The
* module STAYS opt-in (2026-07-17 memo — the HealthKit read prompt
* on the device is not visible consent to a server / Coach holding
* a supplement pattern); this page just makes the toggle
* discoverable in context instead of buried in Settings.
* - module off → the shared `ModuleDisabledNotice`, which names the reason
* and carries the in-context enable CTA when it is the record's own
* switch that is off. The module STAYS opt-in (2026-07-17 memo — the
* HealthKit read prompt on the device is not visible consent to a
* server / Coach holding a supplement pattern); this page just makes
* the toggle discoverable in context instead of buried in Settings.
* - module on, the overview read failed → a `QueryErrorCard` with a
* retry. This branch has to sit ABOVE the empty-state check below:
* `overview.data` is `undefined` on a failed read exactly like it is
@@ -106,11 +108,18 @@ export default function InsightsNutrientsPage() {
if (!nutrientsEnabled) {
return (
<SubPageShell title={t("nutrients.page.title")}>
<EmptyState
{/*
The shared notice rather than this page's own copy: "nutrients is
off" was three situations wearing one sentence, and only one of them
is fixed by the button below. A delegate whose grant does not reach
the measurements section, and an account on an instance where the
operator removed the module, both used to be handed a switch that
would refuse them. The notice reads the reason off `moduleAccess`
and drops the action for the two cases it cannot help.
*/}
<ModuleDisabledNotice
moduleKey="nutrients"
icon={<Leaf className="size-6" />}
title={t("nutrients.page.moduleOffTitle")}
description={t("nutrients.page.moduleOffDescription")}
ctaSize="lg"
action={
<Button
size="sm"
@@ -0,0 +1,92 @@
"use client";
import type { ReactNode } from "react";
import { EmptyState } from "@/components/ui/empty-state";
import { useAuth } from "@/hooks/use-auth";
import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import { useTranslations } from "@/lib/i18n/context";
/**
* The page a person reached for a module that is not there, saying which of
* the three reasons it is.
*
* Modelled on `shared-record-unavailable.tsx`, which solves the same problem
* one layer over. The difference this file exists for is that "module off"
* has never been one situation: the record has it switched off, the grant this
* session is inside does not cover it, or the operator switched it off for the
* whole instance. The boolean map collapses all three into `false`, so an
* empty state built on it either says nothing or says something that may be
* untrue — telling a delegate to go and turn on a switch that is not theirs,
* or telling an account holder to turn on a module the operator removed.
*
* `moduleAccess` carries the reason, so this says it. The action is offered
* ONLY for the record's own switch: nobody else's state is fixable from here,
* and an affordance that cannot work is worse than none.
*
* Paint, not enforcement. Every route behind a module refuses on its own
* through the server gate; deleting this file would restore a wall of 403s,
* not open a door.
*/
const NOTICE_COPY: Record<
Exclude<ModuleAccessState, "enabled">,
{ title: string; description: string }
> = {
disabled: {
title: "moduleOff.disabled.title",
description: "moduleOff.disabled.description",
},
not_granted: {
title: "moduleOff.notGranted.title",
description: "moduleOff.notGranted.description",
},
unavailable: {
title: "moduleOff.unavailable.title",
description: "moduleOff.unavailable.description",
},
};
export interface ModuleDisabledNoticeProps {
moduleKey: ModuleKey;
icon?: ReactNode;
/**
* Rendered only when the record's own switch is what is off. A delegate
* outside the grant and an instance-wide switch both have nothing here the
* reader can act on.
*/
action?: ReactNode;
}
export function ModuleDisabledNotice({
moduleKey,
icon,
action,
}: ModuleDisabledNoticeProps) {
const { t } = useTranslations();
const { user } = useAuth();
// Fall back to the boolean rather than to a guess: a server image that
// predates the field publishes no reason, and "the record has it off" is the
// only one of the three that is true for every account on such a server.
const state: Exclude<ModuleAccessState, "enabled"> =
user?.moduleAccess?.[moduleKey] &&
user.moduleAccess[moduleKey] !== "enabled"
? (user.moduleAccess[moduleKey] as Exclude<ModuleAccessState, "enabled">)
: "disabled";
const copy = NOTICE_COPY[state];
const moduleName = t(MODULE_REGISTRY[moduleKey].labelKey);
return (
<div data-slot="module-disabled-notice" data-module-access={state}>
<EmptyState
icon={icon}
title={t(copy.title, { module: moduleName })}
description={t(copy.description, { module: moduleName })}
ctaSize="lg"
action={state === "disabled" ? action : undefined}
/>
</div>
);
}
+25
View File
@@ -22,6 +22,7 @@ import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format";
import { storeTimezone } from "@/lib/timezone-mirror";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import {
@@ -196,6 +197,22 @@ export interface AuthUser {
* payload so a missing map reads as all-available.
*/
moduleAvailability?: Partial<Record<ModuleKey, boolean>>;
/**
* Why each module is or is not available for the record this browser is
* inside — `"enabled"`, `"disabled"` (the record has it switched off),
* `"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.
*
* Additive beside `modules`, never instead of it: `modules[key]` is exactly
* `moduleAccess[key] === "enabled"`, so every gate keeps reading the boolean
* and only the surfaces that have to EXPLAIN a missing module read this. A
* client holding the boolean alone has to guess between three different
* reasons, which is how an empty state ends up offering a delegate a switch
* they cannot reach. Coerced to `{}` against a stale /me payload, and a
* missing key falls back to the boolean.
*/
moduleAccess?: Partial<Record<ModuleKey, ModuleAccessState>>;
/**
* v1.36.0 — account sharing, resolved server-side. `accounts` is the
* switcher's menu, `active` is the record this browser is inside (null when
@@ -389,6 +406,14 @@ export async function fetchMe(): Promise<AuthUser> {
data.moduleAvailability && typeof data.moduleAvailability === "object"
? data.moduleAvailability
: {},
// The reason behind each module's boolean. Coerced against a stale /me
// payload to an empty map; `useModuleAccess` then falls back to the
// boolean, so an older server image reads "disabled" rather than
// inventing a reason it was never told.
moduleAccess:
data.moduleAccess && typeof data.moduleAccess === "object"
? data.moduleAccess
: {},
accountAccess,
accountAccessStatus,
};
+3 -1
View File
@@ -34,6 +34,7 @@ import {
inviteManagedProfileGuardianSchema,
updateManagedProfileSchema,
} from "@/lib/validations/managed-profiles";
import { moduleAccessMap } from "./profile";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
/**
@@ -448,12 +449,13 @@ const accountPayload = z
id: z.string(),
username: z.string(),
accountAccess: accountAccessBlock,
moduleAccess: moduleAccessMap,
recordSession: recordSessionState.nullable(),
})
.meta({
id: "AccountPayload",
description:
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked.",
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the `modules` booleans it never contradicts.",
});
/**
+26
View File
@@ -87,6 +87,32 @@ const moduleMapResolved = z
"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, and it is masked to the sections the active grant opens — a module outside them reads `false` rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account, nothing is masked and the payload is unchanged.",
});
/**
* The same answer as `ModuleMap`, with the reason attached.
*
* Additive and beside the booleans, which keep their meaning: for every key
* `modules[key]` is exactly `moduleAccess[key] === "enabled"`. A client that
* only gates a surface keeps reading the boolean; a client that has to TELL
* somebody why a surface is missing reads this, because the boolean collapses
* three different situations into one `false`.
*/
export const moduleAccessMap = z
.object(
Object.fromEntries(
MODULE_KEYS.map((k) => [
k,
z
.enum(["enabled", "disabled", "not_granted", "unavailable"])
.describe(`Why the "${k}" module is or is not available here.`),
]),
),
)
.meta({
id: "ModuleAccessMap",
description:
"Why each toggleable module is or is not available for the record this session is inside. `enabled` — the module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the active grant's sections do not open the module's section, which also covers every module that reads across the whole record (achievements, the assistant surfaces, the export, environment, the account's own MCP endpoint) since no scoped grant opens one. `unavailable` — the operator switched the module off for the whole instance. Precedence, highest first: `unavailable` > `not_granted` > `disabled` > `enabled`, so the reason published is the one nothing further in can change. Own-record sessions and unscoped grants never produce `not_granted`. Every key is present, and `modules[key] === (moduleAccess[key] === \"enabled\")` holds for every key — this field adds a reason, never a different gate.",
});
const moduleMapEnvelopeInner = z
.object({ modules: moduleMapResolved, updatedAt: updatedAtTokenField })
.meta({ id: "ModulesResponse" });
@@ -0,0 +1,152 @@
/**
* The two maps `GET /api/auth/me` publishes for a record, and the one thing
* that must stay true between them.
*
* `modules` is the boolean gate every client already reads; `moduleAccess` is
* the same answer with its reason attached. They are built independently in
* `buildModuleDisclosure` — the boolean half still runs through
* `maskModulesToSections` — precisely so this file can assert they agree
* rather than restate a derivation. Delete the mask from either half and the
* invariant leg goes red.
*/
import { describe, it, expect } from "vitest";
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
import type { ShareDomain } from "@/lib/sharing/scope";
import {
MODULE_SHARE_DOMAIN,
buildModuleDisclosure,
} from "../module-disclosure";
function allModules(value: boolean): Record<ModuleKey, boolean> {
return Object.fromEntries(MODULE_KEYS.map((k) => [k, value])) as Record<
ModuleKey,
boolean
>;
}
const AVAILABLE = allModules(true);
describe("buildModuleDisclosure", () => {
it("answers enabled / disabled only for a record with no grant over it", () => {
const resolved = { ...allModules(true), mood: false, labs: false };
const { moduleAccess } = buildModuleDisclosure(resolved, AVAILABLE, null);
expect(moduleAccess.mood).toBe("disabled");
expect(moduleAccess.labs).toBe("disabled");
expect(moduleAccess.sleep).toBe("enabled");
expect(new Set(Object.values(moduleAccess))).toEqual(
new Set(["enabled", "disabled"]),
);
});
it("marks the sections a scoped grant does not open as not_granted", () => {
const sections: ShareDomain[] = ["measurements"];
const { moduleAccess } = buildModuleDisclosure(
allModules(true),
AVAILABLE,
sections,
);
// Inside the grant.
expect(moduleAccess.sleep).toBe("enabled");
expect(moduleAccess.glucose).toBe("enabled");
// A section the grant does not name.
expect(moduleAccess.labs).toBe("not_granted");
expect(moduleAccess.cycle).toBe("not_granted");
// Record-spanning: no scoped grant opens a module with a null domain, so
// the answer is the same one the boolean mask gives.
for (const key of MODULE_KEYS) {
if (MODULE_SHARE_DOMAIN[key] === null) {
expect(moduleAccess[key], key).toBe("not_granted");
}
}
});
it("never says not_granted for an unscoped grant or an own record", () => {
// `null` sections are both cases at once: an own-record session has no
// grant to narrow to, and an unscoped grant names the whole record.
const { moduleAccess } = buildModuleDisclosure(
{ ...allModules(true), coach: false },
AVAILABLE,
null,
);
expect(Object.values(moduleAccess)).not.toContain("not_granted");
expect(moduleAccess.coach).toBe("disabled");
});
it("lets the operator's instance-wide switch outrank every other reason", () => {
const operator = { ...AVAILABLE, labs: false, sleep: false };
// The resolved map already has the operator layer AND-ed in, which is why
// an operator-off module arrives here as `false` and would otherwise be
// indistinguishable from the record's own choice.
const resolved = { ...allModules(true), labs: false, sleep: false };
// Off for the record's own session…
const own = buildModuleDisclosure(resolved, operator, null);
expect(own.moduleAccess.labs).toBe("unavailable");
// …and still the operator's switch under a grant that does not open the
// section, because the switch that would have to move is theirs either way.
const scoped = buildModuleDisclosure(resolved, operator, ["measurements"]);
expect(scoped.moduleAccess.labs).toBe("unavailable");
expect(scoped.moduleAccess.sleep).toBe("unavailable");
expect(scoped.moduleAccess.cycle).toBe("not_granted");
});
it("keeps modules[key] equal to moduleAccess[key] === enabled everywhere", () => {
const sectionSets: Array<readonly ShareDomain[] | null> = [
null,
[],
["measurements"],
["measurements", "labs"],
["cycle", "mind", "medications", "illness", "documents", "profile"],
];
const resolvedMaps = [
allModules(true),
allModules(false),
{ ...allModules(true), mood: false, cycle: false, medications: false },
];
const operatorMaps = [
AVAILABLE,
allModules(false),
{ ...AVAILABLE, insights: false, mood: false },
];
let checked = 0;
for (const sections of sectionSets) {
for (const resolved of resolvedMaps) {
for (const operator of operatorMaps) {
// The operator layer is AND-ed into the resolved map upstream; mirror
// that here or the fixture would describe a state the gate cannot
// produce.
const effective = Object.fromEntries(
MODULE_KEYS.map((k) => [k, resolved[k] && operator[k]]),
) as Record<ModuleKey, boolean>;
const { modules, moduleAccess } = buildModuleDisclosure(
effective,
operator,
sections,
);
for (const key of MODULE_KEYS) {
expect(
modules[key],
`${key} under sections=${JSON.stringify(sections)}`,
).toBe(moduleAccess[key] === "enabled");
checked += 1;
}
}
}
}
expect(checked).toBe(
sectionSets.length *
resolvedMaps.length *
operatorMaps.length *
MODULE_KEYS.length,
);
expect(checked).toBeGreaterThan(0);
});
});
+96 -1
View File
@@ -26,7 +26,11 @@
* own route inventory gives them: `/`, `/achievements` and the narrative
* surfaces read across sections, and a selected-domain grant never opens one.
*/
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
import {
MODULE_KEYS,
isCodeDisabledModule,
type ModuleKey,
} from "@/lib/modules/registry";
import type { ShareDomain } from "@/lib/sharing/scope";
/**
@@ -102,3 +106,94 @@ export function maskModulesToSections(
}
return masked;
}
/**
* Why a module's surfaces are not there.
*
* The boolean map answers "paint it or not", which is all a gate needs and
* strictly less than a person needs. Three different situations collapse into
* the same `false`: the record has the module switched off, the grant this
* session is inside does not cover it, and the operator turned it off for the
* whole instance. A client holding only the boolean has to guess between them,
* so the empty state either says nothing or says something that may be untrue —
* offering a delegate a switch they cannot reach, or telling an account to turn
* on a module the operator removed.
*
* `enabled` is the module the client may paint. `disabled` is the record's own
* switch. `not_granted` is the mask above: the active grant's sections do not
* open this module's section, or the module reads across the record and no
* scoped grant opens one. `unavailable` is the operator's instance-wide switch.
*/
export type ModuleAccessState =
"enabled" | "disabled" | "not_granted" | "unavailable";
/**
* Precedence, highest first: `unavailable` > `not_granted` > `disabled` >
* `enabled`. It runs outside-in — what the operator decided, then what the
* grant opens, then what the record chose — so the reason a client shows is
* the one nothing further in can change. An operator-disabled module reads
* `unavailable` for a delegate too, rather than `not_granted`, because the
* switch that would have to move is the operator's either way.
*/
function resolveModuleAccess(
key: ModuleKey,
modules: Record<ModuleKey, boolean>,
operatorAvailability: Record<ModuleKey, boolean>,
sections: readonly ShareDomain[] | null,
): ModuleAccessState {
// The instance layer, both halves of it: the operator's availability blob
// and the code-level hard-off. `CODE_DISABLED_MODULE_KEYS` is empty today,
// so this term changes no answer; it is here because a module switched off
// in code is not available on this instance either, and without it such a
// module would report itself as the record's own choice.
if (isCodeDisabledModule(key) || operatorAvailability[key] === false) {
return "unavailable";
}
const domain = MODULE_SHARE_DOMAIN[key];
if (
sections !== null &&
(domain === null || !sectionsOpen(sections, domain))
) {
return "not_granted";
}
return modules[key] ? "enabled" : "disabled";
}
/** Both maps the account payload publishes for the active record. */
export interface ModuleDisclosure {
/** The boolean gate map, masked to the grant. Unchanged semantics. */
modules: Record<ModuleKey, boolean>;
/** The same answer with its reason attached. */
moduleAccess: Record<ModuleKey, ModuleAccessState>;
}
/**
* Build both maps for one record, as one grant may read them.
*
* The two halves are computed independently rather than one derived from the
* other, and that is deliberate: `modules[key] === (moduleAccess[key] ===
* "enabled")` is the contract every client leans on, and deriving one from the
* other would turn the test of it into a tautology. `modules` keeps running
* through {@link maskModulesToSections}, byte for byte what it was.
*
* `modules` is the ALREADY-RESOLVED map (`resolveModuleMap`), which has the
* operator layer AND-ed in; `operatorAvailability` is that layer on its own
* (`getOperatorModuleAvailability`), which is what makes the operator's
* decision separable from the record's.
*/
export function buildModuleDisclosure(
modules: Record<ModuleKey, boolean>,
operatorAvailability: Record<ModuleKey, boolean>,
sections: readonly ShareDomain[] | null,
): ModuleDisclosure {
const moduleAccess = {} as Record<ModuleKey, ModuleAccessState>;
for (const key of MODULE_KEYS) {
moduleAccess[key] = resolveModuleAccess(
key,
modules,
operatorAvailability,
sections,
);
}
return { modules: maskModulesToSections(modules, sections), moduleAccess };
}
@@ -84,6 +84,7 @@ async function signIn(userId: string) {
interface MePayload {
modules: Record<string, boolean>;
moduleAccess: Record<string, string>;
cycleTrackingEnabled: boolean;
accountAccess: {
active: { accountId: string; sections: string[] | null } | null;
@@ -97,6 +98,27 @@ async function readMe(): Promise<MePayload> {
return ((await res.json()) as { data: MePayload }).data;
}
/**
* The one thing that must hold between the two module maps, asserted on every
* payload this file reads.
*
* `moduleAccess` is additive: it says WHY, and a client that only gates a
* surface keeps reading the boolean. The moment the two disagree, one of the
* two answers is wrong and there is no way to tell which from the wire — so
* the pairing is checked here against the real route rather than only against
* the pure builder, where a wiring mistake in the route would not show.
*/
function expectAccessMatchesBooleans(payload: MePayload) {
const keys = Object.keys(payload.modules);
expect(keys.length).toBeGreaterThan(10);
for (const key of keys) {
expect(payload.moduleAccess[key], `a reason for ${key}`).toBeDefined();
expect(payload.modules[key], `modules.${key}`).toBe(
payload.moduleAccess[key] === "enabled",
);
}
}
async function patchModules(body: unknown) {
const { PATCH } = await import("@/app/api/record-settings/[family]/route");
return PATCH(
@@ -378,6 +400,48 @@ describe("what a SCOPED grant is told about the record's modules", () => {
}
});
it("says WHY the modules outside the grant are not there", async () => {
// The boolean above cannot tell "the owner switched it off" from "your
// grant does not reach it", so a delegate's empty state either says
// nothing or offers them a switch that is not theirs. This is the field
// that separates the two.
await delegateInside(["measurements"]);
const payload = await readMe();
expectAccessMatchesBooleans(payload);
for (const key of [
"cycle",
"mood",
"mentalHealth",
"illness",
"labs",
"medications",
"inboundDocuments",
] as const) {
expect(payload.moduleAccess[key], `the delegate's ${key}`).toBe(
"not_granted",
);
}
// Record-spanning modules have no single section to open, so no scoped
// grant reaches them — the same answer the boolean mask gives.
for (const key of [
"achievements",
"coach",
"insights",
"doctorReport",
] as const) {
expect(payload.moduleAccess[key], `the delegate's ${key}`).toBe(
"not_granted",
);
}
for (const key of ["sleep", "glucose", "workouts", "recovery"] as const) {
expect(payload.moduleAccess[key], `the delegate's ${key}`).toBe(
"enabled",
);
}
});
it("leaves an unscoped grant reading the record's true map", async () => {
// `scope: null` is the whole record, which every grant written before
// scoping carries. Masking it would break the case the record scoping was
@@ -391,4 +455,79 @@ describe("what a SCOPED grant is told about the record's modules", () => {
expect(payload.modules).toEqual(await resolveModuleMap(owner.id));
expect(payload.cycleTrackingEnabled).toBe(true);
});
it("never tells an unscoped delegate a module is out of their grant", async () => {
// `not_granted` is the mask speaking, and there is no mask here. An
// unscoped grant that reported one would be describing a narrowing the
// owner never made.
await delegateInside(null);
const payload = await readMe();
expectAccessMatchesBooleans(payload);
// `mcp` ships opt-in, so "disabled" is expected here; what must never
// appear is a reason that only a narrowing could produce.
for (const [key, state] of Object.entries(payload.moduleAccess)) {
expect(["enabled", "disabled"], `${key} reads ${state}`).toContain(state);
}
});
});
describe("a module the operator switched off server-wide", () => {
/** The operator's instance-wide blob. Disabled allowlist: only `false` bites. */
async function operatorDisables(keys: string[]) {
await getPrismaClient().appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
moduleAvailabilityJson: Object.fromEntries(keys.map((k) => [k, false])),
},
update: {
moduleAvailabilityJson: Object.fromEntries(keys.map((k) => [k, false])),
},
});
}
it("reads unavailable rather than as the record's own choice", async () => {
// The boolean is `false` either way, which is exactly the confusion: the
// account holder is told to go and turn on a switch the operator removed,
// and the Modules hub they are sent to has no live control to offer.
const user = await makeUser("own");
await signIn(user.id);
await operatorDisables(["labs"]);
const payload = await readMe();
expectAccessMatchesBooleans(payload);
expect(payload.modules.labs).toBe(false);
expect(payload.moduleAccess.labs).toBe("unavailable");
// Untouched keys stay the record's own answer.
expect(payload.moduleAccess.sleep).toBe("enabled");
});
it("outranks the grant mask for a scoped delegate", async () => {
// Precedence, outside-in: the switch that would have to move is the
// operator's whether or not the grant reaches the section.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const { acceptGrant, inviteGrant } = await import("@/lib/sharing/grants");
const grant = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "READ",
scope: ["measurements"],
});
await acceptGrant({ grantId: grant.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchSessionTo(session.id, owner.id);
await operatorDisables(["labs", "sleep"]);
const payload = await readMe();
expectAccessMatchesBooleans(payload);
// Outside the grant AND off server-wide.
expect(payload.moduleAccess.labs).toBe("unavailable");
// Inside the grant, off server-wide.
expect(payload.moduleAccess.sleep).toBe("unavailable");
// Outside the grant, available server-wide.
expect(payload.moduleAccess.cycle).toBe("not_granted");
expect(payload.moduleAccess.glucose).toBe("enabled");
});
});