Add a self-service measurements:write API token scope

Since v1.30.17 there has been no self-service path to a token that can
write a measurement. That release made the Bearer scope default
fail-closed, which closed a real privilege-escalation hole, and removed
the generic mint at POST /api/tokens along with it. The generic mint had
been the only way to get a token that could reach /api/measurements, so
a local sensor pushed through Home Assistant or Node-RED lost its door
while the OAuth integrations kept theirs.

Add a narrow scope accepted by exactly two routes, both writes, both on
the holder's own record.

The scope constant is a leaf module beside the surface that consumes it,
matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an
options object rather than a sibling function, because the sharing
surface guard keys every leg on that one identifier and a second name
would mean two copies of the delegable declaration. AuthContext carries
the admitting scope as a single nullable string rather than the token's
permission array, so nothing downstream can re-derive an authorisation
decision from it.

A scoped credential is refused a record selector before any grant is
read. Placing the refusal above the sharing check rather than beside it
means no future widening of what a grant permits can reach one of these
tokens.

Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH
is refused 422 rather than relabelled: that source is half the
(userId, type, source, externalId) dedup key the iOS client writes into,
it participates in the cross-source merge, and it gates the HealthKit
sync fields. For the same reason a scoped write no longer advances
User.lastSyncedAt, which the phone reads to size its undelivered window.

Also closes a narrower gap found while building this. The idempotency
wrapper resolved a replayed delegated response against a live grant
without considering which credential was presenting it, so a cached
response about a shared record could be returned to a token the handler
itself would refuse. No write was reachable that way; the response body
was.

Guards: T3 gains the mint site, T4's vocabulary gains the scope and
learns to read the requireRecordAuth form (without which this change
would open a way to widen a route that the closed vocabulary could not
see), and a new T5 freezes the set of delegable routes naming a scope.
The sharing surface guard's AST matcher reads only the first two
arguments, so it needed prose but no code.
This commit is contained in:
Antiheld86 committed 2026-08-31 08:46:16 +00:00
1 parent 4308114c5b
commit cba7a0fce5
26 files changed
+1704 -34

No files matched your search

