feat(sharing): open the medication reads to a delegate

Ten routes: the cabinet list, the medication detail, its cadence, schedule
revisions, side effects, inventory, phase config and intake history, plus the
account-wide intake list and the batched compliance read.

`GET /api/medications/compliance` is the one delegable read that spends a
quota, and its rate-limit bucket now keys on the ACTOR while everything below
it scopes to the record. Both halves matter: a delegate hammering the route
must burn their own allowance rather than lock the owner out of their own
cabinet, and switching records must not hand the same caller a fresh one. The
test reads the `rate_limits` row rather than inferring it from a 429, because
a bucket keyed on the wrong person still answers 200.

`GET /api/medications/[id]/api-endpoint` stays refused. It returns two scalar
fields and no secret, and it tells a delegate whether the owner holds a live
ingest credential and for which medication — in the same file as the PUT that
mints one that would survive revocation.

The two intake validation breadcrumbs move onto `auditLog()` for the actor
stamp. The POST arm's breadcrumb stays as it is: that arm cannot be reached
under a switch.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 17:06:23 +02:00
1 parent fe765a7ae2
commit 7a39a649be
14 files changed
+401 -71

No files matched your search

@@ -18,7 +18,7 @@ import { NextRequest } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import {
@@ -42,7 +42,7 @@ const querySchema = z.object({
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
@@ -72,9 +72,7 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
@@ -100,6 +98,7 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({
import { GET, POST } from "../route";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -127,6 +126,10 @@ const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
beforeEach(() => {
vi.resetAllMocks();
// v1.36.0 — the GET's validation breadcrumb goes through the `auditLog`
// helper, whose promise the route attaches a `.catch` to. `resetAllMocks`
// clears the factory's implementation, so it is restored here.
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
@@ -292,13 +295,19 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
await GET(makeRequest("status=junk"), ROUTE_PARAMS);
await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
await new Promise((r) => setTimeout(r, 5));
// v1.36.0 — the GET's breadcrumb goes through the `auditLog` helper (the
// only writer that stamps `actorUserId`); the POST's still writes the row
// directly, because that arm cannot be reached under a switch.
expect(auditLog).toHaveBeenCalledWith(
"medications.intake.list.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
expect(prisma.auditLog.create).toHaveBeenCalled();
const actions = vi
.mocked(prisma.auditLog.create)
.mock.calls.map(
(c) => (c[0] as { data: { action: string } }).data.action,
);
expect(actions).toContain("medications.intake.list.validation-failed");
expect(actions).toContain("medications.intake.create.validation-failed");
});
+12 -16
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -538,7 +538,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
@@ -559,20 +559,16 @@ export const GET = apiHandler(
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a delegate's malformed query would read as the owner's own.
void auditLog("medications.intake.list.validation-failed", {
userId: user.id,
details: { issues: auditIssues, medicationId: id },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -16,7 +16,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
@@ -44,7 +44,7 @@ const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { phaseConfigSchema } from "@/lib/validations/phase-config";
@@ -10,7 +10,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
+2 -2
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -76,7 +76,7 @@ function reconcileScheduleWindow(
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const medication = await prisma.medication.findUnique({
@@ -23,7 +23,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -73,7 +73,7 @@ function summariseEntries(payload: unknown): Array<{
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -19,7 +19,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
@@ -45,7 +45,7 @@ const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
+8 -3
View File
@@ -1,7 +1,7 @@
import pLimit from "p-limit";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
@@ -26,10 +26,15 @@ import { checkRateLimit } from "@/lib/rate-limit";
* detail page keeps the per-id route for the grid.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user, actor } = await requireRecordAuth("read");
// v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to
// the record. Two reasons, and both are the same reason from opposite ends:
// a delegate hammering this route must burn their own allowance rather than
// locking the owner out of their own cabinet, and switching records must not
// hand the same caller a fresh allowance. `user.id` would get both wrong.
const rl = await checkRateLimit(
`medication-compliance-summary:${user.id}`,
`medication-compliance-summary:${actor.id}`,
30,
60_000,
);
@@ -44,9 +44,7 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn().mockResolvedValue(undefined),
@@ -76,6 +74,7 @@ vi.mock("next/headers", () => ({
}));
import { GET, POST } from "../route";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import {
@@ -92,6 +91,10 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
// v1.36.0 — the GET's validation breadcrumb goes through the `auditLog`
// helper, whose promise the route attaches a `.catch` to. `resetAllMocks`
// clears the factory's implementation, so it is restored here.
vi.mocked(auditLog).mockResolvedValue(undefined);
// v1.4.34 IW-G — reset compliance LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
+12 -13
View File
@@ -13,7 +13,7 @@
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -84,7 +84,7 @@ const updateSchema = z.object({
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
@@ -102,17 +102,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a delegate's malformed query would read as the owner's own.
void auditLog("medications.intake.list.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -33,7 +33,7 @@ import { NextRequest } from "next/server";
const APPLE_HEALTH_MIRROR_CAP = 30;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
// Cache the list shape on userId. The 60 s fresh TTL bounds the
// cross-midnight staleness window for `todayEventCount` and the