diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 97cc5c2e8..cb81bd279 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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 ─────── diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index e5c3426ef..8f9889f75 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -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, diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 5f6e1784a..e7d6fef22 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -204,7 +204,7 @@ export const medicationPaths: NonNullable = { 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 } }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 71b291f4f..ec2d35972 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -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