+52 -4
View File
@@ -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";
+46 -2
View File
@@ -1,5 +1,10 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiHandler,
isScopedCredential,
requireRecordAuth,
} from "@/lib/api-handler";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import { annotate } from "@/lib/logging/context";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { auditLog } from "@/lib/auth/audit";
@@ -744,6 +749,23 @@ async function sleepListResponse(
});
}
/**
* Does this body attribute any reading to `APPLE_HEALTH`?
*
* Read off the raw body rather than the parsed result because the answer has to
* be the same for the object arm and the array arm, and those parse against
* different schemas further down. Anything that is not a recognisable
* measurement shape answers false and falls through to the validation error it
* has coming — this function decides attribution, not validity.
*/
function namesAppleHealthSource(body: unknown): boolean {
const names = (entry: unknown): boolean =>
typeof entry === "object" &&
entry !== null &&
(entry as { source?: unknown }).source === "APPLE_HEALTH";
return Array.isArray(body) ? body.some(names) : names(body);
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
async function postMeasurement(request: NextRequest) {
@@ -752,7 +774,17 @@ async function postMeasurement(request: NextRequest) {
// who may or may not be that person. Nothing else in this handler reads the
// caller: the safety-floor check, the reminder satisfaction, the rollup
// recompute and the cache eviction are all statements about the record.
const { user } = await requireRecordAuth("write", "measurements");
//
// v1.38.x — it also declares the third-party ingest scope, so a narrow
// `measurements:write` token reaches this arm. The two declarations do not
// interact: a scoped credential is refused the moment the request names
// another record, before any grant is read, so "delegable" and "reachable by
// a bridge token" stay separate properties of the same route.
const auth = await requireRecordAuth("write", "measurements", {
scope: MEASUREMENTS_WRITE_SCOPE,
});
const { user } = auth;
const scoped = isScopedCredential(auth);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
@@ -760,6 +792,18 @@ async function postMeasurement(request: NextRequest) {
if (jsonError) return jsonError;
// A scoped credential may attribute MANUAL and nothing else — the schema's
// own default, so only an explicit `APPLE_HEALTH` needs refusing. Checked
// before validation branches so the object and array arms share one answer,
// and refused rather than rewritten for the reason the batch route gives:
// relabelling a client's explicit assertion hands it rows it cannot see.
if (scoped && namesAppleHealthSource(body)) {
annotate({ action: { name: "measurements.create.source-not-permitted" } });
return apiError("This credential may only attribute MANUAL readings", 422, {
errorCode: "measurement.create.source_not_permitted",
});
}
// Batch mode (array of measurements, e.g. combined BP + Pulse)
if (Array.isArray(body)) {
const parsed = createBatchMeasurementSchema.safeParse({
@@ -0,0 +1,160 @@
/**
* The measurement-ingest mint.
*
* The assertion that matters most is not the 201 — it is the exact
* `permissions` array handed to `issueApiToken`. That helper defaults to
* `["*"]` when the property is absent, so an edit that drops one line turns a
* user-facing endpoint into a cookie-equivalent credential factory, and a test
* that only checked the status code would go on passing. The structural guard
* catches a literal `permissions: ["*"` and would not catch the omission, so
* this is where that hole is closed.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/auth/issue-token", () => ({
issueApiToken: vi.fn(),
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
import { POST } from "../route";
import { requireAuth } from "@/lib/api-handler";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
const USER = { id: "user-1", role: "USER" as const };
const EXPIRES = new Date("2027-08-31T00:00:00.000Z");
function req(body: unknown): Request {
return new Request("https://health.example/api/tokens/measurements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(issueApiToken).mockResolvedValue({
token: "hlk_" + "a".repeat(64),
expiresAt: EXPIRES,
tokenId: "token-1",
name: "Home Assistant",
} as never);
});
describe("what it mints", () => {
it("names exactly the ingest scope, and no wildcard", async () => {
const res = await POST(req({ name: "Home Assistant" }) as never);
expect(res.status).toBe(201);
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
// Exact equality, not `toContain`: an extra scope is a widening, and this
// is the assertion standing between the endpoint and one.
expect(opts.permissions).toEqual([MEASUREMENTS_WRITE_SCOPE]);
expect(opts.userId).toBe(USER.id);
});
it("returns the raw token once, with its name and expiry", async () => {
const res = await POST(req({ name: "Home Assistant" }) as never);
const json = await res.json();
expect(json.data.token).toMatch(/^hlk_[0-9a-f]{64}$/);
expect(json.data.name).toBe("Home Assistant");
expect(json.data.expiresAt).toBeDefined();
});
it("expires by default rather than living forever", async () => {
await POST(req({ name: "Scale" }) as never);
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
expect(opts.expiresInDays).toBe(365);
});
it("honours an explicit lifetime", async () => {
await POST(req({ name: "Scale", expiresInDays: 30 }) as never);
expect(vi.mocked(issueApiToken).mock.calls[0][0].expiresInDays).toBe(30);
});
it("audits the mint with the token id and the scope", async () => {
await POST(req({ name: "Scale" }) as never);
expect(auditLog).toHaveBeenCalledWith(
"tokens.measurements.create",
expect.objectContaining({
userId: USER.id,
details: expect.objectContaining({
tokenId: "token-1",
scope: MEASUREMENTS_WRITE_SCOPE,
}),
}),
);
});
});
describe("when it refuses", () => {
it("403s while the operator has the API switched off", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const res = await POST(req({ name: "Scale" }) as never);
expect(res.status).toBe(403);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("429s past the mint rate limit, without minting", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(req({ name: "Scale" }) as never);
expect(res.status).toBe(429);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("422s on a missing name", async () => {
const res = await POST(req({}) as never);
expect(res.status).toBe(422);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("422s on a lifetime outside the permitted band", async () => {
const res = await POST(
req({ name: "Scale", expiresInDays: 4000 }) as never,
);
expect(res.status).toBe(422);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("ignores a scope the body tries to smuggle in", async () => {
// There is no `scope` field to validate, so an unknown key is simply not
// read — the permission array is a literal either way. Asserted because
// "the field does not exist" is the security property, and a schema change
// that added one would silently make this body meaningful.
await POST(
req({ name: "Scale", scope: "read_write", permissions: ["*"] }) as never,
);
expect(vi.mocked(issueApiToken).mock.calls[0][0].permissions).toEqual([
MEASUREMENTS_WRITE_SCOPE,
]);
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* `POST /api/tokens/measurements` — mint a Bearer for third-party measurement
* ingest.
*
* The self-service mint that v1.30.17 left missing. That release made the
* Bearer-scope default fail-closed, which was right — a medication-intake token
* had been reaching every authenticated route, the full backup export included
* — and removed the generic mint at the parent path, which was also right,
* since the token it issued could not do its advertised job. What went with them
* was any way at all to push readings in programmatically: the OAuth
* integrations cover Withings and WHOOP, and cover nothing for a local sensor
* behind a home-automation bridge.
*
* What this mints is narrow by construction. `permissions` is a literal, so no
* request shape reaches it; the scope it names is accepted by exactly two
* routes, both writes, both on the holder's own record. It cannot read a
* reading back, cannot edit or delete one, cannot reach the export, and cannot
* mint another token — the `requireAuth()` below names no scope, so a
* measurements token presenting itself here is refused like any other narrow
* credential. That last property is why the mint is worth its own file: the
* blast radius of a credential pasted into a container the user does not
* operate should not include making more credentials.
*
* No listing leg. `/api/tokens` already returns every token with its
* `permissions`, so the settings card filters that one query rather than
* duplicating it — the MCP module needs its own list only because it must hide
* transient OAuth access rows, and there is no such row here. No revoke-all
* either: `DELETE /api/tokens/[id]` is per-row and the card shows the rows. The
* per-medication toggle's bulk revoke exists because that surface is a boolean
* with no list behind it, which is not this surface.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
/**
* Days a token lives when the caller names no lifetime.
*
* Longer than the MCP mint's 90, and the difference is the deployment rather
* than the risk appetite. A connector token is used from a session somebody is
* sitting in front of; this one is pasted into a rule that runs unattended for
* months, where a quiet expiry surfaces as "my weight stopped syncing sometime
* in the spring". A year is long enough to not be that, short enough to still
* be a rotation.
*/
const DEFAULT_EXPIRY_DAYS = 365;
/** Mints per user per minute. A credential mint is worth its own bucket. */
const MINT_RATE_LIMIT_MAX = 10;
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
export const POST = apiHandler(async (request: NextRequest) => {
// No scope argument, and that is load-bearing rather than incidental: it
// keeps the fail-closed default, so the only credentials that reach the mint
// are a cookie session and a cookie-equivalent token. It also refuses any
// acting-account carrier, so a delegate cannot mint a credential against the
// record they are helping with.
const { user } = await requireAuth();
annotate({ action: { name: "tokens.measurements.create" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const rl = await checkRateLimit(
`tokens:measurements:mint:${user.id}`,
MINT_RATE_LIMIT_MAX,
MINT_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many token mints, try again later", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createMeasurementTokenSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const issued = await issueApiToken({
userId: user.id,
name: parsed.data.name,
// A literal, never spread and never derived from the body. `issueApiToken`
// defaults to `["*"]` when this property is absent, so an edit that drops
// it mints a cookie-equivalent credential from a user-facing endpoint —
// which is why the unit suite asserts this array and not merely the 201.
permissions: [MEASUREMENTS_WRITE_SCOPE],
expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS,
});
await auditLog("tokens.measurements.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { tokenId: issued.tokenId, scope: MEASUREMENTS_WRITE_SCOPE },
});
// The raw token, once. It is stored as an HMAC and no path re-reveals it.
return apiSuccess(
{ token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
201,
);
});