mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Since v1.30.17 there has been no self-service path to a token that can write a measurement. That release made the Bearer scope default fail-closed, which closed a real privilege-escalation hole, and removed the generic mint at POST /api/tokens along with it. The generic mint had been the only way to get a token that could reach /api/measurements, so a local sensor pushed through Home Assistant or Node-RED lost its door while the OAuth integrations kept theirs. Add a narrow scope accepted by exactly two routes, both writes, both on the holder's own record. The scope constant is a leaf module beside the surface that consumes it, matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an options object rather than a sibling function, because the sharing surface guard keys every leg on that one identifier and a second name would mean two copies of the delegable declaration. AuthContext carries the admitting scope as a single nullable string rather than the token's permission array, so nothing downstream can re-derive an authorisation decision from it. A scoped credential is refused a record selector before any grant is read. Placing the refusal above the sharing check rather than beside it means no future widening of what a grant permits can reach one of these tokens. Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH is refused 422 rather than relabelled: that source is half the (userId, type, source, externalId) dedup key the iOS client writes into, it participates in the cross-source merge, and it gates the HealthKit sync fields. For the same reason a scoped write no longer advances User.lastSyncedAt, which the phone reads to size its undelivered window. Also closes a narrower gap found while building this. The idempotency wrapper resolved a replayed delegated response against a live grant without considering which credential was presenting it, so a cached response about a shared record could be returned to a token the handler itself would refuse. No write was reachable that way; the response body was. Guards: T3 gains the mint site, T4's vocabulary gains the scope and learns to read the requireRecordAuth form (without which this change would open a way to widen a route that the closed vocabulary could not see), and a new T5 freezes the set of delegable routes naming a scope. The sharing surface guard's AST matcher reads only the first two arguments, so it needed prose but no code.
27 lines
1.1 KiB
TypeScript
27 lines
1.1 KiB
TypeScript
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
|
|
>;
|