From 83b7cd223afbdc549bc9965296901952f422c1de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 12:23:59 +0200 Subject: [PATCH] docs(sharing): the booster needs two sections, and the guide promised it at one level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write-level row listed "a vaccination dose and the booster reminder it suggests" three paragraphs under a section table that puts the vaccination record in Health background. Read together, that tells a self-hoster a health-background WRITE grant can plan a booster — which the route beside it now refuses, because the reminder it mints lives under Readings. The row lists the dose alone, and the exceptions paragraph, which is where a reader looks for exactly this, carries the two-section case and says the dose logs either way. With it, the small things the same pass turned up: The cycle-insights fixture asserted a property it could not show. Its docblock claims the cycle-native answer survives the fence, and it seeded no symptom at all, so `symptomPatterns` was empty in every leg and a regression that emptied it along with the foreign columns would have passed green. One catalogue symptom on the luteal days, asserted on the owner leg and on the cycle-only one. The immunization rows matched on a bare `role="button"`, which passes vacuously the day the card becomes a real button and fails for the wrong reason if anything else in the subtree grows one. They carry the same stable marker the visit card took. `/api/cycle/calendar` reads the same wrist-temperature channel the crosstab does and is deliberately not fenced, and it was the only one of the pair with nothing saying why: no value from it reaches the response. The encrypted export's new 403 documented the switch refusal and not the narrow-scope one it answers first, unlike the two settings operations beside it. Both sheets say at the top that their Save and Delete are gated at the caller, so a third mount reads the constraint before it violates it. And one docblock line ran past the wrap. --- docs/api/openapi.yaml | 9 +++- docs/self-hosting/account-sharing.md | 17 +++++-- src/app/api/cycle/calendar/route.ts | 6 +++ .../delegated-write-affordances.test.tsx | 16 ++++--- src/components/encounters/encounter-sheet.tsx | 10 ++++ src/components/layout/capture-picker.tsx | 8 ++-- .../vaccinations/vaccination-list.tsx | 4 ++ .../vaccinations/vaccination-sheet.tsx | 7 +++ src/lib/openapi/routes/export.ts | 4 +- .../cycle-insights-scoped-delegate.test.ts | 48 ++++++++++++++++++- 10 files changed, 111 insertions(+), 18 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4fad898e2..ea7b675fb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1765,10 +1765,15 @@ paths: format: binary "401": *a1 "403": - description: "Refused: the request was made while the session is acting on another account (`meta.errorCode` = + description: >- + The Bearer token carries a narrow scope. The encrypted archive 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." + level opens it. content: application/json: schema: diff --git a/docs/self-hosting/account-sharing.md b/docs/self-hosting/account-sharing.md index c05814d85..05e2a9b14 100644 --- a/docs/self-hosting/account-sharing.md +++ b/docs/self-hosting/account-sharing.md @@ -84,11 +84,11 @@ Three levels, fixed when the invitation is written. Raising one means revoking and inviting again, because widening a grant in place would carry a consent the other person never gave. -| Level | Adds | -| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Read | Read what the grant opens, and change nothing. | -| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, a vaccination dose and the booster reminder it suggests. Editing, deleting and restoring stay with you. | -| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. | +| Level | Adds | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Read | Read what the grant opens, and change nothing. | +| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, and a vaccination dose. Editing, deleting and restoring stay with you. | +| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. | Some entries are creates only **manage** reaches, and the split runs by entry rather than by section: an allergy, a family-history entry, a mood @@ -96,6 +96,13 @@ entry, a screener, a cycle day, a water entry and a preventive-care reminder are all created at manage, in sections a write grant can otherwise add to. Nothing in the document vault is writable at any level. +One entry needs two sections rather than a higher level. Planning the +booster a vaccination suggests files it as a preventive-care reminder, +which lives under Readings, so a grant that opens Health background alone +logs the dose and is refused the booster. Logging the dose is unaffected +either way, and the app does not offer the booster to a grant that cannot +plan it. + Offering **manage** asks the owner for a fresh second factor if they have one enrolled, which makes it a browser-only act either way: a Bearer caller is refused before anything else happens and told to use a browser. diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 2c69eb869..3ae86a992 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -111,6 +111,12 @@ export const GET = apiHandler(async (request: NextRequest) => { // Apple Watch wrist/skin temperature feeds the temperature-trend // ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly // values; the engine derives the trailing-mean deviation itself. + // + // This is a measurements read on a route declared under `cycle`, and it + // needs no section predicate for one reason: no value from it reaches the + // response. It goes into `predictCycle` and what comes out the other side + // is a shifted date. The crosstab next door reads the same channel and IS + // fenced, because there the values are the answer. prisma.measurement.findMany({ where: { userId: user.id, diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 7b7aacc36..de7a48835 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -714,11 +714,15 @@ describe("the immunization log's add, edit and delete", () => { }); }; + /** The row carries the edit affordance only while it opens something. */ + const rowIsTappable = (html: string) => + html.includes('data-slot-open="vaccination-row-open"'); + 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"'); + expect(rowIsTappable(html)).toBe(true); }); it("offers a read-only delegate none of them", () => { @@ -730,7 +734,7 @@ describe("the immunization log's add, edit and delete", () => { seedList, ); expect(html).not.toContain('data-slot="vaccination-add"'); - expect(html).not.toContain('role="button"'); + expect(rowIsTappable(html)).toBe(false); }); it("offers a WRITE delegate the add and not the row", () => { @@ -740,8 +744,8 @@ describe("the immunization log's add, edit and delete", () => { seedList, ); expect(html).toContain('data-slot="vaccination-add"'); - expect(html, "editing and deleting a dose are MANAGE").not.toContain( - 'role="button"', + expect(rowIsTappable(html), "editing and deleting a dose are MANAGE").toBe( + false, ); }); @@ -752,7 +756,7 @@ describe("the immunization log's add, edit and delete", () => { seedList, ); expect(html).toContain('data-slot="vaccination-add"'); - expect(html).toContain('role="button"'); + expect(rowIsTappable(html)).toBe(true); }); it("offers a MANAGE delegate outside the section neither", () => { @@ -764,7 +768,7 @@ describe("the immunization log's add, edit and delete", () => { seedList, ); expect(html).not.toContain('data-slot="vaccination-add"'); - expect(html).not.toContain('role="button"'); + expect(rowIsTappable(html)).toBe(false); }); it("offers the empty state's add on the same rule", () => { diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 26e2a8d0e..abac9c3fb 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -20,6 +20,16 @@ * saved and a plain sentence saying the checkup stayed open. That is reported * rather than skipped in silence: somebody who believes a checkup was closed * will not look at it again. + * + * Capability note: this sheet carries no capability check of its own, by + * decision. Save is a create on one mount and an edit on the other, and Delete + * exists only in edit mode — so the question belongs to whoever opens it. + * `VisitsSection` hands it a record only under `canManageDomain("profile")`, + * and the two mounts that can only create (`EpisodeVisitsCard`, + * `VorsorgeSection`) pass `encounter={null}` behind their own write check. A + * THIRD mount that hands this sheet a record without asking the manage + * question reintroduces an ungated Delete, and nothing here will fail. Ask at + * the caller, or move the check in here for everyone. */ import { useState } from "react"; import { Trash2 } from "lucide-react"; diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index 6232e6f3d..b3415e891 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -58,9 +58,11 @@ type CaptureKind = "measurement" | "medication" | "mood"; * 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. + * `["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", diff --git a/src/components/vaccinations/vaccination-list.tsx b/src/components/vaccinations/vaccination-list.tsx index 7974b135c..442803175 100644 --- a/src/components/vaccinations/vaccination-list.tsx +++ b/src/components/vaccinations/vaccination-list.tsx @@ -157,6 +157,10 @@ function DoseRow({ ` replaced the card. + data-slot-open={onEdit ? "vaccination-row-open" : undefined} data-vaccination-id={record.id} onClick={onEdit ? () => onEdit(record) : undefined} role={onEdit ? "button" : undefined} diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index a7c6c2022..d8c83951f 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -10,6 +10,13 @@ * On a successful create the caller may raise the booster-mint prompt; this * sheet completes and closes regardless of what the person does with it. The * mint is an offer, never a step. + * + * Capability note: the same posture as the visit sheet. No check here; Delete + * renders only in edit mode, and the one mount that can reach edit mode + * (`VaccinationsView`) asks `canManageDomain("profile")` before handing over a + * record, while its create path asks `canWriteDomain("profile")`. A second + * mount that skips either question reintroduces an ungated control with + * nothing failing. */ import { useState } from "react"; import { Trash2 } from "lucide-react"; diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index 3cf954223..dfd157c28 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -310,7 +310,9 @@ export const exportPaths: NonNullable = { }, }, "403": { - description: SHARING_NOT_PERMITTED_DESCRIPTION, + description: + "The Bearer token carries a narrow scope. The encrypted archive 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 } }, }, ...stdResponses, diff --git a/tests/integration/cycle-insights-scoped-delegate.test.ts b/tests/integration/cycle-insights-scoped-delegate.test.ts index da1cf82d9..482afc6f5 100644 --- a/tests/integration/cycle-insights-scoped-delegate.test.ts +++ b/tests/integration/cycle-insights-scoped-delegate.test.ts @@ -165,6 +165,29 @@ async function seedRecord() { today, ); + // One catalogue symptom, logged on enough phase-labelled days to clear the + // pattern floor. Without it `symptomPatterns` is empty in every leg and the + // "the cycle-native answer still arrives" property could not be shown even + // by a leg that asserted it. + // + // Upserted rather than created: the symptom catalogue is seeded by migration + // and survives the per-test truncation, so a plain create passes on the + // first leg and collides on the second. + const category = await prisma.cycleSymptomCategory.upsert({ + where: { key: "cycle-insights-cat" }, + update: {}, + create: { key: "cycle-insights-cat", labelKey: "cycle.category.test" }, + }); + const symptom = await prisma.cycleSymptom.upsert({ + where: { key: "cycle-insights-cramps" }, + update: {}, + create: { + categoryId: category.id, + key: "cycle-insights-cramps", + labelKey: "cycle.symptom.cramps", + }, + }); + let luteal = 0; let follicular = 0; for (const [day, phase] of phaseByDay) { @@ -195,6 +218,18 @@ async function seedRecord() { tz: "UTC", }, }); + // Cycle-native data, logged on the luteal days only so the pattern has a + // dominant phase to report. + if (high) { + await prisma.cycleDayLog.create({ + data: { + userId: OWNER_ID, + date: day, + tz: "UTC", + symptomLinks: { create: [{ symptomId: symptom.id }] }, + }, + }); + } } expect( @@ -211,7 +246,7 @@ interface InsightsBody { rows: Array<{ metricKey: string }>; headline: { metricKey: string } | null; lagged: { discovered: unknown[] }; - symptomPatterns: unknown[]; + symptomPatterns: Array<{ symptomKey: string; topPhase: string }>; } async function readInsights(): Promise { @@ -243,6 +278,10 @@ describe("cycle insights fence the sections it reads across", () => { "weight", ); expect(keys, "and the mood contrast beside it").toContain("mood"); + expect( + body.symptomPatterns.map((row) => row.symptomKey), + "and the cycle-native patterns the fence must not touch", + ).toContain("cycle-insights-cramps"); }); it("gives an entire-record delegate the same figures", async () => { @@ -267,6 +306,13 @@ describe("cycle insights fence the sections it reads across", () => { body.lagged.discovered, "the lagged matrix is built from the same two sections", ).toHaveLength(0); + // The other half of the property, and the one a blanket refusal would + // break: the symptom-by-phase patterns are cycle data and the grant opens + // cycle, so they arrive in full. + expect( + body.symptomPatterns.map((row) => row.symptomKey), + "the cycle-native answer still arrives", + ).toContain("cycle-insights-cramps"); }); it("gives a cycle+mind delegate the mood figure and not the weight", async () => {