mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
d66c4f6cdc
commit
e39e2b60c2
4 files changed
+124
-5
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1560,6 +1560,112 @@ describe("GET /api/medications/intake", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The GLP-1 read, against the section that opens it.
|
||||
*
|
||||
* Its own block because the asymmetry it closes was invisible to every other
|
||||
* leg in this file: the POST on this path has resolved the record since
|
||||
* v1.37.0 and the GET beside it resolved the CALLER, so a manager could add a
|
||||
* titration step to a medication whose history the same request path refused
|
||||
* them. May write, may not read — a shape no reading of the sharing model
|
||||
* produces, and one nothing here would have caught, because a route that
|
||||
* refuses every switched caller looks exactly like a route nobody classified.
|
||||
*
|
||||
* Both legs are evidence rather than verdict. The positive one asserts the
|
||||
* OWNER's dose value comes back and the delegate's own does not, so a handler
|
||||
* that returned everybody's rows fails it; the negative one asserts the
|
||||
* refusal code and not merely a 403, so the pre-fix `sharing.not_permitted`
|
||||
* (which is also a 403) fails it too.
|
||||
*/
|
||||
describe("GET /api/medications/[id]/glp1 — the read follows the section", () => {
|
||||
/** A live accepted grant scoped to the named sections, minted the shipped way. */
|
||||
async function switchIntoScoped(
|
||||
ownerId: string,
|
||||
delegateId: string,
|
||||
scope: ("medications" | "labs")[],
|
||||
) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: ownerId,
|
||||
granteeId: delegateId,
|
||||
access: "READ",
|
||||
scope,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: delegateId });
|
||||
const session = await signIn(delegateId);
|
||||
await switchSessionTo(session.id, ownerId);
|
||||
}
|
||||
|
||||
async function seedTitration(userId: string, doseValue: number) {
|
||||
const medication = await getPrismaClient().medication.create({
|
||||
data: { userId, name: `glp1-${doseValue}`, dose: `${doseValue}mg` },
|
||||
});
|
||||
await getPrismaClient().medicationDoseChange.create({
|
||||
data: {
|
||||
medicationId: medication.id,
|
||||
effectiveFrom: new Date("2026-07-14T00:00:00Z"),
|
||||
doseValue,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
});
|
||||
return medication;
|
||||
}
|
||||
|
||||
async function readGlp1(medicationId: string) {
|
||||
const { GET } = await import("@/app/api/medications/[id]/glp1/route");
|
||||
return call(GET as Handler, `/api/medications/${medicationId}/glp1`, {
|
||||
id: medicationId,
|
||||
});
|
||||
}
|
||||
|
||||
it("serves the owner's titration history to a delegate scoped to medications", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const ownerMed = await seedTitration(owner.id, 1.5);
|
||||
const delegateMed = await seedTitration(delegate.id, 7.5);
|
||||
|
||||
await switchIntoScoped(owner.id, delegate.id, ["medications"]);
|
||||
|
||||
const response = await readGlp1(ownerMed.id);
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await body(response)).data as {
|
||||
doseChanges: { doseValue: number }[];
|
||||
};
|
||||
expect(payload.doseChanges.map((c) => c.doseValue)).toEqual([1.5]);
|
||||
|
||||
// The delegate's own medication is seeded and must stay out of reach:
|
||||
// the ownership guard resolves against the RECORD, not the caller, so
|
||||
// their own row is the one that 404s while they are switched in.
|
||||
expect((await readGlp1(delegateMed.id)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses the same delegate when the grant does not name medications", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const ownerMed = await seedTitration(owner.id, 1.5);
|
||||
|
||||
await switchIntoScoped(owner.id, delegate.id, ["labs"]);
|
||||
|
||||
// The code, not the status: before the read resolved the record it also
|
||||
// answered 403 here, with `sharing.not_permitted`, and a status-only
|
||||
// assertion would have passed against the defect.
|
||||
await expectDenied(await readGlp1(ownerMed.id));
|
||||
});
|
||||
|
||||
it("is unchanged for a caller who has not switched", async () => {
|
||||
const plain = await makeUser("plain");
|
||||
const med = await seedTitration(plain.id, 2.5);
|
||||
await signIn(plain.id);
|
||||
|
||||
const response = await readGlp1(med.id);
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await body(response)).data as {
|
||||
doseChanges: { doseValue: number }[];
|
||||
};
|
||||
expect(payload.doseChanges.map((c) => c.doseValue)).toEqual([2.5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the per-medication reads", () => {
|
||||
it("resolve the ownership guard against the record", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
@@ -1584,6 +1690,7 @@ describe("the per-medication reads", () => {
|
||||
const phaseConfig =
|
||||
await import("@/app/api/medications/[id]/phase-config/route");
|
||||
const intake = await import("@/app/api/medications/[id]/intake/route");
|
||||
const glp1 = await import("@/app/api/medications/[id]/glp1/route");
|
||||
|
||||
const { grant } = await switchInto(owner.id, delegate.id);
|
||||
|
||||
@@ -1603,6 +1710,7 @@ describe("the per-medication reads", () => {
|
||||
inventory.GET,
|
||||
phaseConfig.GET,
|
||||
intake.GET,
|
||||
glp1.GET,
|
||||
]) {
|
||||
const response = await call(
|
||||
route as Handler,
|
||||
@@ -1622,6 +1730,7 @@ describe("the per-medication reads", () => {
|
||||
inventory.GET,
|
||||
phaseConfig.GET,
|
||||
intake.GET,
|
||||
glp1.GET,
|
||||
]) {
|
||||
const response = await call(
|
||||
route as Handler,
|
||||
@@ -1632,7 +1741,7 @@ describe("the per-medication reads", () => {
|
||||
}
|
||||
|
||||
await revokeGrant(grant.id);
|
||||
for (const route of [detail.GET, cadence.GET, intake.GET]) {
|
||||
for (const route of [detail.GET, cadence.GET, intake.GET, glp1.GET]) {
|
||||
await expectDenied(
|
||||
await call(route as Handler, `/api/medications/${ownerMed.id}/sub`, {
|
||||
id: ownerMed.id,
|
||||
|
||||
Reference in new issue
Block a user