mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge pull request #881 from Antiheld86/feat/measurements-write-scope
Feat/measurements write scope
This commit is contained in:
28 files changed
+1951
-46
No files matched your search
@@ -1,5 +1,67 @@
|
||||
# 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.
|
||||
- Minting one requires being signed in on the web. No API token can mint
|
||||
another, whatever it is allowed to do — the sign-in a phone holds lasts a
|
||||
day and what it could mint here lasts a year, so allowing it would let a
|
||||
credential that leaked for an afternoon leave behind one that outlives
|
||||
cancelling it.
|
||||
- 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.
|
||||
|
||||
+179
-12
@@ -2731,11 +2731,13 @@ paths:
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -2746,6 +2748,86 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/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.
|
||||
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included —
|
||||
not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day
|
||||
and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a
|
||||
credential that outlives revoking it. 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:
|
||||
$ref: "#/components/schemas/CreateMeasurementTokenRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Token minted. The `token` field is the only copy of the secret.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateMeasurementTokenEnvelope"
|
||||
"400":
|
||||
description: Body is not parseable JSON.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). Nothing was minted. A Bearer
|
||||
caller does not reach this arm — it is refused 401 before the switch is read.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413":
|
||||
description: Body exceeds 16 KiB.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"415":
|
||||
description: Content-Type is not `application/json`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422":
|
||||
description: Validation failed. The envelope carries every offending issue, not just the first.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429":
|
||||
description: More than 10 mints from this account in a minute. Nothing was minted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/tokens/{id}:
|
||||
delete:
|
||||
tags:
|
||||
@@ -3564,9 +3646,18 @@ 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
|
||||
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.
|
||||
|
||||
|
||||
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:
|
||||
- *a6
|
||||
requestBody:
|
||||
@@ -3595,7 +3686,14 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"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:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": *a5
|
||||
/api/measurements/{id}:
|
||||
get:
|
||||
@@ -3715,10 +3813,18 @@ 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.
|
||||
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.
|
||||
|
||||
|
||||
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.
|
||||
parameters:
|
||||
- *a6
|
||||
requestBody:
|
||||
@@ -3736,7 +3842,14 @@ paths:
|
||||
$ref: "#/components/schemas/BatchMeasurementsResponse"
|
||||
"401": *a1
|
||||
"409": *a7
|
||||
"422": *a4
|
||||
"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:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": *a5
|
||||
/api/measurements/by-external-ids:
|
||||
delete:
|
||||
@@ -20732,6 +20845,23 @@ components:
|
||||
required:
|
||||
- refreshToken
|
||||
description: Exchange a one-time-use refresh token for a fresh access + refresh pair.
|
||||
CreateMeasurementTokenRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 100
|
||||
expiresInDays:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
required:
|
||||
- name
|
||||
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."
|
||||
RegisterRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -36378,6 +36508,43 @@ components:
|
||||
- createdAt
|
||||
- revoked
|
||||
additionalProperties: false
|
||||
CreateMeasurementTokenEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CreateMeasurementTokenResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CreateMeasurementTokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: 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:
|
||||
type: string
|
||||
expiresAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
required:
|
||||
- token
|
||||
- name
|
||||
- expiresAt
|
||||
additionalProperties: false
|
||||
description: The one response that carries a usable measurement-ingest Bearer value.
|
||||
ApiTokenRevokeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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.
|
||||
+8
-1
@@ -5879,7 +5879,14 @@
|
||||
"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": "Messwert-Tokens",
|
||||
"description": "Erzeuge ein Token, damit eine Waage, eine Uhr oder deine Hausautomatisierung Werte eintragen kann.",
|
||||
"detail": "Das Token wird nur einmal angezeigt.",
|
||||
"scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen — deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte erscheinen wie von Hand eingetragen und lassen sich jederzeit korrigieren.",
|
||||
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut."
|
||||
},
|
||||
"tokenMintMovedDescription": "Tokens werden dort ausgestellt, wo sie gelten: oben für Messwerte, oder im API-Endpoint eines Medikaments für genau dieses Medikament.",
|
||||
"tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)",
|
||||
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
|
||||
"tokenCopy": "Token kopieren",
|
||||
|
||||
+8
-1
@@ -5879,7 +5879,14 @@
|
||||
"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 tokens",
|
||||
"description": "Mint a token so a scale, a watch bridge or your home automation can add readings.",
|
||||
"detail": "The token is shown once.",
|
||||
"scopeNote": "It can only add new readings to your own record — it cannot read your data, change or delete anything, or create another token. Readings arrive as if you had entered them by hand, so you can correct them yourself.",
|
||||
"createFailed": "The token could not be created. Please try again."
|
||||
},
|
||||
"tokenMintMovedDescription": "Tokens are issued where they apply: above for measurements, or in a medication's own API endpoint for that one medication.",
|
||||
"tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)",
|
||||
"tokenCreated": "Token created — copy it now! It won't be shown again.",
|
||||
"tokenCopy": "Copy token",
|
||||
|
||||
+8
-1
@@ -5879,7 +5879,14 @@
|
||||
"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": "Tokens de mediciones",
|
||||
"description": "Crea un token para que una báscula, un reloj o tu domótica añadan mediciones.",
|
||||
"detail": "El token se muestra una sola vez.",
|
||||
"scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial: no puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Las mediciones llegan como si las hubieras introducido a mano, así que puedes corregirlas.",
|
||||
"createFailed": "No se pudo crear el token. Inténtalo de nuevo."
|
||||
},
|
||||
"tokenMintMovedDescription": "Los tokens se emiten donde se aplican: arriba para las mediciones, o en el endpoint de API de un medicamento para ese medicamento concreto.",
|
||||
"tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)",
|
||||
"tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.",
|
||||
"tokenCopy": "Copiar token",
|
||||
|
||||
+8
-1
@@ -5879,7 +5879,14 @@
|
||||
"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": "Jetons de mesures",
|
||||
"description": "Créez un jeton pour qu'une balance, une montre ou votre domotique ajoutent des mesures.",
|
||||
"detail": "Le jeton n'est affiché qu'une fois.",
|
||||
"scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier : il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures arrivent comme si vous les aviez saisies à la main, vous pouvez donc les corriger.",
|
||||
"createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer."
|
||||
},
|
||||
"tokenMintMovedDescription": "Les jetons sont émis là où ils s'appliquent : ci-dessus pour les mesures, ou dans le point de terminaison API d'un médicament pour 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",
|
||||
|
||||
+8
-1
@@ -5879,7 +5879,14 @@
|
||||
"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 misurazioni",
|
||||
"description": "Crea un token perché una bilancia, un orologio o la tua domotica aggiungano misurazioni.",
|
||||
"detail": "Il token viene mostrato una sola volta.",
|
||||
"scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella: non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Le misurazioni arrivano come se le avessi inserite a mano, quindi puoi correggerle.",
|
||||
"createFailed": "Non è stato possibile creare il token. Riprova."
|
||||
},
|
||||
"tokenMintMovedDescription": "I token vengono emessi dove si applicano: qui sopra per le misurazioni, oppure nell'endpoint API di un farmaco per quel singolo farmaco.",
|
||||
"tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)",
|
||||
"tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.",
|
||||
"tokenCopy": "Copia token",
|
||||
|
||||
+8
-1
@@ -5879,7 +5879,14 @@
|
||||
"googleHealthViewData": "걸음 수 보기",
|
||||
"apiTokens": "API 토큰",
|
||||
"apiTokensDescription": "외부에서 복용을 기록할 때 쓰는 API 토큰을 확인하고 폐기해요.",
|
||||
"tokenMintMovedDescription": "토큰은 약별로 발급돼요. 약을 열고 API 엔드포인트를 켜면 그 약에만 적용되는 토큰을 받을 수 있어요.",
|
||||
"measurementsToken": {
|
||||
"title": "측정 토큰",
|
||||
"description": "체중계나 시계, 홈 오토메이션이 측정값을 기록할 수 있도록 토큰을 만들어요.",
|
||||
"detail": "토큰은 한 번만 표시돼요.",
|
||||
"scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 직접 입력한 것처럼 들어오니 언제든 고칠 수 있어요.",
|
||||
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요."
|
||||
},
|
||||
"tokenMintMovedDescription": "토큰은 쓰이는 곳에서 발급돼요. 측정값은 위에서, 특정 약은 그 약의 API 엔드포인트에서 만들 수 있어요.",
|
||||
"tokenNamePlaceholder": "토큰 이름 (예: iPhone 단축어)",
|
||||
"tokenCreated": "토큰을 만들었어요 — 지금 복사하세요! 다시 보여주지 않아요.",
|
||||
"tokenCopy": "토큰 복사",
|
||||
|
||||
+8
-1
@@ -5879,7 +5879,14 @@
|
||||
"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": "Tokeny pomiarów",
|
||||
"description": "Utwórz token, aby waga, zegarek lub automatyka domowa mogły dodawać pomiary.",
|
||||
"detail": "Token pokazujemy tylko raz.",
|
||||
"scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji — nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary trafiają tak, jakbyś wpisał je ręcznie, więc możesz je poprawić.",
|
||||
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie."
|
||||
},
|
||||
"tokenMintMovedDescription": "Tokeny wydajemy tam, gdzie działają: powyżej dla pomiarów albo w punkcie końcowym API danego leku dla tego jednego leku.",
|
||||
"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": {
|
||||
|
||||
@@ -15,6 +15,12 @@ const SELF = {
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireRecordAuth: vi.fn(async () => ({ user: SELF, actor: SELF })),
|
||||
// These cases resolve as a cookie session, which is what "unscoped" means:
|
||||
// no narrow Bearer admitted the request, so the route's source and
|
||||
// sync-checkpoint rules take their ordinary path. Stubbed rather than
|
||||
// omitted because this file mocks the module wholesale, so an export the
|
||||
// route reaches for and the mock does not list fails the import outright.
|
||||
isScopedCredential: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
|
||||
@@ -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,
|
||||
requireCookieAuth: 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 { requireCookieAuth } 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(requireCookieAuth).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,127 @@
|
||||
/**
|
||||
* `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 — no Bearer credential reaches this endpoint at all, at
|
||||
* any scope. 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, requireCookieAuth } 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) => {
|
||||
// Cookie-only, and the reason is lifetime rather than reach.
|
||||
//
|
||||
// `requireAuth()` would refuse a narrow token — a measurements credential
|
||||
// cannot mint its own successor — but it still admits a cookie-EQUIVALENT
|
||||
// one, and that is the case worth closing. A native access token lives a
|
||||
// day; what it could mint here lives a year. So a credential that leaked for
|
||||
// an afternoon would leave behind one that outlives revoking it, and the
|
||||
// revocation would look like it had worked.
|
||||
//
|
||||
// A session cannot do that: it is held by a browser the person is sitting in
|
||||
// front of, and it is the surface this endpoint is reached from anyway. The
|
||||
// same argument the passkey-registration and trusted-device routes make, and
|
||||
// `requireCookieAuth` is the helper they share.
|
||||
const { user } = await requireCookieAuth();
|
||||
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,156 @@ 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>
|
||||
|
||||
<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 +211,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,68 @@ 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 requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. 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`). Nothing was minted. A Bearer caller does not reach this arm — it is refused 401 before the switch is read.",
|
||||
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 } },
|
||||
},
|
||||
// After the spread, not before: `stdResponses` carries its own 422 and
|
||||
// 429, and a key placed ahead of it is silently overwritten by the
|
||||
// generic wording. Same ordering as the MCP mint next door.
|
||||
...stdResponses,
|
||||
"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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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,7 +876,14 @@ 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 } },
|
||||
},
|
||||
// After the spread: `stdResponses` carries a generic 422 that would
|
||||
// otherwise overwrite this one.
|
||||
...stdResponses,
|
||||
"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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -974,7 +982,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,7 +998,14 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
// After the spread: `stdResponses` carries a generic 422 that would
|
||||
// otherwise overwrite this one.
|
||||
...stdResponses,
|
||||
"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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -25,6 +25,10 @@ process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-bearer-scope-enforcement-32-bytes-min-0987654321";
|
||||
|
||||
const { hashToken } = await import("@/lib/auth/hmac");
|
||||
// The canonical name rather than a literal, so a rename cannot leave these
|
||||
// cases setting a header nothing reads — which is the exact failure the
|
||||
// delegation cases below already shipped once.
|
||||
const { ACCOUNT_SELECTOR_HEADER } = await import("@/lib/auth/acting-carrier");
|
||||
|
||||
const USER_ID = "user-bearer-scope-test";
|
||||
const MCP_RESOURCE = "https://health.example/mcp";
|
||||
@@ -434,3 +438,304 @@ 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(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a record for the next request.
|
||||
*
|
||||
* It goes in the `headerJar`, NOT on the `NextRequest`, and the difference
|
||||
* is the whole reason the first version of these cases passed for the wrong
|
||||
* reason. `readSelectorHeader()` resolves through `next/headers`, which this
|
||||
* suite mocks against the jar — a header set on the request object is never
|
||||
* read by the resolver, so the carrier came back `none` and the delegation
|
||||
* cases were silently exercising the ordinary own-record path.
|
||||
*/
|
||||
function claimRecord(accountId: string): void {
|
||||
headerJar.set(ACCOUNT_SELECTOR_HEADER, accountId);
|
||||
}
|
||||
|
||||
function postMeasurement(): NextRequest {
|
||||
return new NextRequest("https://health.example/api/measurements", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: headerJar.get("authorization")!,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
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);
|
||||
// Polled, not read once: the audit write is fire-and-forget, so a bare
|
||||
// read races the request and reports `undefined` for a row that is about
|
||||
// to exist. Same posture as B1 above — on timeout the poll asserts against
|
||||
// whatever WAS written rather than passing by default.
|
||||
await expect
|
||||
.poll(() => lastBearerFailureReason(), { timeout: 5_000, interval: 100 })
|
||||
.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 () => {
|
||||
// 401, not 403, and the difference is the point: the mint is cookie-only,
|
||||
// so a Bearer is turned away as unauthenticated before any scope is read.
|
||||
// The refusal is therefore not "this token's scope is too narrow" — no
|
||||
// token is wide enough, a wildcard included.
|
||||
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(401);
|
||||
});
|
||||
|
||||
it("and neither can a wildcard token", async () => {
|
||||
// The case the cookie-only resolver exists for. A native access token
|
||||
// lives a day; what this endpoint mints lives a year, so admitting one
|
||||
// would let a short-lived compromise leave behind a credential that
|
||||
// survives revoking it.
|
||||
await armToken(["*"], "wildmint");
|
||||
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: "minted by a wildcard" }),
|
||||
} as never),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(
|
||||
await getPrismaClient().apiToken.count({
|
||||
where: { permissions: { has: "measurements:write" } },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
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");
|
||||
claimRecord(ownerId);
|
||||
|
||||
const { POST } = await import("@/app/api/measurements/route");
|
||||
const res = await POST(postMeasurement());
|
||||
|
||||
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");
|
||||
claimRecord(ownerId);
|
||||
|
||||
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: { type: "WEIGHT" },
|
||||
});
|
||||
expect(row.userId).toBe(ownerId);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user