mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Add a self-service measurements:write API token scope
Since v1.30.17 there has been no self-service path to a token that can write a measurement. That release made the Bearer scope default fail-closed, which closed a real privilege-escalation hole, and removed the generic mint at POST /api/tokens along with it. The generic mint had been the only way to get a token that could reach /api/measurements, so a local sensor pushed through Home Assistant or Node-RED lost its door while the OAuth integrations kept theirs. Add a narrow scope accepted by exactly two routes, both writes, both on the holder's own record. The scope constant is a leaf module beside the surface that consumes it, matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an options object rather than a sibling function, because the sharing surface guard keys every leg on that one identifier and a second name would mean two copies of the delegable declaration. AuthContext carries the admitting scope as a single nullable string rather than the token's permission array, so nothing downstream can re-derive an authorisation decision from it. A scoped credential is refused a record selector before any grant is read. Placing the refusal above the sharing check rather than beside it means no future widening of what a grant permits can reach one of these tokens. Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH is refused 422 rather than relabelled: that source is half the (userId, type, source, externalId) dedup key the iOS client writes into, it participates in the cross-source merge, and it gates the HealthKit sync fields. For the same reason a scoped write no longer advances User.lastSyncedAt, which the phone reads to size its undelivered window. Also closes a narrower gap found while building this. The idempotency wrapper resolved a replayed delegated response against a live grant without considering which credential was presenting it, so a cached response about a shared record could be returned to a token the handler itself would refuse. No write was reachable that way; the response body was. Guards: T3 gains the mint site, T4's vocabulary gains the scope and learns to read the requireRecordAuth form (without which this change would open a way to widen a route that the closed vocabulary could not see), and a new T5 freezes the set of delegable routes naming a scope. The sharing surface guard's AST matcher reads only the first two arguments, so it needed prose but no code.
This commit is contained in:
1 parent
4308114c5b
commit
cba7a0fce5
26 files changed
+1704
-34
No files matched your search
@@ -145,6 +145,10 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
// medication grant that `/api/ingest/medication` actually gates on.
|
||||
"app/api/medications/[id]/api-endpoint/route.ts":
|
||||
'["medication:ingest", scope]',
|
||||
// Third-party measurement ingest. One shape, built from a literal — the
|
||||
// body cannot express a scope. Reaches two write routes on the holder's
|
||||
// own record and nothing else, the mint included.
|
||||
"app/api/tokens/measurements/route.ts": "[MEASUREMENTS_WRITE_SCOPE]",
|
||||
// MCP, audience-bound to `/mcp`. `health:write` requires explicit consent.
|
||||
"app/api/mcp/tokens/route.ts": "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
|
||||
"app/api/mcp/oauth/token/route.ts":
|
||||
@@ -170,6 +174,7 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
// password / passkey / refresh exchange. Every other mint is narrow.
|
||||
const userFacing = [
|
||||
"app/api/medications/[id]/api-endpoint/route.ts",
|
||||
"app/api/tokens/measurements/route.ts",
|
||||
"app/api/mcp/tokens/route.ts",
|
||||
"app/api/mcp/oauth/token/route.ts",
|
||||
];
|
||||
@@ -182,6 +187,11 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
// `POST /api/tokens` minted `["medication:ingest"]` — a token that could
|
||||
// not perform its advertised job (it lacked the per-medication grant) and
|
||||
// could reach every other authenticated route. List and revoke stay.
|
||||
//
|
||||
// The sibling `app/api/tokens/measurements/route.ts` does export a POST and
|
||||
// does not contradict this: what was retired is a mint at THIS path that
|
||||
// named no scope and answered for the whole API. A child path minting one
|
||||
// named scope, listed in `MINT_SITES` above, is the shape that replaced it.
|
||||
const tokensRoute = read("app/api/tokens/route.ts");
|
||||
expect(tokensRoute).not.toMatch(/export const POST/);
|
||||
expect(tokensRoute).toMatch(/export const GET/);
|
||||
@@ -210,15 +220,80 @@ describe("T4 — declared scopes are exported constants, never string literals",
|
||||
it("the declared scope vocabulary is closed", () => {
|
||||
// Scopes a route may name. Adding one means adding a route that accepts a
|
||||
// narrow token, which is a widening — it belongs in a reviewed diff.
|
||||
const DECLARED_SCOPES = ["FHIR_READ_SCOPE"];
|
||||
//
|
||||
// Both ways of naming one are collected, and that is the point of the
|
||||
// second matcher rather than a tidiness: since `requireRecordAuth` grew a
|
||||
// `scope` option, a route can widen itself without the word `requireAuth`
|
||||
// appearing anywhere in it. A vocabulary that watched only the first form
|
||||
// would have gone on passing while the surface it describes grew.
|
||||
const DECLARED_SCOPES = ["FHIR_READ_SCOPE", "MEASUREMENTS_WRITE_SCOPE"];
|
||||
const used = new Set<string>();
|
||||
for (const rel of sourceFiles()) {
|
||||
for (const m of read(rel).matchAll(
|
||||
const src = read(rel);
|
||||
for (const m of src.matchAll(
|
||||
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
|
||||
)) {
|
||||
used.add(m[1]);
|
||||
}
|
||||
for (const m of src.matchAll(
|
||||
/requireRecordAuth\([^)]*\bscope:\s*([A-Za-z_$][\w$]*)/g,
|
||||
)) {
|
||||
used.add(m[1]);
|
||||
}
|
||||
}
|
||||
expect([...used].sort()).toEqual(DECLARED_SCOPES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
|
||||
/**
|
||||
* Routes that admit a narrow token on a DELEGABLE surface, and the scope each
|
||||
* one names.
|
||||
*
|
||||
* Two declarations meet on these routes and neither implies the other: the
|
||||
* route may act on somebody else's record, AND it accepts a credential minted
|
||||
* for one job. `requireRecordAuth` refuses that combination at run time — a
|
||||
* scoped credential naming another record is turned away before any grant is
|
||||
* read — and this table is what keeps the SET of routes making both
|
||||
* declarations from growing quietly.
|
||||
*/
|
||||
const SCOPED_RECORD_ROUTES: Record<string, string> = {
|
||||
// Third-party measurement ingest: a scale, a watch bridge, a
|
||||
// home-automation rule. The sibling read, edit and delete legs on this
|
||||
// module name no scope and so still refuse every narrow token.
|
||||
"app/api/measurements/route.ts": "MEASUREMENTS_WRITE_SCOPE",
|
||||
};
|
||||
|
||||
it("only the known routes pass a scope to the record resolver", () => {
|
||||
const naming = filesMatching(
|
||||
/requireRecordAuth\([^)]*\bscope:\s*[A-Za-z_$][\w$]*/,
|
||||
);
|
||||
// Non-zero proof: an empty match set must fail rather than agree with an
|
||||
// emptied table. This is the leg most likely to rot — the matcher spans a
|
||||
// multi-line call, so a formatting change is exactly what would silently
|
||||
// empty it.
|
||||
expect(naming.length).toBeGreaterThan(0);
|
||||
expect(naming).toEqual(Object.keys(SCOPED_RECORD_ROUTES).sort());
|
||||
});
|
||||
|
||||
it("each names the scope this table says it does", () => {
|
||||
for (const [rel, scope] of Object.entries(SCOPED_RECORD_ROUTES)) {
|
||||
const named = [
|
||||
...read(rel).matchAll(
|
||||
/requireRecordAuth\([^)]*\bscope:\s*([A-Za-z_$][\w$]*)/g,
|
||||
),
|
||||
].map((m) => m[1]);
|
||||
expect(named).toEqual([scope]);
|
||||
}
|
||||
});
|
||||
|
||||
it("the record resolver still refuses a scoped credential a selector", () => {
|
||||
// The whole admission rests on one `if` inside `requireRecordAuth`. Delete
|
||||
// it and every other leg in this file stays green while a single-purpose
|
||||
// token gains delegated write — so the refusal is asserted where it lives,
|
||||
// not only through the behaviour that depends on it.
|
||||
const src = read("lib/api-handler.ts");
|
||||
expect(src).toMatch(/narrow_scope_selector/);
|
||||
expect(src).toMatch(/isScopedCredential\(auth\)/);
|
||||
});
|
||||
});
|
||||
@@ -817,7 +817,7 @@ interface ManageEntry extends DelegableEntry {
|
||||
const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
"app/api/measurements/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id`; the POST arms are delegable writes (see the write literal) and the PUT / DELETE beside them are not.",
|
||||
why: "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id`; the POST arms are delegable writes (see the write literal) and the PUT / DELETE beside them are not. The POST additionally names a Bearer scope, so a narrow third-party ingest token reaches it — the two declarations do not compound: a scoped credential naming any record but its owner's is refused before a grant is read, so this route is delegable for a session and single-record for that token.",
|
||||
},
|
||||
"app/api/measurements/[id]/route.ts": {
|
||||
domain: "measurements",
|
||||
@@ -1412,7 +1412,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
*/
|
||||
const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
|
||||
"app/api/measurements/route.ts":
|
||||
"Entering a reading. Both POST arms — single and batch — land rows under the resolved record; the safety-floor check, the reminder satisfaction and the rollup recompute all address the same record and are correct under substitution.",
|
||||
"Entering a reading. Both POST arms — single and batch — land rows under the resolved record; the safety-floor check, the reminder satisfaction and the rollup recompute all address the same record and are correct under substitution. The narrow ingest scope this route also names never reaches the substitution: it is refused a selector outright, so every row it writes is its own holder's.",
|
||||
"app/api/labs/route.ts":
|
||||
"Entering a lab result. The free-text path may mint a biomarker, and mints it into the RECORD's catalogue — a result added to somebody's record that left the marker on the helper's account would be worse than useless to the owner.",
|
||||
"app/api/biomarkers/route.ts":
|
||||
|
||||
@@ -295,7 +295,14 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/ai/openai-provider-form.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/response-timeout-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/runtime-actions-row.tsx": { "text-success": 2 },
|
||||
"src/components/settings/api-section.tsx": { "text-success": 3 },
|
||||
// The two added by the measurement-ingest mint card are the one-shot
|
||||
// token panel and its copy tick, both rendered only once a token actually
|
||||
// exists; the toast fires only after the clipboard write resolves. Same
|
||||
// shape as the MCP connector card two files over.
|
||||
"src/components/settings/api-section.tsx": {
|
||||
"text-success": 5,
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/settings/coach-memory-section.tsx": { "toast.success": 2 },
|
||||
"src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 },
|
||||
"src/components/settings/dashboard-layout-section.tsx": {
|
||||
|
||||
@@ -38,7 +38,8 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -218,7 +219,18 @@ interface EntryResult {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
|
||||
async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// Declares the third-party ingest scope, so a narrow `measurements:write`
|
||||
// token reaches this route alongside cookie sessions and the wildcard tokens
|
||||
// a native login mints. `requireAuth` refuses any acting-account carrier for
|
||||
// every credential shape, so the scoped token is confined to its owner's own
|
||||
// record here without this route adding an arm of its own.
|
||||
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE);
|
||||
const { user } = auth;
|
||||
|
||||
// A scoped credential is a bridge — a scale, a home-automation rule — and not
|
||||
// the native client. Two things below turn on that distinction: what `source`
|
||||
// its rows may carry, and whether it may move the phone's sync checkpoint.
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// v1.4.25 W10 reconcile (security H-2): per-user rate limit. Without
|
||||
// it, a leaked wildcard iOS token can sustain unbounded writes
|
||||
@@ -265,6 +277,27 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
|
||||
}
|
||||
|
||||
// A scoped credential may attribute MANUAL and nothing else. Refused loudly
|
||||
// rather than rewritten quietly: silently relabelling a client's explicit
|
||||
// assertion hands it rows it did not ask for and no way to notice, while a
|
||||
// 422 is fixable on the first call.
|
||||
//
|
||||
// `APPLE_HEALTH` is the label that matters, and forging it is not merely
|
||||
// mislabelling. It is half the `(userId, type, source, externalId)` dedup key,
|
||||
// so a bridge's rows would land in the phone's namespace; it is a mergeable
|
||||
// source, so a forged row joins the cross-source merge and can suppress a
|
||||
// genuine one; and it is what `healthKitSyncSucceeded` reads to decide the
|
||||
// Apple Health card may claim the phone synced.
|
||||
if (scoped && parsed.data.entries.some((e) => e.source === "APPLE_HEALTH")) {
|
||||
annotate({
|
||||
action: { name: "measurement.batch.ingest" },
|
||||
meta: { outcome: "source_not_permitted" },
|
||||
});
|
||||
return apiError("This credential may only attribute MANUAL readings", 422, {
|
||||
errorCode: "measurement.batch.source_not_permitted",
|
||||
});
|
||||
}
|
||||
|
||||
const { entries, syncTrigger } = parsed.data;
|
||||
|
||||
// First pass — map each inbound entry; record skipped entries.
|
||||
@@ -406,7 +439,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
unit: mapped.unit,
|
||||
// v1.8.6 W6 — honour the per-entry source tag, defaulting to
|
||||
// `APPLE_HEALTH` when absent so legacy callers are unchanged.
|
||||
source: entry.source ?? "APPLE_HEALTH",
|
||||
//
|
||||
// A scoped credential defaults to `MANUAL` instead, and can hold no
|
||||
// other value: the guard above already refused an explicit
|
||||
// `APPLE_HEALTH`, so this is the only remaining arm for it. The default
|
||||
// has to move with the credential rather than with the route, because
|
||||
// the route serves both the phone and a third-party bridge.
|
||||
source: scoped ? "MANUAL" : (entry.source ?? "APPLE_HEALTH"),
|
||||
measuredAt: mapped.takenAt,
|
||||
externalId: entry.externalId,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
@@ -671,7 +710,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// trigger writes null over the previous value rather than leaving a stale one
|
||||
// standing: the field describes THIS sync, and an older build's silence is
|
||||
// not evidence about it.
|
||||
if (failedCount === 0) {
|
||||
//
|
||||
// None of that is true of a scoped credential, which is why it is excluded
|
||||
// from the whole block rather than only from the HealthKit half. The
|
||||
// checkpoint's meaning is "the client synced what it was holding", and a
|
||||
// third-party bridge is not the client and holds nothing on its behalf.
|
||||
// `lastSyncedAt` is what `GET /api/sync/state` reports and what the phone
|
||||
// uses to size its undelivered window, so letting a scale advance it can make
|
||||
// the phone skip a window of its own samples — a silent data loss whose cause
|
||||
// would be nowhere near the code that caused it.
|
||||
if (failedCount === 0 && !scoped) {
|
||||
const syncedAt = new Date();
|
||||
const deliveredWithoutTheApp =
|
||||
syncTrigger === "background" || syncTrigger === "push";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
isScopedCredential,
|
||||
requireRecordAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -744,6 +749,23 @@ async function sleepListResponse(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this body attribute any reading to `APPLE_HEALTH`?
|
||||
*
|
||||
* Read off the raw body rather than the parsed result because the answer has to
|
||||
* be the same for the object arm and the array arm, and those parse against
|
||||
* different schemas further down. Anything that is not a recognisable
|
||||
* measurement shape answers false and falls through to the validation error it
|
||||
* has coming — this function decides attribution, not validity.
|
||||
*/
|
||||
function namesAppleHealthSource(body: unknown): boolean {
|
||||
const names = (entry: unknown): boolean =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
(entry as { source?: unknown }).source === "APPLE_HEALTH";
|
||||
return Array.isArray(body) ? body.some(names) : names(body);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
|
||||
|
||||
async function postMeasurement(request: NextRequest) {
|
||||
@@ -752,7 +774,17 @@ async function postMeasurement(request: NextRequest) {
|
||||
// who may or may not be that person. Nothing else in this handler reads the
|
||||
// caller: the safety-floor check, the reminder satisfaction, the rollup
|
||||
// recompute and the cache eviction are all statements about the record.
|
||||
const { user } = await requireRecordAuth("write", "measurements");
|
||||
//
|
||||
// v1.38.x — it also declares the third-party ingest scope, so a narrow
|
||||
// `measurements:write` token reaches this arm. The two declarations do not
|
||||
// interact: a scoped credential is refused the moment the request names
|
||||
// another record, before any grant is read, so "delegable" and "reachable by
|
||||
// a bridge token" stay separate properties of the same route.
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
const { user } = auth;
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
@@ -760,6 +792,18 @@ async function postMeasurement(request: NextRequest) {
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// A scoped credential may attribute MANUAL and nothing else — the schema's
|
||||
// own default, so only an explicit `APPLE_HEALTH` needs refusing. Checked
|
||||
// before validation branches so the object and array arms share one answer,
|
||||
// and refused rather than rewritten for the reason the batch route gives:
|
||||
// relabelling a client's explicit assertion hands it rows it cannot see.
|
||||
if (scoped && namesAppleHealthSource(body)) {
|
||||
annotate({ action: { name: "measurements.create.source-not-permitted" } });
|
||||
return apiError("This credential may only attribute MANUAL readings", 422, {
|
||||
errorCode: "measurement.create.source_not_permitted",
|
||||
});
|
||||
}
|
||||
|
||||
// Batch mode (array of measurements, e.g. combined BP + Pulse)
|
||||
if (Array.isArray(body)) {
|
||||
const parsed = createBatchMeasurementSchema.safeParse({
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The measurement-ingest mint.
|
||||
*
|
||||
* The assertion that matters most is not the 201 — it is the exact
|
||||
* `permissions` array handed to `issueApiToken`. That helper defaults to
|
||||
* `["*"]` when the property is absent, so an edit that drops one line turns a
|
||||
* user-facing endpoint into a cookie-equivalent credential factory, and a test
|
||||
* that only checked the status code would go on passing. The structural guard
|
||||
* catches a literal `permissions: ["*"` and would not catch the omission, so
|
||||
* this is where that hole is closed.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/issue-token", () => ({
|
||||
issueApiToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
const USER = { id: "user-1", role: "USER" as const };
|
||||
const EXPIRES = new Date("2027-08-31T00:00:00.000Z");
|
||||
|
||||
function req(body: unknown): Request {
|
||||
return new Request("https://health.example/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(issueApiToken).mockResolvedValue({
|
||||
token: "hlk_" + "a".repeat(64),
|
||||
expiresAt: EXPIRES,
|
||||
tokenId: "token-1",
|
||||
name: "Home Assistant",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("what it mints", () => {
|
||||
it("names exactly the ingest scope, and no wildcard", async () => {
|
||||
const res = await POST(req({ name: "Home Assistant" }) as never);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
|
||||
// Exact equality, not `toContain`: an extra scope is a widening, and this
|
||||
// is the assertion standing between the endpoint and one.
|
||||
expect(opts.permissions).toEqual([MEASUREMENTS_WRITE_SCOPE]);
|
||||
expect(opts.userId).toBe(USER.id);
|
||||
});
|
||||
|
||||
it("returns the raw token once, with its name and expiry", async () => {
|
||||
const res = await POST(req({ name: "Home Assistant" }) as never);
|
||||
const json = await res.json();
|
||||
expect(json.data.token).toMatch(/^hlk_[0-9a-f]{64}$/);
|
||||
expect(json.data.name).toBe("Home Assistant");
|
||||
expect(json.data.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("expires by default rather than living forever", async () => {
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
|
||||
expect(opts.expiresInDays).toBe(365);
|
||||
});
|
||||
|
||||
it("honours an explicit lifetime", async () => {
|
||||
await POST(req({ name: "Scale", expiresInDays: 30 }) as never);
|
||||
expect(vi.mocked(issueApiToken).mock.calls[0][0].expiresInDays).toBe(30);
|
||||
});
|
||||
|
||||
it("audits the mint with the token id and the scope", async () => {
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"tokens.measurements.create",
|
||||
expect.objectContaining({
|
||||
userId: USER.id,
|
||||
details: expect.objectContaining({
|
||||
tokenId: "token-1",
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when it refuses", () => {
|
||||
it("403s while the operator has the API switched off", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
expect(res.status).toBe(403);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("429s past the mint rate limit, without minting", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
expect(res.status).toBe(429);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a missing name", async () => {
|
||||
const res = await POST(req({}) as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a lifetime outside the permitted band", async () => {
|
||||
const res = await POST(
|
||||
req({ name: "Scale", expiresInDays: 4000 }) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a scope the body tries to smuggle in", async () => {
|
||||
// There is no `scope` field to validate, so an unknown key is simply not
|
||||
// read — the permission array is a literal either way. Asserted because
|
||||
// "the field does not exist" is the security property, and a schema change
|
||||
// that added one would silently make this body meaningful.
|
||||
await POST(
|
||||
req({ name: "Scale", scope: "read_write", permissions: ["*"] }) as never,
|
||||
);
|
||||
expect(vi.mocked(issueApiToken).mock.calls[0][0].permissions).toEqual([
|
||||
MEASUREMENTS_WRITE_SCOPE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* `POST /api/tokens/measurements` — mint a Bearer for third-party measurement
|
||||
* ingest.
|
||||
*
|
||||
* The self-service mint that v1.30.17 left missing. That release made the
|
||||
* Bearer-scope default fail-closed, which was right — a medication-intake token
|
||||
* had been reaching every authenticated route, the full backup export included
|
||||
* — and removed the generic mint at the parent path, which was also right,
|
||||
* since the token it issued could not do its advertised job. What went with them
|
||||
* was any way at all to push readings in programmatically: the OAuth
|
||||
* integrations cover Withings and WHOOP, and cover nothing for a local sensor
|
||||
* behind a home-automation bridge.
|
||||
*
|
||||
* What this mints is narrow by construction. `permissions` is a literal, so no
|
||||
* request shape reaches it; the scope it names is accepted by exactly two
|
||||
* routes, both writes, both on the holder's own record. It cannot read a
|
||||
* reading back, cannot edit or delete one, cannot reach the export, and cannot
|
||||
* mint another token — the `requireAuth()` below names no scope, so a
|
||||
* measurements token presenting itself here is refused like any other narrow
|
||||
* credential. That last property is why the mint is worth its own file: the
|
||||
* blast radius of a credential pasted into a container the user does not
|
||||
* operate should not include making more credentials.
|
||||
*
|
||||
* No listing leg. `/api/tokens` already returns every token with its
|
||||
* `permissions`, so the settings card filters that one query rather than
|
||||
* duplicating it — the MCP module needs its own list only because it must hide
|
||||
* transient OAuth access rows, and there is no such row here. No revoke-all
|
||||
* either: `DELETE /api/tokens/[id]` is per-row and the card shows the rows. The
|
||||
* per-medication toggle's bulk revoke exists because that surface is a boolean
|
||||
* with no list behind it, which is not this surface.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
|
||||
|
||||
/**
|
||||
* Days a token lives when the caller names no lifetime.
|
||||
*
|
||||
* Longer than the MCP mint's 90, and the difference is the deployment rather
|
||||
* than the risk appetite. A connector token is used from a session somebody is
|
||||
* sitting in front of; this one is pasted into a rule that runs unattended for
|
||||
* months, where a quiet expiry surfaces as "my weight stopped syncing sometime
|
||||
* in the spring". A year is long enough to not be that, short enough to still
|
||||
* be a rotation.
|
||||
*/
|
||||
const DEFAULT_EXPIRY_DAYS = 365;
|
||||
|
||||
/** Mints per user per minute. A credential mint is worth its own bucket. */
|
||||
const MINT_RATE_LIMIT_MAX = 10;
|
||||
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// No scope argument, and that is load-bearing rather than incidental: it
|
||||
// keeps the fail-closed default, so the only credentials that reach the mint
|
||||
// are a cookie session and a cookie-equivalent token. It also refuses any
|
||||
// acting-account carrier, so a delegate cannot mint a credential against the
|
||||
// record they are helping with.
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "tokens.measurements.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`tokens:measurements:mint:${user.id}`,
|
||||
MINT_RATE_LIMIT_MAX,
|
||||
MINT_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many token mints, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createMeasurementTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: parsed.data.name,
|
||||
// A literal, never spread and never derived from the body. `issueApiToken`
|
||||
// defaults to `["*"]` when this property is absent, so an edit that drops
|
||||
// it mints a cookie-equivalent credential from a user-facing endpoint —
|
||||
// which is why the unit suite asserts this array and not merely the 201.
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS,
|
||||
});
|
||||
|
||||
await auditLog("tokens.measurements.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { tokenId: issued.tokenId, scope: MEASUREMENTS_WRITE_SCOPE },
|
||||
});
|
||||
|
||||
// The raw token, once. It is stored as an HMAC and no path re-reveals it.
|
||||
return apiSuccess(
|
||||
{ token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Key, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Key,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -19,6 +27,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -45,20 +54,159 @@ export function ApiSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ApiEndpointsCard />
|
||||
<MeasurementTokensCard />
|
||||
<ApiTokensCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token for third-party measurement ingest.
|
||||
*
|
||||
* No list of its own. `ApiTokensCard` below already reads every token the
|
||||
* account holds and shows its scopes, so a second query here would be the same
|
||||
* rows under a different heading — and a revoke button in two places is two
|
||||
* places to keep agreeing about what revoking means. This card mints; the one
|
||||
* below is where credentials are reviewed and killed.
|
||||
*/
|
||||
function MeasurementTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
setTokenMsg(null);
|
||||
setNewToken(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// No scope field: the endpoint mints one shape, so there is nothing
|
||||
// here for a caller to choose or for this form to get wrong.
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setNewToken(json.data.token);
|
||||
setNewName("");
|
||||
// The sibling card lists this token, so it has to hear about it.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
|
||||
} else {
|
||||
// The server's own message when it sent one — it names the actual
|
||||
// cause (switch off, rate limit, validation) — and a message about
|
||||
// this specific action when it did not.
|
||||
setTokenMsg(json.error || t("settings.measurementsToken.createFailed"));
|
||||
}
|
||||
} catch {
|
||||
setTokenMsg(t("common.networkError"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyToken() {
|
||||
if (!newToken) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(newToken);
|
||||
setTokenCopied(true);
|
||||
toast.success(t("settings.tokenCopied"));
|
||||
setTimeout(() => setTokenCopied(false), 2_000);
|
||||
} catch {
|
||||
toast.error(t("settings.tokenCopyFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Upload}
|
||||
title={t("settings.measurementsToken.title")}
|
||||
description={t("settings.measurementsToken.description")}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.measurementsToken.detail")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("settings.measurementsToken.scopeNote")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("settings.measurementsToken.endpointsNote")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("settings.tokenNamePlaceholder")}
|
||||
maxLength={100}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="h-11 sm:h-10"
|
||||
disabled={creating || !newName.trim()}
|
||||
>
|
||||
{creating && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{newToken && (
|
||||
<div
|
||||
className="bg-success/10 rounded-lg p-3 text-sm"
|
||||
data-slot="settings-measurements-token-created"
|
||||
>
|
||||
<p className="text-success mb-1 font-medium">
|
||||
{t("settings.tokenCreated")}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{newToken}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={() => void handleCopyToken()}
|
||||
aria-label={t("settings.tokenCopy")}
|
||||
>
|
||||
{tokenCopied ? (
|
||||
<Check className="text-success h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenMsg && <p className="text-destructive text-sm">{tokenMsg}</p>}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiEndpointsCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.27 MB5 — dual-render: <md gets a stacked card list, md+
|
||||
// keeps the original table. Mobile cards wrap inside their box and
|
||||
// never trigger horizontal scroll, mirroring the pattern from
|
||||
// `<ApiTokenOverviewSection>`. The endpoint catalogue currently
|
||||
// ships a single row but stays array-driven so adding new rows
|
||||
// remains a one-line edit on both surfaces.
|
||||
// `<ApiTokenOverviewSection>`. The endpoint catalogue is array-driven
|
||||
// so adding a row remains a one-line edit on both surfaces — which is
|
||||
// what the two measurement ingest rows below cost.
|
||||
const endpoints = [
|
||||
{
|
||||
method: "POST",
|
||||
@@ -66,6 +214,18 @@ function ApiEndpointsCard() {
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/measurements",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/measurements/batch",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* `requireRecordAuth`'s scope option, and the refusal that comes with it.
|
||||
*
|
||||
* A route can now declare a narrow Bearer scope on a DELEGABLE surface, which
|
||||
* puts two admissions on one call: the route may act on somebody else's record,
|
||||
* and it accepts a credential minted for one job. The interesting cases are all
|
||||
* about the pair, not either half — a scoped credential must reach its own
|
||||
* record and be refused any other, while a session and a wildcard token keep
|
||||
* the delegation behaviour they already had.
|
||||
*
|
||||
* The load-bearing assertion is not the 403. It is that the grant lookup never
|
||||
* runs: the refusal has to sit above the sharing check rather than beside it,
|
||||
* or a future change to what a grant permits would start deciding what a
|
||||
* single-purpose token can reach.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// --- Mocks must be hoisted before importing the module under test. ---
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/sharing/grants", () => ({
|
||||
findActiveGrant: vi.fn(),
|
||||
grantAllows: vi.fn(() => true),
|
||||
grantCoversDomain: vi.fn(() => true),
|
||||
touchGrantUsage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sharing/record-session-fence", () => ({
|
||||
assertRecordSessionFence: vi.fn().mockResolvedValue(undefined),
|
||||
attachRecordContextEcho: vi.fn((res: unknown) => res),
|
||||
}));
|
||||
|
||||
const headersGet = vi.fn<(name: string) => string | null>();
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: headersGet })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// --- Imports use the mocked modules above. ---
|
||||
|
||||
import { requireRecordAuth, isScopedCredential } from "../api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { ACCOUNT_SELECTOR_HEADER } from "@/lib/auth/acting-carrier";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
const FAKE_HASH = "deadbeefcafef00d";
|
||||
const RAW_TOKEN = "hlk_" + "a".repeat(64);
|
||||
const OWNER_ID = "user-owner";
|
||||
const HOLDER_ID = "user-holder";
|
||||
|
||||
const HOLDER = {
|
||||
id: HOLDER_ID,
|
||||
role: "USER" as const,
|
||||
username: "holder",
|
||||
email: "holder@example.test",
|
||||
};
|
||||
|
||||
/** Present a Bearer with these scopes, and optionally a record selector. */
|
||||
function armBearer(permissions: string[], selector?: string): void {
|
||||
headersGet.mockReset();
|
||||
headersGet.mockImplementation((name: string) => {
|
||||
const key = name.toLowerCase();
|
||||
if (key === "authorization") return `Bearer ${RAW_TOKEN}`;
|
||||
if (key === ACCOUNT_SELECTOR_HEADER) return selector ?? null;
|
||||
return null;
|
||||
});
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "token-1",
|
||||
userId: HOLDER_ID,
|
||||
permissions,
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(HOLDER as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
|
||||
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("a scoped credential on its own record", () => {
|
||||
it("is admitted, and looks exactly like the un-switched request it is", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE]);
|
||||
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
|
||||
expect(auth.user.id).toBe(HOLDER_ID);
|
||||
expect(auth.actor.id).toBe(HOLDER_ID);
|
||||
expect(auth.grantId).toBeNull();
|
||||
expect(findActiveGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports itself as scoped, and names the scope that admitted it", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE]);
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
expect(auth.bearerScope).toBe(MEASUREMENTS_WRITE_SCOPE);
|
||||
expect(isScopedCredential(auth)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a scoped credential naming another record", () => {
|
||||
it("is refused, and the grant is never consulted", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE], OWNER_ID);
|
||||
|
||||
await expect(
|
||||
requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// The point of the whole arm: refused ABOVE the sharing check, so no
|
||||
// future reading of what a grant permits can reach a scoped credential.
|
||||
expect(findActiveGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is refused even when a live grant would have allowed it", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE], OWNER_ID);
|
||||
vi.mocked(findActiveGrant).mockResolvedValue({
|
||||
id: "grant-1",
|
||||
grantorId: OWNER_ID,
|
||||
granteeId: HOLDER_ID,
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("writes a durable refusal row naming the reason", async () => {
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE], OWNER_ID);
|
||||
|
||||
await expect(
|
||||
requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"sharing.access.denied",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ reason: "narrow_scope_selector" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the credentials this arm must not touch", () => {
|
||||
it("a wildcard token with a selector still reaches the grant lookup", async () => {
|
||||
// The control. Without it, a refusal that swallowed every delegated
|
||||
// Bearer would pass the tests above for entirely the wrong reason.
|
||||
armBearer(["*"], OWNER_ID);
|
||||
vi.mocked(findActiveGrant).mockResolvedValue(null as never);
|
||||
|
||||
await expect(
|
||||
requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(findActiveGrant).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a wildcard token reads as unscoped however the route was declared", async () => {
|
||||
armBearer(["*"]);
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
expect(auth.bearerScope).toBeNull();
|
||||
expect(isScopedCredential(auth)).toBe(false);
|
||||
});
|
||||
|
||||
it("a cookie session reads as unscoped", async () => {
|
||||
headersGet.mockReset();
|
||||
headersGet.mockReturnValue(null);
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 60_000) },
|
||||
user: HOLDER,
|
||||
} as never);
|
||||
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
expect(isScopedCredential(auth)).toBe(false);
|
||||
expect(auth.grantId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a route that declares no scope", () => {
|
||||
it("still refuses a narrow token outright", async () => {
|
||||
// The read legs on the measurements module are exactly this shape, and it
|
||||
// is what makes the new scope write-only rather than merely write-named.
|
||||
armBearer([MEASUREMENTS_WRITE_SCOPE]);
|
||||
|
||||
await expect(requireRecordAuth("read", "measurements")).rejects.toThrow();
|
||||
expect(findActiveGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+91
-6
@@ -390,8 +390,36 @@ export type AuthContext = {
|
||||
* maps it server-side, the same posture as `userId` being narrowed from auth.
|
||||
*/
|
||||
readonly authMethod: "cookie" | "bearer";
|
||||
/**
|
||||
* The named scope that admitted this Bearer, or null when the credential is
|
||||
* cookie-equivalent — a session, or a `["*"]` token. A non-null value marks a
|
||||
* NARROW, single-purpose credential: one minted for one job, by its own
|
||||
* holder, from their own settings page.
|
||||
*
|
||||
* A single nullable string rather than the token's permission array, and that
|
||||
* is the point: nothing downstream can re-derive an authorisation decision
|
||||
* from it. The decision was made once, in `resolveBearerToken`; this only
|
||||
* reports which arm ran, so a route can refuse a narrow credential something
|
||||
* a cookie session may still do.
|
||||
*
|
||||
* Optional for the reason `actingAsUserId` and `recordEpoch` above are: the
|
||||
* test files that hand-build this object keep compiling, and an absent field
|
||||
* reads as null — "cookie-equivalent", which is what a hand-built context has
|
||||
* always meant.
|
||||
*/
|
||||
readonly bearerScope?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Was this request authenticated by a narrow, single-purpose Bearer scope?
|
||||
*
|
||||
* The one reader of `bearerScope`, exported so no handler re-derives the test
|
||||
* and they cannot drift on what "narrow" means.
|
||||
*/
|
||||
export function isScopedCredential(auth: AuthContext): boolean {
|
||||
return (auth.bearerScope ?? null) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure.
|
||||
*
|
||||
@@ -585,6 +613,17 @@ async function authenticateBearer(
|
||||
session: { id: tokenId, expiresAt },
|
||||
user,
|
||||
authMethod: "bearer",
|
||||
// Non-null exactly when a NAMED scope admitted a narrow token. A `["*"]`
|
||||
// token is cookie-equivalent and reads null however the route was declared;
|
||||
// a narrow token on a route that named no scope never reaches this line,
|
||||
// because `wildcard-only` already refused it in `resolveBearerToken`.
|
||||
//
|
||||
// Derived here rather than in the resolver so `bearer.ts` keeps its single
|
||||
// authorisation arm: this is a report of the decision that already ran, not
|
||||
// a second one.
|
||||
bearerScope: permissions.includes("*")
|
||||
? null
|
||||
: (requiredPermission ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -676,10 +715,12 @@ function auditRefusal(
|
||||
/**
|
||||
* This request says it is acting on another account and this route will not.
|
||||
*
|
||||
* Two ways to arrive: a route that never declared a mode (`undeclared_mode`),
|
||||
* and a selector sent over the transport that does not carry one
|
||||
* (`misplaced_selector`). One response either way — the caller learns that the
|
||||
* request will not be served, and nothing about grants.
|
||||
* Three ways to arrive: a route that never declared a mode (`undeclared_mode`),
|
||||
* a selector sent over the transport that does not carry one
|
||||
* (`misplaced_selector`), and a narrow single-purpose Bearer naming a record
|
||||
* that is not its owner's (`narrow_scope_selector`). One response every way —
|
||||
* the caller learns that the request will not be served, and nothing about
|
||||
* grants.
|
||||
*
|
||||
* The audit rule, stated once because it is a judgement call: a HEADER refusal
|
||||
* always writes a row, a SESSION refusal never does. The header is a per-request
|
||||
@@ -692,7 +733,10 @@ function auditRefusal(
|
||||
function refuseUndeclaredMode(
|
||||
auth: AuthContext,
|
||||
carrier: ActingCarrier,
|
||||
reason: "undeclared_mode" | "misplaced_selector" = "undeclared_mode",
|
||||
reason:
|
||||
| "undeclared_mode"
|
||||
| "misplaced_selector"
|
||||
| "narrow_scope_selector" = "undeclared_mode",
|
||||
): SharingNotPermittedError {
|
||||
annotate({ meta: { sharing_refusal: reason } });
|
||||
if (carrier.kind !== "session") {
|
||||
@@ -744,6 +788,23 @@ export interface RecordAuthContext extends AuthContext {
|
||||
readonly grantId: string | null;
|
||||
}
|
||||
|
||||
/** What a delegable route may additionally declare about the credentials it takes. */
|
||||
export interface RecordAuthOptions {
|
||||
/**
|
||||
* A Bearer scope this route accepts in place of the cookie-equivalent
|
||||
* default. Naming one is a WIDENING — it admits a credential class the route
|
||||
* refused before — and is frozen by `bearer-scope-enforcement-guard.test.ts`
|
||||
* exactly like a `requireAuth` scope, so it lands in a reviewed diff.
|
||||
*
|
||||
* What the option does NOT do is relax anything below it. A credential
|
||||
* admitted this way is single-purpose: it reaches this route on its OWN
|
||||
* record and is refused the moment the request names another, before any
|
||||
* grant is read. The `need` and `domain` checks run unchanged for everyone
|
||||
* else.
|
||||
*/
|
||||
readonly scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.36.0 — a delegable surface: this route may act on a shared record.
|
||||
*
|
||||
@@ -768,12 +829,18 @@ export interface RecordAuthContext extends AuthContext {
|
||||
* routes that are delegable. The value is frozen per module by
|
||||
* `src/__tests__/sharing-surface-guard.test.ts` and reviewed against the
|
||||
* design's clustering table; this function only enforces what was declared.
|
||||
* @param options v1.38.x — optional extra declarations. Today that is `scope`,
|
||||
* naming a narrow Bearer this route admits alongside cookie sessions and
|
||||
* cookie-equivalent tokens. A route that passes none keeps the fail-closed
|
||||
* default and refuses every narrow token, which is what makes adding one a
|
||||
* visible decision rather than an omission.
|
||||
*/
|
||||
export async function requireRecordAuth(
|
||||
need: GrantNeed,
|
||||
domain: ShareScope,
|
||||
options?: RecordAuthOptions,
|
||||
): Promise<RecordAuthContext> {
|
||||
const auth = await authenticateCaller();
|
||||
const auth = await authenticateCaller(options?.scope);
|
||||
// v1.37.0 — before any carrier is read, any grant is looked up, or any record
|
||||
// row is touched: does this request still believe what its session believes?
|
||||
//
|
||||
@@ -791,6 +858,24 @@ export async function requireRecordAuth(
|
||||
await assertRecordSessionFence(auth);
|
||||
const carrier = await readActingCarrier(auth);
|
||||
|
||||
// A narrow, single-purpose Bearer never acts on another record.
|
||||
//
|
||||
// Its holder mints it themselves, for one job, and pastes it into something
|
||||
// that is not this app — a scale bridge, a home-automation rule. Admitting it
|
||||
// under a selector would exercise somebody else's grant through a credential
|
||||
// nobody reviewed for that purpose, and the blast radius of the paste would
|
||||
// stop being "my own record" without anyone deciding it should.
|
||||
//
|
||||
// Placed above the `none` arm and below the carrier read, which is the only
|
||||
// position that is both correct and cheap: no grant is looked up on the way
|
||||
// to the refusal, and a scoped credential on its own record still falls
|
||||
// through to the untouched `none` path below. A `["*"]` token and a cookie
|
||||
// session read `bearerScope: null` here and keep exactly the delegation
|
||||
// behaviour they had.
|
||||
if (carrier.kind !== "none" && isScopedCredential(auth)) {
|
||||
throw refuseUndeclaredMode(auth, carrier, "narrow_scope_selector");
|
||||
}
|
||||
|
||||
if (carrier.kind === "none") {
|
||||
// Do no harm: without a carrier this is byte-for-byte the pre-v1.36.0
|
||||
// request, resolved by the same `authenticateCaller` every other mode uses.
|
||||
|
||||
+47
-1
@@ -426,6 +426,45 @@ async function canReplayDelegatedResponse(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this request presenting a narrow, single-purpose Bearer token?
|
||||
*
|
||||
* Asked only on the delegated-replay path, and it exists because a live grant
|
||||
* is no longer the whole question there. A narrow scope is refused delegation
|
||||
* outright by `requireRecordAuth` — before any grant is read — so a credential
|
||||
* that would be refused by the handler must not be handed a cached delegated
|
||||
* body instead. The actor may genuinely hold the grant and the row may be
|
||||
* genuinely theirs; the point is that THIS credential is not one the grant was
|
||||
* ever exercised through.
|
||||
*
|
||||
* Authorises nothing, exactly like the resolver above: a true answer only
|
||||
* declines to serve the cache, and the handler then issues its own refusal with
|
||||
* the audit trail that belongs to it. One indexed single-row read, on a path
|
||||
* that is already doing a grant lookup.
|
||||
*/
|
||||
async function presentsNarrowScopedToken(): Promise<boolean> {
|
||||
let authHeader: string | null = null;
|
||||
try {
|
||||
const headerList = await headers();
|
||||
authHeader = headerList.get("authorization");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!authHeader?.startsWith("Bearer ")) return false;
|
||||
|
||||
const apiToken = await prisma.apiToken
|
||||
.findUnique({
|
||||
where: { tokenHash: hashToken(authHeader.slice(7)) },
|
||||
select: { permissions: true },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
// Unreadable reads as narrow: the cache is an accelerator, and falling
|
||||
// through to the handler is always a safe answer.
|
||||
if (!apiToken) return true;
|
||||
return !apiToken.permissions.includes("*");
|
||||
}
|
||||
|
||||
export function withIdempotency<
|
||||
Args extends [Request | NextRequest, ...unknown[]],
|
||||
>(
|
||||
@@ -506,7 +545,14 @@ export function withIdempotency<
|
||||
if (cached?.kind === "replay") {
|
||||
if (
|
||||
claimedRecord !== null &&
|
||||
!(await canReplayDelegatedResponse(userId, claimedRecord))
|
||||
(!(await canReplayDelegatedResponse(userId, claimedRecord)) ||
|
||||
// A narrow scope is refused delegation by the handler before any
|
||||
// grant is read, so a live grant is not sufficient here. Without this
|
||||
// arm, a cell filled earlier by the same person under a cookie
|
||||
// session could be replayed by a single-purpose token naming the
|
||||
// owner's record — no row written, but the owner's response body
|
||||
// returned to a credential the handler would have turned away.
|
||||
(await presentsNarrowScopedToken()))
|
||||
) {
|
||||
// Do not replace the route's refusal with a cache-specific response:
|
||||
// it owns the stable 403 envelope and the associated audit trail.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The Bearer scope for third-party measurement ingest.
|
||||
*
|
||||
* A leaf module with no imports, for the reason `FHIR_READ_SCOPE` sits in
|
||||
* `@/lib/fhir/rest` and `SCOPE_HEALTH_*` in `@/lib/mcp/scopes`: a scope belongs
|
||||
* beside the surface that consumes it, and a central scope registry would
|
||||
* invite a route to pick one off a menu — the opposite of the closed vocabulary
|
||||
* `bearer-scope-enforcement-guard.test.ts` freezes. Zero imports because the
|
||||
* mint route, both write routes, the settings card (a client component) and the
|
||||
* guards all read it, and none of them should inherit an import graph for a
|
||||
* string.
|
||||
*
|
||||
* Deliberately alone. There is no read counterpart and must not be one: the
|
||||
* measurement read legs stay cookie-equivalent, so a credential minted for a
|
||||
* scale or a home-automation rule cannot read a health history back out. An
|
||||
* unused `MEASUREMENTS_READ_SCOPE` sitting here is one somebody wires up later
|
||||
* without the review that admitting it deserves.
|
||||
*/
|
||||
|
||||
/** The scope a narrow token must carry to write measurements. */
|
||||
export const MEASUREMENTS_WRITE_SCOPE = "measurements:write";
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
mfaWebauthnLoginVerifySchema,
|
||||
} from "@/lib/validations/mfa";
|
||||
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
|
||||
import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff";
|
||||
import {
|
||||
stepUpMintSchema,
|
||||
@@ -449,6 +450,28 @@ const apiTokenListResponse = z
|
||||
.array(apiTokenInfo)
|
||||
.meta({ id: "ApiTokenListResponse" });
|
||||
|
||||
const createMeasurementTokenRequest = createMeasurementTokenSchema.meta({
|
||||
id: "CreateMeasurementTokenRequest",
|
||||
description:
|
||||
"Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365.",
|
||||
});
|
||||
|
||||
const createMeasurementTokenResponse = z
|
||||
.object({
|
||||
token: z
|
||||
.string()
|
||||
.describe(
|
||||
"The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret in logs, crash reports and analytics.",
|
||||
),
|
||||
name: z.string(),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "CreateMeasurementTokenResponse",
|
||||
description:
|
||||
"The one response that carries a usable measurement-ingest Bearer value.",
|
||||
});
|
||||
|
||||
// ── Registration, credential rotation, Codex device-auth ─────────────
|
||||
|
||||
const registerRequest = registerSchema.meta({
|
||||
@@ -1571,7 +1594,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "List the caller's API tokens",
|
||||
description:
|
||||
"Every `ApiToken` row belonging to the caller, newest first, revoked ones included — presence in this list is not validity, `revoked` is. The hash is never returned and there is no path that re-reveals a token's plaintext.\n\n" +
|
||||
"Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no mint on this path any more — the generic POST that issued `[\"medication:ingest\"]` was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else; the working credential comes from the per-medication API-endpoint toggle. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.",
|
||||
"Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no GENERIC mint any more — the POST at this exact path issued `[\"medication:ingest\"]` and was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The credentials that work are minted where they are scoped: the per-medication API-endpoint toggle, `POST /api/mcp/tokens` for a connector, and the sibling `POST /api/tokens/measurements` for third-party measurement ingest. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's tokens, newest first.",
|
||||
@@ -1588,6 +1611,65 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tokens/measurements": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Mint a measurement-ingest token",
|
||||
description:
|
||||
"Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it.\n\n" +
|
||||
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record, attributing `source: MANUAL`. An entry naming `APPLE_HEALTH` is refused 422 rather than relabelled: that source is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint.\n\n" +
|
||||
"**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" +
|
||||
"Minting needs a cookie session or a cookie-equivalent token. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: createMeasurementTokenRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description:
|
||||
"Token minted. The `token` field is the only copy of the secret.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
createMeasurementTokenResponse,
|
||||
"CreateMeasurementTokenEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Body is not parseable JSON.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator has switched the API off instance-wide (`AppSettings.apiGlobal`), or the caller presented a narrow-scope token rather than a session — a scoped credential cannot mint credentials. Nothing was minted either way.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 16 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"415": {
|
||||
description: "Content-Type is not `application/json`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed. The envelope carries every offending issue, not just the first.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 10 mints from this account in a minute. Nothing was minted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tokens/{id}": {
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -842,7 +842,8 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Create one measurement (or a small array)",
|
||||
description:
|
||||
"Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.",
|
||||
"Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.\n\n" +
|
||||
"Also reachable with a narrow `measurements:write` Bearer, which is the supported way to push readings from a scale, a watch bridge or a home-automation rule. That credential is confined to its owner's own record — a request carrying the account selector is refused 403 before any sharing grant is read, however the record is actually shared — and to `MANUAL` attribution, so an explicit `APPLE_HEALTH` source is refused 422 (`measurement.create.source_not_permitted`). The GET on this same path, and the PUT and DELETE beside it, name no scope and so refuse it.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -875,6 +876,11 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named `APPLE_HEALTH` as the source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -974,7 +980,8 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Apple Health batch ingest",
|
||||
description:
|
||||
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.",
|
||||
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.\n\n" +
|
||||
"Also reachable with a narrow `measurements:write` Bearer, for a bridge that is not the phone. Such a credential writes only on its owner's own record; its entries are attributed `MANUAL` whatever they omit, an entry naming `APPLE_HEALTH` is refused 422 (`measurement.batch.source_not_permitted`), and the call does not advance the native sync checkpoint — `lastSyncedAt` means the CLIENT delivered what it was holding, and a third-party push is not that.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: batchPayloadSchema } },
|
||||
@@ -989,6 +996,11 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation, or (`measurement.batch.source_not_permitted`) carried an entry naming `APPLE_HEALTH` under a narrow `measurements:write` credential. Nothing was written in any of the three cases.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Body of `POST /api/tokens/measurements` — mint a Bearer for third-party
|
||||
* measurement ingest.
|
||||
*
|
||||
* There is deliberately no `scope` field. The MCP mint next door offers a
|
||||
* closed two-value choice because its two shapes mean different things; this
|
||||
* endpoint mints one shape and only one, so the request cannot express a scope
|
||||
* at all. That is the strongest form of the no-mass-assignment rule: not a
|
||||
* field validated against an allowlist, but a field that does not exist.
|
||||
*
|
||||
* `expiresInDays` is bounded rather than optional-and-unbounded because the
|
||||
* credential is pasted into something that runs unattended. The route's default
|
||||
* is deliberately long for the same reason — an automation that stops working
|
||||
* silently is worse than one that stops working loudly — but a caller who wants
|
||||
* a short-lived token can always ask for one.
|
||||
*/
|
||||
export const createMeasurementTokenSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
export type CreateMeasurementTokenInput = z.infer<
|
||||
typeof createMeasurementTokenSchema
|
||||
>;
|
||||
Reference in new issue
Block a user