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": {