From e3598985f59b7acca1a5271baf98c98b5561623e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 12:57:45 +0100 Subject: [PATCH] fix(ingest): answer a scoped token the same for unknown and out-of-scope names A token scoped to one medication got 404 for a name that matched none of the owner's medications and 403 for one that matched outside its scope, so it could confirm which medications exist by name. Its idempotency replay also ran before the scope check and returned any of the owner's doses stored under the key. A token without the wildcard now gets the same 403 for both, the replay is looked up only after the scope check, and a key already spent on another medication answers 409 without that dose. The wildcard token keeps its 404. --- docs/api/openapi.yaml | 25 ++++++---- .../ingest/medication/__tests__/route.test.ts | 48 +++++++++++++++++++ src/app/api/ingest/medication/route.ts | 38 +++++++-------- src/lib/openapi/routes/ingest.ts | 11 +++-- 4 files changed, 92 insertions(+), 30 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9af1fe4af..ac08ed758 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -24863,9 +24863,9 @@ paths: **Auth is Bearer only.** A cookie session does not authenticate here: the route reads the `Authorization` header itself, hashes it and looks the token up, so a browser with a valid login and no token gets 401. The token must carry either the `*` wildcard or the `medication:ingest` scope — AND, once the named medication resolves, either - `*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup, which is - why a token scoped to one medication gets 404 for a name it may not touch only when the name does not exist, and - 403 when it does. + `*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup. A token + scoped to one medication gets the same 403 for a name that does not exist as for one outside its scope, so it + cannot be used to learn which medications exist; only a `*` token gets 404 for an unknown name. **The operator can switch this off.** With the global API kill switch disabled every request is refused with 403 @@ -24873,8 +24873,9 @@ paths: **Replay is by `idempotencyKey`, and the status says which happened.** A key already stored for this token's - owner returns the existing row with **200**; a fresh dose returns **201**. Nothing else distinguishes them — - same body shape, same fields — so branch on the status code. + owner and the same medication returns the existing row with **200**; a fresh dose returns **201**; a key already + used for another medication returns **409** without its row. Replay is answered only after both scope checks. + Nothing else distinguishes them — same body shape, same fields — so branch on the status code. **A dose is attributed to its scheduled slot, not stamped ad-hoc.** When the take falls inside a schedule's @@ -24918,14 +24919,22 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": description: Refused. Either the operator has the global API switch off (the message says so), or the token lacks - `medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope. + `medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope. A + token without `*` also gets this answer for a name that matches no active medication. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": - description: No ACTIVE medication of the token owner's matches `medicationName`. An inactive medication of the right - name is also a 404 — the name is only searched among active ones. + description: "Only for a `*` token: no ACTIVE medication of the token owner's matches `medicationName`. An inactive + medication of the right name is also a 404 — the name is only searched among active ones." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The `idempotencyKey` is already stored for a dose of another medication. Nothing was written and that dose + is not returned. content: application/json: schema: diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index aae965196..1170ba482 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -348,6 +348,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. // The replay probe finds the original event. vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ id: "evt-original", + medicationId: "med-1", } as never); const res = await POST( postReq({ @@ -359,6 +360,53 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. expect(res.status).toBe(200); expect(consumeForIntake).not.toHaveBeenCalled(); }); + /** + * A token handed to an automation for one medication used to tell a name + * it may not touch (403) from one that does not exist (404), and to replay + * any of the owner's doses by key before checking its scope. + */ + function narrowToken(scopes: string[]) { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-2", + userId: "user-1", + permissions: ["medication:ingest", ...scopes], + revoked: false, + expiresAt: null, + lastUsedAt: null, + } as Awaited>); + } + + it("gives a one-medication token the same 403 for an unknown and an out-of-scope name", async () => { + wireHappyPath(); + narrowToken(["medication:med-other:ingest"]); + const known = await POST( + postReq({ medicationName: "Metformin", idempotencyKey: "k-known-1" }), + ); + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null); + const unknown = await POST( + postReq({ medicationName: "Nonexistent", idempotencyKey: "k-unknown-1" }), + ); + expect(known.status).toBe(403); + expect(unknown.status).toBe(403); + expect((await unknown.json()).error).toBe((await known.json()).error); + // No replay was looked up for a medication outside the scope. + expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled(); + }); + + it("never replays another medication's dose to a scoped token", async () => { + wireHappyPath(); + narrowToken(["medication:med-1:ingest"]); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "evt-other", + medicationId: "med-other", + takenAt: new Date(), + } as Awaited>); + const res = await POST( + postReq({ medicationName: "Metformin", idempotencyKey: "k-spent-1" }), + ); + expect(res.status).toBe(409); + expect(JSON.stringify(await res.json())).not.toContain("evt-other"); + }); }); describe("POST /api/ingest/medication — idempotency-key stability floor", () => { diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 35176e405..fafd5f3c3 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -186,17 +186,6 @@ export const POST = apiHandler(async (request: NextRequest) => { const { medicationName, takenAt, idempotencyKey } = parsed.data; - // Idempotency check (scoped to token owner to prevent cross-user lookups) - const existing = await prisma.medicationIntakeEvent.findFirst({ - where: { - idempotencyKey, - userId: apiToken.userId, - }, - }); - if (existing) { - return apiSuccess(existing); - } - // Find medication by name for this user const medication = await prisma.medication.findFirst({ where: { @@ -206,16 +195,27 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - if (!medication) { - return apiError("Medication not found", 404); + // A scoped token gets one answer for unknown and out-of-scope names; only a + // full-access token, which can list them anyway, learns a name is unknown. + const fullAccess = apiToken.permissions.includes("*"); + if ( + !medication || + (!fullAccess && + !apiToken.permissions.includes(`medication:${medication.id}:ingest`)) + ) { + return !medication && fullAccess + ? apiError("Medication not found", 404) + : apiError("API endpoint for this medication is disabled", 403); } - const medicationScope = `medication:${medication.id}:ingest`; - if ( - !apiToken.permissions.includes("*") && - !apiToken.permissions.includes(medicationScope) - ) { - return apiError("API endpoint for this medication is disabled", 403); + // Replay after the scope check, for this medication only (key is per user). + const existing = await prisma.medicationIntakeEvent.findFirst({ + where: { idempotencyKey, userId: apiToken.userId }, + }); + if (existing) { + return existing.medicationId === medication.id + ? apiSuccess(existing) + : apiError("Idempotency key already used for another medication", 409); } // A record-only medication takes no new dose — the same refusal every diff --git a/src/lib/openapi/routes/ingest.ts b/src/lib/openapi/routes/ingest.ts index 93705b487..afdaa06ce 100644 --- a/src/lib/openapi/routes/ingest.ts +++ b/src/lib/openapi/routes/ingest.ts @@ -32,7 +32,7 @@ export const ingestPaths: NonNullable = { tags: ["Medications"], summary: "Record a dose from an external integration", description: - "Logs a medication intake from outside the app — a chat bridge, a home-automation rule, a button on a shelf.\n\n**Auth is Bearer only.** A cookie session does not authenticate here: the route reads the `Authorization` header itself, hashes it and looks the token up, so a browser with a valid login and no token gets 401. The token must carry either the `*` wildcard or the `medication:ingest` scope — AND, once the named medication resolves, either `*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup, which is why a token scoped to one medication gets 404 for a name it may not touch only when the name does not exist, and 403 when it does.\n\n**The operator can switch this off.** With the global API kill switch disabled every request is refused with 403 before the token is even read.\n\n**Replay is by `idempotencyKey`, and the status says which happened.** A key already stored for this token's owner returns the existing row with **200**; a fresh dose returns **201**. Nothing else distinguishes them — same body shape, same fields — so branch on the status code.\n\n**A dose is attributed to its scheduled slot, not stamped ad-hoc.** When the take falls inside a schedule's window band it converges onto that slot's canonical row (closing the pending reminder rather than leaving it open beside a duplicate ad-hoc take); otherwise it lands as a standalone row with `scheduledFor = takenAt`. Only a genuine pending-to-taken transition consumes inventory, so a re-post that converges onto an already-taken slot does not decrement stock twice.\n\nRate-limited to 60 requests per minute **per token**. A household running a chat bridge, a button and a home-automation rule gets three independent allowances rather than three shares of one, which is what the previous per-IP bucket gave them. A request that presents no valid token falls back to a per-IP bucket with the same ceiling, so an unauthenticated flood is still capped.", + "Logs a medication intake from outside the app — a chat bridge, a home-automation rule, a button on a shelf.\n\n**Auth is Bearer only.** A cookie session does not authenticate here: the route reads the `Authorization` header itself, hashes it and looks the token up, so a browser with a valid login and no token gets 401. The token must carry either the `*` wildcard or the `medication:ingest` scope — AND, once the named medication resolves, either `*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup. A token scoped to one medication gets the same 403 for a name that does not exist as for one outside its scope, so it cannot be used to learn which medications exist; only a `*` token gets 404 for an unknown name.\n\n**The operator can switch this off.** With the global API kill switch disabled every request is refused with 403 before the token is even read.\n\n**Replay is by `idempotencyKey`, and the status says which happened.** A key already stored for this token's owner and the same medication returns the existing row with **200**; a fresh dose returns **201**; a key already used for another medication returns **409** without its row. Replay is answered only after both scope checks. Nothing else distinguishes them — same body shape, same fields — so branch on the status code.\n\n**A dose is attributed to its scheduled slot, not stamped ad-hoc.** When the take falls inside a schedule's window band it converges onto that slot's canonical row (closing the pending reminder rather than leaving it open beside a duplicate ad-hoc take); otherwise it lands as a standalone row with `scheduledFor = takenAt`. Only a genuine pending-to-taken transition consumes inventory, so a re-post that converges onto an already-taken slot does not decrement stock twice.\n\nRate-limited to 60 requests per minute **per token**. A household running a chat bridge, a button and a home-automation rule gets three independent allowances rather than three shares of one, which is what the previous per-IP bucket gave them. A request that presents no valid token falls back to a per-IP bucket with the same ceiling, so an unauthenticated flood is still capped.", requestBody: { required: true, content: { "application/json": { schema: externalIntakeRequest } }, @@ -69,12 +69,17 @@ export const ingestPaths: NonNullable = { }, "403": { description: - "Refused. Either the operator has the global API switch off (the message says so), or the token lacks `medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope.", + "Refused. Either the operator has the global API switch off (the message says so), or the token lacks `medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope. A token without `*` also gets this answer for a name that matches no active medication.", content: { "application/json": { schema: errorEnvelope } }, }, "404": { description: - "No ACTIVE medication of the token owner's matches `medicationName`. An inactive medication of the right name is also a 404 — the name is only searched among active ones.", + "Only for a `*` token: no ACTIVE medication of the token owner's matches `medicationName`. An inactive medication of the right name is also a 404 — the name is only searched among active ones.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The `idempotencyKey` is already stored for a dose of another medication. Nothing was written and that dose is not returned.", content: { "application/json": { schema: errorEnvelope } }, }, "413": {