Files
HealthLog/src/lib/openapi/routes/ingest.ts
T
Marc-André Bombeck bb563b6506 Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
2026-10-08 10:32:11 +02:00

102 lines
7.3 KiB
TypeScript

/**
* OpenAPI route table — the external medication-ingest endpoint.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* body is the runtime `externalIntakeSchema`; the response is the shared
* `MedicationIntakeEvent` resource the medications module already publishes,
* so the two surfaces cannot describe the same row differently.
*
* This route is its own module because its AUTH is its own thing. It is one
* of the three surfaces that resolve a Bearer token OUTSIDE `requireAuth`
* (`src/__tests__/bearer-scope-enforcement-guard.test.ts` freezes the set),
* and it is the only one that a home-automation bridge is meant to call. So
* the token rules it applies are written out here rather than left to the
* generic security scheme: no cookie session reaches it, and the scope check
* is a two-stage one the other Bearer surfaces do not have.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { externalIntakeSchema } from "@/lib/validations/medication";
import { medicationIntakeEventResource } from "./medications/schemas";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
const externalIntakeRequest = externalIntakeSchema.meta({
id: "ExternalMedicationIntakeRequest",
description:
"An externally-observed dose. `medicationName` is matched case-insensitively against the token owner's ACTIVE medications — there is no id form on this surface. `takenAt` is optional and defaults to now, bounded by the same plausibility range the interactive create paths use. `idempotencyKey` is an identity, not a windowed replay token: it is stored on the row forever and matched by equality, so a key that rotates per client launch never matches its own earlier row and every re-sync logs the dose again.",
});
export const ingestPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/ingest/medication": {
post: {
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.",
requestBody: {
required: true,
content: { "application/json": { schema: externalIntakeRequest } },
},
responses: {
"201": {
description: "The dose was recorded. Body is the created row.",
content: {
"application/json": {
schema: dataEnvelope(
medicationIntakeEventResource,
"ExternalMedicationIntakeCreatedEnvelope",
),
},
},
},
"200": {
description:
"Replay: a row with this `idempotencyKey` already exists for the token's owner and is returned unchanged. Nothing was written and no inventory was consumed.",
content: {
"application/json": {
schema: dataEnvelope(
medicationIntakeEventResource,
"ExternalMedicationIntakeReplayEnvelope",
),
},
},
},
...stdResponses,
"401": {
description:
"No `Authorization: Bearer …` header, or a token that is unknown, revoked or past its expiry. The three cases are not distinguished on the wire, and all four are charged to the per-IP bucket rather than to any token.",
content: { "application/json": { schema: 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.",
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.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Body exceeds 64 KiB.",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description: "`Content-Type` is not `application/json`.",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description:
"Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` = `medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake: false`); checked after the token's scope, so a token without this medication's scope learns nothing about it.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description:
"More than 60 requests in a minute for this token — or, when no valid token was presented, from this client IP. `meta.errorCode` = `ingest.rate_limited`, plus the standard rate-limit headers: `Retry-After` (whole seconds, at least 1), `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
};