merge(reconcile): r1-modulehub audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 00:59:31 +02:00
commit 5e6e010e2f
14 files changed
+216 -36

No files matched your search

+3 -1
View File
@@ -4120,7 +4120,9 @@
"description": "Die Grundlagen von HealthLog. Diese Bereiche lassen sich nicht abschalten.",
"note": "Kernbereich — immer aktiv"
},
"description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an."
"description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an.",
"manageIn": "In {section} verwalten",
"operatorDisabled": "Serverweit deaktiviert"
},
"gesundheitsakte": {
"title": "Gesundheitsakte",
+3 -1
View File
@@ -4120,7 +4120,9 @@
"description": "HealthLog's foundations. These areas can't be turned off.",
"note": "Core area — always on"
},
"description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away."
"description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away.",
"manageIn": "Manage in {section}",
"operatorDisabled": "Disabled server-wide"
},
"gesundheitsakte": {
"title": "Health record",
+3 -1
View File
@@ -4120,7 +4120,9 @@
"description": "Los pilares de HealthLog. Estas áreas no se pueden desactivar.",
"note": "Área esencial — siempre activa"
},
"description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante."
"description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante.",
"manageIn": "Gestionar en {section}",
"operatorDisabled": "Desactivado en todo el servidor"
},
"gesundheitsakte": {
"title": "Historial médico",
+3 -1
View File
@@ -4120,7 +4120,9 @@
"description": "Les fondations de HealthLog. Ces domaines ne peuvent pas être désactivés.",
"note": "Domaine essentiel — toujours actif"
},
"description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt."
"description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt.",
"manageIn": "Gérer dans {section}",
"operatorDisabled": "Désactivé sur tout le serveur"
},
"gesundheitsakte": {
"title": "Dossier médical",
+3 -1
View File
@@ -4120,7 +4120,9 @@
"description": "Le fondamenta di HealthLog. Questi ambiti non possono essere disattivati.",
"note": "Ambito di base — sempre attivo"
},
"description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito."
"description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito.",
"manageIn": "Gestisci in {section}",
"operatorDisabled": "Disattivato a livello di server"
},
"gesundheitsakte": {
"title": "Cartella clinica",
+3 -1
View File
@@ -4120,7 +4120,9 @@
"description": "Podstawy HealthLog. Tych obszarów nie można wyłączyć.",
"note": "Obszar podstawowy — zawsze aktywny"
},
"description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu."
"description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu.",
"manageIn": "Zarządzaj w {section}",
"operatorDisabled": "Wyłączone na całym serwerze"
},
"gesundheitsakte": {
"title": "Dokumentacja zdrowotna",
+8 -5
View File
@@ -11,15 +11,18 @@
* returns the freshly-resolved module map.
*
* The body schema (`modulePrefsPatchSchema`) is `strict()` over the
* canonical toggleable key set, so a core-domain key (`weight`,
* `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422 —
* the core measurement engine + meds can never be disabled here.
* directly-owned toggleable key set, so a core-domain key (`weight`,
* `bloodPressure`, `pulse`, `medications`), a delegated key (`cycle`,
* `coach`), or any unknown key is a 422 — the core measurement engine +
* meds can never be disabled here, and a value for a delegated module can
* never land inert in `modulePreferencesJson`. The delegated modules are
* managed at their real control (cycle in Account, coach in Settings →
* Coach); the Modules hub deep-links there rather than offering a dead
* toggle.
*
* `userId` is always narrowed from `requireAuth()`; the body never
* carries it. The semantics are a DISABLED allowlist: a key set to
* `false` disables that module; `true` (or absence) leaves it enabled.
* cycle/coach keys are accepted for forward-compat but the gate ignores
* the blob for them (they delegate to their existing source of truth).
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
+17 -2
View File
@@ -6,7 +6,10 @@ import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { resolveModuleMap } from "@/lib/modules/gate";
import {
resolveModuleMap,
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
@@ -22,7 +25,7 @@ export const GET = apiHandler(async () => {
// 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] = await Promise.all([
const [, cycleProfile, modules, moduleAvailability] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
@@ -34,6 +37,12 @@ export const GET = apiHandler(async () => {
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(user.id),
// 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.
getOperatorModuleAvailability(),
]);
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
@@ -99,5 +108,11 @@ export const GET = apiHandler(async () => {
// resolved `disableCoach` + operator master flag, so this map is the
// single thing a client needs to gate every secondary domain.
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,
});
});
@@ -20,7 +20,11 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { MODULE_KEYS, CORE_DOMAIN_KEYS } from "@/lib/modules/registry";
import {
MODULE_KEYS,
CORE_DOMAIN_KEYS,
moduleDelegatesTo,
} from "@/lib/modules/registry";
// Capture the latest useMutation config + a shared mutate spy so we can
// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a
@@ -104,12 +108,22 @@ afterEach(() => {
});
describe("<ModulesSection>", () => {
it("renders a Switch for every toggleable + core module", () => {
it("renders a Switch for every non-delegated toggleable module, a managed deep-link for delegated ones, and locked switches for core", () => {
const html = render();
for (const key of MODULE_KEYS) {
expect(html, `toggleable switch ${key}`).toContain(
`id="module-toggle-${key}"`,
);
if (moduleDelegatesTo(key) !== undefined) {
// Delegated modules (cycle/coach) are owned by their real control
// elsewhere — they render as a read-only "manage in X" deep-link,
// never a live Switch that would write an inert disabled-allowlist
// entry the gate ignores.
expect(html, `delegated has no switch ${key}`).not.toContain(
`id="module-toggle-${key}"`,
);
} else {
expect(html, `toggleable switch ${key}`).toContain(
`id="module-toggle-${key}"`,
);
}
}
for (const key of CORE_DOMAIN_KEYS) {
expect(html, `core switch ${key}`).toContain(`id="module-toggle-${key}"`);
+69 -14
View File
@@ -9,14 +9,25 @@
* row is presentation-only — the parent `<ModulesSection>` owns the mutation
* and cache invalidation, so this component stays a pure, easily-tested leaf.
*
* Two modes:
* Four modes:
* - toggleable: the Switch is live; flipping it calls `onToggle(next)`.
* - locked (core domains): the Switch is checked + disabled with a short
* "always on" note, so the always-on measurement engine + meds read as
* deliberately fixed rather than broken. Palette stays neutral — no
* alarming green-when-on / red-when-off tint.
* deliberately fixed rather than broken.
* - managed elsewhere (delegated modules cycle/coach): no Switch at all —
* the real on/off lives in another section, so the row renders a
* read-only deep-link ("Manage in X") rather than a dead toggle that
* no-ops and snaps back behind a misleading "saved" toast.
* - operator-disabled: the operator turned the module off server-wide, so
* the Switch is replaced by a read-only "disabled server-wide" note —
* a per-user toggle could not re-enable it anyway.
*
* Palette stays neutral throughout — no alarming green-when-on /
* red-when-off tint.
*/
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
@@ -37,6 +48,21 @@ export interface ModuleToggleRowProps {
locked?: boolean;
/** One-line "core / always on" note, only shown when `locked`. */
lockedNote?: string;
/**
* Delegated modules (cycle/coach): the real control lives elsewhere. When
* set, the row renders a read-only deep-link instead of a Switch.
* `label` is the destination name; `href` the in-app link.
*/
managedAt?: { href: string; label: string };
/** Localised "Manage in {section}" link text (managed-elsewhere rows). */
manageLinkLabel?: string;
/**
* Operator turned this module off server-wide. Replaces the Switch with a
* read-only note; a per-user toggle cannot re-enable it. `operatorNote`
* is the localised "disabled server-wide" copy.
*/
operatorDisabled?: boolean;
operatorNote?: string;
/** Disable the Switch while a mutation is in flight (toggleable rows). */
pending?: boolean;
/** Fired with the next desired state on a user toggle (toggleable rows). */
@@ -51,10 +77,18 @@ export function ModuleToggleRow({
enabled,
locked = false,
lockedNote,
managedAt,
manageLinkLabel,
operatorDisabled = false,
operatorNote,
pending = false,
onToggle,
}: ModuleToggleRowProps) {
const inputId = `module-toggle-${moduleKey}`;
// A delegated module whose real control lives elsewhere, or an
// operator-disabled module: either way no live Switch — the per-user
// toggle would be a dead no-op.
const managed = managedAt != null;
return (
<div className="flex items-start justify-between gap-4 py-3">
<div className="flex min-w-0 items-start gap-3">
@@ -63,23 +97,44 @@ export function ModuleToggleRow({
aria-hidden="true"
/>
<div className="min-w-0 space-y-0.5">
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
{managed ? (
<p className="text-sm font-medium">{label}</p>
) : (
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
)}
<p className="text-muted-foreground text-xs">{description}</p>
{locked && lockedNote ? (
<p className="text-muted-foreground/80 text-xs">{lockedNote}</p>
) : null}
</div>
</div>
<Switch
id={inputId}
checked={locked ? true : enabled}
disabled={locked || pending}
onCheckedChange={locked ? undefined : (v) => onToggle?.(v)}
className="mt-0.5 shrink-0"
aria-label={label}
/>
{operatorDisabled ? (
// Operator kill-switch: read-only note, no toggle.
<span className="text-muted-foreground/80 mt-0.5 shrink-0 text-xs">
{operatorNote}
</span>
) : managed ? (
// Delegated module: deep-link to the real control instead of a
// dead toggle.
<Link
href={managedAt.href}
className="text-muted-foreground hover:text-foreground mt-0.5 inline-flex shrink-0 items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{manageLinkLabel}
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
) : (
<Switch
id={inputId}
checked={locked ? true : enabled}
disabled={locked || pending}
onCheckedChange={locked ? undefined : (v) => onToggle?.(v)}
className="mt-0.5 shrink-0"
aria-label={label}
/>
)}
</div>
);
}
@@ -52,6 +52,7 @@ import { queryKeys } from "@/lib/query-keys";
import {
CORE_DOMAIN_KEYS,
MODULE_REGISTRY,
moduleDelegatesTo,
type CoreDomainKey,
type ModuleKey,
} from "@/lib/modules/registry";
@@ -85,12 +86,16 @@ export function ModulesSection() {
const queryClient = useQueryClient();
const modules = user?.modules ?? {};
const moduleAvailability = user?.moduleAvailability ?? {};
const toggle = useMutation({
// Factory-routed; the in-repo eslint rule forbids a bare literal here.
mutationKey: queryKeys.modulesPrefs(),
mutationFn: async (vars: { key: ModuleKey; enabled: boolean }) => {
// DISABLED allowlist: send only the single key the user flipped.
// Delegated keys (cycle/coach) never reach here — they render as
// read-only deep-link rows, so the PATCH only ever carries a
// directly-owned key.
return apiPatch("/api/auth/me/modules", { [vars.key]: vars.enabled });
},
onSuccess: () => {
@@ -131,6 +136,14 @@ export function ModulesSection() {
const def = MODULE_REGISTRY[key];
// Default-on: a module is enabled unless explicitly `false`.
const enabled = modules[key] !== false;
// Delegated modules (cycle/coach) are owned elsewhere — never a
// live toggle here; deep-link to their real control instead.
const delegated = moduleDelegatesTo(key) !== undefined;
// Operator turned this module off server-wide: no per-user
// toggle can re-enable it, so show a read-only note. Delegated
// rows already deep-link, so this only matters for owned keys.
const operatorDisabled =
!delegated && moduleAvailability[key] === false;
return (
<ModuleToggleRow
key={key}
@@ -140,6 +153,23 @@ export function ModulesSection() {
description={t(def.descriptionKey)}
enabled={enabled}
pending={toggle.isPending}
managedAt={
delegated && def.managedAt
? {
href: def.managedAt.href,
label: t(def.managedAt.labelKey),
}
: undefined
}
manageLinkLabel={
delegated && def.managedAt
? t("settings.sections.modules.manageIn", {
section: t(def.managedAt.labelKey),
})
: undefined
}
operatorDisabled={operatorDisabled}
operatorNote={t("settings.sections.modules.operatorDisabled")}
onToggle={(next) => toggle.mutate({ key, enabled: next })}
/>
);
+18
View File
@@ -95,6 +95,17 @@ export interface AuthUser {
* absent), so live code reads a real map.
*/
modules?: Partial<Record<ModuleKey, boolean>>;
/**
* v1.18.0 — operator-layer availability per toggleable module. `false`
* ⇒ the operator disabled the module server-wide (off for every account,
* regardless of personal preference). Distinct from `modules`, which is
* the already-AND-ed effective state and cannot tell operator-off from
* user-off apart. Only the Modules hub needs this distinction (to render
* an operator-disabled module as a read-only "disabled server-wide" row);
* every other gate reads `modules`. Coerced to `{}` against a stale /me
* payload so a missing map reads as all-available.
*/
moduleAvailability?: Partial<Record<ModuleKey, boolean>>;
}
async function fetchMe(): Promise<AuthUser> {
@@ -137,6 +148,13 @@ async function fetchMe(): Promise<AuthUser> {
// open: a missing key reads as enabled and the nav stays intact.
modules:
data.modules && typeof data.modules === "object" ? data.modules : {},
// v1.18.0 — operator availability map, coerced against a stale payload
// to an empty map (all-available) so the Modules hub never shows a
// spurious "disabled server-wide" row on an older server image.
moduleAvailability:
data.moduleAvailability && typeof data.moduleAvailability === "object"
? data.moduleAvailability
: {},
};
}
+17
View File
@@ -65,6 +65,13 @@ export interface ModuleDefinition {
* there is exactly one source of truth. See `ModuleDelegation`.
*/
delegatesTo?: ModuleDelegation;
/**
* For delegated modules only: where the real on/off control lives, so the
* Modules hub can render a read-only "managed in X" row that deep-links to
* the canonical control instead of a dead toggle. `href` is the in-app
* link; `labelKey` names the destination ("Account", "Coach settings").
*/
managedAt?: { href: string; labelKey: string };
}
/**
@@ -138,6 +145,11 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
descriptionKey: "modules.cycle.description",
category: "tracking",
delegatesTo: "cycle",
// The real on/off lives in the Account section's cycle-tracking card.
managedAt: {
href: "/settings/account#cycle-tracking",
labelKey: "settings.sections.account.title",
},
},
mood: {
key: "mood",
@@ -187,6 +199,11 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
descriptionKey: "modules.coach.description",
category: "intelligence",
delegatesTo: "coach",
// The real on/off lives in the dedicated Coach settings section.
managedAt: {
href: "/settings/coach",
labelKey: "settings.sections.coach.title",
},
},
insights: {
key: "insights",
+20 -4
View File
@@ -11,17 +11,33 @@
* `strict()` rejects unknown keys outright rather than silently dropping
* them, so a client gets a clear error instead of a no-op.
*
* Delegated keys (`cycle`, `coach`) are NOT accepted here: their
* enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the
* operator assistant flag), so a value for them would land inert in
* `modulePreferencesJson` and mislead — the Modules hub renders them as
* read-only "managed in X" rows that deep-link to the real control. Omitting
* them from the schema means a stale client sending `{ coach: false }` gets
* a clear 422 instead of a silent no-op behind a green "saved" toast.
*
* The shared shape is reused by the OpenAPI registry so the wire
* contract stays single-source.
*/
import { z } from "zod/v4";
import { MODULE_KEYS } from "@/lib/modules/registry";
import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
/** Build `{ <toggleableKey>?: boolean }` from the canonical key list. */
/**
* The directly-owned (non-delegated) toggleable keys — the only keys this
* PATCH surface may write into `modulePreferencesJson`.
*/
const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
(k) => moduleDelegatesTo(k) === undefined,
);
/** Build `{ <writableKey>?: boolean }` from the directly-owned key list. */
const moduleShape = Object.fromEntries(
MODULE_KEYS.map((k) => [k, z.boolean().optional()]),
) as Record<(typeof MODULE_KEYS)[number], z.ZodOptional<z.ZodBoolean>>;
WRITABLE_MODULE_KEYS.map((k) => [k, z.boolean().optional()]),
) as Record<(typeof WRITABLE_MODULE_KEYS)[number], z.ZodOptional<z.ZodBoolean>>;
/**
* Partial module-preferences PATCH body. Every key is an optional