From e645921852f3d029dee61de5d7231ea22578d40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 10:29:33 +0200 Subject: [PATCH 01/24] fix(cycle): fence the phase crosstab's outcome columns to the sections they belong to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/cycle/insights` is declared `("read", "cycle")`, which is right for the phase labels and wrong for what it compares them against. The crosstab rows carry `lutealAvg`, `follicularAvg` and `delta` in display units for resting heart rate, HRV, sleep duration, steps, weight, body/wrist/skin temperature and blood glucose, plus the mood score read straight off `MoodEntry` — measurement and mind data, published in full to a grant scoped to the cycle alone, with the lagged discovery's narrated interpretation on top. The visits service already had the right shape for this: a predicate over the grant's sections, asked before the foreign rows are read. Both sections are now fenced at the query rather than in the response, so a caller outside them never loads the values in the first place and the rows read exactly as they do for a record with nothing logged. The wrist-temperature read that sharpens the prediction stays: it never reaches the response. The predicate had two verbatim copies, one in the visits service and one in the vaccination service, with a comment on the second saying a second spelling would be a second thing to keep in step. It is one module now, `sharing/acting-domains`, and the third consumer imports it rather than a feature service. `cycle-insights-scoped-delegate.test.ts` drives four grants against a fixture whose separation is large enough that the FDR gate cannot be the reason a row is missing: the owner and an entire-record delegate get both figures, a cycle-only delegate gets neither and no lagged row, and a cycle+mind delegate gets the mood column and not the weight. Both scoped legs fail against the previous handler. --- src/app/api/cycle/insights/route.ts | 80 +++-- src/app/api/encounters/[id]/links/route.ts | 6 +- src/app/api/encounters/[id]/restore/route.ts | 2 +- src/app/api/encounters/[id]/route.ts | 2 +- src/app/api/encounters/route.ts | 2 +- src/app/api/vaccinations/[id]/links/route.ts | 6 +- .../api/vaccinations/[id]/restore/route.ts | 2 +- src/app/api/vaccinations/[id]/route.ts | 2 +- src/lib/encounters/service.ts | 22 -- src/lib/sharing/acting-domains.ts | 32 ++ src/lib/vaccinations/service.ts | 24 -- .../cycle-insights-scoped-delegate.test.ts | 283 ++++++++++++++++++ 12 files changed, 375 insertions(+), 88 deletions(-) create mode 100644 src/lib/sharing/acting-domains.ts create mode 100644 tests/integration/cycle-insights-scoped-delegate.test.ts diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index d23d5c1da..287406817 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -16,6 +16,7 @@ */ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -50,7 +51,7 @@ const WINDOW_DAYS = 365; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read", "cycle"); + const { user, grantId } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; @@ -68,6 +69,19 @@ export const GET = apiHandler(async () => { const today = moodDateKey(new Date(), tz); const from = addDays(today, -WINDOW_DAYS); + // The crosstab is a cycle surface that reads two other sections. The phase + // labels are cycle data; the outcome columns are not. `lutealAvg`, + // `follicularAvg` and `delta` ship resting heart rate, HRV, sleep duration, + // steps, weight, temperature, glucose and the mood score in display units, + // and a grant scoped to the cycle alone was never consent for any of them. + // So the two foreign channels are fenced at the query, not at the response: + // a section the caller is outside of is never read, so nothing downstream + // can leak it back — the rows simply do not exist, exactly as they do not + // for a record with no measurements. + const visible = await actingDomainVisibility(prisma, grantId); + const seesMeasurements = visible("measurements"); + const seesMind = visible("mind"); + const [ cycles, dayLogRows, @@ -116,25 +130,28 @@ export const GET = apiHandler(async () => { select: { measuredAt: true, value: true }, }), // The outcome metrics the phase contrast compares — soft-delete-scoped, - // canonical-source deduped per day inside `metricDayMap`. - prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: { in: PHASE_CROSSTAB_METRIC_TYPES }, - measuredAt: { - gte: new Date(Date.parse(`${from}T00:00:00Z`)), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - source: true, - deviceType: true, - }, - }), + // canonical-source deduped per day inside `metricDayMap`. Skipped whole + // for a caller outside `measurements`. + seesMeasurements + ? prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: { in: PHASE_CROSSTAB_METRIC_TYPES }, + measuredAt: { + gte: new Date(Date.parse(`${from}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }) + : [], prisma.user.findUnique({ where: { id: user.id }, select: { sourcePriorityJson: true }, @@ -142,15 +159,18 @@ export const GET = apiHandler(async () => { // MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a // Measurement row, so read it here and inject it into the crosstab as a // synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards). - prisma.moodEntry.findMany({ - where: { - userId: user.id, - deletedAt: null, - moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, - }, - orderBy: { moodLoggedAt: "asc" }, - select: { score: true, moodLoggedAt: true }, - }), + // Skipped whole for a caller outside `mind`. + seesMind + ? prisma.moodEntry.findMany({ + where: { + userId: user.id, + deletedAt: null, + moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, + }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true }, + }) + : [], ]); // The latest open cycle runs to the predicted next-period start so the @@ -239,6 +259,8 @@ export const GET = apiHandler(async () => { lagged_discovered: lagged.discovered.length, lagged_pairs_tested: lagged.pairsTested, symptom_patterns: symptomPatterns.length, + measurements_visible: seesMeasurements, + mind_visible: seesMind, }, }); diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts index 076ec4547..c22af968e 100644 --- a/src/app/api/encounters/[id]/links/route.ts +++ b/src/app/api/encounters/[id]/links/route.ts @@ -27,10 +27,8 @@ import { } from "@/lib/api-response"; import { encounterLinkSchema } from "@/lib/validations/encounters"; import { linkTargets, unlinkTargets } from "@/lib/links"; -import { - actingDomainVisibility, - loadEncounterLinks, -} from "@/lib/encounters/service"; +import { loadEncounterLinks } from "@/lib/encounters/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/[id]/restore/route.ts b/src/app/api/encounters/[id]/restore/route.ts index c37cd9500..73c5731fd 100644 --- a/src/app/api/encounters/[id]/restore/route.ts +++ b/src/app/api/encounters/[id]/restore/route.ts @@ -22,12 +22,12 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, loadEncounterLinks, resolveOwnerNotificationContext, shouldHaveReminder, } from "@/lib/encounters/service"; import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index b33bc64d9..930e04f4f 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, applyEncounterLinks, closeCheckupForVisit, findOwnAppointmentReminderId, @@ -54,6 +53,7 @@ import { type RestoreSkipLog, } from "@/lib/export/restore-skips"; import type { Prisma } from "@/generated/prisma/client"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 0fc4a5fa7..9946128be 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, applyEncounterLinks, loadEncounterLinksForMany, closeCheckupForVisit, @@ -50,6 +49,7 @@ import { summarizeRestoreSkips, type RestoreSkipLog, } from "@/lib/export/restore-skips"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; export const GET = apiHandler(async (request: NextRequest) => { const { user, grantId } = await requireRecordAuth("read", "profile"); diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts index 7a9b10442..d8ba5cbed 100644 --- a/src/app/api/vaccinations/[id]/links/route.ts +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -26,10 +26,8 @@ import { } from "@/lib/api-response"; import { vaccinationLinkSchema } from "@/lib/validations/vaccinations"; import { linkTargets, unlinkTargets } from "@/lib/links"; -import { - actingDomainVisibility, - loadVaccinationDocuments, -} from "@/lib/vaccinations/service"; +import { loadVaccinationDocuments } from "@/lib/vaccinations/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/vaccinations/[id]/restore/route.ts b/src/app/api/vaccinations/[id]/restore/route.ts index 685e61657..0d5600b60 100644 --- a/src/app/api/vaccinations/[id]/restore/route.ts +++ b/src/app/api/vaccinations/[id]/restore/route.ts @@ -20,10 +20,10 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { toVaccinationDTO } from "@/lib/vaccinations/dto"; import { VACCINATION_INCLUDE, - actingDomainVisibility, loadVaccinationDocuments, resolveSeriesFor, } from "@/lib/vaccinations/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 8a7c77bf2..1b85c023d 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -32,7 +32,6 @@ import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations"; import { toVaccinationDTO } from "@/lib/vaccinations/dto"; import { VACCINATION_INCLUDE, - actingDomainVisibility, applyVaccinationLinks, loadVaccinationDocuments, resolveOwnedEncounter, @@ -40,6 +39,7 @@ import { resolveSeriesFor, } from "@/lib/vaccinations/service"; import type { Prisma } from "@/generated/prisma/client"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 705596437..7f080ca56 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -18,7 +18,6 @@ import { replaceTargets, type LinkTargetKind, } from "@/lib/links"; -import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; import type { EncounterLinksDTO } from "@/lib/encounters/dto"; import { @@ -59,27 +58,6 @@ export const ENCOUNTER_INCLUDE = { reminder: { select: { nextDueAt: true } }, } as const satisfies Prisma.EncounterInclude; -/** - * Which sections of the record the caller is actually inside. - * - * Returns a predicate rather than a set so the owner path costs nothing: with - * no grant there is nothing to look up and every domain is open. A grant that - * vanished between the auth check and here answers "no" to everything, which - * is the fail-closed direction. - */ -export async function actingDomainVisibility( - tx: Prisma.TransactionClient, - grantId: string | null, -): Promise<(domain: ShareDomain) => boolean> { - if (grantId === null) return () => true; - const grant = await tx.accountGrant.findUnique({ - where: { id: grantId }, - select: { scopeJson: true }, - }); - if (!grant) return () => false; - return (domain) => grantCoversDomain(grant, domain); -} - /** * Which section of the record each link family's TARGET belongs to. * diff --git a/src/lib/sharing/acting-domains.ts b/src/lib/sharing/acting-domains.ts new file mode 100644 index 000000000..c8bb830d5 --- /dev/null +++ b/src/lib/sharing/acting-domains.ts @@ -0,0 +1,32 @@ +/** + * Which sections of the record the caller is actually inside. + * + * Lives here rather than beside one feature because three unrelated seams ask + * it — a visit's link labels, a vaccination's, and the cycle crosstab's + * outcome columns — and a route that reads across its own declared section is + * a shape the product will keep growing. Two verbatim copies of it already + * existed in the visits and vaccination services; a third would have been the + * one that drifted. + * + * Returns a predicate rather than a set so the owner path costs nothing: with + * no grant there is nothing to look up and every domain is open. A grant that + * vanished between the auth check and here answers "no" to everything, which + * is the fail-closed direction. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { grantCoversDomain } from "@/lib/sharing/grants"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +export async function actingDomainVisibility( + tx: Prisma.TransactionClient, + grantId: string | null, +): Promise<(domain: ShareDomain) => boolean> { + if (grantId === null) return () => true; + const grant = await tx.accountGrant.findUnique({ + where: { id: grantId }, + select: { scopeJson: true }, + }); + if (!grant) return () => false; + return (domain) => grantCoversDomain(grant, domain); +} diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts index 8d929ff5f..8d2212bd2 100644 --- a/src/lib/vaccinations/service.ts +++ b/src/lib/vaccinations/service.ts @@ -11,7 +11,6 @@ import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { listTargets, replaceTargets } from "@/lib/links"; -import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto"; import { @@ -37,29 +36,6 @@ export const SERIES_SELECT = { seriesDoses: true, } as const satisfies Prisma.VaccinationRecordSelect; -/** - * Which sections of the record the caller is actually inside. - * - * Returns a predicate rather than a set so the owner path costs nothing: with - * no grant there is nothing to look up and every domain is open. A grant that - * vanished between the auth check and here answers "no" to everything, which - * is the fail-closed direction. Same shape as the visits service, deliberately - * — the two features sit on the same seam and a second spelling of this would - * be a second thing to keep in step. - */ -export async function actingDomainVisibility( - tx: Prisma.TransactionClient, - grantId: string | null, -): Promise<(domain: ShareDomain) => boolean> { - if (grantId === null) return () => true; - const grant = await tx.accountGrant.findUnique({ - where: { id: grantId }, - select: { scopeJson: true }, - }); - if (!grant) return () => false; - return (domain) => grantCoversDomain(grant, domain); -} - /** * The whole live history for one account, in the shape the derivation wants. * diff --git a/tests/integration/cycle-insights-scoped-delegate.test.ts b/tests/integration/cycle-insights-scoped-delegate.test.ts new file mode 100644 index 000000000..da1cf82d9 --- /dev/null +++ b/tests/integration/cycle-insights-scoped-delegate.test.ts @@ -0,0 +1,283 @@ +/** + * What a cycle-only delegate learns from `GET /api/cycle/insights`. + * + * The route is declared `("read", "cycle")`, and everything about the phase + * labels belongs there. Its outcome columns do not: `lutealAvg`, + * `follicularAvg` and `delta` carry resting heart rate, HRV, sleep, steps, + * weight, temperature, glucose and the mood score in display units, and those + * are `measurements` and `mind` data reached through a cycle grant. + * + * So two properties, both about somebody holding `["cycle"]` and nothing else: + * + * 1. **No foreign figure crosses the seam.** No row whose `metricKey` names + * a measurement channel, no mood row, and no lagged discovery built out + * of either. + * 2. **The cycle-native answer still arrives.** The symptom-by-phase + * patterns are cycle data and stay, so the fix is a fence rather than a + * blanket refusal. + * + * The unscoped grant is asserted in the same fixture, because a change that + * hid the figures from everyone would satisfy the first property and break + * the feature. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "cycle-insights-owner"; +const DELEGATE_ID = "cycle-insights-delegate"; + +/** Cycle geometry the fixture logs, chosen so both phases clear the day floor. */ +const CYCLE_LENGTH = 28; +const PERIOD_LENGTH = 5; +const OBSERVED_CYCLES = 5; + +function isoDay(offsetDays: number): string { + const d = new Date(Date.now() + offsetDays * 24 * 60 * 60 * 1000); + return d.toISOString().slice(0, 10); +} + +function atNoon(day: string): Date { + return new Date(`${day}T12:00:00Z`); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** Sign in as the delegate and switch into the owner's record. */ +async function switchInto(scope: ShareDomain[] | null) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access: "READ", + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +/** + * Five observed cycles, then a weight and a mood value on every labelled day + * whose size depends on the phase. + * + * The separation is deliberately huge (80 kg vs 70 kg, mood 2 vs 4) so the + * Welch + Benjamini-Hochberg gate the route runs cannot be the reason a row is + * missing: with this fixture a missing row means the fence removed it. + */ +async function seedRecord() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `cycle-insights-${name}`, + email: `cycle-insights-${name}@example.test`, + gender: "FEMALE", + timezone: "UTC", + }, + }); + } + + // Prediction off, so the phase map the route builds is the one this fixture + // can reproduce from the logged cycles alone. + await prisma.cycleProfile.create({ + data: { + userId: OWNER_ID, + cycleTrackingEnabled: true, + predictionEnabled: false, + lutealPhaseLength: 14, + }, + }); + + const starts: string[] = []; + for (let i = OBSERVED_CYCLES; i >= 1; i--) { + starts.push(isoDay(-i * CYCLE_LENGTH)); + } + for (const [index, startDate] of starts.entries()) { + const next = starts[index + 1] ?? null; + await prisma.menstrualCycle.create({ + data: { + userId: OWNER_ID, + startDate, + endDate: next + ? isoDay(-(OBSERVED_CYCLES - index) * CYCLE_LENGTH - 1 + CYCLE_LENGTH) + : null, + periodEndDate: isoDay( + -(OBSERVED_CYCLES - index) * CYCLE_LENGTH + PERIOD_LENGTH - 1, + ), + ovulationDate: isoDay( + -(OBSERVED_CYCLES - index) * CYCLE_LENGTH + CYCLE_LENGTH - 15, + ), + tz: "UTC", + }, + }); + } + + const { buildPhaseDayMap } = await import("@/lib/cycle/engine-adapter"); + const cycles = await prisma.menstrualCycle.findMany({ + where: { userId: OWNER_ID }, + orderBy: { startDate: "asc" }, + }); + const today = isoDay(0); + const phaseByDay = buildPhaseDayMap( + cycles, + null, + 14, + isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH), + today, + today, + ); + + let luteal = 0; + let follicular = 0; + for (const [day, phase] of phaseByDay) { + if (phase !== "LUTEAL" && phase !== "FOLLICULAR") continue; + const high = phase === "LUTEAL"; + if (high) luteal++; + else follicular++; + // A little jitter in both channels, so the Welch test has a variance to + // work with and the row is not dropped for a reason the fence did not + // cause. + const jitter = ((luteal + follicular) % 3) * 0.1; + await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "WEIGHT", + value: (high ? 80 : 70) + jitter, + unit: "kg", + measuredAt: atNoon(day), + }, + }); + await prisma.moodEntry.create({ + data: { + userId: OWNER_ID, + mood: high ? "SCHLECHT" : "GUT", + score: (high ? 1 : 4) + ((luteal + follicular) % 2), + date: day, + moodLoggedAt: atNoon(day), + tz: "UTC", + }, + }); + } + + expect( + luteal, + "the fixture labels enough luteal days to clear the crosstab floor", + ).toBeGreaterThanOrEqual(5); + expect( + follicular, + "the fixture labels enough follicular days to clear the crosstab floor", + ).toBeGreaterThanOrEqual(5); +} + +interface InsightsBody { + rows: Array<{ metricKey: string }>; + headline: { metricKey: string } | null; + lagged: { discovered: unknown[] }; + symptomPatterns: unknown[]; +} + +async function readInsights(): Promise { + const { GET } = await import("@/app/api/cycle/insights/route"); + // The handler declares no parameters; the wrapper still reads the request + // off its first argument, and the method it carries is what the record + // resolver escalates on. + const handler = GET as unknown as (request: Request) => Promise; + const res = await handler(new Request("http://localhost/api/cycle/insights")); + expect(res.status).toBe(200); + const parsed = (await res.json()) as { data: InsightsBody }; + return parsed.data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecord(); +}); + +describe("cycle insights fence the sections it reads across", () => { + it("gives the owner the measurement and the mood contrast", async () => { + await signIn(OWNER_ID); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect(keys, "the weight contrast is the fixture's whole point").toContain( + "weight", + ); + expect(keys, "and the mood contrast beside it").toContain("mood"); + }); + + it("gives an entire-record delegate the same figures", async () => { + await switchInto(null); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect(keys).toContain("weight"); + expect(keys).toContain("mood"); + }); + + it("gives a cycle-only delegate no measurement and no mood figure", async () => { + await switchInto(["cycle"]); + const body = await readInsights(); + + expect( + body.rows, + "every crosstab row is a measurement or a mood column", + ).toHaveLength(0); + expect(body.headline).toBeNull(); + expect( + body.lagged.discovered, + "the lagged matrix is built from the same two sections", + ).toHaveLength(0); + }); + + it("gives a cycle+mind delegate the mood figure and not the weight", async () => { + await switchInto(["cycle", "mind"]); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect( + keys, + "mind is held, so the mood column is within the grant", + ).toContain("mood"); + expect(keys, "measurements is not held").not.toContain("weight"); + }); +}); From 7c7e621fd94e81236d5856aa31b036fc5df77a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 10:36:52 +0200 Subject: [PATCH 02/24] fix(vaccinations): ask the grant before offering to add, edit or delete a dose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The immunization log carried no capability check at all: not one reference to the record hook anywhere under `src/components/vaccinations`. The page is presentable to every `profile`-scoped grant and to every whole-record one, so a READ delegate was shown the add button, a clickable row that opens the edit sheet, and the delete inside it — three controls the server refuses with a 403 each. A WRITE delegate was shown two of them. `POST /api/vaccinations` answers at WRITE; the edit, the delete and the restore behind it are `("manage", "profile")`. The two add buttons now ask `canWriteDomain("profile")` and the row's edit affordance asks `canManageDomain("profile")`, which is the split the address book next door already gets right. The list already renders a non-interactive card when it is handed no edit callback, and the sheet is only reachable through one of the two gated entries, so nothing needs a second check that could drift from the first. `delegated-write-affordances.test.tsx` gained a scoped-grant fixture — every fixture in it was `sections: null`, which is why nothing noticed — and six legs over the log: the owner, a READ, a WRITE and a MANAGE delegate holding `profile`, a MANAGE delegate holding another section, and the empty state's own add. Four of them fail against the previous view. --- .../delegated-write-affordances.test.tsx | 134 +++++++++++++++++- .../vaccinations/vaccinations-view.tsx | 55 ++++--- 2 files changed, 169 insertions(+), 20 deletions(-) diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 4ef817114..c802640c5 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -31,8 +31,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; -import type { AccountAccess } from "@/lib/sharing/account-access-view"; +import type { + AccountAccess, + AccountAccessEntry, +} from "@/lib/sharing/account-access-view"; import { delegatedDomains } from "@/lib/sharing/domain-write-support"; +import type { ShareDomain } from "@/lib/sharing/scope"; const OWNER = { accountId: "acct-owner", @@ -87,6 +91,35 @@ const MANAGING: AccountAccess = { canSwitch: true, }; +/** + * A grant with a SCOPE, published the way the server publishes one. + * + * Every fixture above carries `sections: null`, and that is the hole the + * section-blind controls lived in: a WRITE grant scoped to one section answers + * `canWrite: true` like any other, so a control asking the coarse question was + * offered in sections the grant never opened. The two lists are the level × + * scope × route-table intersection, derived here by the same function the + * server derives them with rather than by hand. + */ +function grant( + level: AccountAccessEntry["level"], + sections: ShareDomain[] | null, +): AccountAccess { + return { + accounts: [OWNER], + active: { + ...OWNER, + access: level === "read" ? "read" : "write", + level, + sections, + canWrite: level !== "read", + writableDomains: delegatedDomains(level, sections, "write"), + manageableDomains: delegatedDomains(level, sections, "manage"), + }, + canSwitch: true, + }; +} + const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD }; vi.mock("@/hooks/use-auth", () => ({ @@ -140,6 +173,7 @@ import { TodayHero } from "@/components/daily/today-hero"; import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card"; import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card"; import { LedgerRowItem } from "@/components/medications/dose-history-ledger"; +import { VaccinationsView } from "@/components/vaccinations/vaccinations-view"; import { queryKeys } from "@/lib/query-keys"; import type { DailyDigest } from "@/lib/daily/digest"; import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; @@ -608,3 +642,101 @@ describe("a form opened before the record answered", () => { expect(admittedQuickEntry(null, OWNER_CAPS)).toBe(null); }); }); + +/* -------------------------------------------------------------------------- */ +/* The immunization log */ +/* -------------------------------------------------------------------------- */ + +describe("the immunization log's add, edit and delete", () => { + const DOSE = { + id: "dose-1", + occurredAt: "2026-03-01T00:00:00.000Z", + antigenSlug: null, + vaccineName: "Tetanus", + doseNumber: null, + seriesDoses: null, + lotNumber: null, + site: null, + catalogEntry: null, + series: [], + practitioner: null, + encounter: null, + reminderId: null, + note: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }; + + const seedList = (client: QueryClient) => { + client.setQueryData(queryKeys.vaccinationList(null), { + vaccinations: [DOSE], + }); + }; + + it("offers all three in the caller's own record", () => { + const html = render(OWN_RECORD, , seedList); + expect(html).toContain('data-slot="vaccination-add"'); + // The row opens the edit sheet, which is where the delete lives. + expect(html).toContain('role="button"'); + }); + + it("offers a read-only delegate none of them", () => { + // `POST /api/vaccinations` is WRITE and everything else is MANAGE, so a + // READ delegate was being shown three controls and refused by all three. + const html = render( + grant("read", ["profile"]), + , + seedList, + ); + expect(html).not.toContain('data-slot="vaccination-add"'); + expect(html).not.toContain('role="button"'); + }); + + it("offers a WRITE delegate the add and not the row", () => { + const html = render( + grant("write", ["profile"]), + , + seedList, + ); + expect(html).toContain('data-slot="vaccination-add"'); + expect(html, "editing and deleting a dose are MANAGE").not.toContain( + 'role="button"', + ); + }); + + it("offers a MANAGE delegate holding the section both", () => { + const html = render( + grant("manage", ["profile"]), + , + seedList, + ); + expect(html).toContain('data-slot="vaccination-add"'); + expect(html).toContain('role="button"'); + }); + + it("offers a MANAGE delegate outside the section neither", () => { + // The page is presentable to a `profile` grant; a grant scoped elsewhere + // that still lands on the URL gets a log it can read and nothing else. + const html = render( + grant("manage", ["labs"]), + , + seedList, + ); + expect(html).not.toContain('data-slot="vaccination-add"'); + expect(html).not.toContain('role="button"'); + }); + + it("offers the empty state's add on the same rule", () => { + const seedEmpty = (client: QueryClient) => { + client.setQueryData(queryKeys.vaccinationList(null), { + vaccinations: [], + }); + }; + expect(render(OWN_RECORD, , seedEmpty)).toContain( + 'data-slot="vaccination-add-empty"', + ); + expect( + render(grant("read", ["profile"]), , seedEmpty), + ).not.toContain('data-slot="vaccination-add-empty"'); + }); +}); diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index 4bc8e5882..98ac9376d 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -18,6 +18,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; import { VaccinationList } from "./vaccination-list"; @@ -27,6 +28,15 @@ import { useVaccinations, type Vaccination } from "./use-vaccinations"; export function VaccinationsView() { const { t } = useTranslations(); + const { canWriteDomain, canManageDomain } = useRecordCapabilities(); + // `POST /api/vaccinations` answers at WRITE; the edit, the delete and the + // restore behind it are all MANAGE. The page is presentable to every + // `profile`-scoped grant, so without this a READ delegate was shown add, + // edit and delete, and every one of them was refused by a server that was + // right to refuse. Same split, same domain, same shape as the address book + // next door. + const canAddDose = canWriteDomain("profile"); + const canManageProfile = canManageDomain("profile"); const { data, isLoading, isError, refetch } = useVaccinations(); const records = data?.vaccinations ?? []; @@ -50,6 +60,17 @@ export function VaccinationsView() { setSheetOpen(true); }; + const addButton = canAddDose ? ( + + ) : null; + return (
} description={t("vaccinations.subtitle")} - actions={ - - } + actions={addButton} /> void refetch()} /> ) : records.length > 0 ? ( - + ) : ( } title={t("vaccinations.empty.title")} description={t("vaccinations.empty.description")} action={ - + canAddDose ? ( + + ) : undefined } /> )} From 3e176db5b769051447a0f16f376ed26a7071c92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 10:39:46 +0200 Subject: [PATCH 03/24] fix(encounters): stop offering the visit edit sheet to a delegate who may not save it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VisitsSection` asked the grant about its add button and nothing else. Every row underneath stayed tappable into the edit sheet, whose Save is `PATCH /api/encounters/{id}` and whose Delete is `DELETE` on the same path — both `("manage", "profile")`. So a READ delegate opened a form that could not be saved, and a WRITE delegate, who may file a new visit, opened one that could not be changed. Reachable wherever the checkups page is, which is every `profile`-scoped grant. The row now takes its open callback only when `canManageDomain("profile")` answers, and the card renders its content as content rather than as a button when it does not. The add button keeps asking the write question, because that is a different question with a different answer. Four legs in the affordances suite over the same scoped fixtures the immunization log uses: the owner opens the sheet, a READ and a WRITE delegate see the row and cannot open it, a WRITE delegate still gets the add button, and a MANAGE delegate opens it only while holding the section. Two of them fail against the previous section. --- .../delegated-write-affordances.test.tsx | 81 +++++++++++++++++++ src/components/encounters/visit-card.tsx | 46 +++++++++-- src/components/encounters/visits-section.tsx | 12 ++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index c802640c5..e433653cd 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -174,6 +174,8 @@ import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsor import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card"; import { LedgerRowItem } from "@/components/medications/dose-history-ledger"; import { VaccinationsView } from "@/components/vaccinations/vaccinations-view"; +import { VisitsSection } from "@/components/encounters/visits-section"; +import type { EncounterDTO } from "@/lib/encounters/dto"; import { queryKeys } from "@/lib/query-keys"; import type { DailyDigest } from "@/lib/daily/digest"; import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; @@ -740,3 +742,82 @@ describe("the immunization log's add, edit and delete", () => { ).not.toContain('data-slot="vaccination-add-empty"'); }); }); + +/* -------------------------------------------------------------------------- */ +/* The visits list */ +/* -------------------------------------------------------------------------- */ + +describe("a visit row's edit sheet", () => { + const VISIT: EncounterDTO = { + id: "visit-1", + occurredAt: "2026-08-01T09:00:00.000Z", + status: "DONE", + kind: "ROUTINE", + practitioner: null, + reason: null, + outcome: null, + reminderNextDueAt: null, + createdAt: "2026-08-01T09:00:00.000Z", + updatedAt: "2026-08-01T09:00:00.000Z", + }; + + const seedList = (client: QueryClient) => { + client.setQueryData( + queryKeys.encounterList(null, null, undefined, undefined), + { + upcoming: [], + past: [VISIT], + }, + ); + }; + + /** The row's content is a button only while it opens something. */ + const rowIsTappable = (html: string) => + html.includes('data-slot="visit-card-open"'); + + it("opens for the owner", () => { + expect(rowIsTappable(render(OWN_RECORD, , seedList))).toBe( + true, + ); + }); + + it("does not open for a READ or a WRITE delegate", () => { + // The add button was the only thing asked, and it asks the right question + // — `POST /api/encounters` is WRITE. The row underneath opens a sheet + // whose Save is `PATCH` and whose Delete is `DELETE`, both MANAGE. + for (const level of ["read", "write"] as const) { + const html = render( + grant(level, ["profile"]), + , + seedList, + ); + expect(html, `${level} still sees the row`).toContain( + 'data-slot="visit-card"', + ); + expect(rowIsTappable(html), `${level} may open the sheet`).toBe(false); + } + }); + + it("keeps the add button for a WRITE delegate", () => { + // The two answers are different questions and must not collapse into one. + expect( + render(grant("write", ["profile"]), , seedList), + ).toContain('data-slot="visits-add"'); + expect( + render(grant("read", ["profile"]), , seedList), + ).not.toContain('data-slot="visits-add"'); + }); + + it("opens for a MANAGE delegate holding the section, and not one outside it", () => { + expect( + rowIsTappable( + render(grant("manage", ["profile"]), , seedList), + ), + ).toBe(true); + expect( + rowIsTappable( + render(grant("manage", ["labs"]), , seedList), + ), + ).toBe(false); + }); +}); diff --git a/src/components/encounters/visit-card.tsx b/src/components/encounters/visit-card.tsx index 7f4ecdf32..0baaf512e 100644 --- a/src/components/encounters/visit-card.tsx +++ b/src/components/encounters/visit-card.tsx @@ -35,7 +35,14 @@ export function VisitCard({ onOpen, }: { encounter: Encounter; - onOpen: (encounter: Encounter) => void; + /** + * Opens the edit sheet. Omitted when the caller may not change the visit — + * `PATCH` and `DELETE /api/encounters/{id}` are both `("manage", "profile")`, + * so a READ or WRITE delegate gets the card as a card and not as a button. + * A row that reads as tappable and answers 403 is the affordance saying + * something about the grant that is not true. + */ + onOpen?: (encounter: Encounter) => void; }) { const { t } = useTranslations(); const format = useFormatters(); @@ -71,11 +78,7 @@ export function VisitCard({ className="gap-2 py-3 md:py-4" > - + ); } + +/** + * The row's content, tappable or not. + * + * One element either way rather than two copies of the content: the read-only + * form keeps the same spacing the button had, so a card that loses its edit + * affordance does not also change shape. + */ +function Body({ + onOpen, + encounter, + children, +}: { + onOpen?: (encounter: Encounter) => void; + encounter: Encounter; + children: React.ReactNode; +}) { + if (!onOpen) return
{children}
; + return ( + + ); +} diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 2af5b67d0..3b2ad8c9a 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -30,9 +30,13 @@ import { VisitCard } from "./visit-card"; export function VisitsSection({ enabled = true }: { enabled?: boolean }) { const { t } = useTranslations(); - const { canWriteDomain } = useRecordCapabilities(); - // `POST /api/encounters` answers at WRITE under the profile section. + const { canWriteDomain, canManageDomain } = useRecordCapabilities(); + // `POST /api/encounters` answers at WRITE under the profile section; the + // edit and the delete behind the row are both MANAGE. Only the add was + // asked, so every row stayed tappable into a sheet whose Save and Delete + // the server refuses. const canAddVisit = canWriteDomain("profile"); + const canManageVisits = canManageDomain("profile"); const list = useEncounters(enabled); // `null` closed, `"new"` create, a row edits it. @@ -109,7 +113,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))}
@@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))} From 927496236ae35ee457069f13e99fa4c69cb020eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 10:45:01 +0200 Subject: [PATCH 04/24] fix(capture): ask each capture surface's own section before offering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture FAB rides the bottom bar on every authenticated page, and its fallback arm was section-blind: return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind); `canAdd` is bound straight to the grant's LEVEL — `grantAllows(grant, "write")`, no scope term at all. So a WRITE grant scoped to `["labs"]` published `canAdd: true` with `writableDomains: ["labs"]`, and a delegate standing on `/labs` was offered a weight form and a dose that the server refuses at `grantCoversDomain`. The worst case is a WRITE grant scoped to `["documents"]`: the vault takes no delegated write at any level, the writable list is empty, and both kinds were still offered. `writableDomains` is already the level × scope × route-table intersection, so one question per kind, asked of that kind's own section, answers the scoped case, the vault case and the mood entry's MANAGE-only create together. The closed kind list and the separate manage arm are both dead once that lands and are gone. The dashboard quick-entry sheets and the header menu carried the same shape — unreachable today, because the dashboard opens only for a whole-record grant — and move with it rather than waiting to become reachable. The picker's legs in the affordances suite now build their capabilities from the same table the server publishes, and four scoped cases join them: labs-only offers nothing, measurements-only offers the reading, documents-only offers nothing at all, and a mind-scoped manage offers the mood entry alone. --- .../delegated-write-affordances.test.tsx | 74 ++++++++++++++----- src/components/dashboard/dashboard-header.tsx | 44 ++++++----- .../dashboard/quick-entry-sheets.tsx | 17 +---- src/components/layout/capture-picker.tsx | 34 +++------ 4 files changed, 98 insertions(+), 71 deletions(-) diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index e433653cd..8ba7a480a 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -120,6 +120,15 @@ function grant( }; } +/** The write answer per section, for the pure helpers that take only that. */ +function writeCaps( + level: AccountAccessEntry["level"], + sections: ShareDomain[] | null, +) { + const writable = new Set(delegatedDomains(level, sections, "write")); + return { canWriteDomain: (domain: ShareDomain) => writable.has(domain) }; +} + const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD }; vi.mock("@/hooks/use-auth", () => ({ @@ -486,17 +495,13 @@ describe("linking a document to an illness episode", () => { describe("the capture picker's kinds", () => { const ALL = ["measurement", "medication", "mood"] as const; - const OWNER_CAPS = { canAdd: true, canManageDomain: () => true }; - const WRITER_CAPS = { canAdd: true, canManageDomain: () => false }; - const READER_CAPS = { canAdd: false, canManageDomain: () => false }; + /** The picker asks one question per kind, of that kind's own section. */ + const caps = writeCaps; + const OWNER_CAPS = { canWriteDomain: () => true }; + const WRITER_CAPS = caps("write", null); + const READER_CAPS = caps("read", null); // A guardian: every section with a delegated route answers at MANAGE. - const GUARDIAN_CAPS = { - canAdd: true, - canManageDomain: (domain: string) => - delegatedDomains("manage", null, "manage").includes( - domain as "measurements", - ), - }; + const GUARDIAN_CAPS = caps("manage", null); it("offers everything in the caller's own record", () => { expect(visibleCaptureKinds(OWNER_CAPS, [...ALL])).toEqual([ @@ -526,6 +531,36 @@ describe("the capture picker's kinds", () => { it("offers a read-only delegate nothing", () => { expect(visibleCaptureKinds(READER_CAPS, [...ALL])).toEqual([]); }); + + it("offers a scoped WRITE delegate only the kinds its sections cover", () => { + // The gap this closes. `canAdd` is the grant's LEVEL with no scope term, + // so a labs-scoped WRITE grant answered true and the picker offered a + // weight form and a dose the server refuses at `grantCoversDomain`. + expect(visibleCaptureKinds(caps("write", ["labs"]), [...ALL])).toEqual([]); + expect( + visibleCaptureKinds(caps("write", ["measurements"]), [...ALL]), + ).toEqual(["measurement"]); + expect( + visibleCaptureKinds(caps("write", ["measurements", "medications"]), [ + ...ALL, + ]), + ).toEqual(["measurement", "medication"]); + }); + + it("offers a documents-scoped WRITE delegate nothing at all", () => { + // The worst case: the vault takes no delegated write at any level, so the + // grant's writable list is empty while `canWrite` still answers true. + expect(visibleCaptureKinds(caps("write", ["documents"]), [...ALL])).toEqual( + [], + ); + }); + + it("offers a scoped MANAGE delegate the mood entry only with the mind section", () => { + expect(visibleCaptureKinds(caps("manage", ["mind"]), [...ALL])).toEqual([ + "mood", + ]); + expect(visibleCaptureKinds(caps("manage", ["labs"]), [...ALL])).toEqual([]); + }); }); /* -------------------------------------------------------------------------- */ @@ -613,7 +648,7 @@ describe("a form opened before the record answered", () => { }); it("withdraws a dashboard quick-entry sheet the delegation does not admit", () => { - const DELEGATE = { canAdd: true, canManageDomain: () => false }; + const DELEGATE = writeCaps("write", null); expect(admittedQuickEntry("mood", DELEGATE)).toBe(null); expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement"); expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe( @@ -622,22 +657,25 @@ describe("a form opened before the record answered", () => { }); it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => { - const GUARDIAN = { - canAdd: true, - canManageDomain: (domain: string) => domain === "mind", - }; - expect(admittedQuickEntry("mood", GUARDIAN)).toBe("mood"); + expect(admittedQuickEntry("mood", writeCaps("manage", null))).toBe("mood"); + }); + + it("withdraws a sheet whose section the grant's scope leaves out", () => { + const LABS_ONLY = writeCaps("write", ["labs"]); + for (const sheet of ["measurement", "mood", "medicationIntake"] as const) { + expect(admittedQuickEntry(sheet, LABS_ONLY), sheet).toBe(null); + } }); it("withdraws every quick-entry sheet from a read-only delegate", () => { - const READER = { canAdd: false, canManageDomain: () => false }; + const READER = writeCaps("read", null); for (const sheet of ["measurement", "mood", "medicationIntake"] as const) { expect(admittedQuickEntry(sheet, READER), sheet).toBe(null); } }); it("leaves the owner's own sheets alone", () => { - const OWNER_CAPS = { canAdd: true, canManageDomain: () => true }; + const OWNER_CAPS = { canWriteDomain: () => true }; for (const sheet of ["measurement", "mood", "medicationIntake"] as const) { expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet); } diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index b4dd74aa2..1e0b554cb 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -36,13 +36,19 @@ export function DashboardHeader({ const { t } = useTranslations(); const { user } = useAuth(); const mounted = useMounted(); - // v1.36.x — the dashboard quick-add offers three kinds; a delegation admits - // two of them (a reading, a dose). Mood stays with the account whose record - // it is, and the customize shortcut points at a settings page sharing does - // not cover at all. - const { canAdd, canManageDomain, inSharedRecord } = useRecordCapabilities(); + // The quick-add offers three kinds, and each one is asked about its own + // section: a reading under `measurements`, a dose under `medications`, a + // mood entry under `mind`. The coarse `canAdd` this used to gate the whole + // menu on carries the grant's LEVEL and no scope, so it answered true for a + // grant that opens neither of the two sections the menu never asked about. + // The customize shortcut points at a settings page sharing does not cover at + // all. + const { canWriteDomain, inSharedRecord } = useRecordCapabilities(); + const canAddMeasurement = canWriteDomain("measurements"); + const canAddIntake = canWriteDomain("medications"); // A mood entry is created at MANAGE under the mind section. - const canAddMood = canManageDomain("mind"); + const canAddMood = canWriteDomain("mind"); + const canAddAnything = canAddMeasurement || canAddIntake || canAddMood; // The pre-hero greeting derivation, kept hydration-safe: `user` comes // from the auth query, which can resolve before this boundary @@ -117,7 +123,7 @@ export function DashboardHeader({ )} - {canAdd && ( + {canAddAnything && ( {/* v1.4.33 maintainer-item-7 — restore proportional sizing @@ -152,10 +158,12 @@ export function DashboardHeader({ phase-A3 fix #1 hardened this with a unit guard at `src/app/__tests__/quick-add-labels.test.ts` — both labels must differ from each other AND from `common.add`. */} - onQuickEntry("measurement")}> - + {canAddMeasurement && ( + onQuickEntry("measurement")}> + + )} {canAddMood && ( onQuickEntry("mood")}> )} diff --git a/src/components/dashboard/quick-entry-sheets.tsx b/src/components/dashboard/quick-entry-sheets.tsx index 4b39eaef5..189958163 100644 --- a/src/components/dashboard/quick-entry-sheets.tsx +++ b/src/components/dashboard/quick-entry-sheets.tsx @@ -34,17 +34,7 @@ import type { ShareDomain } from "@/lib/sharing/scope"; export type QuickEntryDialog = "measurement" | "mood" | "medicationIntake" | null; -/** - * v1.36.x — which of the three the delegation admits, and the same rule the - * capture picker states at length. - * - * A WRITE grant covers entering a reading and marking a dose. A mood entry is - * not an admitted verb and the server refuses it under a switch. - */ -const DELEGABLE_QUICK_ENTRIES: ReadonlySet> = - new Set(["measurement", "medicationIntake"]); - -/** v1.38.12 — the section each sheet writes to; see the capture picker. */ +/** The section each sheet writes to; see the capture picker. */ const QUICK_ENTRY_DOMAIN: Readonly< Record, ShareDomain> > = { @@ -69,11 +59,10 @@ const QUICK_ENTRY_DOMAIN: Readonly< */ export function admittedQuickEntry( open: QuickEntryDialog, - caps: Pick, + caps: Pick, ): QuickEntryDialog { if (open === null) return open; - if (caps.canManageDomain(QUICK_ENTRY_DOMAIN[open])) return open; - return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null; + return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null; } /** diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index c92c0a859..8e52a9318 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -51,24 +51,17 @@ import { type CaptureKind = "measurement" | "medication" | "mood"; /** - * v1.36.x — which of the capture surfaces a delegation admits. + * The section each capture surface writes to. * - * A WRITE grant covers entering a reading and marking a dose. It does not - * cover a mood entry: that verb stays on the owner's own authentication and - * the server refuses it under a switch. The picker is the fastest path into - * all three on a phone, so it is the surface where offering an unadmitted one - * costs the most. - */ -const DELEGABLE_CAPTURE_KINDS: ReadonlySet = new Set([ - "measurement", - "medication", -]); - -/** - * v1.38.12 — the section each capture surface writes to. A MANAGE grant that - * reaches the section's routes is offered the surface; a mood entry is a - * MANAGE create under `mind`, so a guardian gets it and a WRITE delegate does - * not. + * One question per kind, asked of that kind's own section. The picker used to + * ask a coarse one as well — a closed list of kinds a WRITE grant admits, + * offered whenever `canAdd` was true — and `canAdd` is bound to the grant's + * LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]` + * published `canAdd: true` and got a weight form the server refuses; one + * scoped to `["documents"]`, which takes no delegated write at any level, got + * both. `writableDomains` is already the level × scope × route-table + * intersection, so asking it per section answers both cases and the mood + * entry's MANAGE-only create as well. */ const CAPTURE_KIND_DOMAIN: Readonly> = { measurement: "measurements", @@ -117,13 +110,10 @@ export function admittedCaptureKind( * cannot tap the button that opens it. */ export function visibleCaptureKinds( - caps: Pick, + caps: Pick, kinds: ReadonlyArray, ): CaptureKind[] { - return kinds.filter((kind) => { - if (caps.canManageDomain(CAPTURE_KIND_DOMAIN[kind])) return true; - return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind); - }); + return kinds.filter((kind) => caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind])); } interface CapturePickerProps { From b6d3f9903f0fb0a1e0d4873df9891d5a7fa1860a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 10:49:12 +0200 Subject: [PATCH 05/24] docs(api): publish the switch refusal on the wipe, the deletion and the encrypted export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three routes that act on the whole of an account — `DELETE /api/settings/data`, `DELETE /api/settings/account`, `POST /api/export/encrypted` — name no record. They resolve the caller and act on whatever the caller owns, so the question a switched session puts to them is what happens when the browser believes it is somewhere else. They already refuse: all three resolve through `requireFreshMfaIfEnrolled` or its erasure sibling, and both open with `requireAuth()`, which reads the acting carrier and throws `sharing.not_permitted` for anything but `none`. What was missing is that the contract never said so. `/api/settings/{data,account}` published a 403 about narrow Bearer scope alone, and the encrypted export published no 403 at all, so a client that switches records had no way to learn which of its calls stop working short of trying one. One sentence, spliced onto the three, byte-identical for the same reason the delegable refusal is one string. `switched-session-destructive-refusal.test.ts` drives each route with a live whole-record MANAGE switch and asserts both halves: the status, the code and the wording, and that neither record moved — a refusal raised after the transaction would satisfy the first alone. The export leg additionally asserts no archive rode along with the 403. --- docs/api/openapi.yaml | 27 ++- src/lib/openapi/routes/export.ts | 10 +- src/lib/openapi/routes/settings.ts | 7 +- src/lib/openapi/routes/shared.ts | 19 ++ ...itched-session-destructive-refusal.test.ts | 183 ++++++++++++++++++ 5 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 tests/integration/switched-session-destructive-refusal.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0acb08f3d..b2586c84e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1764,6 +1764,15 @@ paths: type: string format: binary "401": *a1 + "403": + description: "Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/check-user: @@ -12203,8 +12212,15 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + description: >- + The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. content: application/json: schema: @@ -12426,8 +12442,15 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + description: >- + The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. content: application/json: schema: diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index 6cc215f33..3cf954223 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -20,7 +20,11 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { backupPayloadSchema } from "@/lib/validations/backup"; -import { errorEnvelope, stdResponses } from "./shared"; +import { + errorEnvelope, + SHARING_NOT_PERMITTED_DESCRIPTION, + stdResponses, +} from "./shared"; const exportQuery = z .object({ @@ -305,6 +309,10 @@ export const exportPaths: NonNullable = { }, }, }, + "403": { + description: SHARING_NOT_PERMITTED_DESCRIPTION, + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 5edde619c..224f66438 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -19,6 +19,7 @@ import { dataEnvelope, errorEnvelope, recordRefusal, + SHARING_NOT_PERMITTED_DESCRIPTION, stdResponses, } from "./shared"; @@ -615,7 +616,8 @@ export const settingsPaths: NonNullable = { }, "403": { description: - "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -801,7 +803,8 @@ export const settingsPaths: NonNullable = { }, "403": { description: - "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 43b625a9e..eb9f900b1 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -379,6 +379,25 @@ export function idempotentWrite(): IdempotentConflictResponse { export const SHARING_ACCESS_DENIED_DESCRIPTION = "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers one further case: a session that has entered a shared record and sends a request without its record-context assertion, which is a client older than the fence — leave the record and reload. A request that names no record but the caller's own never reaches this response, so a client that never switches and never sends the per-request account selector will not see it."; +/** + * The OTHER sharing refusal, for a route that names no record at all. + * + * A route resolving through `requireAuth` serves the caller and only the + * caller, and refuses outright while the browser is inside somebody else's + * record rather than quietly answering with the caller's own rows. That + * posture matters most on the routes where being wrong is unrecoverable — the + * record wipe, the account deletion, the encrypted archive — and it was + * documented nowhere on them. A client that switches records has to know which + * of its calls stop working, and finding out by wiping the wrong record is not + * a contract. + * + * One sentence, spliced onto whatever else the operation's 403 already says, + * for the same reason the delegable refusal is one string: a paraphrase per + * path invites a client to tell them apart. + */ +export const SHARING_NOT_PERMITTED_DESCRIPTION = + "Refused: the request was made while the session is acting on another account (`meta.errorCode` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it."; + /** * The 403 above, optionally sharing the status with the reasons a route already * refuses for. diff --git a/tests/integration/switched-session-destructive-refusal.test.ts b/tests/integration/switched-session-destructive-refusal.test.ts new file mode 100644 index 000000000..dfd48f675 --- /dev/null +++ b/tests/integration/switched-session-destructive-refusal.test.ts @@ -0,0 +1,183 @@ +/** + * The three destructive account routes, driven with a live record switch on. + * + * `DELETE /api/settings/data` wipes a record, `DELETE /api/settings/account` + * erases an account, and `POST /api/export/encrypted` puts a whole record into + * one file. None of them names a record: they resolve the CALLER and act on + * whatever that caller owns. So the question a switched session asks of them is + * not "may I do this to the record I am inside" — it is "what happens when the + * browser believes it is somewhere else and the route does not check". + * + * The answer must be a refusal, and the same one every undeclared route gives: + * 403, `sharing.not_permitted`, "cannot be used while acting on another + * account". Silently serving the delegate's own record is the failure this + * pins against, and it is the worst-shaped one in the product — a person who + * believes they are looking after somebody else's record presses "delete + * everything" and erases their own. + * + * Both halves are asserted: the status AND that nothing moved. A refusal that + * happens after the transaction would satisfy the first alone. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "switch-destructive-owner"; +const DELEGATE_ID = "switch-destructive-delegate"; + +/** The delegate's own reading. It must still be there afterwards. */ +async function seedRecords() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `switch-destructive-${name}`, + email: `switch-destructive-${name}@example.test`, + timezone: "UTC", + }, + }); + } + await prisma.measurement.create({ + data: { + userId: DELEGATE_ID, + type: "WEIGHT", + value: 72, + unit: "kg", + measuredAt: new Date(), + }, + }); +} + +/** + * Sign the delegate in and switch into the owner's record at MANAGE — the + * widest grant the product mints, so nothing below can be explained away by + * the grant simply being too narrow. + */ +async function switchIntoOwnersRecord() { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access: "MANAGE", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await getPrismaClient().session.create({ + data: { + userId: DELEGATE_ID, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + await switchSessionTo(session.id, OWNER_ID); +} + +/** The one refusal all three must give. */ +async function expectSwitchRefusal(response: Response) { + expect(response.status).toBe(403); + const body = (await response.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("sharing.not_permitted"); + expect(body.error).toBe( + "This endpoint cannot be used while acting on another account", + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecords(); + await switchIntoOwnersRecord(); +}); + +describe("a switched session reaches none of the record-wide destructive routes", () => { + it("refuses the record wipe and wipes neither record", async () => { + const { DELETE } = await import("@/app/api/settings/data/route"); + await expectSwitchRefusal( + await DELETE( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE" }), + }) as never, + ), + ); + + const prisma = getPrismaClient(); + expect( + await prisma.measurement.count({ where: { userId: DELEGATE_ID } }), + "the delegate's own reading survived", + ).toBe(1); + expect( + await prisma.user.count({ + where: { id: { in: [OWNER_ID, DELEGATE_ID] } }, + }), + ).toBe(2); + }); + + it("refuses the account deletion and deletes neither account", async () => { + const { DELETE } = await import("@/app/api/settings/account/route"); + await expectSwitchRefusal( + await DELETE( + new Request("http://localhost/api/settings/account", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }) as never, + ), + ); + + expect( + await getPrismaClient().user.count({ + where: { id: { in: [OWNER_ID, DELEGATE_ID] } }, + }), + ).toBe(2); + }); + + it("refuses the encrypted export and hands out no archive", async () => { + const { POST } = await import("@/app/api/export/encrypted/route"); + const response = await POST( + new Request("http://localhost/api/export/encrypted", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ passphrase: "a-long-enough-passphrase-123" }), + }) as never, + ); + await expectSwitchRefusal(response); + // Not an archive under any content type: a refusal that still streamed the + // bytes would read as a 403 to a status check and as an export to a user. + expect(response.headers.get("content-type")).not.toContain( + "application/octet-stream", + ); + }); +}); From e7a1950f3a6f05f46a4f8c4c1258d29979ca7532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 11:02:32 +0200 Subject: [PATCH 06/24] refactor(sharing): retire the coarse "may this person add" answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `canWrite` is the grant's LEVEL and carries no scope term. Its docblock said "May this caller ADD to that record", and the client hook republished it as `canAdd`, so fourteen controls across measurements, medications, labs, illness and the dashboard gated a create on a boolean that answers `true` for a WRITE grant scoped to the document vault — a grant whose writable-section list is empty, because the vault takes no delegated write at any level. Nothing shipped was visibly wrong before the capture picker, because the nav drops a section a grant does not open. That is a property of one surface, not of the answer, and the answer is the thing many controls read. So the coarse answer is gone from the hook, and every consumer asks `canWriteDomain` for its own section instead: `measurements` on the two measurement surfaces and the dashboard's empty state, `medications` on the medication list, table, ledger, card menu and side effects, `labs` on the lab list and biomarker detail, `illness` on the episode list. Each of those is the section its own route declares. `canWrite` keeps its name and its wire shape — native clients read it — and its docblock now says what it is: whether the grant is a reading one, with the per-section lists beside it for the question a control actually has. The hook table gained the vault-scoped WRITE grant as its own leg: level true, every section false. The take-all-due fixture described a grant the server never mints — `canWrite: true` over an empty writable list — and now derives its list from the same table the server derives it from. --- .../sharing-legacy-client-contract.test.ts | 9 ++-- src/app/labs/page.tsx | 5 +- src/app/measurements/page.tsx | 9 ++-- .../take-all-due-affordance.test.tsx | 13 ++++- src/app/medications/page-client.tsx | 17 ++++--- src/app/page-client.tsx | 5 +- .../delegated-write-affordances.test.tsx | 11 ++-- .../__tests__/cycle-view-error-state.test.tsx | 2 +- src/components/illness/illness-view.tsx | 7 +-- src/components/labs/lab-biomarker-detail.tsx | 7 +-- src/components/labs/lab-list.tsx | 5 +- .../__tests__/capture-picker-discard.test.tsx | 1 - .../layout/__tests__/capture-picker.test.tsx | 1 - src/components/layout/capture-picker.tsx | 9 ++-- ...rsorge-dashboard-card-error-state.test.tsx | 2 +- .../measurements/measurement-list.tsx | 5 +- .../card-parts/medication-intake-actions.tsx | 5 +- .../medications/dose-history-add-dialog.tsx | 2 +- .../medications/dose-history-ledger.tsx | 12 +++-- .../medications/medication-card-menu.tsx | 5 +- .../medications/medication-table.tsx | 5 +- .../medications/side-effects-section.tsx | 5 +- .../__tests__/use-record-capabilities.test.ts | 50 ++++++++++++++----- src/hooks/use-record-capabilities.ts | 23 +++++---- src/lib/sharing/account-access-view.ts | 19 +++++-- 25 files changed, 146 insertions(+), 88 deletions(-) diff --git a/src/__tests__/sharing-legacy-client-contract.test.ts b/src/__tests__/sharing-legacy-client-contract.test.ts index 67eaa3d0b..63dd4f71e 100644 --- a/src/__tests__/sharing-legacy-client-contract.test.ts +++ b/src/__tests__/sharing-legacy-client-contract.test.ts @@ -161,12 +161,11 @@ describe("what the canonical readers make of the same payload", () => { expect(capabilities.sections).toEqual(fixture.sections); expect(capabilities.recordKind).toBe(fixture.recordKind); expect(capabilities.inSharedRecord).toBe(true); - // Resolved server-side and rendered, never re-derived: `canAdd` follows - // `canWrite`, and `canManage` follows the published `manageableDomains` - // list rather than the level — a delegate's management reach is the - // server's answer, section by section. + // Resolved server-side and rendered, never re-derived: `canWrite` is the + // published level, and both per-section answers follow the published + // lists rather than the level — a delegate's reach is the server's + // answer, section by section. expect(capabilities.canWrite).toBe(fixture.canWrite); - expect(capabilities.canAdd).toBe(fixture.canWrite); const manageable = active?.manageableDomains ?? []; expect(capabilities.canManage).toBe(manageable.length > 0); expect(manageable.length > 0).toBe(fixture.level === "manage"); diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 768d7e5b5..8d6710f6e 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -44,7 +44,8 @@ export default function LabsPage() { // Scanning a report is not: it files a document and commits through the OCR // route, and neither is delegated. So a WRITE delegate keeps the manual add // and loses the choice menu around it. - const { canAdd, inSharedRecord } = useRecordCapabilities(); + const { canWriteDomain, inSharedRecord } = useRecordCapabilities(); + const canAddLab = canWriteDomain("labs"); const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); @@ -152,7 +153,7 @@ export default function LabsPage() { - ) : canAdd ? ( + ) : canAddLab ? ( diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 2f077bee4..e11c4ed98 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -93,7 +93,8 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) { export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const { t } = useTranslations(); - const { canAdd } = useRecordCapabilities(); + const { canWriteDomain } = useRecordCapabilities(); + const canAddLab = canWriteDomain("labs"); const { prefs } = useModuleListPrefs("labs"); // v1.22 — a short, factual line under each marker heading describing what the @@ -184,7 +185,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { title={t("labs.emptyTitle")} description={t("labs.emptyDescription")} action={ - onAddFirst && canAdd ? ( + onAddFirst && canAddLab ? ( ) : undefined } diff --git a/src/components/layout/__tests__/capture-picker-discard.test.tsx b/src/components/layout/__tests__/capture-picker-discard.test.tsx index a2c0b3819..f701b3a94 100644 --- a/src/components/layout/__tests__/capture-picker-discard.test.tsx +++ b/src/components/layout/__tests__/capture-picker-discard.test.tsx @@ -18,7 +18,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: () => true, canManageDomain: () => true, diff --git a/src/components/layout/__tests__/capture-picker.test.tsx b/src/components/layout/__tests__/capture-picker.test.tsx index 52a3fb878..ecb0d3463 100644 --- a/src/components/layout/__tests__/capture-picker.test.tsx +++ b/src/components/layout/__tests__/capture-picker.test.tsx @@ -11,7 +11,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: () => true, canManageDomain: () => true, diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index 8e52a9318..6232e6f3d 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -55,11 +55,10 @@ type CaptureKind = "measurement" | "medication" | "mood"; * * One question per kind, asked of that kind's own section. The picker used to * ask a coarse one as well — a closed list of kinds a WRITE grant admits, - * offered whenever `canAdd` was true — and `canAdd` is bound to the grant's - * LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]` - * published `canAdd: true` and got a weight form the server refuses; one - * scoped to `["documents"]`, which takes no delegated write at any level, got - * both. `writableDomains` is already the level × scope × route-table + * offered whenever the grant could write anywhere — and that answer is the + * grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]` + * read as true and got a weight form the server refuses; one scoped to + * `["documents"]`, which takes no delegated write at any level, got both. `writableDomains` is already the level × scope × route-table * intersection, so asking it per section answers both cases and the mood * entry's MANAGE-only create as well. */ diff --git a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx index 584e016f4..74b48d6a2 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/hooks/use-encounters", () => ({ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ canManage: true, - canAdd: true, + canWriteDomain: () => true, canManageDomain: () => true, }), })); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index efa88d127..d85ad6a8c 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -292,8 +292,9 @@ export function MeasurementList({ // v1.36.x — inside somebody else's record, only the add path survives: // entering a reading is an admitted delegated write, editing and deleting // one are not. The row controls are dropped rather than disabled. - const { canAdd, canManage, inSharedRecord, canWrite } = + const { canWriteDomain, canManage, inSharedRecord, canWrite } = useRecordCapabilities(); + const canAddMeasurement = canWriteDomain("measurements"); const activeRecord = user?.accountAccess?.active ?? null; // Named only when the sentence below is owed: a delegate who may add but // not change. In the caller's own record, and for a read-only delegate who @@ -1068,7 +1069,7 @@ export function MeasurementList({ > {t("measurements.emptyResetFilter")} - ) : onAddFirst && canAdd ? ( + ) : onAddFirst && canAddMeasurement ? (