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

+164 -4
View File
@@ -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 (