fix(modules): let the record's own module map close its pages for a delegate too

Six page gates read `inSharedRecord || user?.modules?.X`, with a comment saying
the delegate's own module map does not govern the owner's record. That was true
when it was written and stopped being true in v1.38.14: `GET /api/auth/me` now
publishes the RECORD's map, masked to what the grant opens, so the prefix that
protected against reading the wrong map now widens the gate instead. The
navigation drops the entry correctly while a typed URL still renders the page
for a record whose owner switched the module off, and `/api/vaccinations`
carries no module gate of its own, so it renders and works.

Deleting the prefix answers both cases from the one map, which is what it is
for. A structural guard holds the six expressions: it locates each gate, proves
it is a module gate, and then asserts it does not widen on the sharing
question. It finds nothing to assert on if the expression moves, and says so,
rather than passing on an empty match. All six legs fail against the previous
gates.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 11:04:49 +02:00
1 parent e7a1950f3a
commit 0ee26625be
7 files changed
+72 -20

No files matched your search

@@ -0,0 +1,62 @@
/**
* A module gate on a page decides from the RECORD's map, never from "am I a
* delegate".
*
* The six surfaces below each resolve one module before they render, and each
* of them used to read `inSharedRecord || user?.modules?.X`. The prefix was
* right when it was written: `GET /api/auth/me` published the ACTOR's module
* map, so a delegate's own preferences would have closed a page in somebody
* else's record. Since the map became the record's, masked to what the grant
* opens, the same prefix fails open in the other direction — the navigation
* correctly drops the entry while a typed URL still renders the page for a
* record whose owner switched the module off.
*
* A structural check rather than a render, because the property is about the
* EXPRESSION: there is no way to reach these pages in a unit test without
* standing up the auth query, the router and the shell, and the thing that
* regressed is one `||`. The matcher asserts it found each gate before it
* asserts anything about it, so an expression that moves or is renamed fails
* loudly instead of passing on an empty match.
*
* Its limit, stated: it holds these six files and knows nothing about a
* seventh. A new module page reads none of this.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
/** file → the gate assignment's left-hand side. */
const GATES: ReadonlyArray<[string, string]> = [
["src/app/vaccinations/page.tsx", "enabled"],
["src/app/cycle/page.tsx", "enabled"],
["src/app/mental-wellbeing/page.tsx", "enabled"],
["src/app/illness/page.tsx", "enabled"],
["src/app/illness/[id]/page.tsx", "enabled"],
["src/components/documents/documents-view.tsx", "moduleEnabled"],
];
describe("a page's module gate reads the record's map alone", () => {
it.each(GATES)("%s", (file, binding) => {
const source = readFileSync(join(ROOT, file), "utf8");
const match = source.match(
new RegExp(`const ${binding}\\s*=\\s*([^;]+);`, "s"),
);
expect(match, `no \`const ${binding} = …\` gate found in ${file}`).not.toBe(
null,
);
const expression = match![1];
// It has to be a module gate at all — otherwise the assertion below would
// pass on any expression that simply does not mention sharing.
expect(
expression,
`${binding} in ${file} is not the module gate`,
).toContain("modules");
expect(
expression,
`${file} widens its module gate for a delegate; the record's masked map already answers for both cases`,
).not.toContain("inSharedRecord");
});
});
+1 -3
View File
@@ -4,7 +4,6 @@ import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { CycleView } from "@/components/cycle/cycle-view";
@@ -23,10 +22,9 @@ import { CycleView } from "@/components/cycle/cycle-view";
*/
export default function CyclePage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
const enabled = inSharedRecord || user?.modules?.cycle !== false;
const enabled = user?.modules?.cycle !== false;
useEffect(() => {
if (isLoading) return;
+1 -3
View File
@@ -14,7 +14,6 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
import { BackLink } from "@/components/ui/back-link";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
@@ -25,12 +24,11 @@ export default function IllnessEpisodePage({
}) {
const { id } = use(params);
const { user, isAuthenticated, isLoading } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const { t } = useTranslations();
const enabled = inSharedRecord || user?.modules?.illness === true;
const enabled = user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
+1 -3
View File
@@ -4,7 +4,6 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessView } from "@/components/illness/illness-view";
@@ -21,11 +20,10 @@ import { IllnessView } from "@/components/illness/illness-view";
*/
export default function IllnessPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
// Born-gated: enabled ONLY when the resolved map says `true`.
const enabled = inSharedRecord || user?.modules?.illness === true;
const enabled = user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
+1 -3
View File
@@ -4,7 +4,6 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
@@ -27,10 +26,9 @@ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
*/
export default function MentalWellbeingPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
const enabled = inSharedRecord || user?.modules?.mentalHealth === true;
const enabled = user?.modules?.mentalHealth === true;
useEffect(() => {
if (isLoading) return;
+5 -6
View File
@@ -4,7 +4,6 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
@@ -22,13 +21,13 @@ import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
*/
export default function VaccinationsPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
// Default-on: hidden only when the resolved map says `false` explicitly. A
// shared-record session always sees it — the delegate's own module map does
// not govern the owner's record.
const enabled = inSharedRecord || user?.modules?.vaccinations !== false;
// Default-on: hidden only when the resolved map says `false` explicitly.
// The map is the RECORD's, masked to what the grant opens, so a delegate is
// answered about the record they are in rather than about their own — and a
// module the owner has switched off closes this page for them too.
const enabled = user?.modules?.vaccinations !== false;
useEffect(() => {
if (isLoading) return;
+1 -2
View File
@@ -251,8 +251,7 @@ export function DocumentsView() {
const queryClient = useQueryClient();
const coachLaunch = useCoachLaunch();
const moduleEnabled =
inSharedRecord || user?.modules?.inboundDocuments === true;
const moduleEnabled = user?.modules?.inboundDocuments === true;
// UX redirect only — the API routes enforce the gate server-side.
useEffect(() => {