feat(custom-metrics): accept the measurement token on value logging

A script or a home-automation rule holding a measurement token can now
post a value to POST /api/custom-metrics/{id}/entries for a custom metric
its owner already created. The route names MEASUREMENTS_WRITE_SCOPE on
the POST only; the value list, the metric list and every create, edit,
delete and restore leg still name no scope and refuse the token 403.

The token stays on its owner's record: requireAuth refuses any account
selector, and an id that is unknown, deleted or another account's answers
the same 404. The entry has no source column, so the audit row names the
token that wrote it.

The bearer-scope guard now freezes the scope's reach at three files, with
the custom-metric file naming it once and only on the POST. Integration
cases cover the admitted write, idempotent retry, the foreign and deleted
ids, five other narrow scopes, every other custom-metric leg, and a shared
record. The OpenAPI descriptions and the token card text in every locale
say what the token can do and where a metric's id is found.

Refs #1377
This commit is contained in:
Marc-André Bombeck committed 2026-11-14 08:47:42 +01:00
1 parent deb7db2ffa
commit 2167e251c1
17 files changed
+422 -41

No files matched your search

@@ -430,6 +430,49 @@ describe("T4 — declared scopes are exported constants, never string literals",
});
});
describe("T4a — `measurements:write` opens exactly three doors", () => {
it("the two measurement writes and the custom-metric value write, POST only", () => {
// The set of files naming the scope IS its reach. The single and batch
// measurement writes, and logging a value against a custom metric the
// person already made. Not the measurement reads, not the custom-metric
// list or detail, and not creating, editing or deleting a metric: a
// fourth file here is a widening and belongs in review.
const declaring = sourceFiles().filter((rel) => {
const src = read(rel);
return (
requireAuthScopes(src).includes("MEASUREMENTS_WRITE_SCOPE") ||
recordAuthScopes(src).includes("MEASUREMENTS_WRITE_SCOPE")
);
});
expect(declaring).toEqual([
"app/api/custom-metrics/[id]/entries/route.ts",
"app/api/measurements/batch/route.ts",
"app/api/measurements/route.ts",
]);
// Within the custom-metric file, once, and on the POST: the GET beside it
// lists the metric's values and must keep refusing the token.
const entries = readSource(
"src/app/api/custom-metrics/[id]/entries/route.ts",
"MEASUREMENTS_WRITE_SCOPE",
);
expect(
requireAuthScopes(entries).filter(
(x) => x === "MEASUREMENTS_WRITE_SCOPE",
),
).toHaveLength(1);
expect(recordAuthScopes(entries)).toEqual([]);
const postBody = entries.slice(
entries.indexOf("async function postCustomMetricEntry"),
);
expect(
requireAuthScopes(postBody).filter(
(x) => x === "MEASUREMENTS_WRITE_SCOPE",
),
).toHaveLength(1);
});
});
describe("T4b — `documents:write` opens exactly two doors", () => {
it("only the vault upload and the source-key lookup declare the scope", () => {
// The scope admits a narrow token wherever it is named, so the set of
@@ -1,6 +1,11 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiHandler,
isScopedCredential,
requireAuth,
requireRecordAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -16,6 +21,7 @@ import { encryptNote } from "@/lib/crypto/note-cipher";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import {
createCustomMetricEntrySchema,
listCustomMetricEntriesSchema,
@@ -28,8 +34,9 @@ import {
* GET lists the metric's values with offset pagination (the chart + history
* feed). POST records a value, snapshotting the metric's current `unit` onto
* the entry at write time (historical truth). `userId` is always narrowed from
* the session and the parent metric is verified owner-scoped (a forged / foreign
* id is a 404) before any value is read or written.
* the session (or, on POST, the measurement-ingest token) and the parent metric
* is verified owner-scoped (a forged / foreign id is a 404) before any value is
* read or written.
*/
type RouteParams = { params: Promise<{ id: string }> };
@@ -105,7 +112,27 @@ async function postCustomMetricEntry(
// product did not offer. Whoever builds a delegate-reachable way to log a
// tracked value adds it back in the same diff: the argument for admitting it
// was never wrong, and the GET arm above stays delegable meanwhile.
const { user } = await requireAuth();
//
// It does accept the measurement-ingest token (#1377): a custom metric's
// value is a reading the person defined themselves, and the bridge that
// pushes their weight is the one that would push it. What that admits is
// this POST and nothing beside it. The GET above names no scope, nor do the
// metric's own create / edit / delete routes, so the token can add a value
// to a metric that already exists and cannot list the metrics, read one
// back, or make a new one. The id it writes to comes from the person, who
// reads it off the metric's page; a token that has only ever been pasted
// into a scale's uploader never learns one.
//
// `requireAuth` rather than the record resolver, so the selector refusal is
// the undeclared-mode one every credential meets here: the token is on its
// owner's record or nowhere. The ownership query below then turns any id
// that is not that owner's live metric into the same 404 an unknown id gets.
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE);
const { user } = auth;
// A custom-metric entry has no source column, so the row cannot say a
// bridge wrote it the way an `EXTERNAL` measurement does. The audit row
// can, and it names the token, so revoking the right one is possible later.
const ingestTokenId = isScopedCredential(auth) ? auth.session.id : null;
const { id } = await params;
@@ -156,12 +183,20 @@ async function postCustomMetricEntry(
await auditLog("customMetricEntry.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { customMetricId: id, entryId: created.id },
details: {
customMetricId: id,
entryId: created.id,
...(ingestTokenId ? { ingestTokenId } : {}),
},
});
annotate({
action: { name: "custom-metric.entry.create" },
meta: { customMetricId: id, entryId: created.id },
meta: {
customMetricId: id,
entryId: created.id,
via_ingest_scope: ingestTokenId !== null,
},
});
invalidateUserCorrelationPatterns(user.id);
+2 -2
View File
@@ -2011,8 +2011,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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. Rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it 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. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own.\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" +
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record, and `POST /api/custom-metrics/{id}/entries` for a custom metric its owner already created (it cannot list, create, edit or delete metrics, so the id comes from the metric's page in the app). Measurement rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it 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. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own.\n\n" +
"**What it cannot do.** Everything else, including the measurement reads on the same paths, the custom-metric list and value reads, 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 with a fresh proof: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. 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, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.",
requestBody: {
required: true,
+2 -1
View File
@@ -294,7 +294,8 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Custom metrics"],
summary: "Log a value",
description:
"Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.",
"Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.\n\n" +
"**Narrow token.** Also reachable with a `measurements:write` Bearer (minted at `POST /api/tokens/measurements`), the supported way to push a custom metric's value from a script or a home-automation rule. Such a credential writes only to its owner's own live metrics: an id that is unknown, deleted, or another account's answers the same 404, and a request carrying the account selector is refused 403 before any sharing grant is read. This POST is all the scope reaches on custom metrics. The value list on this same path, the metric list, and creating, editing or deleting a metric or a value name no scope and refuse it 403, so the metric id has to come from its owner: it is the path segment after `/custom-metrics/` in the address of the metric's page in the app. The entry carries no source of its own; the audit row names the token that wrote it. Every other narrow token, `workouts:write` included, is refused 403.",
requestParams: { path: z.object({ id: z.string() }) },
parameters: [idempotencyKeyParameter],
requestBody: {