mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(tokens): mint a workouts:write token for headless workout ingest
A bridge that pushes workouts from a watch vendor had no credential that reached POST /api/workouts/batch: the route called requireAuth() with no scope, so under the fail-closed default only a cookie session or a wildcard token got through, and a measurements:write token was refused 403. The route's header still described the pre-fail-closed behaviour. Add a separate workouts:write scope rather than widening measurements:write, so a token already handed to a scale does not start accepting workouts. - POST /api/tokens/workouts mints it, mirroring the measurement and document mints: cookie session plus a fresh proof, a literal permission array, 10 mints a minute, at most 10 live tokens (409 tokens.workouts.ceiling_reached), a one-year default lifetime, audited. - POST /api/workouts/batch names the scope. Sessions and wildcard tokens behave as before. A scoped caller's workouts are stored as EXTERNAL, resolved from the credential, and a batch in which any entry names a source is refused 422 (workout.batch.source_not_permitted), the same rule the measurement batch applies. EXTERNAL is on no source ladder, so when the phone recorded the same session the phone's copy is shown. - Settings > API offers a workout token card next to the measurement one, and the endpoint list shows the batch route. - Guards: the mint site, the declared scope vocabulary, the recent-proof routes, and a new check that only the batch declares the scope. - OpenAPI documents the mint, the batch's narrow-token behaviour and both error codes; docs/api/README.md lists the workout and document mints. Refs #1054
This commit is contained in:
1 parent
ae6a5ac693
commit
e7166cb3c6
25 files changed
+1340
-32
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
@@ -62,6 +63,7 @@ export function ApiSection() {
|
||||
<div className="space-y-6">
|
||||
<ApiEndpointsCard />
|
||||
<MeasurementTokensCard />
|
||||
<WorkoutTokensCard />
|
||||
<DocumentTokensCard />
|
||||
<ApiTokensCard />
|
||||
</div>
|
||||
@@ -95,6 +97,29 @@ function MeasurementTokensCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token a workout bridge pushes through (#1054): a watch vendor's sync
|
||||
* relayed by a small service, or a script replaying an export. Same card,
|
||||
* same no-list reasoning as the measurement one above.
|
||||
*/
|
||||
function WorkoutTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<IngestTokenCard
|
||||
endpoint="/api/tokens/workouts"
|
||||
icon={Activity}
|
||||
slot="settings-workouts-token-created"
|
||||
copy={{
|
||||
title: t("settings.workoutsToken.title"),
|
||||
description: t("settings.workoutsToken.description"),
|
||||
detail: t("settings.workoutsToken.detail"),
|
||||
scopeNote: t("settings.workoutsToken.scopeNote"),
|
||||
createFailed: t("settings.workoutsToken.createFailed"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token another document system uploads through (#1038): a Paperless
|
||||
* workflow or the import script. Same card, same no-list reasoning as the
|
||||
@@ -125,7 +150,10 @@ function IngestTokenCard({
|
||||
slot,
|
||||
copy,
|
||||
}: {
|
||||
endpoint: "/api/tokens/measurements" | "/api/tokens/documents";
|
||||
endpoint:
|
||||
| "/api/tokens/measurements"
|
||||
| "/api/tokens/workouts"
|
||||
| "/api/tokens/documents";
|
||||
icon: LucideIcon;
|
||||
slot: string;
|
||||
copy: {
|
||||
@@ -296,6 +324,12 @@ function ApiEndpointsCard() {
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/workouts/batch",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/documents/inbound",
|
||||
|
||||
@@ -71,6 +71,8 @@ describe.each([
|
||||
["components/settings/share-link-create-form.tsx", "/api/share-links"],
|
||||
["components/settings/mcp-section.tsx", "/api/mcp/tokens"],
|
||||
["components/settings/api-section.tsx", "/api/tokens/measurements"],
|
||||
["components/settings/api-section.tsx", "/api/tokens/documents"],
|
||||
["components/settings/api-section.tsx", "/api/tokens/workouts"],
|
||||
["components/admin/backups-section.tsx", "/download"],
|
||||
["components/admin/backups-section.tsx", "/restore"],
|
||||
["components/admin/backups-section.tsx", "/api/admin/backups/upload"],
|
||||
|
||||
Reference in new issue
Block a user