fix(vaccinations): keep the booster mint inside the sections the grant opens

`POST /api/vaccinations/{id}/booster` is declared `("write", "profile")`, and
that is right for the act — filing the Impfpass and arming the booster it
suggests are one thing to the person doing them. The row it writes is not: a
`MeasurementReminder` belongs to the measurements section, every direct route
over the model declares it there, and creating one needs MANAGE. So a WRITE
grant scoped to the health background alone produced a reminder on the owner's
checkup list that the delegate could neither read nor change through the
section that owns it.

The seam is fenced the way the visits service fences a checkup closure, with
one difference stated at the call site: this refuses rather than skips. There
the closure is one effect of a larger save and reporting it as not done keeps
the save honest; here the reminder is the whole request, and a 201 over a row
that was never written would tell the person their booster is planned while
nothing rings. The prompt that raises the call asks the same question client-
side, so a delegate outside the section is not offered it in the first place;
the dose itself still logs either way.

`vaccination-booster-scoped-delegate.test.ts` drives the owner, a delegate
holding both sections, a profile-only delegate and a whole-record one. The
scoped-refusal leg fails against the previous handler, which answered 201 and
left the row.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 11:11:38 +02:00
1 parent 0ee26625be
commit 1b74bf8b54
5 files changed
+231 -7

No files matched your search

+26 -1
View File
@@ -13,11 +13,21 @@
* even when a delegate is transcribing. The antigen is read from the dose's
* catalogue entry server-side, never from the body — a client cannot key a
* reminder onto an antigen the dose does not contain.
*
* The row it mints is a `MeasurementReminder`, and that model belongs to the
* `measurements` section: every direct route over it declares that domain, and
* creating one needs MANAGE there. So the act crosses a seam, and a grant
* scoped to the health background alone was never consent for the other side
* of it — it would produce a row on the owner's checkup list that the delegate
* can neither read nor change through the section that owns it. The seam is
* fenced the way the visits service fences a checkup closure: the mint runs
* only while the caller is inside `measurements`.
*/
import { NextRequest } from "next/server";
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 { auditLog } from "@/lib/auth/audit";
import {
@@ -36,9 +46,24 @@ type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("write", "profile");
const { user, grantId } = await requireRecordAuth("write", "profile");
const { id } = await params;
// Refused rather than skipped, and that is the difference from the visit's
// checkup closure: there the closure is one effect of a larger save and
// saying "not done" keeps the save honest, while here the reminder IS the
// request. Reporting success over a row that was never written would tell
// the person their booster is planned when nothing rings.
const visible = await actingDomainVisibility(prisma, grantId);
if (!visible("measurements")) {
annotate({ meta: { sharing_refusal: "booster_out_of_scope" } });
return apiError(
"Planning a booster needs access to this record's measurements",
403,
{ errorCode: "vaccination.booster-out-of-scope" },
);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
@@ -37,6 +37,10 @@ export function VaccinationsView() {
// next door.
const canAddDose = canWriteDomain("profile");
const canManageProfile = canManageDomain("profile");
// Planning the booster mints a `MeasurementReminder`, which lives in the
// measurements section rather than this one. A grant that opened only the
// health background is refused there, so the offer is not made.
const canPlanBooster = canWriteDomain("measurements");
const { data, isLoading, isError, refetch } = useVaccinations();
const records = data?.vaccinations ?? [];
@@ -91,7 +95,8 @@ export function VaccinationsView() {
onCreated={(created) => {
// Offer the booster only when the catalogue entry carries an
// interval; a free-text or one-off dose never prompts.
if (boosterOfferFor(created)) setBoosterFor(created);
if (canPlanBooster && boosterOfferFor(created))
setBoosterFor(created);
}}
/>
+5 -2
View File
@@ -362,14 +362,17 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "Plan a booster reminder from a dose",
description:
"Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.",
"Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.\n\n" +
"The operation is declared under the health background and the row it mints belongs to the measurements section, so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does not.",
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationBoosterRequest } },
},
responses: {
...recordRefusal(),
...recordRefusal(
"Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written.",
),
"200": {
description: "Existing booster reminder re-anchored.",
content: {