fix(medications): cap externally mirrored rows and surface their provenance

A client that mints a fresh externalId for the same source concept on every
sync sweep never matches the (userId, externalSource, externalId) idempotency
triple, so each sweep would otherwise create a new mirror row without bound.
Add a growth guard on POST /api/medications: once an account already holds 30
medications mirrored from an external source, a genuinely new mirror create is
refused with 422 and the errorCode medications.mirror.limit_exceeded. The guard
sits after the idempotency lookup, so an idempotent re-post of a medication the
account already mirrors still returns 200, and native creates never run the
count.

Echo externalSource on the medications list read so the web UI and an operator
can tell a mirrored row from a native one, and document both in the OpenAPI
contract.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 23:18:14 +02:00
1 parent 6c8e921faa
commit dbe3c3dc26
4 files changed
+49 -2

No files matched your search

+34
View File
@@ -19,6 +19,15 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { readMedicationsListCached } from "@/lib/medications/list-read";
import { NextRequest } from "next/server";
// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
// A client that mints a fresh `externalId` for the same source concept on
// every sync sweep (a known iOS unstable-key bug class) never matches the
// idempotency triple, so each sweep would otherwise create a new mirror
// row unbounded. Apple's own medication list makes >30 genuinely distinct
// meds implausible, so this caps the damage a buggy client can do without
// breaking any legitimate use.
const APPLE_HEALTH_MIRROR_CAP = 30;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -133,6 +142,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (existing) {
return respondWithExistingMirror(existing);
}
// v1.32.25 — growth guard. This point is only reached when the
// idempotency lookup MISSED, i.e. this is a genuinely NEW mirror row,
// not a replay of one the user already holds (a replay returned 200
// above). An idempotent re-post of an existing mirror is therefore
// never blocked, even for a user already at the cap. Refuse only the
// minting of a fresh row once the user already holds the cap of
// Apple-Health-mirrored medications, so a client feeding a rotating
// key can no longer fill the list with duplicates. The count runs on
// the mirror-create-miss path only; native creates (no
// `externalSource`) never reach this branch and pay nothing.
const mirroredCount = await prisma.medication.count({
where: { userId: user.id, externalSource: "APPLE_HEALTH" },
});
if (mirroredCount >= APPLE_HEALTH_MIRROR_CAP) {
annotate({
action: { name: "medication.mirror.limit_exceeded" },
meta: { existing: mirroredCount },
});
return apiError(
"This account already mirrors the maximum number of medications from an external source",
422,
{ errorCode: "medications.mirror.limit_exceeded" },
);
}
}
// ── v1.5 route invariants for the new scheduling primitives ───────
+5
View File
@@ -239,6 +239,11 @@ export async function buildMedicationsList(
// the string Prisma would otherwise serialise a Decimal to.
unitsPerDose: Number(m.unitsPerDose),
category: categoryMap[m.id] ?? "OTHER",
// v1.32.25 — provenance echo. Surfacing the mirror source lets the
// web UI and an operator tell an externally-mirrored row (today only
// Apple Health) from a native HealthLog medication. NULL for a native
// medication.
externalSource: m.externalSource ?? null,
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
nextDueAt: display ? display.at.toISOString() : null,
+1 -1
View File
@@ -204,7 +204,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Create a medication with at least one schedule",
description:
"Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`.",
"Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`. v1.28 — a mirror create (`externalSource` + `externalId`) re-posting a pair the caller already holds returns the existing medication with 200 instead of a duplicate. v1.32.25 — a NEW mirror create is refused with 422 (`meta.errorCode = medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never gated.",
requestBody: {
required: true,
content: { "application/json": { schema: createMedicationSchema } },
@@ -223,6 +223,14 @@ export const medicationResource = z
export const medicationListEntry = medicationResource
.extend({
category: medicationCategoryEnum,
// v1.32.25 — mirrored-medication provenance echoed on the list read so
// the web UI and an operator can tell a mirror row from a native one.
externalSource: z
.literal("APPLE_HEALTH")
.nullable()
.describe(
"v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ HealthKit Medications sync; NULL is a native HealthLog medication. Read-only echo of the stored provenance — the create route sets it, the list read surfaces it so mirrored-vs-native is visible without inspecting the iOS client's local registry.",
),
// The list read always computes these two, so they are required here
// even though the shared base leaves them optional for the write paths.
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
@@ -257,7 +265,7 @@ export const medicationListEntry = medicationResource
.meta({
id: "MedicationListEntry",
description:
"List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.",
"List-row variant of the medication resource enriched with the joined `category`, the v1.32.25 `externalSource` provenance echo, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.",
});
export const medicationDetailEntry = medicationResource