mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Add a self-service measurements:write API token scope
Since v1.30.17 there has been no self-service path to a token that can write a measurement. That release made the Bearer scope default fail-closed, which closed a real privilege-escalation hole, and removed the generic mint at POST /api/tokens along with it. The generic mint had been the only way to get a token that could reach /api/measurements, so a local sensor pushed through Home Assistant or Node-RED lost its door while the OAuth integrations kept theirs. Add a narrow scope accepted by exactly two routes, both writes, both on the holder's own record. The scope constant is a leaf module beside the surface that consumes it, matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an options object rather than a sibling function, because the sharing surface guard keys every leg on that one identifier and a second name would mean two copies of the delegable declaration. AuthContext carries the admitting scope as a single nullable string rather than the token's permission array, so nothing downstream can re-derive an authorisation decision from it. A scoped credential is refused a record selector before any grant is read. Placing the refusal above the sharing check rather than beside it means no future widening of what a grant permits can reach one of these tokens. Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH is refused 422 rather than relabelled: that source is half the (userId, type, source, externalId) dedup key the iOS client writes into, it participates in the cross-source merge, and it gates the HealthKit sync fields. For the same reason a scoped write no longer advances User.lastSyncedAt, which the phone reads to size its undelivered window. Also closes a narrower gap found while building this. The idempotency wrapper resolved a replayed delegated response against a live grant without considering which credential was presenting it, so a cached response about a shared record could be returned to a token the handler itself would refuse. No write was reachable that way; the response body was. Guards: T3 gains the mint site, T4's vocabulary gains the scope and learns to read the requireRecordAuth form (without which this change would open a way to widen a route that the closed vocabulary could not see), and a new T5 freezes the set of delegable routes naming a scope. The sharing surface guard's AST matcher reads only the first two arguments, so it needed prose but no code.
This commit is contained in:
1 parent
4308114c5b
commit
cba7a0fce5
26 files changed
+1704
-34
No files matched your search
@@ -1,5 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
You can push readings in from your own hardware again.
|
||||
|
||||
Since v1.30.17 there was no way to do it. That release made API tokens
|
||||
enforce their scope, which closed a real hole — a token issued for
|
||||
medication intake had been reaching every other endpoint, the full backup
|
||||
export included — and removed the generic "create API token" button along
|
||||
with it. What went unnoticed was that the button had been the only
|
||||
self-service route to a token that could write a measurement. A scale or a
|
||||
watch bridge piped through Home Assistant had no door left, and the OAuth
|
||||
integrations do not reach local sensors.
|
||||
|
||||
There is now a token for exactly that job, and for nothing else.
|
||||
|
||||
### Added
|
||||
|
||||
- **A measurement ingest token, minted from Settings → API & Tokens.** It
|
||||
posts readings to `/api/measurements` and `/api/measurements/batch`, on
|
||||
your own record. That is the whole list. It cannot read a reading back,
|
||||
cannot change or delete one, cannot reach the export, and cannot mint
|
||||
another token — so a credential sitting in a container you did not write
|
||||
has a worst case of junk in your own record, not your health history
|
||||
leaving the building. Shown once, expires after a year, and revoked from
|
||||
the same page as every other token.
|
||||
- **A guide for wiring one up**, with a worked Home Assistant
|
||||
`rest_command` and automation, the measurement types a scale or a watch
|
||||
bridge tends to report, and what each error response means.
|
||||
- The API endpoint list on that settings page now shows the two ingest
|
||||
endpoints alongside the medication one.
|
||||
|
||||
### Changed
|
||||
|
||||
- Readings pushed with this token are recorded as manually entered, which
|
||||
means you can correct one in the app if a sensor sends something wrong.
|
||||
A push that claims to come from Apple Health is refused rather than
|
||||
quietly relabelled: that label is half of how the iOS app recognises its
|
||||
own rows, and a bridge borrowing it corrupts the phone's sync instead of
|
||||
merely mislabelling a reading.
|
||||
- Such a push no longer moves the phone's sync checkpoint. It never should
|
||||
have: the checkpoint records that your phone delivered what it was
|
||||
holding, and a scale advancing it could make the phone skip a window of
|
||||
its own samples.
|
||||
|
||||
### Security
|
||||
|
||||
- The token is confined to your own record. Pointing it at a record
|
||||
somebody has shared with you is refused, and refused before the sharing
|
||||
grant is even consulted — so no future change to what sharing permits can
|
||||
widen what one of these tokens reaches.
|
||||
- Closed a narrower gap found while building this. A repeated request
|
||||
carrying an `Idempotency-Key` could be answered from the cache after a
|
||||
check that asked only whether a sharing grant was live, and not which
|
||||
credential was being presented. Nothing could be written that way, but a
|
||||
cached response about a shared record could be returned to a token that
|
||||
the endpoint itself would have turned away.
|
||||
|
||||
## [1.38.0] — 2026-08-30
|
||||
|
||||
The health score works with the record a person actually keeps.
|
||||
|
||||
+16
-2
@@ -56,8 +56,22 @@ before treating a warning as a regression.
|
||||
`Labs`, `Illness`, `Documents`, `Records`, `Admin`, `Meta`,
|
||||
`Retired`).
|
||||
- **`securitySchemes`**:
|
||||
- `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`.
|
||||
Required on `/api/ingest/medication`.
|
||||
- `bearerAuth` — `hlk_*` API tokens. A scope grants what it names and
|
||||
nothing adjacent, so where a token comes from decides what it reaches:
|
||||
- a completed password / passkey / refresh exchange mints the
|
||||
cookie-equivalent `["*"]` token the native client carries;
|
||||
- a medication's own API-endpoint toggle mints `medication:ingest` plus
|
||||
`medication:<id>:ingest`, the pair `/api/ingest/medication` gates on;
|
||||
- `POST /api/mcp/tokens` mints `health:read` (optionally plus
|
||||
`health:write`), audience-bound to `/mcp` and refused on REST writes;
|
||||
- `POST /api/tokens/measurements` mints `measurements:write`, accepted by
|
||||
`POST /api/measurements` and `POST /api/measurements/batch` only, on the
|
||||
holder's own record only.
|
||||
|
||||
`POST /api/tokens` is **not** among them — that generic mint was removed in
|
||||
v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`)
|
||||
every token above, whichever surface issued it.
|
||||
|
||||
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
|
||||
password and passkey login flows. Used by the web app and any iOS
|
||||
client running inside a web view.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Home Assistant and other push integrations
|
||||
|
||||
HealthLog pulls from Withings, WHOOP, Fitbit, Oura, Polar and Strava over
|
||||
OAuth. A local sensor has no such door: a BLE scale, a Garmin bridge or a
|
||||
Zigbee thermometer lives in your own automation platform and has to push. This
|
||||
page covers that path — a token you mint yourself, and the two endpoints it
|
||||
reaches.
|
||||
|
||||
Nothing here is specific to Home Assistant. Node-RED, a cron job with `curl`,
|
||||
or anything else that can set a header works the same way.
|
||||
|
||||
## Mint a token
|
||||
|
||||
Settings → API & Tokens → **Measurement ingest token**. Name it after the thing
|
||||
you are pasting it into, because that name is what you will be reading later
|
||||
when you decide whether to revoke it.
|
||||
|
||||
The token is shown **once**. It is stored as an HMAC, so there is no page that
|
||||
can show it to you again — if you lose it, mint another and revoke the old one.
|
||||
|
||||
Tokens expire after a year by default. They appear in the API Tokens list on
|
||||
the same page alongside every other credential on the account, and are revoked
|
||||
there.
|
||||
|
||||
## What the token can and cannot do
|
||||
|
||||
It can add readings, on your own record, through:
|
||||
|
||||
- `POST /api/measurements` — one reading, or a small array of them
|
||||
- `POST /api/measurements/batch` — up to 500 entries per call
|
||||
|
||||
It **cannot** read a reading back, change or delete one, reach the export, or
|
||||
mint another token. That is the point of it being a separate credential rather
|
||||
than a general one: a container you do not fully trust — a Home Assistant
|
||||
add-on, a script from a forum — holds something whose worst case is junk in
|
||||
your own record, not your health history leaving the building.
|
||||
|
||||
It also cannot be pointed at somebody else's record. If an account has shared
|
||||
their record with you, this credential still will not write to it; use the web
|
||||
app for that.
|
||||
|
||||
## Readings arrive as manually entered
|
||||
|
||||
Rows written with this token carry `source: MANUAL`, the same label a reading
|
||||
you type in by hand gets. Two consequences worth knowing:
|
||||
|
||||
- You can correct them in the UI. Readings owned by a connected provider are
|
||||
read-only; these are not.
|
||||
- They do not claim to be from Apple Health. An entry that explicitly names
|
||||
`APPLE_HEALTH` is refused with a 422 rather than quietly relabelled — that
|
||||
source is half the deduplication key the iOS app writes into, and a bridge
|
||||
borrowing it corrupts the phone's sync rather than merely mislabelling a row.
|
||||
|
||||
For the same reason, a push through this token does not move the native sync
|
||||
checkpoint. Your phone's own sync window is unaffected by your scale.
|
||||
|
||||
## Home Assistant example
|
||||
|
||||
Add a `rest_command` to `configuration.yaml`. Keep the token in
|
||||
`secrets.yaml`, not here.
|
||||
|
||||
```yaml
|
||||
rest_command:
|
||||
healthlog_measurement:
|
||||
url: "https://healthlog.example.com/api/measurements"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: !secret healthlog_token
|
||||
Content-Type: "application/json"
|
||||
payload: >-
|
||||
{
|
||||
"type": "{{ type }}",
|
||||
"value": {{ value }},
|
||||
"measuredAt": "{{ now().isoformat() }}"
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# secrets.yaml
|
||||
healthlog_token: "Bearer hlk_replace_me"
|
||||
```
|
||||
|
||||
Then call it from an automation. This one forwards a scale whenever it reports
|
||||
a new weight:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Push weight to HealthLog"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: sensor.bathroom_scale_weight
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.to_state.state not in ['unknown', 'unavailable'] }}"
|
||||
action:
|
||||
- service: rest_command.healthlog_measurement
|
||||
data:
|
||||
type: WEIGHT
|
||||
value: "{{ trigger.to_state.state | float }}"
|
||||
```
|
||||
|
||||
The `condition` is not decoration. Home Assistant sensors publish `unknown` and
|
||||
`unavailable` as ordinary states, and forwarding one produces a 422 on every
|
||||
restart.
|
||||
|
||||
## Measurement types
|
||||
|
||||
`type` is an enum, not free text. Some of the ones a Garmin or scale
|
||||
integration tends to have:
|
||||
|
||||
| What the sensor reports | `type` | Unit expected |
|
||||
| ------------------------------ | ------------------------ | ------------- |
|
||||
| Weight | `WEIGHT` | kg |
|
||||
| Body fat | `BODY_FAT` | % |
|
||||
| Resting heart rate | `RESTING_HEART_RATE` | bpm |
|
||||
| Heart-rate variability (SDNN) | `HEART_RATE_VARIABILITY` | ms |
|
||||
| Heart-rate variability (RMSSD) | `HRV_RMSSD` | ms |
|
||||
| Blood oxygen | `OXYGEN_SATURATION` | % |
|
||||
| VO₂ max | `VO2_MAX` | ml/kg/min |
|
||||
| Respiratory rate | `RESPIRATORY_RATE` | breaths/min |
|
||||
| Sleep duration | `SLEEP_DURATION` | minutes |
|
||||
| Steps | `ACTIVITY_STEPS` | count |
|
||||
| Body temperature | `BODY_TEMPERATURE` | °C |
|
||||
|
||||
Units are derived server-side from the type, so send the number in the unit
|
||||
above and do not send a `unit` field. The full enum is in
|
||||
`docs/api/openapi.yaml` under `MeasurementType`.
|
||||
|
||||
## Sending several readings at once
|
||||
|
||||
A bare array writes up to five readings in one request — the shape to use for
|
||||
blood pressure, which is two rows:
|
||||
|
||||
```bash
|
||||
curl -X POST https://healthlog.example.com/api/measurements \
|
||||
-H "Authorization: Bearer hlk_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[
|
||||
{ "type": "BLOOD_PRESSURE_SYS", "value": 118, "measuredAt": "2026-03-14T07:20:00Z" },
|
||||
{ "type": "BLOOD_PRESSURE_DIA", "value": 76, "measuredAt": "2026-03-14T07:20:00Z" }
|
||||
]'
|
||||
```
|
||||
|
||||
For a backfill of hundreds of rows, use `/api/measurements/batch` instead. It
|
||||
takes up to 500 entries and reports a per-entry status so a partial failure
|
||||
tells you which rows to retry.
|
||||
|
||||
## Retries and duplicates
|
||||
|
||||
Send an `Idempotency-Key` header with a value your automation can reproduce for
|
||||
the same reading. A retry with the same key returns the original response
|
||||
instead of writing a second row:
|
||||
|
||||
```yaml
|
||||
headers:
|
||||
Authorization: !secret healthlog_token
|
||||
Content-Type: "application/json"
|
||||
Idempotency-Key: "{{ type }}-{{ now().strftime('%Y-%m-%dT%H') }}"
|
||||
```
|
||||
|
||||
Independently of that, a reading with the same type, timestamp and source as an
|
||||
existing row is refused with a 409 — so a duplicate push is a visible error
|
||||
rather than a duplicated data point.
|
||||
|
||||
## When it stops working
|
||||
|
||||
| Response | What it means |
|
||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `401` | The token is wrong, revoked, or expired. Mint a new one. |
|
||||
| `403` | The endpoint is outside the token's scope — check you are calling one of the two above and not a read — or the operator has switched the API off instance-wide. |
|
||||
| `409` | A reading with this type, timestamp and source already exists. |
|
||||
| `422` | The payload is wrong: an unknown `type`, a non-numeric `value`, an `unavailable` sensor state, or an entry naming `APPLE_HEALTH`. |
|
||||
| `429` | Too many requests. The batch route allows 60 calls per minute. |
|
||||
|
||||
The response envelope is `{ "data": ..., "error": null }` on success and
|
||||
`{ "data": null, "error": "<message>" }` on failure, so logging `error` from
|
||||
the body will usually say exactly what was wrong.
|
||||
+9
-1
@@ -5879,7 +5879,15 @@
|
||||
"googleHealthViewData": "Schritte ansehen",
|
||||
"apiTokens": "API-Tokens",
|
||||
"apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen.",
|
||||
"tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.",
|
||||
"measurementsToken": {
|
||||
"title": "Token für Messwert-Import",
|
||||
"description": "Werte von einer smarten Waage, einer Uhren-Bridge oder einer Hausautomatisierung übertragen.",
|
||||
"detail": "Sende ihn als Authorization: Bearer-Header. Das Token wird nur einmal angezeigt — kopiere es jetzt.",
|
||||
"scopeNote": "Es kann ausschließlich Werte hinzufügen, und nur zu deiner eigenen Akte. Es kann deine Daten nicht lesen, keinen Wert ändern oder löschen und kein weiteres Token erstellen.",
|
||||
"endpointsNote": "Funktioniert mit POST /api/measurements und POST /api/measurements/batch. Werte kommen als manuell erfasst an.",
|
||||
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut."
|
||||
},
|
||||
"tokenMintMovedDescription": "Tokens werden dort ausgestellt, wo ihr Geltungsbereich liegt. Erstelle oben ein Token für den Messwert-Import, oder öffne ein Medikament und aktiviere dessen API-Endpoint für ein Token, das nur für dieses Medikament gilt.",
|
||||
"tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)",
|
||||
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
|
||||
"tokenCopy": "Token kopieren",
|
||||
|
||||
+9
-1
@@ -5879,7 +5879,15 @@
|
||||
"googleHealthViewData": "View your steps",
|
||||
"apiTokens": "API Tokens",
|
||||
"apiTokensDescription": "Review and revoke API tokens for external medication intake.",
|
||||
"tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.",
|
||||
"measurementsToken": {
|
||||
"title": "Measurement ingest token",
|
||||
"description": "Push readings from a smart scale, a watch bridge or a home-automation rule.",
|
||||
"detail": "Send it as an Authorization: Bearer header. The token is shown once — copy it now.",
|
||||
"scopeNote": "It can only add readings, and only to your own record. It cannot look at your data, change or delete a reading, or create another token.",
|
||||
"endpointsNote": "Works on POST /api/measurements and POST /api/measurements/batch. Readings arrive marked as entered by hand.",
|
||||
"createFailed": "Could not create the token. Please try again."
|
||||
},
|
||||
"tokenMintMovedDescription": "Tokens are issued where they are scoped. Mint a measurement ingest token above, or open a medication and enable its API endpoint for a token limited to that medication alone.",
|
||||
"tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)",
|
||||
"tokenCreated": "Token created — copy it now! It won't be shown again.",
|
||||
"tokenCopy": "Copy token",
|
||||
|
||||
+9
-1
@@ -5879,7 +5879,15 @@
|
||||
"googleHealthViewData": "Ver tus pasos",
|
||||
"apiTokens": "Tokens de API",
|
||||
"apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación.",
|
||||
"tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.",
|
||||
"measurementsToken": {
|
||||
"title": "Token de importación de mediciones",
|
||||
"description": "Envía lecturas desde una báscula inteligente, un puente de reloj o una regla de domótica.",
|
||||
"detail": "Mándalo como cabecera Authorization: Bearer. El token se muestra una sola vez: cópialo ahora.",
|
||||
"scopeNote": "Solo puede añadir lecturas, y únicamente a tu propio historial. No puede consultar tus datos, ni modificar o borrar una lectura, ni crear otro token.",
|
||||
"endpointsNote": "Funciona en POST /api/measurements y POST /api/measurements/batch. Las lecturas llegan marcadas como introducidas a mano.",
|
||||
"createFailed": "No se pudo crear el token. Inténtalo de nuevo."
|
||||
},
|
||||
"tokenMintMovedDescription": "Los tokens se emiten allí donde está su alcance. Crea arriba un token de importación de mediciones, o abre un medicamento y activa su endpoint de API para obtener uno limitado únicamente a ese medicamento.",
|
||||
"tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)",
|
||||
"tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.",
|
||||
"tokenCopy": "Copiar token",
|
||||
|
||||
+9
-1
@@ -5879,7 +5879,15 @@
|
||||
"googleHealthViewData": "Voir vos pas",
|
||||
"apiTokens": "Jetons API",
|
||||
"apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments.",
|
||||
"tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.",
|
||||
"measurementsToken": {
|
||||
"title": "Jeton d'import de mesures",
|
||||
"description": "Envoyez des relevés depuis une balance connectée, une passerelle de montre ou une règle domotique.",
|
||||
"detail": "Transmettez-le dans un en-tête Authorization: Bearer. Le jeton n'est affiché qu'une fois — copiez-le maintenant.",
|
||||
"scopeNote": "Il peut uniquement ajouter des relevés, et seulement à votre propre dossier. Il ne peut pas consulter vos données, ni modifier ou supprimer un relevé, ni créer un autre jeton.",
|
||||
"endpointsNote": "Fonctionne sur POST /api/measurements et POST /api/measurements/batch. Les relevés arrivent marqués comme saisis à la main.",
|
||||
"createFailed": "Impossible de créer le jeton. Veuillez réessayer."
|
||||
},
|
||||
"tokenMintMovedDescription": "Les jetons sont émis là où porte leur périmètre. Créez ci-dessus un jeton d'import de mesures, ou ouvrez un médicament et activez son point de terminaison API pour un jeton limité à ce seul médicament.",
|
||||
"tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)",
|
||||
"tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.",
|
||||
"tokenCopy": "Copier le jeton",
|
||||
|
||||
+9
-1
@@ -5879,7 +5879,15 @@
|
||||
"googleHealthViewData": "Vedi i tuoi passi",
|
||||
"apiTokens": "Token API",
|
||||
"apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci.",
|
||||
"tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.",
|
||||
"measurementsToken": {
|
||||
"title": "Token per l'importazione di misurazioni",
|
||||
"description": "Invia letture da una bilancia smart, da un bridge per orologi o da una regola di domotica.",
|
||||
"detail": "Invialo come intestazione Authorization: Bearer. Il token viene mostrato una sola volta: copialo adesso.",
|
||||
"scopeNote": "Può soltanto aggiungere letture, e solo alla tua cartella. Non può leggere i tuoi dati, modificare o eliminare una lettura, né creare un altro token.",
|
||||
"endpointsNote": "Funziona su POST /api/measurements e POST /api/measurements/batch. Le letture arrivano contrassegnate come inserite a mano.",
|
||||
"createFailed": "Non è stato possibile creare il token. Riprova."
|
||||
},
|
||||
"tokenMintMovedDescription": "I token vengono emessi dove si applicano. Crea qui sopra un token per l'importazione di misurazioni, oppure apri un farmaco e attiva il suo endpoint API per un token valido solo per quel farmaco.",
|
||||
"tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)",
|
||||
"tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.",
|
||||
"tokenCopy": "Copia token",
|
||||
|
||||
+9
-1
@@ -5879,7 +5879,15 @@
|
||||
"googleHealthViewData": "걸음 수 보기",
|
||||
"apiTokens": "API 토큰",
|
||||
"apiTokensDescription": "외부에서 복용을 기록할 때 쓰는 API 토큰을 확인하고 폐기해요.",
|
||||
"tokenMintMovedDescription": "토큰은 약별로 발급돼요. 약을 열고 API 엔드포인트를 켜면 그 약에만 적용되는 토큰을 받을 수 있어요.",
|
||||
"measurementsToken": {
|
||||
"title": "측정값 전송 토큰",
|
||||
"description": "스마트 체중계나 시계 브리지, 홈 오토메이션 규칙에서 측정값을 보내요.",
|
||||
"detail": "Authorization: Bearer 헤더로 보내세요. 토큰은 한 번만 표시되니 지금 복사해 두세요.",
|
||||
"scopeNote": "측정값을 추가하는 것만, 그것도 본인 기록에만 할 수 있어요. 데이터를 열어 볼 수도, 측정값을 고치거나 지울 수도, 다른 토큰을 만들 수도 없어요.",
|
||||
"endpointsNote": "POST /api/measurements와 POST /api/measurements/batch에서 쓸 수 있어요. 들어온 측정값은 직접 입력한 것으로 표시돼요.",
|
||||
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요."
|
||||
},
|
||||
"tokenMintMovedDescription": "토큰은 권한이 미치는 곳에서 발급돼요. 위에서 측정값 전송 토큰을 만들거나, 약을 열고 API 엔드포인트를 켜서 그 약에만 적용되는 토큰을 받으세요.",
|
||||
"tokenNamePlaceholder": "토큰 이름 (예: iPhone 단축어)",
|
||||
"tokenCreated": "토큰을 만들었어요 — 지금 복사하세요! 다시 보여주지 않아요.",
|
||||
"tokenCopy": "토큰 복사",
|
||||
|
||||
+9
-1
@@ -5879,7 +5879,15 @@
|
||||
"googleHealthViewData": "Zobacz swoje kroki",
|
||||
"apiTokens": "Tokeny API",
|
||||
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków.",
|
||||
"tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.",
|
||||
"measurementsToken": {
|
||||
"title": "Token do importu pomiarów",
|
||||
"description": "Wysyłaj odczyty z inteligentnej wagi, mostka zegarka lub reguły automatyki domowej.",
|
||||
"detail": "Prześlij go w nagłówku Authorization: Bearer. Token pokazujemy tylko raz — skopiuj go teraz.",
|
||||
"scopeNote": "Może wyłącznie dodawać odczyty i tylko do Twojej własnej dokumentacji. Nie odczyta Twoich danych, nie zmieni ani nie usunie odczytu i nie utworzy kolejnego tokenu.",
|
||||
"endpointsNote": "Działa na POST /api/measurements oraz POST /api/measurements/batch. Odczyty trafiają oznaczone jako wpisane ręcznie.",
|
||||
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie."
|
||||
},
|
||||
"tokenMintMovedDescription": "Tokeny wydajemy tam, gdzie sięga ich zakres. Utwórz powyżej token do importu pomiarów albo otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.",
|
||||
"tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)",
|
||||
"tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.",
|
||||
"tokenCopy": "Kopiuj token",
|
||||
|
||||
@@ -145,6 +145,10 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
// medication grant that `/api/ingest/medication` actually gates on.
|
||||
"app/api/medications/[id]/api-endpoint/route.ts":
|
||||
'["medication:ingest", scope]',
|
||||
// Third-party measurement ingest. One shape, built from a literal — the
|
||||
// body cannot express a scope. Reaches two write routes on the holder's
|
||||
// own record and nothing else, the mint included.
|
||||
"app/api/tokens/measurements/route.ts": "[MEASUREMENTS_WRITE_SCOPE]",
|
||||
// MCP, audience-bound to `/mcp`. `health:write` requires explicit consent.
|
||||
"app/api/mcp/tokens/route.ts": "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
|
||||
"app/api/mcp/oauth/token/route.ts":
|
||||
@@ -170,6 +174,7 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
// password / passkey / refresh exchange. Every other mint is narrow.
|
||||
const userFacing = [
|
||||
"app/api/medications/[id]/api-endpoint/route.ts",
|
||||
"app/api/tokens/measurements/route.ts",
|
||||
"app/api/mcp/tokens/route.ts",
|
||||
"app/api/mcp/oauth/token/route.ts",
|
||||
];
|
||||
@@ -182,6 +187,11 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
// `POST /api/tokens` minted `["medication:ingest"]` — a token that could
|
||||
// not perform its advertised job (it lacked the per-medication grant) and
|
||||
// could reach every other authenticated route. List and revoke stay.
|
||||
//
|
||||
// The sibling `app/api/tokens/measurements/route.ts` does export a POST and
|
||||
// does not contradict this: what was retired is a mint at THIS path that
|
||||
// named no scope and answered for the whole API. A child path minting one
|
||||
// named scope, listed in `MINT_SITES` above, is the shape that replaced it.
|
||||
const tokensRoute = read("app/api/tokens/route.ts");
|
||||
expect(tokensRoute).not.toMatch(/export const POST/);
|
||||
expect(tokensRoute).toMatch(/export const GET/);
|
||||
@@ -210,15 +220,80 @@ describe("T4 — declared scopes are exported constants, never string literals",
|
||||
it("the declared scope vocabulary is closed", () => {
|
||||
// Scopes a route may name. Adding one means adding a route that accepts a
|
||||
// narrow token, which is a widening — it belongs in a reviewed diff.
|
||||
const DECLARED_SCOPES = ["FHIR_READ_SCOPE"];
|
||||
//
|
||||
// Both ways of naming one are collected, and that is the point of the
|
||||
// second matcher rather than a tidiness: since `requireRecordAuth` grew a
|
||||
// `scope` option, a route can widen itself without the word `requireAuth`
|
||||
// appearing anywhere in it. A vocabulary that watched only the first form
|
||||
// would have gone on passing while the surface it describes grew.
|
||||
const DECLARED_SCOPES = ["FHIR_READ_SCOPE", "MEASUREMENTS_WRITE_SCOPE"];
|
||||
const used = new Set<string>();
|
||||
for (const rel of sourceFiles()) {
|
||||
for (const m of read(rel).matchAll(
|
||||
const src = read(rel);
|
||||
for (const m of src.matchAll(
|
||||
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
|
||||
)) {
|
||||
used.add(m[1]);
|
||||
}
|
||||
for (const m of src.matchAll(
|
||||
/requireRecordAuth\([^)]*\bscope:\s*([A-Za-z_$][\w$]*)/g,
|
||||
)) {
|
||||
used.add(m[1]);
|
||||
}
|
||||
}
|
||||
expect([...used].sort()).toEqual(DECLARED_SCOPES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
|
||||
/**
|
||||
* Routes that admit a narrow token on a DELEGABLE surface, and the scope each
|
||||
* one names.
|
||||
*
|
||||
* Two declarations meet on these routes and neither implies the other: the
|
||||
* route may act on somebody else's record, AND it accepts a credential minted
|
||||
* for one job. `requireRecordAuth` refuses that combination at run time — a
|
||||
* scoped credential naming another record is turned away before any grant is
|
||||
* read — and this table is what keeps the SET of routes making both
|
||||
* declarations from growing quietly.
|
||||
*/
|
||||
const SCOPED_RECORD_ROUTES: Record<string, string> = {
|
||||
// Third-party measurement ingest: a scale, a watch bridge, a
|
||||
// home-automation rule. The sibling read, edit and delete legs on this
|
||||
// module name no scope and so still refuse every narrow token.
|
||||
"app/api/measurements/route.ts": "MEASUREMENTS_WRITE_SCOPE",
|
||||
};
|
||||
|
||||
it("only the known routes pass a scope to the record resolver", () => {
|
||||
const naming = filesMatching(
|
||||
/requireRecordAuth\([^)]*\bscope:\s*[A-Za-z_$][\w$]*/,
|
||||
);
|
||||
// Non-zero proof: an empty match set must fail rather than agree with an
|
||||
// emptied table. This is the leg most likely to rot — the matcher spans a
|
||||
// multi-line call, so a formatting change is exactly what would silently
|
||||
// empty it.
|
||||
expect(naming.length).toBeGreaterThan(0);
|
||||
expect(naming).toEqual(Object.keys(SCOPED_RECORD_ROUTES).sort());
|
||||
});
|
||||
|
||||
it("each names the scope this table says it does", () => {
|
||||
for (const [rel, scope] of Object.entries(SCOPED_RECORD_ROUTES)) {
|
||||
const named = [
|
||||
...read(rel).matchAll(
|
||||
/requireRecordAuth\([^)]*\bscope:\s*([A-Za-z_$][\w$]*)/g,
|
||||
),
|
||||
].map((m) => m[1]);
|
||||
expect(named).toEqual([scope]);
|
||||
}
|
||||
});
|
||||
|
||||
it("the record resolver still refuses a scoped credential a selector", () => {
|
||||
// The whole admission rests on one `if` inside `requireRecordAuth`. Delete
|
||||
// it and every other leg in this file stays green while a single-purpose
|
||||
// token gains delegated write — so the refusal is asserted where it lives,
|
||||
// not only through the behaviour that depends on it.
|
||||
const src = read("lib/api-handler.ts");
|
||||
expect(src).toMatch(/narrow_scope_selector/);
|
||||
expect(src).toMatch(/isScopedCredential\(auth\)/);
|
||||
});
|
||||
});
|
||||
@@ -817,7 +817,7 @@ interface ManageEntry extends DelegableEntry {
|
||||
const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
"app/api/measurements/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id`; the POST arms are delegable writes (see the write literal) and the PUT / DELETE beside them are not.",
|
||||
why: "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id`; the POST arms are delegable writes (see the write literal) and the PUT / DELETE beside them are not. The POST additionally names a Bearer scope, so a narrow third-party ingest token reaches it — the two declarations do not compound: a scoped credential naming any record but its owner's is refused before a grant is read, so this route is delegable for a session and single-record for that token.",
|
||||
},
|
||||
"app/api/measurements/[id]/route.ts": {
|
||||
domain: "measurements",
|
||||
@@ -1412,7 +1412,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
*/
|
||||
const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
|
||||
"app/api/measurements/route.ts":
|
||||
"Entering a reading. Both POST arms — single and batch — land rows under the resolved record; the safety-floor check, the reminder satisfaction and the rollup recompute all address the same record and are correct under substitution.",
|
||||
"Entering a reading. Both POST arms — single and batch — land rows under the resolved record; the safety-floor check, the reminder satisfaction and the rollup recompute all address the same record and are correct under substitution. The narrow ingest scope this route also names never reaches the substitution: it is refused a selector outright, so every row it writes is its own holder's.",
|
||||
"app/api/labs/route.ts":
|
||||
"Entering a lab result. The free-text path may mint a biomarker, and mints it into the RECORD's catalogue — a result added to somebody's record that left the marker on the helper's account would be worse than useless to the owner.",
|
||||
"app/api/biomarkers/route.ts":
|
||||
|
||||
@@ -295,7 +295,14 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/ai/openai-provider-form.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/response-timeout-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/runtime-actions-row.tsx": { "text-success": 2 },
|
||||
"src/components/settings/api-section.tsx": { "text-success": 3 },
|
||||
// The two added by the measurement-ingest mint card are the one-shot
|
||||
// token panel and its copy tick, both rendered only once a token actually
|
||||
// exists; the toast fires only after the clipboard write resolves. Same
|
||||
// shape as the MCP connector card two files over.
|
||||
"src/components/settings/api-section.tsx": {
|
||||
"text-success": 5,
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/settings/coach-memory-section.tsx": { "toast.success": 2 },
|
||||
"src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 },
|
||||
"src/components/settings/dashboard-layout-section.tsx": {
|
||||
|
||||
@@ -38,7 +38,8 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -218,7 +219,18 @@ interface EntryResult {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
|
||||
async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// Declares the third-party ingest scope, so a narrow `measurements:write`
|
||||
// token reaches this route alongside cookie sessions and the wildcard tokens
|
||||
// a native login mints. `requireAuth` refuses any acting-account carrier for
|
||||
// every credential shape, so the scoped token is confined to its owner's own
|
||||
// record here without this route adding an arm of its own.
|
||||
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE);
|
||||
const { user } = auth;
|
||||
|
||||
// A scoped credential is a bridge — a scale, a home-automation rule — and not
|
||||
// the native client. Two things below turn on that distinction: what `source`
|
||||
// its rows may carry, and whether it may move the phone's sync checkpoint.
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// v1.4.25 W10 reconcile (security H-2): per-user rate limit. Without
|
||||
// it, a leaked wildcard iOS token can sustain unbounded writes
|
||||
@@ -265,6 +277,27 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
|
||||
}
|
||||
|
||||
// A scoped credential may attribute MANUAL and nothing else. Refused loudly
|
||||
// rather than rewritten quietly: silently relabelling a client's explicit
|
||||
// assertion hands it rows it did not ask for and no way to notice, while a
|
||||
// 422 is fixable on the first call.
|
||||
//
|
||||
// `APPLE_HEALTH` is the label that matters, and forging it is not merely
|
||||
// mislabelling. It is half the `(userId, type, source, externalId)` dedup key,
|
||||
// so a bridge's rows would land in the phone's namespace; it is a mergeable
|
||||
// source, so a forged row joins the cross-source merge and can suppress a
|
||||
// genuine one; and it is what `healthKitSyncSucceeded` reads to decide the
|
||||
// Apple Health card may claim the phone synced.
|
||||
if (scoped && parsed.data.entries.some((e) => e.source === "APPLE_HEALTH")) {
|
||||
annotate({
|
||||
action: { name: "measurement.batch.ingest" },
|
||||
meta: { outcome: "source_not_permitted" },
|
||||
});
|
||||
return apiError("This credential may only attribute MANUAL readings", 422, {
|
||||
errorCode: "measurement.batch.source_not_permitted",
|
||||
});
|
||||
}
|
||||
|
||||
const { entries, syncTrigger } = parsed.data;
|
||||
|
||||
// First pass — map each inbound entry; record skipped entries.
|
||||
@@ -406,7 +439,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
unit: mapped.unit,
|
||||
// v1.8.6 W6 — honour the per-entry source tag, defaulting to
|
||||
// `APPLE_HEALTH` when absent so legacy callers are unchanged.
|
||||
source: entry.source ?? "APPLE_HEALTH",
|
||||
//
|
||||
// A scoped credential defaults to `MANUAL` instead, and can hold no
|
||||
// other value: the guard above already refused an explicit
|
||||
// `APPLE_HEALTH`, so this is the only remaining arm for it. The default
|
||||
// has to move with the credential rather than with the route, because
|
||||
// the route serves both the phone and a third-party bridge.
|
||||
source: scoped ? "MANUAL" : (entry.source ?? "APPLE_HEALTH"),
|
||||
measuredAt: mapped.takenAt,
|
||||
externalId: entry.externalId,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
@@ -671,7 +710,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// trigger writes null over the previous value rather than leaving a stale one
|
||||
// standing: the field describes THIS sync, and an older build's silence is
|
||||
// not evidence about it.
|
||||
if (failedCount === 0) {
|
||||
//
|
||||
// None of that is true of a scoped credential, which is why it is excluded
|
||||
// from the whole block rather than only from the HealthKit half. The
|
||||
// checkpoint's meaning is "the client synced what it was holding", and a
|
||||
// third-party bridge is not the client and holds nothing on its behalf.
|
||||
// `lastSyncedAt` is what `GET /api/sync/state` reports and what the phone
|
||||
// uses to size its undelivered window, so letting a scale advance it can make
|
||||
// the phone skip a window of its own samples — a silent data loss whose cause
|
||||
// would be nowhere near the code that caused it.
|
||||
if (failedCount === 0 && !scoped) {
|
||||
const syncedAt = new Date();
|
||||
const deliveredWithoutTheApp =
|
||||
syncTrigger === "background" || syncTrigger === "push";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
isScopedCredential,
|
||||
requireRecordAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -744,6 +749,23 @@ async function sleepListResponse(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this body attribute any reading to `APPLE_HEALTH`?
|
||||
*
|
||||
* Read off the raw body rather than the parsed result because the answer has to
|
||||
* be the same for the object arm and the array arm, and those parse against
|
||||
* different schemas further down. Anything that is not a recognisable
|
||||
* measurement shape answers false and falls through to the validation error it
|
||||
* has coming — this function decides attribution, not validity.
|
||||
*/
|
||||
function namesAppleHealthSource(body: unknown): boolean {
|
||||
const names = (entry: unknown): boolean =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
(entry as { source?: unknown }).source === "APPLE_HEALTH";
|
||||
return Array.isArray(body) ? body.some(names) : names(body);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
|
||||
|
||||
async function postMeasurement(request: NextRequest) {
|
||||
@@ -752,7 +774,17 @@ async function postMeasurement(request: NextRequest) {
|
||||
// who may or may not be that person. Nothing else in this handler reads the
|
||||
// caller: the safety-floor check, the reminder satisfaction, the rollup
|
||||
// recompute and the cache eviction are all statements about the record.
|
||||
const { user } = await requireRecordAuth("write", "measurements");
|
||||
//
|
||||
// v1.38.x — it also declares the third-party ingest scope, so a narrow
|
||||
// `measurements:write` token reaches this arm. The two declarations do not
|
||||
// interact: a scoped credential is refused the moment the request names
|
||||
// another record, before any grant is read, so "delegable" and "reachable by
|
||||
// a bridge token" stay separate properties of the same route.
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
const { user } = auth;
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
@@ -760,6 +792,18 @@ async function postMeasurement(request: NextRequest) {
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// A scoped credential may attribute MANUAL and nothing else — the schema's
|
||||
// own default, so only an explicit `APPLE_HEALTH` needs refusing. Checked
|
||||
// before validation branches so the object and array arms share one answer,
|
||||
// and refused rather than rewritten for the reason the batch route gives:
|
||||
// relabelling a client's explicit assertion hands it rows it cannot see.
|
||||
if (scoped && namesAppleHealthSource(body)) {
|
||||
annotate({ action: { name: "measurements.create.source-not-permitted" } });
|
||||
return apiError("This credential may only attribute MANUAL readings", 422, {
|
||||
errorCode: "measurement.create.source_not_permitted",
|
||||
});
|
||||
}
|
||||
|
||||
// Batch mode (array of measurements, e.g. combined BP + Pulse)
|
||||
if (Array.isArray(body)) {
|
||||
const parsed = createBatchMeasurementSchema.safeParse({
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The measurement-ingest mint.
|
||||
*
|
||||
* The assertion that matters most is not the 201 — it is the exact
|
||||
* `permissions` array handed to `issueApiToken`. That helper defaults to
|
||||
* `["*"]` when the property is absent, so an edit that drops one line turns a
|
||||
* user-facing endpoint into a cookie-equivalent credential factory, and a test
|
||||
* that only checked the status code would go on passing. The structural guard
|
||||
* catches a literal `permissions: ["*"` and would not catch the omission, so
|
||||
* this is where that hole is closed.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/issue-token", () => ({
|
||||
issueApiToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
const USER = { id: "user-1", role: "USER" as const };
|
||||
const EXPIRES = new Date("2027-08-31T00:00:00.000Z");
|
||||
|
||||
function req(body: unknown): Request {
|
||||
return new Request("https://health.example/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(issueApiToken).mockResolvedValue({
|
||||
token: "hlk_" + "a".repeat(64),
|
||||
expiresAt: EXPIRES,
|
||||
tokenId: "token-1",
|
||||
name: "Home Assistant",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("what it mints", () => {
|
||||
it("names exactly the ingest scope, and no wildcard", async () => {
|
||||
const res = await POST(req({ name: "Home Assistant" }) as never);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
|
||||
// Exact equality, not `toContain`: an extra scope is a widening, and this
|
||||
// is the assertion standing between the endpoint and one.
|
||||
expect(opts.permissions).toEqual([MEASUREMENTS_WRITE_SCOPE]);
|
||||
expect(opts.userId).toBe(USER.id);
|
||||
});
|
||||
|
||||
it("returns the raw token once, with its name and expiry", async () => {
|
||||
const res = await POST(req({ name: "Home Assistant" }) as never);
|
||||
const json = await res.json();
|
||||
expect(json.data.token).toMatch(/^hlk_[0-9a-f]{64}$/);
|
||||
expect(json.data.name).toBe("Home Assistant");
|
||||
expect(json.data.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("expires by default rather than living forever", async () => {
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
|
||||
expect(opts.expiresInDays).toBe(365);
|
||||
});
|
||||
|
||||
it("honours an explicit lifetime", async () => {
|
||||
await POST(req({ name: "Scale", expiresInDays: 30 }) as never);
|
||||
expect(vi.mocked(issueApiToken).mock.calls[0][0].expiresInDays).toBe(30);
|
||||
});
|
||||
|
||||
it("audits the mint with the token id and the scope", async () => {
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"tokens.measurements.create",
|
||||
expect.objectContaining({
|
||||
userId: USER.id,
|
||||
details: expect.objectContaining({
|
||||
tokenId: "token-1",
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when it refuses", () => {
|
||||
it("403s while the operator has the API switched off", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
expect(res.status).toBe(403);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("429s past the mint rate limit, without minting", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
expect(res.status).toBe(429);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a missing name", async () => {
|
||||
const res = await POST(req({}) as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a lifetime outside the permitted band", async () => {
|
||||
const res = await POST(
|
||||
req({ name: "Scale", expiresInDays: 4000 }) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a scope the body tries to smuggle in", async () => {
|
||||
// There is no `scope` field to validate, so an unknown key is simply not
|
||||
// read — the permission array is a literal either way. Asserted because
|
||||
// "the field does not exist" is the security property, and a schema change
|
||||
// that added one would silently make this body meaningful.
|
||||
await POST(
|
||||
req({ name: "Scale", scope: "read_write", permissions: ["*"] }) as never,
|
||||
);
|
||||
expect(vi.mocked(issueApiToken).mock.calls[0][0].permissions).toEqual([
|
||||
MEASUREMENTS_WRITE_SCOPE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* `POST /api/tokens/measurements` — mint a Bearer for third-party measurement
|
||||
* ingest.
|
||||
*
|
||||
* The self-service mint that v1.30.17 left missing. That release made the
|
||||
* Bearer-scope default fail-closed, which was right — a medication-intake token
|
||||
* had been reaching every authenticated route, the full backup export included
|
||||
* — and removed the generic mint at the parent path, which was also right,
|
||||
* since the token it issued could not do its advertised job. What went with them
|
||||
* was any way at all to push readings in programmatically: the OAuth
|
||||
* integrations cover Withings and WHOOP, and cover nothing for a local sensor
|
||||
* behind a home-automation bridge.
|
||||
*
|
||||
* What this mints is narrow by construction. `permissions` is a literal, so no
|
||||
* request shape reaches it; the scope it names is accepted by exactly two
|
||||
* routes, both writes, both on the holder's own record. It cannot read a
|
||||
* reading back, cannot edit or delete one, cannot reach the export, and cannot
|
||||
* mint another token — the `requireAuth()` below names no scope, so a
|
||||
* measurements token presenting itself here is refused like any other narrow
|
||||
* credential. That last property is why the mint is worth its own file: the
|
||||
* blast radius of a credential pasted into a container the user does not
|
||||
* operate should not include making more credentials.
|
||||
*
|
||||
* No listing leg. `/api/tokens` already returns every token with its
|
||||
* `permissions`, so the settings card filters that one query rather than
|
||||
* duplicating it — the MCP module needs its own list only because it must hide
|
||||
* transient OAuth access rows, and there is no such row here. No revoke-all
|
||||
* either: `DELETE /api/tokens/[id]` is per-row and the card shows the rows. The
|
||||
* per-medication toggle's bulk revoke exists because that surface is a boolean
|
||||
* with no list behind it, which is not this surface.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
|
||||
|
||||
/**
|
||||
* Days a token lives when the caller names no lifetime.
|
||||
*
|
||||
* Longer than the MCP mint's 90, and the difference is the deployment rather
|
||||
* than the risk appetite. A connector token is used from a session somebody is
|
||||
* sitting in front of; this one is pasted into a rule that runs unattended for
|
||||
* months, where a quiet expiry surfaces as "my weight stopped syncing sometime
|
||||
* in the spring". A year is long enough to not be that, short enough to still
|
||||
* be a rotation.
|
||||
*/
|
||||
const DEFAULT_EXPIRY_DAYS = 365;
|
||||
|
||||
/** Mints per user per minute. A credential mint is worth its own bucket. */
|
||||
const MINT_RATE_LIMIT_MAX = 10;
|
||||
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// No scope argument, and that is load-bearing rather than incidental: it
|
||||
// keeps the fail-closed default, so the only credentials that reach the mint
|
||||
// are a cookie session and a cookie-equivalent token. It also refuses any
|
||||
// acting-account carrier, so a delegate cannot mint a credential against the
|
||||
// record they are helping with.
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "tokens.measurements.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`tokens:measurements:mint:${user.id}`,
|
||||
MINT_RATE_LIMIT_MAX,
|
||||
MINT_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many token mints, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createMeasurementTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: parsed.data.name,
|
||||
// A literal, never spread and never derived from the body. `issueApiToken`
|
||||
// defaults to `["*"]` when this property is absent, so an edit that drops
|
||||
// it mints a cookie-equivalent credential from a user-facing endpoint —
|
||||
// which is why the unit suite asserts this array and not merely the 201.
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS,
|
||||
});
|
||||
|
||||
await auditLog("tokens.measurements.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { tokenId: issued.tokenId, scope: MEASUREMENTS_WRITE_SCOPE },
|
||||
});
|
||||
|
||||
// The raw token, once. It is stored as an HMAC and no path re-reveals it.
|
||||
return apiSuccess(
|
||||
{ token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Key, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Key,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -19,6 +27,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -45,20 +54,159 @@ export function ApiSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ApiEndpointsCard />
|
||||
<MeasurementTokensCard />
|
||||
<ApiTokensCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token for third-party measurement ingest.
|
||||
*
|
||||
* No list of its own. `ApiTokensCard` below already reads every token the
|
||||
* account holds and shows its scopes, so a second query here would be the same
|
||||
* rows under a different heading — and a revoke button in two places is two
|
||||
* places to keep agreeing about what revoking means. This card mints; the one
|
||||
* below is where credentials are reviewed and killed.
|
||||
*/
|
||||
function MeasurementTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
setTokenMsg(null);
|
||||
setNewToken(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// No scope field: the endpoint mints one shape, so there is nothing
|
||||
// here for a caller to choose or for this form to get wrong.
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setNewToken(json.data.token);
|
||||
setNewName("");
|
||||
// The sibling card lists this token, so it has to hear about it.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
|
||||
} else {
|
||||
// The server's own message when it sent one — it names the actual
|
||||
// cause (switch off, rate limit, validation) — and a message about
|
||||
// this specific action when it did not.
|
||||
setTokenMsg(json.error || t("settings.measurementsToken.createFailed"));
|
||||
}
|
||||
} catch {
|
||||
setTokenMsg(t("common.networkError"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyToken() {
|
||||
if (!newToken) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(newToken);
|
||||
setTokenCopied(true);
|
||||
toast.success(t("settings.tokenCopied"));
|
||||
setTimeout(() => setTokenCopied(false), 2_000);
|
||||
} catch {
|
||||
toast.error(t("settings.tokenCopyFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Upload}
|
||||
title={t("settings.measurementsToken.title")}
|
||||
description={t("settings.measurementsToken.description")}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.measurementsToken.detail")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("settings.measurementsToken.scopeNote")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("settings.measurementsToken.endpointsNote")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("settings.tokenNamePlaceholder")}
|
||||
maxLength={100}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="h-11 sm:h-10"
|
||||
disabled={creating || !newName.trim()}
|
||||
>
|
||||
{creating && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{newToken && (
|
||||
<div
|
||||
className="bg-success/10 rounded-lg p-3 text-sm"
|
||||
data-slot="settings-measurements-token-created"
|
||||
>
|
||||
<p className="text-success mb-1 font-medium">
|
||||
{t("settings.tokenCreated")}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{newToken}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={() => void handleCopyToken()}
|
||||
aria-label={t("settings.tokenCopy")}
|
||||
>
|
||||
{tokenCopied ? (
|
||||
<Check className="text-success h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenMsg && <p className="text-destructive text-sm">{tokenMsg}</p>}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiEndpointsCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.27 MB5 — dual-render: <md gets a stacked card list, md+
|
||||
// keeps the original table. Mobile cards wrap inside their box and
|
||||
// never trigger horizontal scroll, mirroring the pattern from
|
||||
// `<ApiTokenOverviewSection>`. The endpoint catalogue currently
|
||||
// ships a single row but stays array-driven so adding new rows
|
||||
// remains a one-line edit on both surfaces.
|
||||
// `<ApiTokenOverviewSection>`. The endpoint catalogue is array-driven
|
||||
// so adding a row remains a one-line edit on both surfaces — which is
|
||||
// what the two measurement ingest rows below cost.
|
||||
const endpoints = [
|
||||
{
|
||||
method: "POST",
|
||||
@@ -66,6 +214,18 @@ function ApiEndpointsCard() {
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/measurements",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/measurements/batch",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* `requireRecordAuth`'s scope option, and the refusal that comes with it.
|
||||
*
|
||||
* A route can now declare a narrow Bearer scope on a DELEGABLE surface, which
|
||||
* puts two admissions on one call: the route may act on somebody else's record,
|
||||
* and it accepts a credential minted for one job. The interesting cases are all
|
||||
* about the pair, not either half — a scoped credential must reach its own
|
||||
* record and be refused any other, while a session and a wildcard token keep
|
||||
* the delegation behaviour they already had.
|
||||
*
|
||||
* The load-bearing assertion is not the 403. It is that the grant lookup never
|
||||
* runs: the refusal has to sit above the sharing check rather than beside it,
|
||||
* or a future change to what a grant permits would start deciding what a
|
||||
* single-purpose token can reach.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// --- Mocks must be hoisted before importing the module under test. ---
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/sharing/grants", () => ({
|
||||
findActiveGrant: vi.fn(),
|
||||
grantAllows: vi.fn(() => true),
|
||||
grantCoversDomain: vi.fn(() => true),
|
||||
touchGrantUsage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sharing/record-session-fence", () => ({
|
||||
assertRecordSessionFence: vi.fn().mockResolvedValue(undefined),
|
||||
attachRecordContextEcho: vi.fn((res: unknown) => res),
|
||||
}));
|
||||
|
||||
const headersGet = vi.fn<(name: string) => string | null>();
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: headersGet })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// --- Imports use the mocked modules above. ---
|
||||
|
||||
import { requireRecordAuth, isScopedCredential } from "../api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { ACCOUNT_SELECTOR_HEADER } from "@/lib/auth/acting-carrier";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
const FAKE_HASH = "deadbeefcafef00d";
|
||||
const RAW_TOKEN = "hlk_" + "a".repeat(64);
|
||||
const OWNER_ID = "user-owner";
|
||||
const HOLDER_ID = "user-holder";
|
||||
|
||||
const HOLDER = {
|
||||
id: HOLDER_ID,
|
||||
role: "USER" as const,
|
||||
username: "holder",
|
||||
email: "holder@example.test",
|
||||
};
|
||||
|
||||
/** Present a Bearer with these scopes, and optionally a record selector. */
|
||||
function armBearer(permissions: string[], selector?: string): void {
|
||||
headersGet.mockReset();
|
||||
headersGet.mockImplementation((name: string) => {
|
||||
const key = name.toLowerCase();
|
||||
if (key === "authorization") return `Bearer ${RAW_TOKEN}`;
|
||||
if (key === ACCOUNT_SELECTOR_HEADER) return selector ?? null;
|
||||
return null;
|
||||
});
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "token-1",
|
||||
userId: HOLDER_ID,
|
||||
permissions,
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(HOLDER as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
|
||||
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("a scoped credential on its own record", () => {
|
||||
it("is admitted, and looks exactly like the un-switched request it is", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE]);
|
||||
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
|
||||
expect(auth.user.id).toBe(HOLDER_ID);
|
||||
expect(auth.actor.id).toBe(HOLDER_ID);
|
||||
expect(auth.grantId).toBeNull();
|
||||
expect(findActiveGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports itself as scoped, and names the scope that admitted it", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE]);
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
expect(auth.bearerScope).toBe(MEASUREMENTS_WRITE_SCOPE);
|
||||
expect(isScopedCredential(auth)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a scoped credential naming another record", () => {
|
||||
it("is refused, and the grant is never consulted", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE], OWNER_ID);
|
||||
|
||||
await expect(
|
||||
requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// The point of the whole arm: refused ABOVE the sharing check, so no
|
||||
// future reading of what a grant permits can reach a scoped credential.
|
||||
expect(findActiveGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is refused even when a live grant would have allowed it", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE], OWNER_ID);
|
||||
vi.mocked(findActiveGrant).mockResolvedValue({
|
||||
id: "grant-1",
|
||||
grantorId: OWNER_ID,
|
||||
granteeId: HOLDER_ID,
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("writes a durable refusal row naming the reason", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE], OWNER_ID);
|
||||
|
||||
await expect(
|
||||
requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"sharing.access.denied",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ reason: "narrow_scope_selector" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the credentials this arm must not touch", () => {
|
||||
it("a wildcard token with a selector still reaches the grant lookup", async () => {
|
||||
// The control. Without it, a refusal that swallowed every delegated
|
||||
// Bearer would pass the tests above for entirely the wrong reason.
|
||||
armBearer(["*"], OWNER_ID);
|
||||
vi.mocked(findActiveGrant).mockResolvedValue(null as never);
|
||||
|
||||
await expect(
|
||||
requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(findActiveGrant).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a wildcard token reads as unscoped however the route was declared", async () => {
|
||||
armBearer(["*"]);
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
expect(auth.bearerScope).toBeNull();
|
||||
expect(isScopedCredential(auth)).toBe(false);
|
||||
});
|
||||
|
||||
it("a cookie session reads as unscoped", async () => {
|
||||
headersGet.mockReset();
|
||||
headersGet.mockReturnValue(null);
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 60_000) },
|
||||
user: HOLDER,
|
||||
} as never);
|
||||
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
expect(isScopedCredential(auth)).toBe(false);
|
||||
expect(auth.grantId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a route that declares no scope", () => {
|
||||
it("still refuses a narrow token outright", async () => {
|
||||
// The read legs on the measurements module are exactly this shape, and it
|
||||
// is what makes the new scope write-only rather than merely write-named.
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE]);
|
||||
|
||||
await expect(requireRecordAuth("read", "measurements")).rejects.toThrow();
|
||||
expect(findActiveGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+91
-6
@@ -390,8 +390,36 @@ export type AuthContext = {
|
||||
* maps it server-side, the same posture as `userId` being narrowed from auth.
|
||||
*/
|
||||
readonly authMethod: "cookie" | "bearer";
|
||||
/**
|
||||
* The named scope that admitted this Bearer, or null when the credential is
|
||||
* cookie-equivalent — a session, or a `["*"]` token. A non-null value marks a
|
||||
* NARROW, single-purpose credential: one minted for one job, by its own
|
||||
* holder, from their own settings page.
|
||||
*
|
||||
* A single nullable string rather than the token's permission array, and that
|
||||
* is the point: nothing downstream can re-derive an authorisation decision
|
||||
* from it. The decision was made once, in `resolveBearerToken`; this only
|
||||
* reports which arm ran, so a route can refuse a narrow credential something
|
||||
* a cookie session may still do.
|
||||
*
|
||||
* Optional for the reason `actingAsUserId` and `recordEpoch` above are: the
|
||||
* test files that hand-build this object keep compiling, and an absent field
|
||||
* reads as null — "cookie-equivalent", which is what a hand-built context has
|
||||
* always meant.
|
||||
*/
|
||||
readonly bearerScope?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Was this request authenticated by a narrow, single-purpose Bearer scope?
|
||||
*
|
||||
* The one reader of `bearerScope`, exported so no handler re-derives the test
|
||||
* and they cannot drift on what "narrow" means.
|
||||
*/
|
||||
export function isScopedCredential(auth: AuthContext): boolean {
|
||||
return (auth.bearerScope ?? null) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure.
|
||||
*
|
||||
@@ -585,6 +613,17 @@ async function authenticateBearer(
|
||||
session: { id: tokenId, expiresAt },
|
||||
user,
|
||||
authMethod: "bearer",
|
||||
// Non-null exactly when a NAMED scope admitted a narrow token. A `["*"]`
|
||||
// token is cookie-equivalent and reads null however the route was declared;
|
||||
// a narrow token on a route that named no scope never reaches this line,
|
||||
// because `wildcard-only` already refused it in `resolveBearerToken`.
|
||||
//
|
||||
// Derived here rather than in the resolver so `bearer.ts` keeps its single
|
||||
// authorisation arm: this is a report of the decision that already ran, not
|
||||
// a second one.
|
||||
bearerScope: permissions.includes("*")
|
||||
? null
|
||||
: (requiredPermission ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -676,10 +715,12 @@ function auditRefusal(
|
||||
/**
|
||||
* This request says it is acting on another account and this route will not.
|
||||
*
|
||||
* Two ways to arrive: a route that never declared a mode (`undeclared_mode`),
|
||||
* and a selector sent over the transport that does not carry one
|
||||
* (`misplaced_selector`). One response either way — the caller learns that the
|
||||
* request will not be served, and nothing about grants.
|
||||
* Three ways to arrive: a route that never declared a mode (`undeclared_mode`),
|
||||
* a selector sent over the transport that does not carry one
|
||||
* (`misplaced_selector`), and a narrow single-purpose Bearer naming a record
|
||||
* that is not its owner's (`narrow_scope_selector`). One response every way —
|
||||
* the caller learns that the request will not be served, and nothing about
|
||||
* grants.
|
||||
*
|
||||
* The audit rule, stated once because it is a judgement call: a HEADER refusal
|
||||
* always writes a row, a SESSION refusal never does. The header is a per-request
|
||||
@@ -692,7 +733,10 @@ function auditRefusal(
|
||||
function refuseUndeclaredMode(
|
||||
auth: AuthContext,
|
||||
carrier: ActingCarrier,
|
||||
reason: "undeclared_mode" | "misplaced_selector" = "undeclared_mode",
|
||||
reason:
|
||||
| "undeclared_mode"
|
||||
| "misplaced_selector"
|
||||
| "narrow_scope_selector" = "undeclared_mode",
|
||||
): SharingNotPermittedError {
|
||||
annotate({ meta: { sharing_refusal: reason } });
|
||||
if (carrier.kind !== "session") {
|
||||
@@ -744,6 +788,23 @@ export interface RecordAuthContext extends AuthContext {
|
||||
readonly grantId: string | null;
|
||||
}
|
||||
|
||||
/** What a delegable route may additionally declare about the credentials it takes. */
|
||||
export interface RecordAuthOptions {
|
||||
/**
|
||||
* A Bearer scope this route accepts in place of the cookie-equivalent
|
||||
* default. Naming one is a WIDENING — it admits a credential class the route
|
||||
* refused before — and is frozen by `bearer-scope-enforcement-guard.test.ts`
|
||||
* exactly like a `requireAuth` scope, so it lands in a reviewed diff.
|
||||
*
|
||||
* What the option does NOT do is relax anything below it. A credential
|
||||
* admitted this way is single-purpose: it reaches this route on its OWN
|
||||
* record and is refused the moment the request names another, before any
|
||||
* grant is read. The `need` and `domain` checks run unchanged for everyone
|
||||
* else.
|
||||
*/
|
||||
readonly scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.36.0 — a delegable surface: this route may act on a shared record.
|
||||
*
|
||||
@@ -768,12 +829,18 @@ export interface RecordAuthContext extends AuthContext {
|
||||
* routes that are delegable. The value is frozen per module by
|
||||
* `src/__tests__/sharing-surface-guard.test.ts` and reviewed against the
|
||||
* design's clustering table; this function only enforces what was declared.
|
||||
* @param options v1.38.x — optional extra declarations. Today that is `scope`,
|
||||
* naming a narrow Bearer this route admits alongside cookie sessions and
|
||||
* cookie-equivalent tokens. A route that passes none keeps the fail-closed
|
||||
* default and refuses every narrow token, which is what makes adding one a
|
||||
* visible decision rather than an omission.
|
||||
*/
|
||||
export async function requireRecordAuth(
|
||||
need: GrantNeed,
|
||||
domain: ShareScope,
|
||||
options?: RecordAuthOptions,
|
||||
): Promise<RecordAuthContext> {
|
||||
const auth = await authenticateCaller();
|
||||
const auth = await authenticateCaller(options?.scope);
|
||||
// v1.37.0 — before any carrier is read, any grant is looked up, or any record
|
||||
// row is touched: does this request still believe what its session believes?
|
||||
//
|
||||
@@ -791,6 +858,24 @@ export async function requireRecordAuth(
|
||||
await assertRecordSessionFence(auth);
|
||||
const carrier = await readActingCarrier(auth);
|
||||
|
||||
// A narrow, single-purpose Bearer never acts on another record.
|
||||
//
|
||||
// Its holder mints it themselves, for one job, and pastes it into something
|
||||
// that is not this app — a scale bridge, a home-automation rule. Admitting it
|
||||
// under a selector would exercise somebody else's grant through a credential
|
||||
// nobody reviewed for that purpose, and the blast radius of the paste would
|
||||
// stop being "my own record" without anyone deciding it should.
|
||||
//
|
||||
// Placed above the `none` arm and below the carrier read, which is the only
|
||||
// position that is both correct and cheap: no grant is looked up on the way
|
||||
// to the refusal, and a scoped credential on its own record still falls
|
||||
// through to the untouched `none` path below. A `["*"]` token and a cookie
|
||||
// session read `bearerScope: null` here and keep exactly the delegation
|
||||
// behaviour they had.
|
||||
if (carrier.kind !== "none" && isScopedCredential(auth)) {
|
||||
throw refuseUndeclaredMode(auth, carrier, "narrow_scope_selector");
|
||||
}
|
||||
|
||||
if (carrier.kind === "none") {
|
||||
// Do no harm: without a carrier this is byte-for-byte the pre-v1.36.0
|
||||
// request, resolved by the same `authenticateCaller` every other mode uses.
|
||||
|
||||
+47
-1
@@ -426,6 +426,45 @@ async function canReplayDelegatedResponse(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this request presenting a narrow, single-purpose Bearer token?
|
||||
*
|
||||
* Asked only on the delegated-replay path, and it exists because a live grant
|
||||
* is no longer the whole question there. A narrow scope is refused delegation
|
||||
* outright by `requireRecordAuth` — before any grant is read — so a credential
|
||||
* that would be refused by the handler must not be handed a cached delegated
|
||||
* body instead. The actor may genuinely hold the grant and the row may be
|
||||
* genuinely theirs; the point is that THIS credential is not one the grant was
|
||||
* ever exercised through.
|
||||
*
|
||||
* Authorises nothing, exactly like the resolver above: a true answer only
|
||||
* declines to serve the cache, and the handler then issues its own refusal with
|
||||
* the audit trail that belongs to it. One indexed single-row read, on a path
|
||||
* that is already doing a grant lookup.
|
||||
*/
|
||||
async function presentsNarrowScopedToken(): Promise<boolean> {
|
||||
let authHeader: string | null = null;
|
||||
try {
|
||||
const headerList = await headers();
|
||||
authHeader = headerList.get("authorization");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!authHeader?.startsWith("Bearer ")) return false;
|
||||
|
||||
const apiToken = await prisma.apiToken
|
||||
.findUnique({
|
||||
where: { tokenHash: hashToken(authHeader.slice(7)) },
|
||||
select: { permissions: true },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
// Unreadable reads as narrow: the cache is an accelerator, and falling
|
||||
// through to the handler is always a safe answer.
|
||||
if (!apiToken) return true;
|
||||
return !apiToken.permissions.includes("*");
|
||||
}
|
||||
|
||||
export function withIdempotency<
|
||||
Args extends [Request | NextRequest, ...unknown[]],
|
||||
>(
|
||||
@@ -506,7 +545,14 @@ export function withIdempotency<
|
||||
if (cached?.kind === "replay") {
|
||||
if (
|
||||
claimedRecord !== null &&
|
||||
!(await canReplayDelegatedResponse(userId, claimedRecord))
|
||||
(!(await canReplayDelegatedResponse(userId, claimedRecord)) ||
|
||||
// A narrow scope is refused delegation by the handler before any
|
||||
// grant is read, so a live grant is not sufficient here. Without this
|
||||
// arm, a cell filled earlier by the same person under a cookie
|
||||
// session could be replayed by a single-purpose token naming the
|
||||
// owner's record — no row written, but the owner's response body
|
||||
// returned to a credential the handler would have turned away.
|
||||
(await presentsNarrowScopedToken()))
|
||||
) {
|
||||
// Do not replace the route's refusal with a cache-specific response:
|
||||
// it owns the stable 403 envelope and the associated audit trail.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The Bearer scope for third-party measurement ingest.
|
||||
*
|
||||
* A leaf module with no imports, for the reason `FHIR_READ_SCOPE` sits in
|
||||
* `@/lib/fhir/rest` and `SCOPE_HEALTH_*` in `@/lib/mcp/scopes`: a scope belongs
|
||||
* beside the surface that consumes it, and a central scope registry would
|
||||
* invite a route to pick one off a menu — the opposite of the closed vocabulary
|
||||
* `bearer-scope-enforcement-guard.test.ts` freezes. Zero imports because the
|
||||
* mint route, both write routes, the settings card (a client component) and the
|
||||
* guards all read it, and none of them should inherit an import graph for a
|
||||
* string.
|
||||
*
|
||||
* Deliberately alone. There is no read counterpart and must not be one: the
|
||||
* measurement read legs stay cookie-equivalent, so a credential minted for a
|
||||
* scale or a home-automation rule cannot read a health history back out. An
|
||||
* unused `MEASUREMENTS_READ_SCOPE` sitting here is one somebody wires up later
|
||||
* without the review that admitting it deserves.
|
||||
*/
|
||||
|
||||
/** The scope a narrow token must carry to write measurements. */
|
||||
export const MEASUREMENTS_WRITE_SCOPE = "measurements:write";
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
mfaWebauthnLoginVerifySchema,
|
||||
} from "@/lib/validations/mfa";
|
||||
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
|
||||
import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff";
|
||||
import {
|
||||
stepUpMintSchema,
|
||||
@@ -449,6 +450,28 @@ const apiTokenListResponse = z
|
||||
.array(apiTokenInfo)
|
||||
.meta({ id: "ApiTokenListResponse" });
|
||||
|
||||
const createMeasurementTokenRequest = createMeasurementTokenSchema.meta({
|
||||
id: "CreateMeasurementTokenRequest",
|
||||
description:
|
||||
"Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365.",
|
||||
});
|
||||
|
||||
const createMeasurementTokenResponse = z
|
||||
.object({
|
||||
token: z
|
||||
.string()
|
||||
.describe(
|
||||
"The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret in logs, crash reports and analytics.",
|
||||
),
|
||||
name: z.string(),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "CreateMeasurementTokenResponse",
|
||||
description:
|
||||
"The one response that carries a usable measurement-ingest Bearer value.",
|
||||
});
|
||||
|
||||
// ── Registration, credential rotation, Codex device-auth ─────────────
|
||||
|
||||
const registerRequest = registerSchema.meta({
|
||||
@@ -1571,7 +1594,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "List the caller's API tokens",
|
||||
description:
|
||||
"Every `ApiToken` row belonging to the caller, newest first, revoked ones included — presence in this list is not validity, `revoked` is. The hash is never returned and there is no path that re-reveals a token's plaintext.\n\n" +
|
||||
"Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no mint on this path any more — the generic POST that issued `[\"medication:ingest\"]` was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else; the working credential comes from the per-medication API-endpoint toggle. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.",
|
||||
"Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no GENERIC mint any more — the POST at this exact path issued `[\"medication:ingest\"]` and was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The credentials that work are minted where they are scoped: the per-medication API-endpoint toggle, `POST /api/mcp/tokens` for a connector, and the sibling `POST /api/tokens/measurements` for third-party measurement ingest. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's tokens, newest first.",
|
||||
@@ -1588,6 +1611,65 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tokens/measurements": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Mint a measurement-ingest token",
|
||||
description:
|
||||
"Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it.\n\n" +
|
||||
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record, attributing `source: MANUAL`. An entry naming `APPLE_HEALTH` is refused 422 rather than relabelled: that source is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint.\n\n" +
|
||||
"**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" +
|
||||
"Minting needs a cookie session or a cookie-equivalent token. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: createMeasurementTokenRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description:
|
||||
"Token minted. The `token` field is the only copy of the secret.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
createMeasurementTokenResponse,
|
||||
"CreateMeasurementTokenEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Body is not parseable JSON.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator has switched the API off instance-wide (`AppSettings.apiGlobal`), or the caller presented a narrow-scope token rather than a session — a scoped credential cannot mint credentials. Nothing was minted either way.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 16 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"415": {
|
||||
description: "Content-Type is not `application/json`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed. The envelope carries every offending issue, not just the first.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 10 mints from this account in a minute. Nothing was minted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tokens/{id}": {
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -842,7 +842,8 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Create one measurement (or a small array)",
|
||||
description:
|
||||
"Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.",
|
||||
"Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.\n\n" +
|
||||
"Also reachable with a narrow `measurements:write` Bearer, which is the supported way to push readings from a scale, a watch bridge or a home-automation rule. That credential is confined to its owner's own record — a request carrying the account selector is refused 403 before any sharing grant is read, however the record is actually shared — and to `MANUAL` attribution, so an explicit `APPLE_HEALTH` source is refused 422 (`measurement.create.source_not_permitted`). The GET on this same path, and the PUT and DELETE beside it, name no scope and so refuse it.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -875,6 +876,11 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named `APPLE_HEALTH` as the source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -974,7 +980,8 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Apple Health batch ingest",
|
||||
description:
|
||||
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.",
|
||||
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.\n\n" +
|
||||
"Also reachable with a narrow `measurements:write` Bearer, for a bridge that is not the phone. Such a credential writes only on its owner's own record; its entries are attributed `MANUAL` whatever they omit, an entry naming `APPLE_HEALTH` is refused 422 (`measurement.batch.source_not_permitted`), and the call does not advance the native sync checkpoint — `lastSyncedAt` means the CLIENT delivered what it was holding, and a third-party push is not that.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: batchPayloadSchema } },
|
||||
@@ -989,6 +996,11 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation, or (`measurement.batch.source_not_permitted`) carried an entry naming `APPLE_HEALTH` under a narrow `measurements:write` credential. Nothing was written in any of the three cases.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Body of `POST /api/tokens/measurements` — mint a Bearer for third-party
|
||||
* measurement ingest.
|
||||
*
|
||||
* There is deliberately no `scope` field. The MCP mint next door offers a
|
||||
* closed two-value choice because its two shapes mean different things; this
|
||||
* endpoint mints one shape and only one, so the request cannot express a scope
|
||||
* at all. That is the strongest form of the no-mass-assignment rule: not a
|
||||
* field validated against an allowlist, but a field that does not exist.
|
||||
*
|
||||
* `expiresInDays` is bounded rather than optional-and-unbounded because the
|
||||
* credential is pasted into something that runs unattended. The route's default
|
||||
* is deliberately long for the same reason — an automation that stops working
|
||||
* silently is worse than one that stops working loudly — but a caller who wants
|
||||
* a short-lived token can always ask for one.
|
||||
*/
|
||||
export const createMeasurementTokenSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
export type CreateMeasurementTokenInput = z.infer<
|
||||
typeof createMeasurementTokenSchema
|
||||
>;
|
||||
@@ -434,3 +434,255 @@ describe("B7 — cookie sessions are untouched", () => {
|
||||
await expect(requireAdmin()).rejects.toBeInstanceOf(HttpError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("B8 — the measurement-ingest scope reaches its two routes and no others", () => {
|
||||
/**
|
||||
* A second account, plus a live, accepted, record-wide grant letting the
|
||||
* token's owner write to it.
|
||||
*
|
||||
* The grant is the whole point of the delegation cases below. Asserting a
|
||||
* 403 against a record nobody shared proves nothing — every credential is
|
||||
* refused that. What has to hold is that the scoped token is refused a
|
||||
* record its own holder genuinely may write to, which is the case a reader
|
||||
* would otherwise assume works.
|
||||
*/
|
||||
async function seedSharedOwner(): Promise<string> {
|
||||
const ownerId = "user-bearer-scope-owner";
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: ownerId,
|
||||
username: "scope-owner",
|
||||
email: "scope-owner@example.test",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
await getPrismaClient().accountGrant.create({
|
||||
data: {
|
||||
grantorId: ownerId,
|
||||
granteeId: USER_ID,
|
||||
access: "WRITE",
|
||||
acceptedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return ownerId;
|
||||
}
|
||||
|
||||
function measurementBody(): string {
|
||||
return JSON.stringify({
|
||||
type: "WEIGHT",
|
||||
value: 74.2,
|
||||
measuredAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function postMeasurement(selector?: string): NextRequest {
|
||||
const headers: Record<string, string> = {
|
||||
authorization: headerJar.get("authorization")!,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (selector) headers["x-healthlog-account"] = selector;
|
||||
return new NextRequest("https://health.example/api/measurements", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: measurementBody(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("admits the scope on POST /api/measurements, attributed MANUAL", async () => {
|
||||
await armToken(["measurements:write"], "mwrite1");
|
||||
const { POST } = await import("@/app/api/measurements/route");
|
||||
const res = await POST(postMeasurement());
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const row = await getPrismaClient().measurement.findFirstOrThrow({
|
||||
where: { userId: USER_ID, type: "WEIGHT" },
|
||||
});
|
||||
expect(row.source).toBe("MANUAL");
|
||||
});
|
||||
|
||||
it("admits the scope on the batch route, attributed MANUAL", async () => {
|
||||
await armToken(["measurements:write"], "mwrite2");
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
const res = await POST(
|
||||
new NextRequest("https://health.example/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: headerJar.get("authorization")!,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
||||
value: 70,
|
||||
unit: "kg",
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date().toISOString(),
|
||||
externalId: "uuid-measurements-write-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as never),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = await getPrismaClient().measurement.findFirstOrThrow({
|
||||
where: { userId: USER_ID, externalId: "uuid-measurements-write-1" },
|
||||
});
|
||||
// Absent `source` defaults to APPLE_HEALTH for the phone and MANUAL here.
|
||||
expect(row.source).toBe("MANUAL");
|
||||
});
|
||||
|
||||
it("does not move the native sync checkpoint", async () => {
|
||||
await armToken(["measurements:write"], "mwrite3");
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
await POST(
|
||||
new NextRequest("https://health.example/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: headerJar.get("authorization")!,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
||||
value: 71,
|
||||
unit: "kg",
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date().toISOString(),
|
||||
externalId: "uuid-measurements-write-2",
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as never),
|
||||
);
|
||||
|
||||
const user = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: USER_ID },
|
||||
});
|
||||
expect(user.lastSyncedAt).toBeNull();
|
||||
expect(user.healthKitLastSyncedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an entry forging the HealthKit source", async () => {
|
||||
await armToken(["measurements:write"], "mwrite4");
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
const res = await POST(
|
||||
new NextRequest("https://health.example/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: headerJar.get("authorization")!,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
||||
value: 72,
|
||||
unit: "kg",
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date().toISOString(),
|
||||
externalId: "uuid-measurements-write-3",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as never),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(await getPrismaClient().measurement.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("is refused on the read leg of the very same path", async () => {
|
||||
// What makes the scope write-only rather than write-named.
|
||||
await armToken(["measurements:write"], "mwrite5");
|
||||
const { GET } = await import("@/app/api/measurements/route");
|
||||
const res = await GET(req("/api/measurements"));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(await lastBearerFailureReason()).toBe("undeclared_scope");
|
||||
});
|
||||
|
||||
it("is refused on the manage legs and on an unrelated surface", async () => {
|
||||
await armToken(["measurements:write"], "mwrite6");
|
||||
|
||||
const bulk = await import("@/app/api/measurements/bulk-delete/route");
|
||||
expect(
|
||||
(await bulk.POST(req("/api/measurements/bulk-delete", "POST"))).status,
|
||||
).toBe(403);
|
||||
|
||||
const restore = await import("@/app/api/measurements/restore/route");
|
||||
expect(
|
||||
(await restore.POST(req("/api/measurements/restore", "POST"))).status,
|
||||
).toBe(403);
|
||||
|
||||
const backup = await import("@/app/api/export/full-backup/route");
|
||||
expect((await backup.GET(req("/api/export/full-backup"))).status).toBe(403);
|
||||
|
||||
const labs = await import("@/app/api/labs/route");
|
||||
expect((await labs.GET(req("/api/labs"))).status).toBe(403);
|
||||
});
|
||||
|
||||
it("cannot mint another token", async () => {
|
||||
await armToken(["measurements:write"], "mwrite7");
|
||||
const { POST } = await import("@/app/api/tokens/measurements/route");
|
||||
const res = await POST(
|
||||
new NextRequest("https://health.example/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: headerJar.get("authorization")!,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: "second" }),
|
||||
} as never),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("cannot reach the medication ingest surface", async () => {
|
||||
await armToken(["measurements:write"], "mwrite8");
|
||||
const { POST } = await import("@/app/api/ingest/medication/route");
|
||||
const res = await POST(
|
||||
new NextRequest("https://health.example/api/ingest/medication", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: headerJar.get("authorization")!,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ medicationName: "x" }),
|
||||
} as never),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("is refused a shared record its own holder may genuinely write to", async () => {
|
||||
const ownerId = await seedSharedOwner();
|
||||
await armToken(["measurements:write"], "mwrite9");
|
||||
|
||||
const { POST } = await import("@/app/api/measurements/route");
|
||||
const res = await POST(postMeasurement(ownerId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
// Nothing landed on the owner, and nothing quietly landed on the holder
|
||||
// instead — the reverse data-mixing failure the resolver exists to avoid.
|
||||
expect(await getPrismaClient().measurement.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("but a wildcard token writes to that same shared record", async () => {
|
||||
// The control for the case above. Without it, a delegation path broken
|
||||
// outright would make that 403 look like the scope working.
|
||||
const ownerId = await seedSharedOwner();
|
||||
await armToken(["*"], "wildshared");
|
||||
|
||||
const { POST } = await import("@/app/api/measurements/route");
|
||||
const res = await POST(postMeasurement(ownerId));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const row = await getPrismaClient().measurement.findFirstOrThrow({
|
||||
where: { type: "WEIGHT" },
|
||||
});
|
||||
expect(row.userId).toBe(ownerId);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user