diff --git a/docs/api/README.md b/docs/api/README.md index 6349c4ff0..1d4ddd005 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -66,7 +66,13 @@ before treating a warning as a regression. `health:write`), audience-bound to `/mcp` and refused on REST writes; - `POST /api/tokens/measurements` mints `measurements:write`, accepted by `POST /api/measurements` and `POST /api/measurements/batch` only, on the - holder's own record only. + holder's own record only; + - `POST /api/tokens/workouts` mints `workouts:write`, accepted by + `POST /api/workouts/batch` only, on the holder's own record only; the + workouts it writes carry `source: EXTERNAL`; + - `POST /api/tokens/documents` mints `documents:write`, accepted by + `POST /api/documents/inbound` and `GET /api/documents/inbound/source` + only. `POST /api/tokens` is **not** among them — that generic mint was removed in v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ed7a4c391..5c90a07fe 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2134,10 +2134,10 @@ paths: description: >- Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST - /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/documents, and the admin backup download, - upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` - with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five - minutes ago. + /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the + admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 + `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or + re-proved more than five minutes ago. The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, @@ -2977,10 +2977,10 @@ paths: 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, the sibling `POST /api/tokens/measurements` for third-party measurement - ingest, and `POST /api/tokens/documents` for document upload from another system. 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. + ingest, `POST /api/tokens/workouts` for workout ingest from a bridge, and `POST /api/tokens/documents` for + document upload from another system. 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. @@ -3088,6 +3088,93 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/tokens/workouts: + post: + tags: + - Auth + summary: Mint a workout-ingest token + description: >- + Mints a Bearer scoped to exactly `workouts:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once; + it is stored as an HMAC and no path re-reveals it. + + + **What it can do.** `POST /api/workouts/batch`, on its owner's own record, and nothing else. It is the door a + workout bridge pushes through: a watch vendor's sync relayed by a small service, or a script replaying an + exported history. Workouts it writes carry `source: EXTERNAL`, resolved from the credential rather than taken + from the body, and a batch in which any entry names a source is refused 422 + (`workout.batch.source_not_permitted`) rather than relabelled. `EXTERNAL` is on no default source ladder, so + when the phone also recorded the same session the phone's copy is the one the workout list shows. + + + **What it cannot do.** List or read a workout, reach the measurement or document ingest routes, act on a shared + record, or mint another token. It is a separate scope from `measurements:write` on purpose: a token already + handed to a scale does not start accepting workouts, and a `measurements:write` token is refused 403 on the + workout batch. Every other route names no scope and refuses it 403. + + + Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a + day-lived native access token must not be able to leave behind a year-lived credential. 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 workout + tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateWorkoutTokenRequest" + responses: + "201": + description: Token minted. The `token` field is the only copy of the secret. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateWorkoutTokenEnvelope" + "400": + description: Body is not parseable JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "403": + description: The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 + before the switch is read. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The account already holds 10 live workout tokens (`meta.errorCode` = `tokens.workouts.ceiling_reached`). + Nothing was minted. Revoke one at `DELETE /api/tokens/{id}`; only live tokens carrying `workouts:write` + count. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: Body exceeds 16 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: Content-Type is not `application/json`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: Validation failed. The envelope carries every offending issue. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than 10 mints from this account in a minute. Nothing was minted. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/tokens/documents: post: tags: @@ -4579,16 +4666,26 @@ paths: tags: - Measurements summary: Typed workout batch ingest (v1.4.25 W16b) - description: 'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up - to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a - 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). + description: >- + Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 + workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 + `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with - `workout.batch.payload_too_large`.' + `workout.batch.payload_too_large`. + + + **Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the + supported way to push workouts from a bridge that is not the phone. This route is the scope's only one. Such a + credential writes only on its owner's own record; its workouts are attributed `EXTERNAL`, resolved from the + credential, and it may not name a source itself, so a batch in which any entry carries one, `MANUAL` and + `APPLE_HEALTH` alike, is refused 422 (`workout.batch.source_not_permitted`) and nothing is written. `EXTERNAL` + gives those writes their own `(userId, source, externalId)` namespace, so a bridge's ids can never collide with + the phone's. Every other narrow token, `measurements:write` included, is refused 403. parameters: - *a6 requestBody: @@ -4618,7 +4715,14 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a4 + "422": + description: A narrow `workouts:write` credential sent a batch in which an entry names a source, any source + (`workout.batch.source_not_permitted`). Nothing was written; resending the batch unchanged is refused the + same way. Omit `source` and the server attributes the rows `EXTERNAL`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/devices: post: @@ -22919,6 +23023,21 @@ components: 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." + CreateWorkoutTokenRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + expiresInDays: + type: integer + minimum: 1 + maximum: 365 + required: + - name + description: "Mint a workout-ingest Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is + listed under; name it after the bridge it is pasted into. `expiresInDays` defaults to 365." CreateDocumentTokenRequest: type: object properties: @@ -31636,10 +31755,11 @@ components: sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens — tokens.documents.ceiling_reached, - tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid, - vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, - vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, - vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, + vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, + vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — + workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, @@ -39650,6 +39770,42 @@ components: - expiresAt additionalProperties: false description: The one response that carries a usable measurement-ingest Bearer value. + CreateWorkoutTokenEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CreateWorkoutTokenResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CreateWorkoutTokenResponse: + type: object + properties: + token: + type: string + description: The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash and cannot be + retrieved later. Treat it as a secret. + name: + type: string + expiresAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - token + - name + - expiresAt + additionalProperties: false + description: The one response that carries a usable workout-ingest Bearer value. CreateDocumentTokenEnvelope: type: object properties: @@ -40443,7 +40599,8 @@ components: - EXTERNAL description: Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a - home-automation rule. No client may name that value on a write; the server resolves it from the credential. + home-automation rule — and workouts pushed in under a narrow `workouts:write` Bearer carry it too. No client may + name that value on a write; the server resolves it from the credential. SyncMeasurementTombstone: type: object properties: diff --git a/messages/de.json b/messages/de.json index 096e51bd7..5f53f27af 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6172,6 +6172,13 @@ "scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen — deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst, und lassen sich jederzeit korrigieren.", "createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut." }, + "workoutsToken": { + "title": "Training-Tokens", + "description": "Erzeuge ein Token, damit eine Uhren-Brücke oder ein Sync-Skript deine Trainings eintragen kann.", + "detail": "Das Token wird nur einmal angezeigt.", + "scopeNote": "Es kann ausschließlich neue Trainings in deine eigene Akte eintragen. Es kann deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Trainings, die darüber kommen, werden als von einem externen Gerät gekennzeichnet. Hat dein Telefon dieselbe Einheit aufgezeichnet, siehst du die Aufzeichnung des Telefons.", + "createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut." + }, "documentsToken": { "title": "Dokument-Tokens", "description": "Erzeuge ein Token, damit Paperless-ngx, Papra oder das Import-Skript Dokumente in deine Ablage legen können.", diff --git a/messages/en.json b/messages/en.json index 3261cdbe7..9d82e619f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6172,6 +6172,13 @@ "scopeNote": "It can only add new readings to your own record — it cannot read your data, change or delete anything, or create another token. Readings are marked as coming from an external device, so you can tell them apart later, and you can still correct them yourself.", "createFailed": "The token could not be created. Please try again." }, + "workoutsToken": { + "title": "Workout tokens", + "description": "Mint a token so a watch bridge or a sync script can add your workouts.", + "detail": "The token is shown once.", + "scopeNote": "It can only add new workouts to your own record. It cannot read your data, change or delete anything, or create another token. Workouts it adds are marked as coming from an external device, and when your phone recorded the same session, you see the phone's copy.", + "createFailed": "The token could not be created. Please try again." + }, "documentsToken": { "title": "Document tokens", "description": "Mint a token so Paperless-ngx, Papra or the import script can add documents to your vault.", diff --git a/messages/es.json b/messages/es.json index 856524ed4..9d547f52a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6172,6 +6172,13 @@ "scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial: no puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Las mediciones se marcan como procedentes de un dispositivo externo, así las distingues más tarde, y puedes corregirlas igualmente.", "createFailed": "No se pudo crear el token. Inténtalo de nuevo." }, + "workoutsToken": { + "title": "Tokens de entrenamientos", + "description": "Crea un token para que un puente de tu reloj o un script de sincronización añadan tus entrenamientos.", + "detail": "El token se muestra una sola vez.", + "scopeNote": "Solo puede añadir entrenamientos nuevos a tu propio historial. No puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Los entrenamientos que añade se marcan como procedentes de un dispositivo externo, y si tu teléfono registró la misma sesión, ves la copia del teléfono.", + "createFailed": "No se pudo crear el token. Inténtalo de nuevo." + }, "documentsToken": { "title": "Tokens de documentos", "description": "Crea un token para que Paperless-ngx, Papra o el script de importación añadan documentos a tu archivo.", diff --git a/messages/fr.json b/messages/fr.json index d6209604a..155342572 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6172,6 +6172,13 @@ "scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier : il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures sont signalées comme provenant d'un appareil externe, ce qui vous permet de les reconnaître plus tard, et vous pouvez toujours les corriger.", "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer." }, + "workoutsToken": { + "title": "Jetons d'entraînements", + "description": "Créez un jeton pour qu'une passerelle de montre ou un script de synchronisation ajoutent vos entraînements.", + "detail": "Le jeton n'est affiché qu'une fois.", + "scopeNote": "Il peut uniquement ajouter de nouveaux entraînements à votre propre dossier. Il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les entraînements qu'il ajoute sont signalés comme provenant d'un appareil externe, et si votre téléphone a enregistré la même séance, c'est la copie du téléphone que vous voyez.", + "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer." + }, "documentsToken": { "title": "Jetons de documents", "description": "Créez un jeton pour que Paperless-ngx, Papra ou le script d'import ajoutent des documents à votre coffre.", diff --git a/messages/it.json b/messages/it.json index 151e22053..2c5658e2e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6172,6 +6172,13 @@ "scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella: non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Le misurazioni vengono contrassegnate come provenienti da un dispositivo esterno, così le riconosci in seguito, e puoi comunque correggerle.", "createFailed": "Non è stato possibile creare il token. Riprova." }, + "workoutsToken": { + "title": "Token per allenamenti", + "description": "Crea un token perché un ponte dell'orologio o uno script di sincronizzazione aggiungano i tuoi allenamenti.", + "detail": "Il token viene mostrato una sola volta.", + "scopeNote": "Può soltanto aggiungere nuovi allenamenti alla tua cartella. Non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Gli allenamenti che aggiunge vengono contrassegnati come provenienti da un dispositivo esterno e, se il telefono ha registrato la stessa sessione, vedi la copia del telefono.", + "createFailed": "Non è stato possibile creare il token. Riprova." + }, "documentsToken": { "title": "Token per documenti", "description": "Crea un token perché Paperless-ngx, Papra o lo script di importazione aggiungano documenti al tuo archivio.", diff --git a/messages/ko.json b/messages/ko.json index dd81bcdb4..b7b35b0bb 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6172,6 +6172,13 @@ "scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.", "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요." }, + "workoutsToken": { + "title": "운동 토큰", + "description": "시계 연동 브리지나 동기화 스크립트가 운동 기록을 추가할 수 있도록 토큰을 만들어요.", + "detail": "토큰은 한 번만 표시돼요.", + "scopeNote": "본인 기록에 새 운동을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 이 토큰으로 추가된 운동은 외부 기기에서 온 것으로 표시되고, 휴대폰이 같은 운동을 기록했다면 휴대폰의 기록이 보여요.", + "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요." + }, "documentsToken": { "title": "문서 토큰", "description": "Paperless-ngx, Papra 또는 가져오기 스크립트가 문서함에 문서를 추가할 수 있도록 토큰을 만들어요.", diff --git a/messages/pl.json b/messages/pl.json index 33cec9808..4a01568a7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6172,6 +6172,13 @@ "scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji — nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary są oznaczane jako pochodzące z urządzenia zewnętrznego, dzięki czemu później je rozpoznasz, i nadal możesz je poprawić.", "createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie." }, + "workoutsToken": { + "title": "Tokeny treningów", + "description": "Utwórz token, aby mostek zegarka lub skrypt synchronizacji mogły dodawać Twoje treningi.", + "detail": "Token pokazujemy tylko raz.", + "scopeNote": "Może wyłącznie dodawać nowe treningi do Twojej dokumentacji. Nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Dodane przez niego treningi są oznaczane jako pochodzące z urządzenia zewnętrznego, a jeśli telefon zapisał tę samą sesję, widzisz zapis z telefonu.", + "createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie." + }, "documentsToken": { "title": "Tokeny dokumentów", "description": "Utwórz token, aby Paperless-ngx, Papra lub skrypt importu mogły dodawać dokumenty do Twojego archiwum.", diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index 301a57130..5b2562c98 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -152,6 +152,9 @@ describe("T3 — the mint sites are frozen", () => { // Document upload from another system (#1038). Same shape as the // measurement mint: a literal, one scope, one route that accepts it. "app/api/tokens/documents/route.ts": "[DOCUMENTS_WRITE_SCOPE]", + // Workout ingest from a bridge (#1054). Same shape again: a literal, one + // scope, one route that accepts it. + "app/api/tokens/workouts/route.ts": "[WORKOUTS_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": @@ -179,6 +182,7 @@ describe("T3 — the mint sites are frozen", () => { "app/api/medications/[id]/api-endpoint/route.ts", "app/api/tokens/measurements/route.ts", "app/api/tokens/documents/route.ts", + "app/api/tokens/workouts/route.ts", "app/api/mcp/tokens/route.ts", "app/api/mcp/oauth/token/route.ts", ]; @@ -234,6 +238,7 @@ describe("T4 — declared scopes are exported constants, never string literals", "DOCUMENTS_WRITE_SCOPE", "FHIR_READ_SCOPE", "MEASUREMENTS_WRITE_SCOPE", + "WORKOUTS_WRITE_SCOPE", ]; const used = new Set(); for (const rel of sourceFiles()) { @@ -284,6 +289,27 @@ describe("T4b — `documents:write` opens exactly two doors", () => { }); }); +describe("T4c — `workouts:write` opens exactly one door", () => { + it("only the workout batch declares the scope, and only on its POST", () => { + // The set of files naming the scope IS its reach. The batch ingest and + // nothing else: not the workout list or detail, which would hand a + // bridge the history it has no business reading back. + const declaring = filesMatching( + /requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)|requireRecordAuth\([^)]*\bscope:\s*WORKOUTS_WRITE_SCOPE\b/, + ); + expect(declaring).toEqual(["app/api/workouts/batch/route.ts"]); + const batch = read("app/api/workouts/batch/route.ts"); + expect( + batch.match(/requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)/g), + ).toHaveLength(1); + expect( + [...batch.matchAll(/export const (GET|POST|PUT|PATCH|DELETE)\b/g)].map( + (m) => m[1], + ), + ).toEqual(["POST"]); + }); +}); + 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 diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index bb334ff98..4c818ed17 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -114,6 +114,7 @@ const COOKIE_PROOF_ROUTES = [ "app/api/auth/reproof/route.ts", "app/api/tokens/documents/route.ts", "app/api/tokens/measurements/route.ts", + "app/api/tokens/workouts/route.ts", ].sort(); describe("the recent-proof gate", () => { diff --git a/src/app/api/tokens/workouts/__tests__/route.test.ts b/src/app/api/tokens/workouts/__tests__/route.test.ts new file mode 100644 index 000000000..e8f722929 --- /dev/null +++ b/src/app/api/tokens/workouts/__tests__/route.test.ts @@ -0,0 +1,245 @@ +/** + * The workout-ingest mint (#1054). + * + * 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, + requireCookieAuth: vi.fn(), + assertRecentCookieProof: 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), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { apiToken: { count: vi.fn() } }, +})); + +import { POST } from "../route"; +import { assertRecentCookieProof, requireCookieAuth } 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 { prisma } from "@/lib/db"; +import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/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/workouts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireCookieAuth).mockResolvedValue({ + user: USER, + session: { id: "sess-1" }, + } as never); + vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(auditLog).mockResolvedValue(undefined as never); + // Below the ceiling by default; the cases that care set their own count. + vi.mocked(prisma.apiToken.count).mockResolvedValue(0 as never); + vi.mocked(issueApiToken).mockResolvedValue({ + token: "hlk_" + "a".repeat(64), + expiresAt: EXPIRES, + tokenId: "token-1", + name: "Watch bridge", + } as never); +}); + +describe("the fresh proof in front of the mint", () => { + it("mints nothing when the session carries no recent proof", async () => { + vi.mocked(assertRecentCookieProof).mockRejectedValue( + new Error("auth.reproof.required"), + ); + await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow( + "auth.reproof.required", + ); + expect(issueApiToken).not.toHaveBeenCalled(); + }); + + it("asks about the caller's own session", async () => { + await POST(req({ name: "Scale" }) as never); + expect(assertRecentCookieProof).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + "sess-1", + ); + }); +}); + +describe("what it mints", () => { + it("names exactly the workout scope, and no wildcard", async () => { + const res = await POST(req({ name: "Watch bridge" }) 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([WORKOUTS_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: "Watch bridge" }) as never); + const json = await res.json(); + expect(json.data.token).toMatch(/^hlk_[0-9a-f]{64}$/); + expect(json.data.name).toBe("Watch bridge"); + expect(json.data.expiresAt).toBeDefined(); + }); + + it("expires by default rather than living forever", async () => { + await POST(req({ name: "Export replay" }) 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: "Export replay", 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: "Export replay" }) as never); + expect(auditLog).toHaveBeenCalledWith( + "tokens.workouts.create", + expect.objectContaining({ + userId: USER.id, + details: expect.objectContaining({ + tokenId: "token-1", + scope: WORKOUTS_WRITE_SCOPE, + }), + }), + ); + }); + + it("counts only live tokens carrying this scope", async () => { + // The whole shape of the query, asserted rather than assumed. Counting + // every row would let the `["*"]` tokens a login mints consume the budget + // and refuse a mint for a reason the person cannot see; counting revoked + // or expired rows would mean revoking never frees a slot. + await POST(req({ name: "Export replay" }) as never); + + const where = vi.mocked(prisma.apiToken.count).mock.calls[0][0]?.where; + expect(where).toMatchObject({ + userId: USER.id, + revoked: false, + permissions: { has: WORKOUTS_WRITE_SCOPE }, + }); + expect(where?.OR).toEqual([ + { expiresAt: null }, + { expiresAt: { gt: expect.any(Date) } }, + ]); + }); +}); + +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: "Export replay" }) 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: "Export replay" }) 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: "Export replay", expiresInDays: 4000 }) as never, + ); + expect(res.status).toBe(422); + expect(issueApiToken).not.toHaveBeenCalled(); + }); + + it("409s at the live-token ceiling, without minting", async () => { + vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never); + const res = await POST(req({ name: "Export replay" }) as never); + + // 409, not 429: a conflict with the account's current state, whose remedy + // is revoking rather than waiting. A 429 would say the opposite. + expect(res.status).toBe(409); + expect((await res.json()).meta?.errorCode).toBe( + "tokens.workouts.ceiling_reached", + ); + expect(issueApiToken).not.toHaveBeenCalled(); + }); + + it("still mints on the last free slot", async () => { + // The off-by-one that would make the ceiling nine. Worth pinning, because + // `>=` versus `>` is invisible in review and only one of them is right. + vi.mocked(prisma.apiToken.count).mockResolvedValue(9 as never); + const res = await POST(req({ name: "Export replay" }) as never); + expect(res.status).toBe(201); + }); + + it("checks the ceiling only after the body validates", async () => { + // A malformed request should hear about the malformation, not the + // ceiling — and should not spend a query establishing it. + vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never); + const res = await POST(req({}) as never); + expect(res.status).toBe(422); + expect(prisma.apiToken.count).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: "Export replay", + scope: "read_write", + permissions: ["*"], + }) as never, + ); + expect(vi.mocked(issueApiToken).mock.calls[0][0].permissions).toEqual([ + WORKOUTS_WRITE_SCOPE, + ]); + }); +}); diff --git a/src/app/api/tokens/workouts/route.ts b/src/app/api/tokens/workouts/route.ts new file mode 100644 index 000000000..fca9acce0 --- /dev/null +++ b/src/app/api/tokens/workouts/route.ts @@ -0,0 +1,135 @@ +/** + * `POST /api/tokens/workouts` — mint a Bearer that can push workouts into the + * holder's own record and do nothing else (#1054). + * + * The door a workout bridge pushes through: a watch vendor's sync relayed by a + * small service, or a script replaying an exported history. It is the + * measurement-ingest mint (`../measurements/route.ts`) copied for a third + * scope, and every argument made there holds here — read that file for the + * reasoning behind each guard. In short: + * + * - `permissions` is a literal, so no request shape reaches it; + * - `workouts:write` is accepted by `POST /api/workouts/batch` alone, on the + * holder's own record; the rows it writes carry `EXTERNAL` provenance, and + * it cannot list or read a workout back, reach any other route, or mint + * another token; + * - minting takes a cookie session, because a short-lived native access token + * must not be able to leave behind a credential that lives a year; + * - ten mints a minute and ten live tokens bound a runaway loop. + * + * No listing and no revoke of its own: `GET /api/tokens` lists every token + * with its permissions and `DELETE /api/tokens/[id]` revokes one. + */ +import { NextRequest } from "next/server"; + +import { + apiHandler, + assertRecentCookieProof, + requireCookieAuth, +} 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 { prisma } from "@/lib/db"; +import { issueApiToken } from "@/lib/auth/issue-token"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes"; +import { createWorkoutTokenSchema } from "@/lib/validations/tokens"; + +/** + * Days a token lives when the caller names no lifetime. A year, like the + * measurement token: it is pasted into a bridge that runs unattended, where a + * quiet expiry surfaces as "my runs stopped showing up sometime in the spring". + */ +const DEFAULT_EXPIRY_DAYS = 365; + +/** Mints per user per minute. */ +const MINT_RATE_LIMIT_MAX = 10; +const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000; + +/** Live workout tokens an account may hold at once; catches a loop. */ +const MAX_LIVE_TOKENS = 10; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user, session } = await requireCookieAuth(); + // And a fresh proof on top of the session: the token lives far longer than + // a stolen session would, so minting one needs the person, not just the + // browser. + await assertRecentCookieProof(user, session.id); + annotate({ action: { name: "tokens.workouts.create" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } + + const rl = await checkRateLimit( + `tokens:workouts: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 = createWorkoutTokenSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + // Counted live and per scope, after validation and before the create; the + // count-then-create race is bounded by the mint bucket above. See the + // measurement mint for the full argument. + const live = await prisma.apiToken.count({ + where: { + userId: user.id, + revoked: false, + permissions: { has: WORKOUTS_WRITE_SCOPE }, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + }); + if (live >= MAX_LIVE_TOKENS) { + annotate({ + action: { name: "tokens.workouts.create" }, + meta: { outcome: "ceiling_reached", live_token_count: live }, + }); + return apiError( + `You already have ${MAX_LIVE_TOKENS} workout tokens. Revoke one before creating another.`, + 409, + { errorCode: "tokens.workouts.ceiling_reached" }, + ); + } + + 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, which is why the unit + // suite asserts this array and not merely the 201. + permissions: [WORKOUTS_WRITE_SCOPE], + expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS, + }); + + await auditLog("tokens.workouts.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { tokenId: issued.tokenId, scope: WORKOUTS_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, + ); +}); diff --git a/src/app/api/workouts/__tests__/batch-scope.test.ts b/src/app/api/workouts/__tests__/batch-scope.test.ts new file mode 100644 index 000000000..6fe7001f2 --- /dev/null +++ b/src/app/api/workouts/__tests__/batch-scope.test.ts @@ -0,0 +1,265 @@ +/** + * Who may call `POST /api/workouts/batch` (#1054). + * + * The route names `workouts:write`, so the set of credentials it admits is: + * a cookie session, a wildcard (`["*"]`) token, and a narrow token carrying + * exactly that scope. Every other narrow token — `measurements:write` + * included, however adjacent it looks — is refused 403 by the fail-closed + * default. + * + * The real `requireAuth` and the real `resolveBearerToken` run here; only the + * token row lookup is stubbed, so the admission decision under test is the + * production one rather than a re-implementation of it. + * + * The second half pins what a scoped caller writes: its rows are `EXTERNAL`, + * resolved from the credential, and a body naming any source is refused 422 + * rather than relabelled. A session and a wildcard token keep the source they + * send. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + workout: { + findMany: vi.fn(), + createManyAndReturn: vi.fn(), + }, + workoutRoute: { createMany: vi.fn() }, + workoutSamples: { createManyAndReturn: vi.fn() }, + $transaction: vi.fn(async (fn: unknown) => { + if (typeof fn === "function") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)(prisma as unknown as { workout: unknown }); + } + }), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/arrivals/emit-shared", () => ({ + emitDataArrival: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../batch/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { headers } from "next/headers"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { auditLog } from "@/lib/auth/audit"; +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; +import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes"; +import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes"; + +const USER = { + id: "user-1", + username: "tester", + role: "USER" as const, + sourcePriorityJson: null, +}; +const TOKEN = "hlk_" + "c".repeat(64); + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/workouts/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function workout( + externalId: string, + overrides: Record = {}, +): Record { + return { + sportType: "running", + startedAt: "2026-09-20T06:30:00.000Z", + endedAt: "2026-09-20T07:15:00.000Z", + externalId, + ...overrides, + }; +} + +/** Present `TOKEN` as a Bearer whose row carries `permissions`. */ +function armToken(permissions: string[]) { + vi.mocked(getSession).mockResolvedValue(null as never); + vi.mocked(headers).mockResolvedValue({ + get: (name: string) => + name.toLowerCase() === "authorization" ? `Bearer ${TOKEN}` : null, + } as never); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-1", + userId: USER.id, + permissions, + revoked: false, + expiresAt: new Date(Date.now() + 86_400_000), + } as never); +} + +function armSession() { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: USER, + } as never); +} + +/** The rows the route handed to `INSERT ... RETURNING`. */ +function insertedRows(): Array> { + return vi + .mocked(prisma.workout.createManyAndReturn) + .mock.calls.flatMap(([args]) => { + const data = (args as { data: unknown }).data; + return (Array.isArray(data) ? data : [data]) as Array< + Record + >; + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(64)); + vi.mocked(headers).mockResolvedValue({ get: () => null } as never); + vi.mocked(getSession).mockResolvedValue(null as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + limit: 60, + remaining: 60, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + // The refusal path chains `.catch` on the audit write, so the reset mock + // has to hand back a promise again. + vi.mocked(auditLog).mockResolvedValue(undefined as never); + // One row serves both lookups: the resolver's user fetch and the route's + // source-priority read. + vi.mocked(prisma.user.findUnique).mockResolvedValue(USER as never); + vi.mocked(prisma.workout.findMany).mockResolvedValue([]); + vi.mocked(prisma.workout.createManyAndReturn).mockImplementation( + (async (args: { data: Array> }) => + args.data.map((row, index) => ({ + id: `w-${index}`, + source: row.source, + externalId: row.externalId, + }))) as never, + ); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("POST /api/workouts/batch — which credentials it admits", () => { + it("admits a narrow workouts:write token", async () => { + armToken([WORKOUTS_WRITE_SCOPE]); + const res = await POST(makeRequest({ workouts: [workout("s-1")] })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.inserted).toBe(1); + }); + + it("still admits a cookie session", async () => { + armSession(); + const res = await POST( + makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }), + ); + expect(res.status).toBe(200); + }); + + it("still admits a wildcard token", async () => { + armToken(["*"]); + const res = await POST( + makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }), + ); + expect(res.status).toBe(200); + }); + + it.each([ + ["measurements:write", [MEASUREMENTS_WRITE_SCOPE]], + ["documents:write", [DOCUMENTS_WRITE_SCOPE]], + ["medication:ingest", ["medication:ingest", "medication:m-1:ingest"]], + ["health:read", ["health:read"]], + ])("refuses a %s token with 403 and writes nothing", async (_, perms) => { + armToken(perms); + const res = await POST(makeRequest({ workouts: [workout("s-1")] })); + expect(res.status).toBe(403); + expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/workouts/batch — what a scoped caller writes", () => { + it("attributes its rows to EXTERNAL", async () => { + armToken([WORKOUTS_WRITE_SCOPE]); + const res = await POST( + makeRequest({ workouts: [workout("s-1"), workout("s-2")] }), + ); + expect(res.status).toBe(200); + const rows = insertedRows(); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.source)).toEqual(["EXTERNAL", "EXTERNAL"]); + }); + + it("probes for duplicates in the EXTERNAL namespace", async () => { + armToken([WORKOUTS_WRITE_SCOPE]); + await POST(makeRequest({ workouts: [workout("s-1")] })); + const probe = vi + .mocked(prisma.workout.findMany) + .mock.calls.map(([args]) => args as { where?: { OR?: unknown } }) + .find((args) => args.where?.OR !== undefined); + expect(probe?.where?.OR).toEqual([ + { source: "EXTERNAL", externalId: "s-1" }, + ]); + }); + + it.each(["APPLE_HEALTH", "MANUAL"])( + "refuses a body naming %s with 422 and writes nothing", + async (source) => { + armToken([WORKOUTS_WRITE_SCOPE]); + const res = await POST( + makeRequest({ + workouts: [workout("s-1"), workout("s-2", { source })], + }), + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta?.errorCode).toBe("workout.batch.source_not_permitted"); + expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled(); + }, + ); + + it("leaves the source a wildcard caller sends alone", async () => { + armToken(["*"]); + await POST( + makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }), + ); + expect(insertedRows().map((r) => r.source)).toEqual(["APPLE_HEALTH"]); + }); + + it("leaves the source a session sends alone", async () => { + armSession(); + await POST( + makeRequest({ workouts: [workout("s-1", { source: "MANUAL" })] }), + ); + expect(insertedRows().map((r) => r.source)).toEqual(["MANUAL"]); + }); +}); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 5e1fe8b95..9d03b0390 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -1,7 +1,7 @@ /** * `POST /api/workouts/batch` — typed workout ingest with nested route. * - * Consumers (v1.5): + * Consumers: * 1. The native iOS app draining its HealthKit observer queue — * maps `HKWorkout` + `HKWorkoutRoute` samples into the request * shape locked by `createBatchWorkoutSchema` in @@ -15,12 +15,18 @@ * (MANUAL / APPLE_HEALTH), so this server path must write its * `WITHINGS`-sourced rows directly via `prisma.workout.upsert` * (as the WHOOP / Fitbit syncs already do), not through this route. + * 3. A third-party bridge holding a narrow `workouts:write` token + * (#1054) — a watch vendor's sync relayed by a small service. Its + * rows are attributed `EXTERNAL`, resolved from the credential; see + * the source guard in the handler. * * Cross-cutting concerns mirror `/api/measurements/batch` so the iOS * sync engine can re-use the same retry / cursor plumbing: - * - `requireAuth()` — cookie + Bearer (narrow-scope-token safe per - * v1.4.25 W10 fix-C: when the route declares no scope, any - * authenticated token passes). + * - `requireAuth` naming `WORKOUTS_WRITE_SCOPE` — a cookie session, a + * wildcard (`["*"]`) token, or a narrow `workouts:write` token. Every + * other narrow token is refused 403: under the fail-closed default a + * route admits exactly the scope it names, so a `measurements:write` + * credential does not reach this route. * - `withIdempotency` — `Idempotency-Key` header replays the cached * response on retry (24h window). * - `checkRateLimit("workouts:batch:${user.id}", 60, 60s)` — same @@ -59,7 +65,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -85,6 +91,8 @@ import { type UnstableExternalIdShape, } from "@/lib/validations/external-id"; import { dedupeWorkoutBatch } from "@/lib/workouts/canonical-rows"; +import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes"; +import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; import { Prisma, type MeasurementSource } from "@/generated/prisma/client"; @@ -169,7 +177,17 @@ async function emitWorkoutArrivals( export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch)); async function postBatch(request: NextRequest): Promise { - const { user } = await requireAuth(); + // Declares the workout-ingest scope, so a narrow `workouts:write` token + // reaches this route alongside cookie sessions and the wildcard tokens a + // native login mints. The only route that names it. `requireAuth` refuses + // any acting-account carrier for every credential shape, so the scoped token + // is confined to its owner's own record without an arm of its own here. + const auth = await requireAuth(WORKOUTS_WRITE_SCOPE); + const { user } = auth; + + // A scoped credential is a bridge, not the native client: the source of + // its rows is resolved from the credential below rather than from the body. + const scoped = isScopedCredential(auth); // v1.4.43 W9 — resolve the per-user source-priority blob once at // the start of the handler so the write-time canonical-row picker @@ -268,6 +286,27 @@ async function postBatch(request: NextRequest): Promise { }); } + // A scoped credential may attribute nothing: its rows are `EXTERNAL`, + // resolved from the credential. Refused loudly rather than relabelled, the + // measurement batch's rule and for its reasons. `APPLE_HEALTH` is half the + // `(userId, source, externalId)` dedup key the phone writes into and ranks + // first in the cross-source picker, so a bridge naming it would land in the + // phone's namespace and could shadow the phone's own copy of a session; + // `MANUAL` would make a bridged workout indistinguishable from one entered + // by hand. Checked on the raw body because the schema defaults an absent + // `source` to `MANUAL`, which would make "named" and "omitted" look alike. + if (scoped && namesAnySource(rawBody)) { + annotate({ + action: { name: "workout.batch.ingest" }, + meta: { outcome: "source_not_permitted" }, + }); + return apiError( + "This credential resolves the source itself; omit the field", + 422, + { errorCode: "workout.batch.source_not_permitted" }, + ); + } + const { workouts } = parsed.data; const refusedSampleIndices = new Set(parsed.refusedSampleIndices); @@ -359,7 +398,11 @@ async function postBatch(request: NextRequest): Promise { stepCount: w.stepCount ?? null, elevationM: w.elevationM ?? null, pauseDurationSec: w.pauseDurationSec ?? null, - source: w.source, + // `EXTERNAL` under a scoped credential, and nothing else: the guard + // above refused any body that named a source, so nothing asserted is + // discarded here. It also moves the dedup key into its own namespace, + // so a bridge's ids can never collide with the phone's. + source: scoped ? EXTERNAL_SOURCE : w.source, externalId: w.externalId ?? null, externalSourceVersion: w.externalSourceVersion ?? null, metadata: w.metadata @@ -373,7 +416,10 @@ async function postBatch(request: NextRequest): Promise { route, samples, dedupKey: w.externalId - ? { source: w.source, externalId: w.externalId } + ? { + source: scoped ? EXTERNAL_SOURCE : w.source, + externalId: w.externalId, + } : null, }, ]; @@ -719,3 +765,16 @@ async function postBatch(request: NextRequest): Promise { entries: results, }); } + +/** Whether any entry of a raw batch body names a `source` at all. */ +function namesAnySource(rawBody: unknown): boolean { + if (typeof rawBody !== "object" || rawBody === null) return false; + const workouts = (rawBody as { workouts?: unknown }).workouts; + if (!Array.isArray(workouts)) return false; + return workouts.some( + (w) => + typeof w === "object" && + w !== null && + (w as { source?: unknown }).source !== undefined, + ); +} diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 36c4ffa50..16d1afff2 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -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() {
+
@@ -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 ( + + ); +} + /** * 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", diff --git a/src/components/settings/security-section/__tests__/recent-proof-callers.test.tsx b/src/components/settings/security-section/__tests__/recent-proof-callers.test.tsx index 465d1eb45..6d22165d5 100644 --- a/src/components/settings/security-section/__tests__/recent-proof-callers.test.tsx +++ b/src/components/settings/security-section/__tests__/recent-proof-callers.test.tsx @@ -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"], diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 4dca876f8..39ea316f3 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -339,6 +339,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = tokens: [ "tokens.documents.ceiling_reached", "tokens.measurements.ceiling_reached", + "tokens.workouts.ceiling_reached", ], vaccination: [ "vaccination.booster-invalid", @@ -354,6 +355,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = workout: [ "workout.batch.invalid", "workout.batch.payload_too_large", + "workout.batch.source_not_permitted", "workout.batch.too_large", ], "(unprefixed)": [ diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 7d377de84..aaf86cc57 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -33,6 +33,7 @@ import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; import { createDocumentTokenSchema, createMeasurementTokenSchema, + createWorkoutTokenSchema, } from "@/lib/validations/tokens"; import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff"; import { @@ -489,6 +490,28 @@ const createMeasurementTokenResponse = z "The one response that carries a usable measurement-ingest Bearer value.", }); +const createWorkoutTokenRequest = createWorkoutTokenSchema.meta({ + id: "CreateWorkoutTokenRequest", + description: + "Mint a workout-ingest Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is listed under; name it after the bridge it is pasted into. `expiresInDays` defaults to 365.", +}); + +const createWorkoutTokenResponse = z + .object({ + token: z + .string() + .describe( + "The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash and cannot be retrieved later. Treat it as a secret.", + ), + name: z.string(), + expiresAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "CreateWorkoutTokenResponse", + description: + "The one response that carries a usable workout-ingest Bearer value.", + }); + const createDocumentTokenRequest = createDocumentTokenSchema.meta({ id: "CreateDocumentTokenRequest", description: @@ -881,7 +904,7 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Re-prove a credential on a browser session (cookie only)", description: - "Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" + + "Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" + 'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' + "Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.", requestBody: { @@ -1687,7 +1710,7 @@ export const authPaths: NonNullable = { 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 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, the sibling `POST /api/tokens/measurements` for third-party measurement ingest, and `POST /api/tokens/documents` for document upload from another system. 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, the sibling `POST /api/tokens/measurements` for third-party measurement ingest, `POST /api/tokens/workouts` for workout ingest from a bridge, and `POST /api/tokens/documents` for document upload from another system. 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.", @@ -1771,6 +1794,70 @@ export const authPaths: NonNullable = { }, }, }, + "/api/tokens/workouts": { + post: { + tags: ["Auth"], + summary: "Mint a workout-ingest token", + description: + "Mints a Bearer scoped to exactly `workouts:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once; it is stored as an HMAC and no path re-reveals it.\n\n" + + "**What it can do.** `POST /api/workouts/batch`, on its owner's own record, and nothing else. It is the door a workout bridge pushes through: a watch vendor's sync relayed by a small service, or a script replaying an exported history. Workouts it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, and a batch in which any entry names a source is refused 422 (`workout.batch.source_not_permitted`) rather than relabelled. `EXTERNAL` is on no default source ladder, so when the phone also recorded the same session the phone's copy is the one the workout list shows.\n\n" + + "**What it cannot do.** List or read a workout, reach the measurement or document ingest routes, act on a shared record, or mint another token. It is a separate scope from `measurements:write` on purpose: a token already handed to a scale does not start accepting workouts, and a `measurements:write` token is refused 403 on the workout batch. Every other route names no scope and refuses it 403.\n\n" + + "Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a day-lived native access token must not be able to leave behind a year-lived credential. 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 workout tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.", + requestBody: { + required: true, + content: { + "application/json": { schema: createWorkoutTokenRequest }, + }, + }, + responses: { + "201": { + description: + "Token minted. The `token` field is the only copy of the secret.", + content: { + "application/json": { + schema: dataEnvelope( + createWorkoutTokenResponse, + "CreateWorkoutTokenEnvelope", + ), + }, + }, + }, + "400": { + description: "Body is not parseable JSON.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "403": { + description: + "The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 before the switch is read.", + 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 } }, + }, + ...stdResponses, + "422": { + description: + "Validation failed. The envelope carries every offending issue.", + 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 } }, + }, + "409": { + description: + "The account already holds 10 live workout tokens (`meta.errorCode` = `tokens.workouts.ceiling_reached`). Nothing was minted. Revoke one at `DELETE /api/tokens/{id}`; only live tokens carrying `workouts:write` count.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/tokens/documents": { post: { tags: ["Auth"], diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 6df23e8f4..c96613bc0 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -144,7 +144,7 @@ export const measurementTypeEnum = measurementTypeEnumBase.meta({ export const measurementSourceEnum = measurementSourceEnumBase.meta({ id: "MeasurementSource", description: - "Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a home-automation rule. No client may name that value on a write; the server resolves it from the credential.", + "Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a home-automation rule — and workouts pushed in under a narrow `workouts:write` Bearer carry it too. No client may name that value on a write; the server resolves it from the credential.", }); export const loginPasswordSchema = loginPasswordSchemaBase.meta({ diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 9420d7fbd..2ef30d3bf 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -319,7 +319,8 @@ export const workoutPaths: NonNullable = { tags: ["Measurements"], summary: "Typed workout batch ingest (v1.4.25 W16b)", description: - 'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.', + 'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' + + "**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the supported way to push workouts from a bridge that is not the phone. This route is the scope's only one. Such a credential writes only on its owner's own record; its workouts are attributed `EXTERNAL`, resolved from the credential, and it may not name a source itself, so a batch in which any entry carries one, `MANUAL` and `APPLE_HEALTH` alike, is refused 422 (`workout.batch.source_not_permitted`) and nothing is written. `EXTERNAL` gives those writes their own `(userId, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. Every other narrow token, `measurements:write` included, is refused 403.", requestBody: { required: true, content: { "application/json": { schema: createBatchWorkoutSchema } }, @@ -347,6 +348,13 @@ export const workoutPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + // After the spread: `stdResponses` carries a generic 422 that would + // otherwise overwrite this wording. + "422": { + description: + "A narrow `workouts:write` credential sent a batch in which an entry names a source, any source (`workout.batch.source_not_permitted`). Nothing was written; resending the batch unchanged is refused the same way. Omit `source` and the server attributes the rows `EXTERNAL`.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, diff --git a/src/lib/sources/__tests__/pick-canonical-workout.test.ts b/src/lib/sources/__tests__/pick-canonical-workout.test.ts index 97bbf86ce..ecb9afbda 100644 --- a/src/lib/sources/__tests__/pick-canonical-workout.test.ts +++ b/src/lib/sources/__tests__/pick-canonical-workout.test.ts @@ -445,3 +445,34 @@ describe("pickCanonicalWorkout — Strava brick-workout regression", () => { expect(sports).toEqual(["cycling", "running"]); }); }); + +/** + * #1054 — workouts a `workouts:write` bridge pushes in carry `EXTERNAL`, which + * is on no default ladder. Pinned because the settings copy promises what + * follows from that: a bridged session the phone also recorded shows once, as + * the phone's copy, and a bridged session nothing else recorded still shows. + */ +describe("pickCanonicalWorkout — the unranked EXTERNAL source", () => { + it("is on no default ladder", () => { + expect(DEFAULT_WORKOUT_SOURCE_PRIORITY).not.toContain("EXTERNAL"); + }); + + it("yields to the phone's copy of the same session", () => { + const result = pickCanonicalWorkout([ + row("bridge-1", "EXTERNAL", "2026-09-20T06:30:00.000Z"), + row("apple-1", "APPLE_HEALTH", "2026-09-20T06:31:00.000Z"), + ]); + expect(result.canonical.map((w) => w.id)).toEqual(["apple-1"]); + }); + + it("survives when nothing else recorded the session", () => { + const result = pickCanonicalWorkout([ + row("bridge-1", "EXTERNAL", "2026-09-20T06:30:00.000Z"), + row("apple-1", "APPLE_HEALTH", "2026-09-20T09:00:00.000Z"), + ]); + expect(result.canonical.map((w) => w.id).sort()).toEqual([ + "apple-1", + "bridge-1", + ]); + }); +}); diff --git a/src/lib/validations/tokens.ts b/src/lib/validations/tokens.ts index d6e2ae03f..d0fcca994 100644 --- a/src/lib/validations/tokens.ts +++ b/src/lib/validations/tokens.ts @@ -34,3 +34,13 @@ export const createDocumentTokenSchema = z.object({ name: z.string().min(1, "Name required").max(100), expiresInDays: z.number().int().min(1).max(365).optional(), }); + +/** + * Body of `POST /api/tokens/workouts` — mint a Bearer that can push workouts + * and nothing else (#1054). The same shape as the other two mints and for the + * same reasons: no scope field, a bounded lifetime. + */ +export const createWorkoutTokenSchema = z.object({ + name: z.string().min(1, "Name required").max(100), + expiresInDays: z.number().int().min(1).max(365).optional(), +}); diff --git a/src/lib/workouts/scopes.ts b/src/lib/workouts/scopes.ts new file mode 100644 index 000000000..26a7775a4 --- /dev/null +++ b/src/lib/workouts/scopes.ts @@ -0,0 +1,21 @@ +/** + * The Bearer scope for pushing workouts in from another system (#1054): a + * watch vendor's bridge, a script replaying an export. + * + * A zero-import leaf for the reason `MEASUREMENTS_WRITE_SCOPE` is one (see + * `@/lib/measurements/scopes`): the mint route, the batch route, the settings + * card and the guards all read it, and none of them should inherit an import + * graph for a string. + * + * Its own scope rather than a second door for `measurements:write`. A token + * someone already pasted into a scale's uploader should not quietly start + * accepting workouts because a later release widened what the name means; a + * scope grants what it names, and a bridge that sends both mints both. + * + * Deliberately alone. There is no read counterpart and must not be one: the + * workout list and detail stay cookie-equivalent, so a credential pasted into + * a bridge can add a session and learn nothing about the ones already stored. + */ + +/** The scope a narrow token must carry to write workouts. */ +export const WORKOUTS_WRITE_SCOPE = "workouts:write"; diff --git a/tests/integration/bearer-scope-enforcement.test.ts b/tests/integration/bearer-scope-enforcement.test.ts index 5d968d8e5..40a5b3b0d 100644 --- a/tests/integration/bearer-scope-enforcement.test.ts +++ b/tests/integration/bearer-scope-enforcement.test.ts @@ -951,3 +951,173 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe expect(row.userId).toBe(ownerId); }); }); + +describe("B9 — the workout-ingest scope reaches the workout batch and nothing else", () => { + function postWorkouts(entries: Array>): NextRequest { + return new NextRequest("https://health.example/api/workouts/batch", { + method: "POST", + headers: { + authorization: headerJar.get("authorization")!, + "content-type": "application/json", + }, + body: JSON.stringify({ workouts: entries }), + } as never); + } + + function bridgedRun( + externalId: string, + extra: Record = {}, + ): Record { + return { + sportType: "running", + startedAt: "2026-09-20T06:30:00.000Z", + endedAt: "2026-09-20T07:15:00.000Z", + externalId, + ...extra, + }; + } + + it("admits the scope on POST /api/workouts/batch, attributed EXTERNAL", async () => { + await armToken(["workouts:write"], "wwrite1"); + const { POST } = await import("@/app/api/workouts/batch/route"); + const res = await POST(postWorkouts([bridgedRun("bridge-run-1")])); + + expect(res.status).toBe(200); + const row = await getPrismaClient().workout.findFirstOrThrow({ + where: { userId: USER_ID, externalId: "bridge-run-1" }, + }); + // The body names no source; the schema would default it to MANUAL, and + // the route resolves EXTERNAL from the credential instead. + expect(row.source).toBe("EXTERNAL"); + }); + + it("re-posting the same bridged workout is a duplicate, not a second row", async () => { + // The dedup probe has to look in the EXTERNAL namespace the insert wrote + // to. Probing under the body's defaulted MANUAL would miss the stored row + // and the unique index would be the only thing standing in the way. + await armToken(["workouts:write"], "wwrite1b"); + const { POST } = await import("@/app/api/workouts/batch/route"); + await POST(postWorkouts([bridgedRun("bridge-run-2")])); + const again = await POST(postWorkouts([bridgedRun("bridge-run-2")])); + + expect(again.status).toBe(200); + expect((await again.json()).data.entries[0].status).toBe("duplicate"); + expect(await getPrismaClient().workout.count()).toBe(1); + }); + + it.each(["APPLE_HEALTH", "MANUAL"])( + "refuses a batch in which an entry names %s", + async (source) => { + await armToken(["workouts:write"], `wwrite2-${source}`); + const { POST } = await import("@/app/api/workouts/batch/route"); + const res = await POST( + postWorkouts([ + bridgedRun("bridge-run-3"), + bridgedRun("bridge-run-4", { source }), + ]), + ); + + expect(res.status).toBe(422); + expect((await res.json()).meta?.errorCode).toBe( + "workout.batch.source_not_permitted", + ); + expect(await getPrismaClient().workout.count()).toBe(0); + }, + ); + + it("a measurements:write token is refused on the workout batch", async () => { + // Separate scopes on purpose: a token handed to a scale does not start + // accepting workouts. + await armToken(["measurements:write"], "wwrite3"); + const { POST } = await import("@/app/api/workouts/batch/route"); + const res = await POST(postWorkouts([bridgedRun("bridge-run-5")])); + + expect(res.status).toBe(403); + expect(await getPrismaClient().workout.count()).toBe(0); + }); + + it("and a workouts:write token is refused on the measurement batch", async () => { + await armToken(["workouts:write"], "wwrite4"); + const { POST } = await import("@/app/api/measurements/batch/route"); + const res = await POST( + new NextRequest("https://health.example/api/measurements/batch", { + method: "POST", + headers: { + authorization: headerJar.get("authorization")!, + "content-type": "application/json", + }, + body: JSON.stringify({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 70, + unit: "kg", + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + externalId: "uuid-workouts-write-1", + }, + ], + }), + } as never), + ); + + expect(res.status).toBe(403); + expect(await getPrismaClient().measurement.count()).toBe(0); + }); + + it("is refused on the workout read legs", async () => { + await armToken(["workouts:write"], "wwrite5"); + const { GET } = await import("@/app/api/workouts/route"); + expect((await GET(req("/api/workouts"))).status).toBe(403); + await expect + .poll(() => lastBearerFailureReason(), { timeout: 5_000, interval: 100 }) + .toBe("undeclared_scope"); + }); + + it("cannot mint another token", async () => { + await armToken(["workouts:write"], "wwrite6"); + const { POST } = await import("@/app/api/tokens/workouts/route"); + const res = await POST( + new NextRequest("https://health.example/api/tokens/workouts", { + method: "POST", + headers: { + authorization: headerJar.get("authorization")!, + "content-type": "application/json", + }, + body: JSON.stringify({ name: "second" }), + } as never), + ); + expect(res.status).toBe(401); + }); + + it("the mint issues exactly this scope, and the token it issues writes", async () => { + // Mint on a cookie session with a fresh proof, then use the raw token the + // response carries: the round trip a bridge operator makes. + await useCookieSession(); + const { POST: mint } = await import("@/app/api/tokens/workouts/route"); + const minted = await mint( + new NextRequest("https://health.example/api/tokens/workouts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "watch bridge" }), + } as never), + ); + expect(minted.status).toBe(201); + const raw = (await minted.json()).data.token as string; + + const row = await getPrismaClient().apiToken.findFirstOrThrow({ + where: { name: "watch bridge" }, + }); + expect(row.permissions).toEqual(["workouts:write"]); + + cookieJar.clear(); + headerJar.set("authorization", `Bearer ${raw}`); + const { POST } = await import("@/app/api/workouts/batch/route"); + const res = await POST(postWorkouts([bridgedRun("bridge-run-6")])); + expect(res.status).toBe(200); + const written = await getPrismaClient().workout.findFirstOrThrow({ + where: { externalId: "bridge-run-6" }, + }); + expect(written.source).toBe("EXTERNAL"); + }); +});