Let the GLP-1 read reach the record its own write already reaches

`GET /api/medications/{id}/glp1` resolved the caller as themselves while
the POST beside it has resolved the record since v1.37.0. A manager
inside a shared record could therefore add a titration step to a
medication whose history the same path refused them: may write, may not
read. No reading of the sharing model produces that shape, and the
sibling reads on the same medication — `/inventory`, `/side-effects`,
`/cadence` — have all resolved the record at READ level for releases.

The read joins them at that level rather than at the POST's MANAGE,
because it is strictly narrower than the write already admitted. The
module was already on both reviewed allowlists, so nothing is newly
delegable; its reason line said the module was reached through the
MANAGE arm alone, which is no longer true and now says so.

The gap survived this long because a route that refuses every switched
caller is indistinguishable from a route nobody classified. Three legs
close that: a delegate scoped to medications reads the owner's titration
history and still cannot reach their own medication, a delegate scoped
elsewhere is refused, and a caller who never switched is unchanged. Both
delegate legs assert the refusal CODE rather than the status, because the
pre-fix answer was also a 403 — verified by reverting the handler, at
which point the first leg reads 403 for 200 and the second reads
`sharing.not_permitted` for `sharing.access.denied`.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 13:04:33 +02:00
1 parent d66c4f6cdc
commit e39e2b60c2
4 files changed
+124 -5

No files matched your search

+1 -1
View File
@@ -1214,7 +1214,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
},
"app/api/medications/[id]/glp1/route.ts": {
domain: "medications",
why: "Recording a titration step. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
why: "Reading the titration history, the recent injections and the supply math, and recording a titration step. The READ arm joined the MANAGE one when the route was published: the write had resolved the record since v1.37.0 while the read still resolved the caller, so a manager could add a dose the history refused them — may write, may not read, which the model does not produce. The read is at the level `/inventory`, `/side-effects` and `/cadence` use for the same medication's data and is strictly narrower than the write already admitted; the argument for the write is the manage literal's reason line.",
},
"app/api/medications/[id]/intake/[eventId]/route.ts": {
domain: "medications",
+11 -2
View File
@@ -15,7 +15,7 @@
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
@@ -53,7 +53,16 @@ const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// The read resolves the RECORD, at the level its siblings use. It did not
// until the route was published, and the asymmetry that produced is the
// reason it is written down here: the POST below has resolved the record
// since v1.37.0, so a manager could add a titration step to a medication
// whose history the GET refused them — may write, may not read, which is
// not a shape any reading of the sharing model produces. `read` rather
// than the POST's `manage` because that is what `/inventory`,
// `/side-effects` and `/cadence` ask for the same medication's data, and
// this read is strictly narrower than the write already permitted.
const { user } = await requireRecordAuth("read", "medications");
const { id } = await params;
// v1.5.5 F-1 C-4 — close the §10 invariant 24 gap. The destructive
+2 -1
View File
@@ -259,9 +259,10 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "GLP-1 detail — titration, recent injections, supply",
description:
"Returns the extras the GLP-1 card variant, the dashboard tile and the doctor-report section read: the full dose-change history, the last twelve injections with their sites, and the running supply math carrying the same low-stock verdict the daily notification uses. NOT delegable — unlike the POST on this same path, this read resolves the caller as themselves, so a manager acting inside a shared record is answered 404 here while their write on the same medication succeeds. Cookie or Bearer auth.",
"Returns the extras the GLP-1 card variant, the dashboard tile and the doctor-report section read: the full dose-change history, the last twelve injections with their sites, and the running supply math carrying the same low-stock verdict the daily notification uses. Delegable at READ level over the `medications` section, the level `/inventory`, `/side-effects` and `/cadence` ask for the same medication's data — and strictly narrower than the MANAGE-level write the POST on this path already permits. Cookie or Bearer auth.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...recordRefusal(),
"200": {
description: "The GLP-1 detail block.",
content: {